Skip to content

18 min read

Redaction is decided before the first payload lands in a trace

On this page

A trace of an agent run is thin by default. Every span carries a name, a start and an end time, a pointer to its parent, a status and a handful of counts, and none of that is anybody's personal data. The version teams reach for second adds the payloads — what the model was asked, what each tool returned — because the shape of a run tells you where it went strange and the payloads tell you why. That version answers a far wider class of question, and in the same stroke it becomes a copy of the most sensitive fields in whatever systems the tools read. The agent throughout this chapter is a claims-triage agent at an insurer: its tools read claim records out of a claims database, lift text out of documents a claimant has uploaded, price repairs, and write a decision. Its traces go to a hosted tracing vendor, which is the ordinary arrangement and is also the whole of the problem, because the claims database and the tracing dashboard are two stores with two different populations of reader.

That arrangement is why the decision usually gets made by accident rather than on purpose. Traces get richer incrementally, each increment is obviously useful, and no single increment looks like a data-handling decision. It becomes one at the moment payloads land, and by then a month of traces exists and the argument is about deletion rather than design.

What a full-fidelity tool span actually holds

Here is the tool.fetch_claim span from a real run, with nothing removed:

json
{
  "name": "tool.fetch_claim",
  "trace_id": "4c1e9a2f8b7d4e10",
  "span_id": "b3",
  "parent_span_id": "b1",
  "attributes": {
    "tool.name": "fetch_claim",
    "tool.call_id": "toolu_01WkP9Qm",
    "tool.input": "{\"claim_id\": \"CLM-88214\"}",
    "tool.output": "{\"claim_id\":\"CLM-88214\",\"status\":\"open\",\"policy\":\"POL-4471902\",\"claimant\":{\"name\":\"Hannah Okonjo\",\"dob\":\"1987-04-19\",\"address\":\"14 Ardleigh Rd, Norwich NR2 4TF\",\"phone\":\"+44 7700 900412\"},\"incident\":{\"date\":\"2026-02-27\",\"description\":\"Windscreen shattered on the A47 when a lorry threw up a stone. I have whiplash in my neck and my GP has signed me off for two weeks.\"},\"deductible_cents\":25000,\"documents\":[{\"id\":\"DOC-3391\",\"kind\":\"gp_letter\"}]}"
  }
}

Two attributes out of six carry the problem, and one of them carries almost all of it. tool.input is a claim identifier — an internal key with no meaning outside the system. tool.output is a customer record including a name, a date of birth, a postal address, a phone number, and an unstructured narrative that in this instance volunteers a medical condition. Whatever the policy turns out to be, it is going to be about that second string.

A specimen teaches this better than a field list would, and the reason is in the shape of the JSON rather than in what it says. The tool returned a structured record; the instrumentation serialised that whole record into a single string and set the string as one attribute. Everything below the top level — the name, the date of birth, the address, the narrative — is now inside a string, and a string is opaque to every system downstream of it. The tracing backend does not know a date of birth is in there, the vendor's access controls cannot be aimed at it, and no policy expressed in terms of fields can be applied to it at all. That is the step which decides where redaction has to happen: something must parse the string back into structure before any per-field rule can touch it, which is why the code below parses, rewrites and re-serialises rather than matching patterns over text.

The narrative behaves differently from every other item in the record. claimant.name and claimant.address hold what the schema asked for; incident.description holds whatever the claimant chose to type, and here that is a medical condition. Nobody designed the form to collect health data, and the free-text field collected it anyway — the largest exposure in a record usually sits in the field with the loosest type, precisely because it is the one field whose contents nobody specified.

Three treatments, and the capability each costs

The productive way to run the conversation with legal is not to ask whether traces may contain customer data, which invites a no, but to go field by field and say what each one buys. Every field ends up in one of three treatments: kept verbatim, reduced to structural metadata — facts about the shape of the value rather than the value, such as its length, type, character class, presence or absence, and enumerated category — or dropped entirely.

