Skip to content

PII in Your Logs: The Compliance Time Bomb

5 min read · updated August 3, 2026

Application logs contain personal data by accident. Prompt logs contain it by construction — users paste contracts, medical notes, spreadsheets of customers and their own credentials into a text box, and your logging captures all of it verbatim, forever, in a system with different access control from your database.

Why this log is worse than your other logs

Four properties combine badly, and it is worth being specific about them rather than gesturing at compliance.

  • The content is unbounded and unstructured. You cannot enumerate the fields, because there are no fields. A classifier that works on your user table is useless against free text a user pasted.
  • Volume hides it. Nobody reads a million prompts. The special-category data — health, biometrics, political opinions, union membership, under GDPR Article 9 — is in there and nobody has seen it.
  • Retention defaults are wrong. Observability backends are configured for months of retention because that is what debugging metrics wants. Applied to message content that is a liability that accrues.
  • It spreads. The same content goes to your observability vendor, your eval platform, your data warehouse and whichever notebook someone exported it to. Erasure means finding all of those, and you will not.

Regulatorily, the relevant principle is data minimisation — GDPR Article 5(1)(c) requires personal data to be adequate, relevant and limited to what is necessary — and the right to erasure in Article 17 applies to your logs exactly as it applies to your database. “It is in the logs” has never been an exemption.

Redact at capture, never at read

The tempting design is to store everything and redact when displaying. It is wrong for a reason that has nothing to do with implementation quality: once raw content has been written, it exists on that disk, in that backup, in that replica and in that vendor’s index. Every subsequent control is a filter over data you still hold.

Redaction at capture means the sensitive bytes never leave the process that had a legitimate reason to see them. It costs you fidelity — some debugging becomes harder — and that trade is the correct one to make, because the alternative failure is not a harder debugging session.

The same logic explains why OpenTelemetry’s GenAI conventions default message-content capture to off, requiring an explicit opt-in. If you turn it on, turn it on with a redactor between the message and the exporter.

A working redactor

import { createHmac } from "node:crypto";

type Rule = { label: string; re: RegExp; verify?: (m: string) => boolean };

const RULES: Rule[] = [
  { label: "EMAIL",  re: /[\w.+-]+@[\w-]+\.[\w.-]{2,}/g },
  { label: "IPV4",   re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g },
  { label: "SSN",    re: /\b\d{3}-\d{2}-\d{4}\b/g },
  { label: "IBAN",   re: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g },
  { label: "JWT",    re: /\beyJ[\w-]+\.[\w-]+\.[\w-]+/g },
  { label: "APIKEY", re: /\b(?:sk|pk|rk)[-_][A-Za-z0-9_-]{16,}/g },
  { label: "PHONE",  re: /\+?\d[\d\s().-]{7,16}\d/g },
  // Card numbers produce false positives on any long digit run, so the
  // pattern is only half the rule — the checksum is the other half.
  { label: "CARD",   re: /\b(?:\d[ -]?){13,19}\b/g, verify: luhn },
];

function luhn(raw: string): boolean {
  const d = raw.replace(/\D/g, "");
  if (d.length < 13 || d.length > 19) return false;
  let sum = 0, alt = false;
  for (let i = d.length - 1; i >= 0; i--) {
    let n = d.charCodeAt(i) - 48;
    if (alt) { n *= 2; if (n > 9) n -= 9; }
    sum += n; alt = !alt;
  }
  return sum % 10 === 0;
}

/**
 * Replaces matches with a stable, keyed pseudonym: [EMAIL:3f9a1c].
 * Stable so the same value in two places is visibly the same value —
 * which preserves most of the debugging signal. Keyed so the mapping
 * cannot be reversed by anyone who does not hold the key.
 */
