Release candidate — 1.0.0-rc.7
StitchAPI

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

ApproachWhat it isWhere it breaks
Denylist of field namesScrub email, ssn, card.Misses renamed, nested and free-text fields. Silently incomplete the day the vendor adds one.
Allowlist of safe fieldsLog only what you named.Correct by construction; needs the whole response shape — the thing that drifts.
Don't log bodies at allMetadata only.Safe and often unusable: the body is what you need when debugging an integration.
Scrub at the aggregatorFilter on ingest.The data already left your process and crossed a network.
Gateway/sidecar redactionStrip 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 bodycarries 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 entrystart / 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

  1. sensitive: true does not mean "do not log". It changed the leak at 1 of 11 destinations — the cache. Across all of packages/core/src there is exactly one read of the value, engine.ts:1051, inside ensureCache. 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":true beside the record it did not protect.
  2. .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's defaultInspect was never implemented.
  3. JSON.stringify(err) leaks the whole record; console.error(err) does not. StitchError.body is an own enumerable property while message is not. So err.stack and String(err) are clean, and logger.error({ err }) is the leak. The same shape repeats on success: ADR 0016's non-enumerability protects .inspect().raw and nothing else, because .inspect().data holds the same record enumerably.
  4. 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 into detail verbatim, 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 reads Invalid option: expected one of "enterprise"|"free", and every sink measures clean. A message that does embed the input — a custom refine/check message, or Zod 3's enum wording — is measured reaching the JSONL file, consoleSink and loggerSink — the two sinks that are otherwise 0 of 7. Only OTLP stays clean, because it drops detail.
  5. The fileSink body cap is a size control, not a privacy control. On a 2.9 KB body all seven sentinels still persisted into the preview; only an eighth planted past character 2048 was absent. It keeps a prefix, so reordering the vendor's JSON changes which fields leak.
  6. Only hooks.onResponse covers the failure path. On a 200, interpret, transform and the hook are equivalent. On a 500 the engine throws carrying the untouched response, so StitchError.body stays at 7/7 under transform and under a stripping interpret — the hook holds at 0/7 only because it mutated ctx.res.body in place. Its type is (ctx) => void, and mutating the body there is nowhere described as a privacy mechanism.
  7. 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-implementing drift.ts's walker — 23 lines, and not exported.
  8. 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 seamshooks.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

On this page