FieldTreatmentWhat survives in the traceWhat debugging you lose
claim_id, policy_id, document_idKeepThe identifier itselfNothing; these are internal keys and are the join back to the source system
status, kind, currencyKeepLow-cardinality enum valuesNothing; the value set is public and fixed
deductible_cents, payout_centsKeepThe numberNothing directly, though amounts plus a timestamp narrow re-identification
claimant.name, .phone, .addressStructural{"type":"string","len":13,"present":true}You can see that a name was there and roughly how long; you cannot compare it to what the model wrote out
claimant.dobStructural{"type":"date","present":true}Age-band bugs become invisible; a rule that misfires for under-25s cannot be reproduced from traces
incident.descriptionStructural{"type":"string","len":147,"lang":"en","token_estimate":34}The largest loss. Prompt-injection investigation, "why did the model conclude whiplash", and any question about how wording drove behaviour are gone
ocr_document text outputStructural{"pages":2,"chars":4118,"confidence":0.71}You keep the OCR confidence, which is the field that actually explains most OCR-caused failures, and lose the text
Tool error messagesKeep, with a scrub pass"estimate service unavailable"Little, provided upstream errors are prevented from echoing request bodies back

Read that table as a set of gaps rather than as a ranking of sensitivity. Behind every row sit two quantities: what the raw value would let you debug, and what the treatment still lets you debug. The decision on each row is the difference between those two, and a field is easy to give up exactly when that difference is small — which has nothing to do with how sensitive the field is. Sensitivity decides which rows you are forced to argue about; the gap decides how much the argument is worth having. Where a gap is wide, as the incident.description row says it is, the honest move is to name the capability being given up and stop describing the reduced field as a substitute for it.

Working the table is the whole exercise, and the two useful discoveries usually come from the last two rows. Error strings are the leak nobody plans: a validation failure from a downstream API very often quotes the offending payload back at you, so a span whose payload policy is airtight can still contain a customer address inside error.message. And the OCR row is the one that reframes the conversation, because confidence: 0.71 is a structural fact that explains a large share of the failures the text would have explained, at no privacy cost at all. Most fields have a cheap structural proxy like that. Finding it is more productive than arguing for the raw value.

claimant.dob is the row that shows how a gap can hide. Reducing a date of birth to a note that a date was present looks cheap, since nothing about the person is read back. What it costs is any bug whose trigger is a function of the value, and the table's example is a rule that misfires for claimants below a certain age. The mechanism is the shape of every loss of this kind: the rule fires on an age, the age is computed from the date, and the trace records only that a date existed. The input that selected the wrong branch is gone, so no query over the traces can sort the runs into the ones that would have triggered the bug and the ones that would not.

The three Keep rows are not a concession granted before the argument starts, and a policy written by somebody anxious about the review will try to reduce those too. Identifiers are the join back to the source system: a claim identifier in a span is what lets an investigator open the claim itself, under the access controls that already govern it. Take them out and the reduced tier stops being a reduced record of something and becomes a set of shapes belonging to nothing. That is a requirement rather than a preference, and the one place on the table where the case for keeping a field does not rest on debugging value at all.

Why hashing passes the review and helps nobody

The proposal that arrives within about ten minutes of the first refusal is to hash the sensitive values: store sha256(incident.description) instead of the description, so the trace contains no readable customer data but you can still tell whether two spans saw the same value. It clears the review, because the reviewer's test is "can a person with dashboard access read a claimant's medical history", and the answer is no.

The belief underneath that proposal is worth naming, because it is the shape most privacy defences fail in and it is not a careless belief. The reviewer's test is a good test, and a hash genuinely passes it. What the test cannot see is that it returns the same answer for every field it is pointed at: no for a free-text narrative, no for a date of birth, and no whether the hashed value has ten-to-the-eighth possible forms or a few tens of thousands. The test result is the quantity that stays constant, and the property that actually decides whether a hash is opaque varies across those fields by several orders of magnitude without the test registering any of it.

It also destroys the field. Every diagnostic question that motivated storing the payload is a question about what the value was, not about whether two opaque values happened to match. Why did the model call estimate_repair with a negative amount — you need the text. Did the claimant's description contain something that looked like an instruction — you need the text. Does the failure cluster around claims that mention a second vehicle — you need the text. A hash answers exactly one question, "is this byte-identical to that", and free text is almost never byte-identical across runs, so in practice it answers nothing. You have paid the full storage and plumbing cost of the field to keep a column that is always distinct.

There is a second problem, and it is the reason to push back on hashing even when someone insists the equality check is enough. A hash is only opaque if the input has enough entropy to resist enumeration. A UK postcode has roughly 1.8 million possible values; a policy number in a known eight-digit format has 10⁸; a date of birth over a plausible eighty-year range has about 29,000. Any of those can be exhaustively hashed on a laptop in well under a minute, which means an unsalted hash of a low-entropy field is a reversible encoding of that field and is treated as personal data by most readings of the regulation you are trying to satisfy. Salting per tenant, which mixes a secret value into the input before it is hashed, fixes the reversibility and simultaneously removes cross-tenant comparability, which was the last thing the hash was doing for you. The honest summary is that hashing converts a useful, sensitive field into a useless, still-arguably-sensitive one.