export function redact(text: string, key: Buffer): { text: string; counts: Record<string, number> } {
  const counts: Record<string, number> = {};
  let out = text;
  for (const rule of RULES) {
    out = out.replace(rule.re, (m) => {
      if (rule.verify && !rule.verify(m)) return m;
      counts[rule.label] = (counts[rule.label] ?? 0) + 1;
      const tag = createHmac("sha256", key).update(m).digest("hex").slice(0, 6);
      return "[" + rule.label + ":" + tag + "]";
    });
  }
  return { text: out, counts };
}

Two design points. The counts return value is not decoration — exporting it as a metric tells you how much sensitive data is flowing through, and a sudden jump in CARD hits is a product problem worth knowing about independently of the redaction. And the keyed HMAC rather than a plain hash matters: an unkeyed hash of an email address is trivially reversed by anyone with a list of email addresses.

One thing to be precise about: pseudonymised data is still personal data under GDPR (Recital 26 is explicit that data which can be attributed with additional information remains in scope). Redaction of this kind reduces exposure substantially. It does not take the log out of the regulation.

What a pattern redactor cannot do

Being honest about the ceiling is what makes the floor useful. Regex catches formatted identifiers. It does not catch:

Out of reach for patternsDescription
Names and addressesNo pattern distinguishes a person's name from any other capitalised word. This needs named-entity recognition — Microsoft Presidio is the common open-source option, and it brings a latency and an accuracy cost that need to be measured against your own traffic before you rely on it.
Free-text health, financial or biographical detail'my mother was diagnosed last March' is special-category data with no format at all. Nothing short of a classifier reaches it, and no classifier reaches all of it.
Re-identification by combinationA redacted transcript can still identify someone through a combination of unremarkable facts. This is a retention problem, not a redaction one.
Uploaded documents and imagesIf your feature accepts files, the file is the payload and the prompt is a wrapper. Decide separately whether file content is ever logged; the honest default is no.
Content in tool arguments and outputsRedactors usually get pointed at the prompt and the completion and miss the tool call arguments, which frequently carry exactly the identifiers you were trying to remove.

Which leads to the conclusion most teams reach eventually and could reach immediately: for most services, the correct default is to log message content for a small sampled fraction, redacted, with a short TTL, and to log metadata for everything. The redactor above is a safety net for the sample, not a licence to keep everything.

Retention and erasure as a layout problem

Erasure requests are unanswerable if content is smeared across a wide table, a trace backend, an eval store and a warehouse. They are straightforward if content lives in exactly one place and everything else holds a pointer — which is the reason the request log schema keeps input_ref and output_ref rather than the text.

  • One content store, addressed by hash. Object storage with a lifecycle rule. Erasure is a delete on a prefix; every metric, cost figure and latency percentile survives untouched.
  • Two clocks. Metadata retention measured in months or years because it is small and useful; content retention measured in days or weeks because it is large and hazardous. They are set independently, which is the entire benefit of the split.
  • An extension for flagged items. Content attached to a thumbs-down, an incident or an open ticket gets a longer TTL by explicit marking. That is a small, defensible, documented exception rather than a blanket long retention.
  • Tenant-scoped keys. If the HMAC key is per tenant, destroying the key destroys the ability to correlate that tenant’s pseudonyms — a useful additional lever at contract termination.
  • Write the retention down where the code is. The policy that exists only in a compliance document is the one that silently does not match the bucket lifecycle rule.

Finally, do the inventory once. List every destination message content reaches: your logs, your traces, your eval tool, your prompt registry if it stores examples, your shadow-comparison table, your warehouse, and every notebook or export. Most teams find at least one they had forgotten, and the forgotten one is always the one without a retention policy.

Two organisational notes, because this is one of the few engineering topics where the process matters as much as the code. Whoever owns data protection should know that prompt content is logged, at what rate and for how long — a control they learn about from an auditor is a control they will ask you to remove entirely. And the decision to enable content capture should be recorded somewhere durable with a reason attached, because the default drifts: an environment variable set once during a debugging session has a way of persisting into production for years, and nobody can later say who turned it on or why.

PII in Your Logs: The Compliance Time Bomb · Multigrid