The customer data you didn't mean to log
Response bodies reach 13 destinations and metadata reaches 11 — with nothing in between. An output allowlist takes it to zero; sensitive: true does not, and only gates the cache.
The problem
You integrate a vendor API that returns customer records — names, emails, addresses, health or legal detail. You add tracing, because you are responsible about observability. Six months later someone greps the log aggregator and finds all of it in plain text.
Nobody logs PII on purpose. Middleware does — the two most-cited causes in the field are a debug endpoint returning full user objects and a logging middleware that captures raw request bodies, and the second is exactly what a well-instrumented client library is.
Three things make it structural rather than careless. You cannot enumerate what is sensitive in
advance — a denylist of field names misses primaryContactMail, profile.contact.mail, and
an address inside a free-text note. The vendor adds a field and it starts flowing, which makes
this a drift problem, not a configuration one. And the regulation is about defaults: GDPR
Article 25 mandates protection by design and by default, so "we can turn redaction on" is not
the same thing.
The common solutions
| Approach | What it is | Where it breaks |
|---|---|---|
| Denylist of field names | Scrub email, ssn, card. | Misses renamed, nested and free-text fields. Silently incomplete the day the vendor adds one. |
| Allowlist of safe fields | Log only what you named. | Correct by construction; needs the whole response shape — the thing that drifts. |
| Don't log bodies at all | Metadata only. | Safe and often unusable: the body is what you need when debugging an integration. |
| Scrub at the aggregator | Filter on ingest. | The data already left your process and crossed a network. |
| Gateway/sidecar redaction | Strip in a proxy. | Another hop, and it cannot know which field is sensitive in your domain. |
What StitchAPI does
The exposure is binary, which makes it auditable
Measured with seven distinct sentinels — a name, an email, an SSN, a nested
profile.contact.mail, an email inside a prose note, one in contacts[1].email, and a renamed
primaryContactMail. Thirteen destinations carry all seven. Eleven carry zero. Nothing is
partially redacted.
| carries the whole body | carries none of it |
|---|---|
the result event · fileSink at any non-zero cap · .inspect().raw · .inspect().data · JSON.stringify(inspect()) · .report() · StitchError.body · JSON.stringify(err) · the cache entry | start / progress / done / error events · consoleSink · loggerSink · otlpSink · StitchError.message · String(err) · err.stack |
Exactly one event carries the response body — result, on data. "The event spine leaks" is
really "one event leaks", which is a much smaller thing to reason about.
An output allowlist genuinely filters — 9 lines
This is the answer, and it works because the engine serves the validated value —
measured in scenario 20, and since
#663 no longer an asymmetry: an input schema
now shapes the request the same way (scenario 20 caught it merely checking;
#648 filed the gap).
Either direction filters the slots you declare — a slot with no schema on it still flows
through untouched, which is why the nine lines below have to be written.
const SAFE = z.object({
id: z.string(),
status: z.string(),
total: z.number(),
});
const getCustomer = stitch({ url: '…', output: drift(SAFE) });A four-field schema took the JSONL sink, the result event, the console line, the cache entry
and the whole .inspect() wrapper from 7 sentinels to 0 — at depth (profile.contact gone)
and inside array elements (contacts[].email gone), without naming a single PII field
anywhere. That is the allowlist property: it survives the vendor adding a field.
drift() gives you the inventory without the data
Wrapped in drift(), the same allowlist emits a value-free record of everything it stripped —
seven undeclared findings whose paths name the fields and whose details are kinds only
(undeclared field (string)), with zero sentinels across every finding, every drift event and
every sink. That is exactly the log the detection, not the data shape the guidance recommends.
When the vendor adds taxId at three levels, you get exactly three new undeclared findings.
Declarative credentials never enter the spine
auth.apply runs on a request clone inside the attempt loop, while the start event was built
from the pre-auth request — so a bearer token or apiKey in query or cookie is 0 of 3 even
for a naive custom sink. Hand-rolled request credentials are scrubbed by every built-in sink:
"authorization":"[REDACTED]", a scrubbed url, and no headers at all on console, logger or
OTLP.
What StitchAPI does not solve
sensitive: truedoes not mean "do not log". It changed the leak at 1 of 11 destinations — the cache. Across all ofpackages/core/srcthere is exactly one read of the value,engine.ts:1051, insideensureCache. No sink, no trace module and no event builder mentions it. It means do not persist this to the cache, and while it is set the JSONL sink still writes the full body to disk. It also survives onto the public__config, so.report()prints"sensitive":truebeside the record it did not protect..inspect({ redact: true })removes 0 of 7. The shared denylist is the credential list (token,secret,password,apikey,signature, …) reused — and no PII field name is on it.redact: ['mail', 'email']does work, at depth and across array elements, but only for names you enumerate; a renamed key and an address in free text are unreachable by construction. There is no stitch-level or process-level default — ADR 0018'sdefaultInspectwas never implemented.JSON.stringify(err)leaks the whole record;console.error(err)does not.StitchError.bodyis an own enumerable property whilemessageis not. Soerr.stackandString(err)are clean, andlogger.error({ err })is the leak. The same shape repeats on success: ADR 0016's non-enumerability protects.inspect().rawand nothing else, because.inspect().dataholds the same record enumerably.- A validation error can carry the value into a log — when the validator's message echoes
it.
validationErrors(drift.ts:50-56) copies the validator's own message intodetailverbatim, so what escapes is decided by the message's wording, not by anything in this library. Stock Zod 4 (the workspace's validator since #589) no longer echoes the received value — its enum error readsInvalid option: expected one of "enterprise"|"free", and every sink measures clean. A message that does embed the input — a customrefine/checkmessage, or Zod 3's enum wording — is measured reaching the JSONL file,consoleSinkandloggerSink— the two sinks that are otherwise 0 of 7. Only OTLP stays clean, because it dropsdetail. - The
fileSinkbody cap is a size control, not a privacy control. On a 2.9 KB body all seven sentinels still persisted into thepreview; only an eighth planted past character 2048 was absent. It keeps a prefix, so reordering the vendor's JSON changes which fields leak. - Only
hooks.onResponsecovers the failure path. On a 200,interpret,transformand the hook are equivalent. On a 500 the engine throws carrying the untouched response, soStitchError.bodystays at 7/7 undertransformand under a strippinginterpret— the hook holds at 0/7 only because it mutatedctx.res.bodyin place. Its type is(ctx) => void, and mutating the body there is nowhere described as a privacy mechanism. - The boundary and the drift signal are mutually exclusive. Stripping in the hook takes
drift()to 0 findings, because drift diffs exactly the bytes the boundary removed. Having both means re-implementingdrift.ts's walker — 23 lines, and not exported. - Nothing here is upstream of the
Adapter, which read the bytes first. A spy inside the transport still sees all seven.
A credential that rides the payload is treated exactly like PII. An
access_token in a response body, or a client_secret in a request
body, is written to the JSONL log in full, because that sink's redactor is a
five-name header denylist rather than the deep secret-key scrubber. The
same file already ships that scrubber and applies it to a request body for
the serve SSE transport — the disk sink simply never calls it.
redactHeaders is the one config-reachable way to point the redactor at a
body key, and its type and JSDoc both say "header names", so nothing tells
you that works.
The assembled setup
42 executable lines across 2 seams — hooks.onResponse plus output: drift(SAFE) — reaches
0 of 9 destinations on both the success and the failure path, and still recovers a names-only
inventory of every undeclared field. Of those 42 lines, 23 re-implement the walker drift.ts
already contains.
The cheaper variants are honest about their edges: the allowlist alone is 9 lines and 1 of 10 on
a 200 (only .inspect().raw, by design) but leaves the 500 path untouched; the boundary alone is
7 lines and 0 of 10 on both paths, at the cost of the entire drift signal.
See also
- Trace sinks — the sinks measured above
- Drift — the inventory channel
- Scenario: the ID that changed on the way in — where
output's serve-the-parsed-value behaviour was measured - Scenario: the agent picks the arguments — the other scenario about data reaching somewhere it should not
The ID that changed on the way in
JSON.parse turns a 64-bit snowflake into a different number, silently. wire.response text plus transform recovers the exact digits in 16 lines.
The migration you have to run twice
Dual-running a vendor v1 and v2 when you own neither endpoint. One of five isolation channels is safe by default, and the combinator that looks built for this broadcasts one input to both.