The last of those counts is worth checking by hand, since a date of birth over an eighty-year range has as many possible values as that range has days. The attack is then three steps with no cleverness in any of them: take a candidate date, hash it with the same public function the trace used, and compare against the stored hash; repeat until something matches. A match is proof rather than evidence, because the function is unkeyed and deterministic, so nothing in it distinguishes the attacker's guess from the original input. Every step runs on the size of the candidate set alone, and the strength of the hash function never enters it.

The shape worth arguing for

What generally survives contact with a security team is two tiers with different retention and different access, because the objection is rarely to the data existing and is almost always to forty-one people being able to read it for ninety days.

Full-fidelity tierRedacted tier
ContentsEvery payload verbatimIdentifiers, enums, numbers, structural metadata
Retention72 hours, hard-deleted90 days
AccessBreak-glass, four named engineers, every read logged and reviewed weeklyStandard dashboard access
DestinationYour own object storage in the same account as the claims databaseThe tracing vendor
Daily volume~605 MB (6,000 runs × ~21 spans × ~4.8 KB)~76 MB (~0.6 KB per span)
Storage at retention~1.8 GB~6.8 GB

The 72-hour window is not arbitrary; it is chosen to cover the interval between a failure happening and someone deciding to investigate it, which for an on-call rotation is a weekend. Beyond that, the honest position is that you are keeping data for investigations that will never begin. The numbers in the last two rows are what makes this arguable rather than aspirational: full fidelity for three days is under two gigabytes, which is small enough that nobody has to weigh privacy against a storage bill.

Those two rows are worth working rather than accepting, because the whole argument turns on the answer being small enough to be uninteresting. Start from the rate the agent runs at: six thousand runs a day, each leaving about twenty-one spans, which comes to a hundred and twenty-six thousand spans a day. Multiply that count by the size of one verbatim span — a little under five kilobytes, most of it the serialised tool output shown above — and the day's full-fidelity traffic is the six-hundred-megabyte figure in the table. Multiply the same count by the size of a reduced span, a little over half a kilobyte, and the day's redacted traffic is the seventy-six megabytes beside it. Retention supplies the last factor in each column: three days of the first, ninety of the second.

Reading the two steady-state figures against each other gives the result that makes the compromise available at all. A verbatim span is around eight times the size of a reduced one, so on daily volume the full-fidelity tier is much the heavier; at steady state it is the lighter, by a wide margin, because the reduced tier is kept for thirty times as long. Retention multiplies harder than fidelity does, which is why the tier holding every payload verbatim is the cheaper tier to store — and why the objection to it was never about storage.

One row of that table is a requirement and the rest are decisions, and keeping the two apart is what makes the proposal negotiable in a room. That the claimant's medical narrative must not sit in a forty-one-seat store for ninety days is the requirement, and it is not yours to trade. The length of the verbatim window, the choice of destination, and naming four engineers rather than a group are decisions, each with a reason above it rather than a number copied off a template, and each movable without touching the requirement.

Implement the split at the span processor — the SDK hook that every finished span passes through on its way to storage — rather than at the call site. A policy applied where set_attribute is called is a policy that a new tool added next quarter will not have, and the failure mode is silent.

Walk that failure, because it is what makes the placement a decision rather than a detail. A call-site policy is a line of code sitting beside each set_attribute that writes a payload. Somebody adds a tool next quarter, copies the shape of an existing one, sets their attributes, and does not copy the redaction line, because nothing about writing an attribute requires one. The tool works. The tests pass, since no test asserts on what a span does not contain. The trace looks normal in the viewer, since a verbatim payload renders exactly like a payload that was always allowed to be verbatim. Nothing anywhere raises, and the first signal is a security review some months later. A span processor inverts every one of those steps: it sits on the single path all finished spans take, so a new tool is covered by default, and escaping the policy means editing the processor, which is a change somebody reviews.

python
from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan

KEEP = {"claim_id", "policy", "policy_id", "status", "kind", "currency",
        "deductible_cents", "payout_cents", "document_id", "confidence", "pages"}


