Session 9. Trace and evaluate an agent — Thu 24 Sep

Redaction, and the missing span

A trace is the longest-lived copy of your data

Nobody logs an API key on purpose. It arrives as a passenger: somebody logs the request that was sent, and the request had an Authorization header on it.

{
  "kind": "llm_call",
  "detail": "POST /v1/messages key=sk-a1b2c3d4e5f60718293a status=200",
  "headers": "authorization: Bearer eyJhbGciOiJIUzI1NiJ9.ZmFrZS1wYXlsb2Fk",
  "actor": "run opened by analyst@dev3pack.example.com at 09:12",
  "question": "How does chunking work in retrieval-augmented generation?",
  "attempt": 1,
}

Three things in there must not be kept: a key, a token, an email address. The rest is the reason the event exists. The whole difficulty of redaction is that both sentences are true at once.

And the log is the worst place for a secret to land. It is replicated, shipped to a search index, retained for a year by a policy nobody remembers writing, and readable by everyone on call. A key in a request lives for one call. A key in a log lives until the retention policy expires.

Four rules, and each one is a way to get it wrong

Rule The failure it prevents
Replace, never delete a field that vanishes reads as a field that never existed
Everything else byte-identical a blanket scrub keeps the trace and throws away what it was for
Same secret, same placeholder two keys read as one, or one key reads as two
Never raise detail can be None; a redactor that crashes takes the trace with it

Rule one is the one people argue about. Deleting the field feels safer — nothing left to leak. But then the reader of an incident cannot tell "we removed a credential here" from "no credential was involved", and those two facts lead to completely different next steps. The placeholder is the record that something was removed:

detail: POST /v1/messages key=[redacted:api_key:4e3ecbe6] status=200

Rule three is the one that gets skipped, and it costs the most. Replace every secret with the string [redacted] and this event:

attempt 1 key=[redacted] -> 401
attempt 2 key=[redacted] -> 401
attempt 3 key=[redacted] -> 200

now hides the only fact that mattered: attempt 3 used a different key. Derive the placeholder from the secret and the log says so without ever printing it:

def placeholder(label: str, secret: str) -> str:
    return f"[redacted:{label}:{hashlib.sha256(secret.encode()).hexdigest()[:8]}]"

Same input, same eight characters. Different input, different eight characters. The reader can count distinct secrets and can never read one.

Declared patterns, not guesses

PATTERNS = {
    "api_key": re.compile(r"sk-[A-Za-z0-9]{16,}"),
    "bearer_token": re.compile(r"(?<=Bearer )[A-Za-z0-9._-]{12,}"),
    "email": re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
}

Note what the bearer pattern does not match. The word Bearer is context, so it stays; only the token after it goes. A redactor that eats the word too leaves a line nobody can interpret.

Then the loop, and it is short:

for label, pattern in PATTERNS.items():
    for secret in sorted(set(pattern.findall(value))):
        value = value.replace(secret, placeholder(label, secret))

Be honest about the limit. This removes what you named and nothing else. A key in a format you did not declare goes straight through, and so does a name, an address, or an account number sitting in a free-text field. Redaction at the sink is the last line, not the first: the first is not putting the secret in the event at all. Sessions 2 and 4 already do that — credentials are injected at the transport edge, and the model never sees them, so they cannot appear in anything it produced.

The missing span

Now the other failure, and it is quieter. Take the same run and lose one event:

[retrieve ] top_k=3 -> [('rag-basics', 0), ('rag-basics', 1), ('rag-basics', 2)]
[decision ] answered with citations ['rag-basics']

Nothing raised. Nothing looks broken. There is no error line, no gap marker, no .... And the trace now says something false: that an answer appeared without a model call — which, in this codebase, is the shape of a refusal or of a cached result. The bucket you would reach for is wrong, and you would reach for it confidently.

A trace with a gap is worse than no trace, because no trace makes you go and look, and a gap makes you stop looking.

How a span goes missing What it looks like afterwards
an exception between the call and the append a step that never happened
a buffered writer that loses its tail a run that ends early
a filter that matches too much a whole kind that appears extinct
sampling: 1 run in 10 kept a rare failure that "never reproduces"

Two defences, and both are cheap. Record the event before you use its result, so an exception cannot delete the evidence of the step that caused it. And write the count you expect: a run of answer_question produces at least one retrieve and exactly one decision, so a log line that violates that is a broken logger, not a broken agent. An invariant on the trace is the only thing that can tell you the trace is lying.

Recap

Lesson One line
The event kind and detail, named after your step and not your vendor
The eval fixed cases, a code pass condition, one command anybody can rerun
Error analysis five buckets; the trace picks the bucket, the bucket aims the fix
Redaction replace, keep everything else, one placeholder per secret, never raise
The missing span a gap does not read as a gap — hold the trace to a shape

What the two checks judge

Check Judges Fails when
ch09-e1 the bucket you named for the baseline failure the answer is unwritten, or blames retrieval when the trace shows retrieval worked
ch09-e2 your redact, on four events it builds itself a secret survives, a field is deleted, an innocent value is changed, two secrets collapse into one placeholder, or a non-string value makes it raise

ch09-e2 supplies an event with no secrets in it and three values that look like some — the words "API key" in a question, an @ that is not an address, a hyphenated version string. A redactor that blanks them fails, for the same reason a parser that accepts anything is not a parser.