fix(backend): stop one rejected prompt from filling the log - #422
Merged
Conversation
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>
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A tool result carrying Drizzle
Datevalues fails the SDK's prompt validation, and the resultingInvalidPromptErrorwraps aZodErrorwhoseinvalid_uniontree 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:
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.issuesnon-enumerably —Object.keys(zodError)returns['name', 'message']— so nothing enumeration-based ever saw it. What serialized wasZodError.message, a single pre-rendered JSON dump of the whole union search space. A serializer that walks the object graph looking forissuesarrays would have found nothing.The one actionable sentence was absent, not buried.
messageandstackare non-enumerable onError, anderroris not pino's configurederrorKey, 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
errkey would have made it worse, not better: pino's standard serializer walks the samecausechain and adds a stack per link, giving 1.28 MB.Approach
Errors are serialized by one registration covering both the
erroranderrkeys, so an entry reads the same whichever key a call site used — roughly 25 sites useerror, three useerr, 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:
expected "assistant"and sent the reader after the wrong thing.Equally-shallow alternatives merge into one line naming every accepted type, so a
Dateagainst a JSON-value union readsexpected null | string | number | boolean | record | arrayinstead of implyingnullwas 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
formatStreamErrorreturns 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:
NODE_ENV=production,errorkeyNODE_ENV=development(pino-pretty)errkeypnpm typecheckandpnpm lintclean; full suite green (1466 backend, 63 frontend).The four new guards were mutation-tested — dropping the
errregistration, 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
CLAUDE.md.formatStreamErrorstill renders raw issues, per the brief's byte-for-byte constraint. The better paths would help there too — worth a follow-up if wanted.Datevalues 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