Chapter 1 of 6 · 11 min read
Redact by field class and keep the structure around it
On this page
An HR software vendor ran a complaint-intake assistant for about five months before anyone read the traces closely. The pipeline had a redactor on it, written the week before launch and signed off by the general counsel: a regular expression pass over gen_ai.prompt and gen_ai.completion that replaced email addresses, UK and US phone numbers, sixteen-digit card-shaped strings, and anything matching a national insurance number, each with a [REDACTED] marker. The reasoning was straightforward — take out the identifiers and what remains is not personal data.
The span that ended that belief looked like this after redaction:
{
"trace_id": "b1c9f4a207e34d55",
"attributes": {
"gen_ai.prompt": "I want to raise a formal grievance. I am the only wheelchair user on the night shift at the Salford depot and since the new shift lead started in January my breaks have been moved to times when the accessible toilet is locked. I have raised it twice with [REDACTED] and nothing has happened.",
"gen_ai.completion": "I'm sorry to hear that. To open a formal grievance I'll need..."
}
}Every pattern fired that could fire. What is left identifies exactly one employee to anyone who has ever visited that depot, discloses a disability, and describes a dispute with a named manager whose name the same regex helpfully removed from one sentence and left intact three sentences later where it appeared without a title. The trace viewer was open to about forty engineers and six support staff. One of them worked out of Salford.
Two requirements pull against each other in that pipeline, and the rest of this topic follows from the pull. A trace is the only record that explains why an assistant behaved as it did, which is the reason the tracing topic argued for capturing prompts and completions in the first place, and the same record is a durable copy of whatever the customer typed. Most of what a customer types into a product like this is personally identifiable information — PII: content that identifies a living person on its own, as an email address does, or in combination with something else you already hold, as a shift pattern and a depot do. Redaction is the operation that resolves the conflict, removing or replacing sensitive content on its way into the store so that the diagnostic record outlives the personal detail inside it. Around it sit the other controls this topic covers: how long each copy is kept, which jurisdiction holds it, who is allowed to read it, and how every copy is found again when someone asks to be erased. Those questions tend to arrive together, through a customer's security questionnaire, a contract negotiation, or a question nobody at your company was expecting.
The way this came to light is worth noting, because it was not a breach and nobody misused anything. A works council at the customer asked, under an ordinary information request, for a description of every system that held grievance content and who could read it. Answering that question honestly required someone to open the trace viewer and read what was actually in it, which had not happened since the redactor was written.
The same sample produced the opposite failure in larger numbers. Over 10,000 spans drawn from a week of production, the redactor fired 4,812 times; a manual review of a 400-span subset found that 21% of those firings were on strings that were not personal data at all — order references in the format 4532-1109-8871-0043, ISO timestamps caught by a date pattern, and part numbers from the product catalogue. When an engineer opened a trace to work out why the assistant had quoted the wrong returns policy, the retrieved document snippet that would have answered it read ...applies to orders placed before [REDACTED] under scheme [REDACTED]....
Classification happens where the field is created, not where the text arrives
The regex approach fails in both directions because it operates on the one representation that has thrown away everything useful: a flat string. By the time the prompt has been rendered, the account identifier the retrieval step looked up, the free-text box the user typed into, the internal policy document that was inlined, and the tool result carrying a database row are all the same kind of thing — characters. A pattern matcher has to guess which characters matter, and guessing is the wrong operation when the calling code knew the answer a few milliseconds earlier.
Your application does know. The field the user typed into is a form field with a name. The account record came back from a service whose response schema you own. The retrieved chunks came from a document store where every chunk has an id and a source. The instruction to classify at source means attaching a class to each piece of content at the point it enters the request, carrying that class through prompt assembly, and applying a per-class policy when the span is written. The classes that matter for an LLM workload are these:
| Class | Examples | Policy at ingest | What survives on the span |
|---|---|---|---|
direct_identifier | name, email, phone, account number, employee id | drop from text; keep as pseudonym | subject_ref: "u_8f21c" |
user_free_text | the complaint body, the chat turn, an uploaded CV | route to raw window; drop from the durable span | length, language, token count, hash |
secret | API keys, bearer tokens, connection strings | drop everywhere, alert on presence | secret_detected: true |
retrieved_content | knowledge base chunks, policy documents | keep ids and scores; drop text unless the corpus is non-personal | doc_ids, scores, corpus_version |
tool_io | tool arguments and tool results | per tool: schema-level policy | argument names, result row count, status |
operational | model version, effort, stop reason, error code, latency | keep in full | everything |
correlation | trace id, session id, tenant id, template version | keep in full | everything |
The column that does the most work is the last one. A policy that says "drop the text" is only tolerable if something informative is written in its place, and the informative thing is almost always structural: how long was it, how many tokens did it cost, which documents were retrieved and with what scores, which template version rendered it, what did the model do at the end.
Here is the same span under a class-driven policy. It contains no complaint text and it is more useful for debugging than the version at the top of this chapter:
{
"trace_id": "b1c9f4a207e34d55",
"attributes": {
"gen_ai.request.model": "claude-opus-5",
"gen_ai.response.finish_reason": "end_turn",
"gen_ai.usage.input_tokens": 3141,
"gen_ai.usage.output_tokens": 218,
"prompt.template_id": "grievance.intake",
"prompt.template_version": "v11",
"tenant.id": "t_northgate",
"subject_ref": "u_8f21c",
"user_text.chars": 268,
"user_text.tokens": 61,
"user_text.lang": "en",
"user_text.sha256_8": "4d9b1a70",
"retrieval.doc_ids": ["kb_0912", "kb_1144", "kb_0311", "kb_2087", "kb_0004"],
"retrieval.scores": [0.34, 0.33, 0.32, 0.31, 0.29],
"retrieval.corpus_version": "2026-02-28",
"raw_ref": "rw/2026/03/12/b1c9f4a207e34d55",
"raw_expires_at": "2026-03-15T09:41:00Z"
}
}The wrong-returns-policy investigation resolves off this span without anyone reading a sentence the customer wrote. Five documents came back with scores between 0.29 and 0.34, all below the 0.5 floor the retrieval layer was supposed to enforce, which means the query embedded badly and the model was answering from whatever the store happened to rank highest. That is the bug. The redacted text would not have shown it; the score array shows it in one glance.
The questions structure has to be able to answer
The test for whether a redaction policy has kept enough is not aesthetic, and it is worth writing down as a list of investigations the team actually runs. A truncated answer is diagnosed from the output token count sitting exactly on the max_tokens ceiling together with the stop reason, both of which are operational fields and neither of which contains anything the customer said. A regression that appeared after a prompt change is scoped by prompt.template_version, which is why that field is worth carrying even though it is redundant with the deploy log. A tenant-specific failure is found by grouping on tenant.id. A retrieval fault is found by the document ids and scores, as above. A cost anomaly is found from the four token counts kept separately, in the way the tracing topic established.
Every one of those is answerable from fields that carry no content. The investigations that genuinely require the text are narrower than they feel: the model misread something specific and you need to see what it misread, or a guardrail fired and you need to see on what. Both are real, both are a small minority of cases, and both are what the raw window exists for. Writing the list out before designing the policy is what stops the argument from being conducted in the abstract, where "engineers need the data" and "legal needs it gone" are both unfalsifiable.
The patterns stay, one layer further in
Classification at source is a claim about where content comes from, and users defeat it routinely by putting the wrong thing in the right box. Someone pastes an entire CV into a field your schema calls question_summary. A tool that was specified to return an order status returns the whole order row because a colleague widened the SELECT. An engineer adds a debugging line that inlines the raw request body into an error message. Each of these moves content across a class boundary without anything in the type system noticing.
Pattern detection is the control for that, running after classification rather than instead of it, and on a much smaller surface — the fields the policy already decided to keep. Detecting an email address inside user_text.lang means something has gone wrong upstream, which makes the detector a correctness alarm as well as a scrubber. Microsoft's Presidio is the usual open-source choice here, and it is worth reaching for over hand-written regular expressions because it combines patterns with a named-entity model and a context-word check, so 4532-1109-8871-0043 next to the word "order" is scored differently from the same digits next to "card". Azure AI Language ships the same idea as a managed service; the Microsoft walkthrough on wiring PII redaction into a pipeline is a reasonable model for where the call belongs, which is inside the exporter rather than bolted onto the viewer.
POLICY = {
"direct_identifier": "pseudonymise",
"user_free_text": "raw_window",
"secret": "drop_and_alert",
"retrieved_content": "ids_only",
"tool_io": "per_tool",
"operational": "keep",
"correlation": "keep",
}
def build_span_attributes(parts: list[Field], ctx: RequestContext) -> dict:
attrs = {}
for field in parts:
action = POLICY[field.cls]
if action == "keep":
attrs[field.name] = field.value
elif action == "pseudonymise":
attrs[f"{field.name}_ref"] = pseudonym(field.value, ctx.tenant_id)
elif action == "ids_only":
attrs[f"{field.name}.doc_ids"] = [c.id for c in field.value]
attrs[f"{field.name}.scores"] = [round(c.score, 2) for c in field.value]
elif action == "user_free_text" or action == "raw_window":
attrs[f"{field.name}.chars"] = len(field.value)
attrs[f"{field.name}.tokens"] = ctx.count_tokens(field.value)
attrs[f"{field.name}.sha256_8"] = digest8(field.value, ctx.pepper)
elif action == "drop_and_alert":
attrs[f"{field.name}.secret_detected"] = True
ctx.alert("secret_in_prompt", field.name)
# Backstop: anything that survived the policy still gets scanned.
findings = analyzer.analyze(text=" ".join(map(str, attrs.values())),
language="en")
if findings:
ctx.alert("pii_in_kept_field", [f.entity_type for f in findings])
attrs = anonymizer.anonymize_dict(attrs, findings)
return attrsThe ctx.alert calls are the part people cut first and regret. A redactor that silently cleans up a leak looks identical in production to a redactor that has nothing to clean up, and the difference between those two states is the whole of your assurance position. At the HR vendor, the pii_in_kept_field alert fired 60 to 90 times a day for the first fortnight, all from two tool results whose schemas had drifted, and then dropped to fewer than three a day once those were fixed — which is the shape you want, a noisy first week that converges rather than a permanent trickle nobody reads.
Cost is the usual objection to running an entity model in the ingest path, and the numbers are manageable if the call sits in the right place. Presidio's default analyser over a 3 KB span runs in roughly 30 to 60 ms on a single core, which is unacceptable on the request path and irrelevant in a batching span exporter that already buffers for five seconds. Putting it in the exporter also fixes the ordering problem that catches people: redaction has to happen before the span leaves the process, because once it has been handed to a collector it is already outside your boundary and in somebody's queue.
A short window for the text you genuinely need
Some investigations cannot be done without the words. A model that produces a confidently wrong summary of a document has done something to specific input text, and the score array will not tell you what. The answer is not to keep raw prompts for the same 30 days as the rest of the trace, but to keep them somewhere else, for much less time, behind a different door.
At the vendor in the opening, raw prompt and completion text goes to a separate bucket with a 72-hour object lifecycle policy, encrypted with a distinct key held in a key management service — a KMS, which lets the application encrypt and decrypt without the key material ever being readable by it — readable by a five-person group, with every read written to an audit log that names the reader, the trace id and a ticket reference. The durable span keeps raw_ref and raw_expires_at so that anyone looking at a trace can see whether the text still exists and stop guessing. Across the first quarter of operation, 1.1 million traces were written and the raw window was read 340 times — about 0.03% — which is the number that makes the design affordable. Storing 9 KB of prompt text per model call for 72 hours instead of 30 days took that tier from roughly 1.6 TB of retained text to about 55 GB, and more importantly it took the population of people who could read a customer's grievance from forty-six to five.
The window is also what makes the rest of the policy survive contact with engineers. Redaction schemes get quietly disabled when they make the job impossible; a scheme that leaves the structure intact and offers a documented path to the text for the 0.03% of cases that need it does not generate that pressure.
What this does not do
Class-driven redaction is a control over what your trace store holds. It is not anonymisation, and the subject_ref and sha256_8 fields above are the reason — both are stable pseudonyms that re-identify the moment they are joined against anything else you hold, which is precisely why they are useful and precisely why they keep the span inside your privacy obligations. The next chapter takes that apart properly.
It also does nothing about the copies that live outside the span. The raw text was still sent to the model provider, still passed through whatever gateway sits in front of it, and may still be sitting in an evaluation set that someone curated from last month's failures. Redacting at trace ingest is one gate on one path.
And it degrades over time without maintenance. Every new field added to a prompt template arrives unclassified, and an unclassified field is a decision someone has to make under time pressure during a feature launch. The version of this that holds up treats the class as a required argument — a field with no class fails the build rather than defaulting to keep — which converts a policy question into a compile error and is the only mechanism that has ever kept a taxonomy like this current.
The alternative design that most teams reach for instead is to store everything as it arrives and hide the sensitive parts in the trace viewer, which sounds like the same protection with none of the debugging cost. It is a different control entirely, and the difference shows up in the obligations it leaves untouched.
Data retention and privacy in logs — read the current version at https://daviddominguez.dev/learn/en/data-retention-and-privacy-in-logs/redact-by-field-class-and-keep-the-structure-around-it