def structural(value) -> dict:
    if isinstance(value, str):
        return {"type": "string", "len": len(value), "present": True}
    if isinstance(value, (int, float)):
        return {"type": "number", "present": True}
    if value is None:
        return {"present": False}
    return {"type": type(value).__name__, "present": True}


def redact(node):
    if isinstance(node, dict):
        return {k: (v if k in KEEP else redact(v)) for k, v in node.items()}
    if isinstance(node, list):
        return {"type": "array", "len": len(node), "items": [redact(i) for i in node[:1]]}
    return structural(node)


class RedactingProcessor(SpanProcessor):
    """Writes verbatim payloads to the short-retention sink, redacted ones onward."""

    def __init__(self, vault_exporter, downstream: SpanProcessor):
        self._vault = vault_exporter
        self._downstream = downstream

    def on_end(self, span: ReadableSpan) -> None:
        payload_keys = [k for k in span.attributes if k in ("tool.input", "tool.output",
                                                            "gen_ai.prompt", "gen_ai.completion")]
        if payload_keys:
            self._vault.export_verbatim(span)          # 72h, break-glass, own account
        scrubbed = dict(span.attributes)
        for key in payload_keys:
            scrubbed[key] = json.dumps(redact(json.loads(span.attributes[key])))
            scrubbed[f"{key}.redacted"] = True
        self._downstream.on_end(span.with_attributes(scrubbed))

The .redacted marker is worth the two lines. A trace that has been redacted and a trace whose tool genuinely returned nothing look identical without it, and six months later someone will spend an afternoon on that ambiguity.

Where a reduced trace still identifies somebody

Redaction is not access control, and the two are routinely confused in the same meeting. Redaction decides what exists in the store; access control decides who can read what exists. A redacted trace in a system with forty-one readers is still a claim identifier, a timestamp, an amount and a decision, and joining those four against the claims database re-identifies the claimant in one query for anyone who has both. If the concern is the reader population rather than the raw strings, redaction is the wrong lever and you need the access change regardless.

Which is to say that the reduced tier does not protect the claimant from a reader who holds both logins at once. It protects them from a reader who has the dashboard and nothing else, and naming which of those two populations you are defending against is the difference between a policy and a gesture.

That is the hashing mistake again, one level further out. The redaction level is a property of the store, and it stays exactly where you set it. Re-identifiability is a property of the reader, and it varies with whatever else that reader can already reach — a quantity your redaction policy has no visibility into and no ability to constrain. The same unchanged policy is strong for a contractor with one login and close to decorative for an engineer with two.

Nor is it anonymisation in the formal sense. Structural metadata leaks: a 147-character incident description with a token estimate of 34 and a document of kind gp_letter narrows the population meaningfully when combined with a timestamp and a postcode district you kept elsewhere. Calling the redacted tier "anonymous" in a policy document is a claim you cannot defend; calling it "reduced" is one you can.

The capability that dies first is faithful replay. Re-running a failed trajectory means feeding the agent the same model and tool responses it saw the first time, and under this policy those responses no longer exist after 72 hours. Replay becomes a thing you can do on a fresh incident and not on a month-old one, which is a real constraint and one worth stating out loud when someone proposes building it. The backwards causal walk degrades rather than dies: you can still establish that step 9's argument came from step 4's tool result, because identifiers and structure survive, but you cannot always see what about that result produced the bad value.

Those two losses are the residual on the two-tier split, and they have one cause between them: the split buys the argument by making the sensitive copy short-lived, so every capability that needs a payload inherits that clock. Replay is the extreme case, since a replay with nothing recorded is a fresh run wearing the word; the causal walk is the mild one, because identifiers and structure are precisely what it follows from step to step.

Both of those are chapters of their own, and the decision this one is about gets made either way. A team that never holds the conversation has decided that every payload goes to the tracing vendor for as long as the vendor keeps it, and has decided it by shipping a change called "add payloads to tool spans". Holding it adds only that the decision is written down, defensible row by row, and arguable later without deleting a month of traces first. Work the fields, take the structural proxy wherever the gap between it and the raw value is narrow, refuse the hash, and put the verbatim copy somewhere with a short clock and a narrow door.

The capability that survives the policy completely is cost, because token counts, model names and step counts are neither customer data nor derived from it — which is what makes per-step spend the first thing worth building on top of a trace, and the first thing that turns out to be wrong.

Agent observability — read the current version at https://daviddominguez.dev/learn/en/agent-observability/redaction-is-decided-before-the-first-payload-lands-in-a-trace

Back to top