Skip to content

fix(backend): stop one rejected prompt from filling the log - #422

Merged
willdady merged 1 commit into
mainfrom
fix/414-zod-error-log-bloat
Aug 6, 2026
Merged

fix(backend): stop one rejected prompt from filling the log#422
willdady merged 1 commit into
mainfrom
fix/414-zod-error-log-bloat

Conversation

@willdady

@willdady willdady commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

A tool result carrying Drizzle Date values fails the SDK's prompt validation, and the resulting InvalidPromptError wraps a ZodError whose invalid_union tree nests one level per union member per field per array element. Logged, that was 320 KB on a single line for one failed generation.

The same failure now logs at 1.4 KB, naming the offending field:

[0].content[0].output.value.columns[0].cards[0].createdAt:
  Invalid input: expected null | string | number | boolean | record | array, received Date  (+21 more)

What was actually wrong

Two problems, and each one ruled out an obvious-looking fix.

The bloat was a string, not an object tree. Zod defines ZodError.issues non-enumerably — Object.keys(zodError) returns ['name', 'message'] — so nothing enumeration-based ever saw it. What serialized was ZodError.message, a single pre-rendered JSON dump of the whole union search space. A serializer that walks the object graph looking for issues arrays would have found nothing.

The one actionable sentence was absent, not buried. message and stack are non-enumerable on Error, and error is not pino's configured errorKey, so the serialized object was only {name, cause}. Invalid prompt: The messages do not match the ModelMessage[] schema. never reached the log at all. This was an information-loss bug wearing a bloat bug's clothes.

Switching the call sites to the err key would have made it worse, not better: pino's standard serializer walks the same cause chain and adds a stack per link, giving 1.28 MB.

Approach

Errors are serialized by one registration covering both the error and err keys, so an entry reads the same whichever key a call site used — roughly 25 sites use error, three use err, and any of them can hit this.

Each link in the chain keeps its type, message and properties under a cap. A Zod failure anywhere in the chain is reduced to leaf issues carrying the absolute path of the offending field, which exists nowhere in the tree until the union descent concatenates it — the top-level issue is a bare {code: "invalid_union", path: [0]}.

Two heuristics keep the summary honest rather than merely short:

  • Deepest branch wins. A union reports why every branch failed, which is the validator's search space, not the mistake. The branch that reached furthest into the value is the one the value was trying to be.
  • Fewest objections breaks a tie. A tool message also satisfies the shape of an assistant message carrying a tool result, so both branches descend to the same field. Without this the entry led with expected "assistant" and sent the reader after the wrong thing.

Equally-shallow alternatives merge into one line naming every accepted type, so a Date against a JSON-value union reads expected null | string | number | boolean | record | array instead of implying null was the only option.

Every cap says when it trimmed something — issue count, message length, property size, cause depth, and union nesting.

The issue-formatting helpers move out of the stream-error module so the logger can share them without an import cycle. What formatStreamError returns to the user and the model is unchanged.

Verification

Measured end to end through the real logger config, using the SDK's own validation path:

Before After
NODE_ENV=production, error key 320,676 bytes ~1.5 KB
NODE_ENV=development (pino-pretty) 111 lines / 323 KB ~1.5 KB
err key 1,281,815 bytes ~1.5 KB, identical payload

pnpm typecheck and pnpm lint clean; full suite green (1466 backend, 63 frontend).

The four new guards were mutation-tested — dropping the err registration, suppressing the message on the presence rather than the summary of issues, cutting the cause chain silently, and returning nothing from an empty union selection each fail a test.

Notes for review

  • No new environment variable. The caps are module constants; no configuration docs change, and the diff intersects no row of the docs table in CLAUDE.md.
  • The union collapsing deliberately does not reach the user-facing text. formatStreamError still renders raw issues, per the brief's byte-for-byte constraint. The better paths would help there too — worth a follow-up if wanted.
  • Date values reaching tool results at all is the underlying bug this one made hard to diagnose. Not addressed here; it needs its own issue.

Closes #414

🤖 Generated with Claude Code

A tool result carrying Drizzle `Date` values fails the SDK's prompt
validation, and the resulting `InvalidPromptError` wraps a `ZodError`
whose `invalid_union` tree nests one level per union member per field
per array element. Logged, that was 320 KB on a single line for one
failed generation — a disk-fill risk on a busy instance, and hundreds
of screens to scroll past for an Operator tailing the logs.

Serializing the error under the `err` key instead would have been
worse, not better: pino's standard serializer walks the same `cause`
chain and adds a stack per link, giving 1.28 MB.

Two things were wrong at once. The bloat is a pre-rendered string, not
an object tree — zod defines `issues` non-enumerably, so what actually
serialized was `ZodError.message`. And `message`/`stack` are
non-enumerable on `Error`, so under a key that is not pino's
`errorKey` the one actionable sentence never reached the log at all.

Errors are now serialized by a registration covering both the `error`
and `err` keys, so an entry reads the same whichever a call site used.
Each link keeps its type, message and properties under a cap; a Zod
failure anywhere in the chain is reduced to leaf issues carrying the
absolute path of the offending field, which exists nowhere in the tree
until the union descent concatenates it. The same failure now logs at
1.4 KB, naming the field. Every cap says when it trimmed something.

The issue-formatting helpers move out of the stream-error module so
the logger can share them without a cycle. What `formatStreamError`
returns to the user and the model is unchanged.

Closes #414

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested ZodError serialization turns one failed turn into thousands of log lines

1 participant