fix: serialize an error that is the whole record, and stop redaction throwing out of the log call - #148
Conversation
`AbortSignal.timeout()` rejects with a `DOMException`, and logging one threw a
TypeError out of the `logger.error(...)` call itself, so the caller lost the log
line and everything it meant to do next.
`redactValues` deep-clones the record before it redacts it. es-toolkit clones an
`Error` with `structuredClone` and then re-assigns `message` and `name`, but
`structuredClone` rebuilds a `DOMException` as a `DOMException`, whose `message`
and `name` are getter-only prototype accessors. The assignment throws. It is
specific to `DOMException`: `URL`, `Headers`, `AbortSignal`, `Request`,
`Response`, `Error` and `AggregateError` all clone without complaint.
Substitute the plain object `serializeError` already builds before cloning, and
carry own symbols across so a `DOMException` given to the logger as the whole
record keeps winston's `LEVEL` and `SPLAT` routing symbols.
`serializeErrorFormat` now walks `SPLAT` as well. Winston keeps the raw metadata
argument there in addition to merging its properties onto `info`, so an `Error`
logged as `logger.error(msg, { error })` is reachable twice. Serializing only the
string keys left the live `Error` under `SPLAT` for every later format to trip
over, which is how the `DOMException` reached the clone. It also covers
`logger.error('failed: %j', { error })`, where winston builds `info` from `SPLAT`
alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI is red on a pre-existing audit failure, not on this change
Locally, on this branch: 78 tests pass (70 existing, 8 new), Worth noting that |
`npm run audit` reported 25 advisories across 8 packages, all of them dev-only (`npm audit --omit=dev` was already clean) and all reachable within the existing semver ranges. `npm audit fix` resolved seven: brace-expansion 5.0.5, 1.1.14 -> 5.0.9, 1.1.18 fast-uri 3.1.0 -> 3.1.6 js-yaml 4.1.1 -> 4.3.2 nanoid 3.3.11 -> 3.3.18 postcss 8.5.10 -> 8.5.26 shell-quote 1.8.3 -> 1.10.0 vite 8.0.8 -> 8.2.2 The eighth, esbuild, needed a direct bump: the advisory is fixed in 0.28.1, and `vite@8.2.2` already allows `^0.27.0 || ^0.28.0`, but `tsx@4.21.0` pins `~0.27.0`. `tsx@4.23.13` moves to `~0.28.0`, so esbuild resolves to 0.28.2. The `postcss` override floor moves from `^8.5.10` to `^8.5.23`, the first release outside the advisory range, so a resolution from scratch cannot land back below the fix. `.nsprc` stays empty — no advisory needed an exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI is green
The eighth, The CI now passes audit, lint, check-types and test (78 passed). |
There was a problem hiding this comment.
Pull request overview
Fixes logging failures when redaction encounters DOMException values.
Changes:
- Serializes errors stored under Winston’s
SPLATsymbol. - Safely clones and redacts
DOMExceptionvalues with regression tests. - Updates documentation, package version, and tooling dependencies.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/serialize-error-format.ts |
Adds SPLAT traversal. |
src/serialize-error-format.spec.ts |
Tests SPLAT serialization. |
src/redact-values.ts |
Adds safe DOMException cloning. |
src/redact-values.spec.ts |
Tests cloning, redaction, and symbols. |
src/index.spec.ts |
Adds logger-level regression tests. |
README.md |
Documents DOMException logging. |
package.json |
Bumps release and tooling versions. |
package-lock.json |
Refreshes resolved dependencies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Three findings from review, all confirmed against the code.
The `SPLAT` walk was destructive. The main walk rebuilds every object it visits
as a plain record, which is right for metadata bound for a transport but wrong
for `SPLAT`: `format.splat()` interpolates those values into the message, and a
`Date` rebuilt as a plain record holds no own enumerable keys, so
`logger.info('%j', date)` rendered `{}` in place of the ISO value. Replace
errors under `SPLAT` with a separate walk that recurses through arrays and plain
objects only, rebuilds a container only when it really holds an error, and
returns the same reference for anything untouched.
Redaction ignored a configured `errorSerializer`. The substitution for a
`DOMException` hard-coded `serializeError`, so with both `redactPaths` and an
`errorSerializer` a `DOMException` reached the transports in the default shape
while every other error used the consumer's. `redactValuesWith` now takes the
serializer as an optional second argument, `redactFormat` forwards it, and
`createLogger` passes the one it was given.
The README misstated the platform API: `AbortSignal.timeout()` returns a signal,
it does not reject. An operation cancelled through the signal rejects with the
signal's `reason`, which is the `DOMException`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving `replaceErrors` above `serializeErrorFormat` left the format's doc block attached to `isPlainObject`. Put it back on the export it describes. `redact-values.ts` still named `serializeError` as what it substitutes, which stopped being true once the configured serializer was threaded through, and `errorSerializer` in `CreateLoggerOptions` did not mention that `redactFormat` now uses it. Two comments repeated the wording review corrected in the README: `AbortSignal.timeout()` returns a signal, it does not reject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`logger.error('failed', error)` takes a different winston path from both shapes
already covered: with a message and a non-plain second argument, winston lifts
`message`, `stack` and `cause` onto a fresh info object and keeps the raw error
under `SPLAT` — which is where the clone used to find it and throw. It fails on
`main` with the same TypeError, and no log line is written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`logger.error(err)` takes a winston branch of its own: it assigns `level` and the
routing symbols onto the error and writes the error as the record. `message`,
`stack` and `name` are not own enumerable properties of an `Error`, so every
transport that spreads or enumerates the record loses them. It is not a corner
case — this library's own `CallbackTransport` hands `meta` to its callback
without a stack, and `{ ...info }` yields no message at all.
`serializeErrorFormat` now replaces such a record with its serialized form,
re-applying `level` and every own symbol afterwards: those are winston's
routing, not error data, and a custom serializer has no reason to return them.
`message` is left to the serializer, so one that drops it produces a record
without one, exactly as it already does for a nested error. Detection is
`instanceof Error`, so a plain info object that happens to carry `message` and
`stack` is untouched.
Minor rather than patch: a custom transport that received an `Error` instance
for this call shape now receives a plain object.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scope expanded to two fixesRetitled, and the description is rewritten around A) a record that is itself an A came out of the review thread on Version moves to Two earlier comments on this PR are superseded and left as they stand for the record: the first said the audit belonged in a separate PR (it is in 89 tests, CI green. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
README.md:309
logger.log()'s one-argument overload expects a complete log entry with its ownlevel; passing a bareErrordoes not select theinfolevel or exercise the whole-record path covered by this PR. Use a level helper such aslogger.error(error)so this example is executable and accurately demonstrates the changed behavior.
logger.log(new Error('cause')) // { ...info } is { level: 'info' } — no message, no stack
Three findings, all reproduced before fixing.
Both places that stamp winston's routing back onto a serialized error mutated
the serializer's return value. A serializer is free to return a frozen record,
or a cached one shared between calls, and stamping either throws out of the log
call — the very failure this PR exists to fix:
logger.error(new TypeError('boom'))
TypeError: Cannot add property level, object is not extensible
Copy first, in `serializeRecord` and in `plainDomException`.
The `SPLAT` walk returned the source container on a back-edge, so a rebuilt
cyclic branch linked back to the unprocessed original and a live error stayed
reachable through the cycle. Replace the `WeakSet` with a `Map` from source to
replacement, which resolves a back-edge to the replacement and, as a bonus,
keeps a container reached twice without a cycle shared.
The README example used `logger.log(err)`, whose one-argument overload wants a
complete entry carrying its own `level`. A bare error there is dropped without
being logged at all, so it demonstrated nothing. `logger.error(err)` is the
shape the section is about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second review round addressed in
|
Redaction wrote into the values a serializer returned. `plainDomException`
spread the serialized record shallowly, so its nested values stayed shared with
whatever the serializer handed back — a cached record, or a reference to the
error's own properties. Redaction then wrote through them:
serializer returns a cached { context: { authorization: 'secret' } }
→ cached.context.authorization becomes '<redacted>'
The substitution is now built by deep-cloning the serialized record through the
same customizer, with each `DOMException` mapped to the object standing in for
it, so a serializer that puts the error back into its own output resolves to
that substitute instead of recursing forever.
The `SPLAT` walk rebuilt a cyclic argument even when it held no error: a
back-edge always resolves to something that is not the source, which counts as a
change. That cost an unrelated argument its identity and every symbol and
non-enumerable property on it. Gate the walk per argument on whether an error is
reachable at all.
Found while testing the above, and fixed here because this PR claims redaction
never throws on the record it is given: the redaction walk had no cycle guard,
so any cyclic record overflowed the stack — on `main` too, with no
`DOMException` involved. `logger.error('x', cyclic)` with `redactPaths` was a
RangeError out of the log call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third review round addressed in
|
Checking the claim that reverting any one piece of these fixes breaks a test showed it was not true of `redact-format.ts` or `index.ts`. Both carry the `errorSerializer` through to redaction, and once a record that is itself an error is serialized, `serializeErrorFormat` leaves nothing for redaction to substitute in the `createLogger` pipeline at all. `redactFormat` is a public export, though, and composed on its own it is the only thing between a `DOMException` and a deep clone that cannot rebuild one. That path had no test; it has one now, along with the rest of the format's behaviour. The pass-through in `createLogger` stays, with a comment saying it is for consistency rather than effect. It matters for a caller composing `redactFormat` themselves, and it keeps the shapes aligned if a future path ever does let a live error reach redaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilding a container to replace an error inside it dropped everything `Object.keys` does not report. Measured before the fix: object: symbol=undefined nonEnumerable=undefined nullProto: proto=Object.prototype array: extra=undefined symbol=undefined The doc comment claims the walk substitutes errors and changes nothing else, so that was an overclaim: a custom format reading `SPLAT` saw more than the error substitution. A rebuilt object is now created on the source's own prototype, and both branches copy across every own key the walk did not visit, descriptor intact — symbols, non-enumerable properties, and an array's non-index properties. `length` is skipped, since an array manages its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth review round addressed in
|
Three error-handling defects. A is silent data loss; B and C are the same symptom — redaction throwing out of the
logger.error(...)call itself, so the caller loses the log line and everything it meant to do next. Acatchthat logs and then records the failure never reaches the recording.Each was found by working on the one before it, which is why they are together.
A. A record that is itself an
Erroris never serializedlogger.error(err)takes a winston branch of its own — it assignsleveland the routing symbols onto the error and writes the error itself as the record:message,stackandnameare not own enumerable properties of anError, so every transport that spreads or enumerates the record loses them. Measured:{ ...info }forlogger.error(err)code, levelcode, level, message, name, stack{ ...info }for aDOMExceptionlevelcode, level, message, name, stackCallbackTransportmetacodecode, name, stackNot a corner case:
CallbackTransporthandsmetato its callback with no stack, and a spreading transport gets no message at all. Console output was never affected —format.json's replacer catches the record on its way out — which is why it went unnoticed.Fix.
serializeErrorFormatreplaces such a record with its serialized form, re-applyingleveland every own symbol afterwards: those are winston's routing, not error data, and a custom serializer has no reason to return them.messageis left to the serializer, so one that drops it produces a record without one, exactly as it already does for a nested error. Detection isinstanceof Error, so a plain info object that happens to carrymessageandstackis untouched.Only the second-argument shape was already fine — winston explicitly lifts
message,stackandcauseforlogger.error('failed', err).B. Logging a
DOMExceptionthrows out of the log callAn
AbortSignalcarries aDOMExceptionas itsreason, so aDOMExceptionis what acatchblock receives whenever a fetch, a stream or a job is abandoned on a deadline.Only
redactPathstriggers it. With every othercreateLoggeroption the same call logs fine:Why
logger.error(msg, { error })— winston stores the raw metadata twice: merged ontoinfo, and under theSPLATsymbol.serializeErrorFormatwalksObject.keys(record)only, deliberately, to protect winston's routing symbols. Soinfo.errorbecomes a plain object butinfo[SPLAT][0].errorstays a liveDOMException.redactValuescallscloneDeep(info), and es-toolkit's clone does copy symbols. It reaches theDOMExceptionand takes itsinstanceof Errorbranch —structuredClone(value), thenresult.message = value.message.structuredClonerebuilds aDOMExceptionas aDOMException, whosemessageandnameare getter-only prototype accessors. The assignment throws.It is specific to
DOMException.URL,Headers,AbortSignal,Request,Response,ErrorandAggregateErrorall clone without complaint —structuredClonepreserving theDOMExceptiontype is what makes it the exception.Fix
redact-values.ts— clone with a customizer that substitutes the object the configured serializer builds, deep-cloned so redaction cannot write back into a serializer's cached record or into the error's own properties, and with eachDOMExceptionmapped to its substitute first so a serializer returning the error it was handed resolves rather than recursing forever.redactValuesWithtakes the serializer as an optional second argument (defaulting toserializeError),redactFormatforwards it, andcreateLoggerpasses theerrorSerializerit was given, so aDOMExceptionreaches the transports in the same shape as every other error.serialize-error-format.ts— replace errors underSPLATtoo, so no liveErrorsurvives anywhere in the record for a later format or transport to trip over. This also coverslogger.error('failed: %j', { error }), where winston buildsinfofromSPLATalone.SPLATgets its own walk rather than sharing the metadata one. The metadata walk rebuilds every object it visits as a plain record, which is right for something bound for a transport but wrong forSPLAT, whichformat.splat()interpolates into the message: aDaterebuilt as a plain record has no own enumerable keys, so%jwould render{}in place of its value. TheSPLATwalk therefore recurses through arrays and plain objects only, is gated per argument on whether an error is reachable at all, and returns the same reference for anything untouched. A container it must rebuild to replace an error inside it keeps its prototype and every own key the rebuild did not replace, descriptor intact — symbols, non-enumerable properties, an array's non-index properties.All three call shapes are covered and each threw on
main:logger.error(msg, { error }),logger.error('failed', error), andlogger.error(error).C. A cyclic record overflows the stack during redaction
Found while testing B, and pre-existing on
mainwith noDOMExceptioninvolved anywhere:redactValuesdeep-clones its input, andcloneDeepfaithfully reproduces the cycle — but the walk that applies the redaction paths to the clone has no cycle guard, so it never returns.logger.error('x', cyclic)withredactPathsconfigured has always been aRangeErrorout of the log call.Fix. A
WeakSetin the redaction walk. A node is visited once whether it was reached through a cycle or shared by two branches; redaction writes the same value either way, so visiting it again was never doing anything.This is independent of A and B — it needs no
DOMExceptionand no whole-record error — so it is the one piece that could be split out if you would rather land it separately.Included here rather than deferred because it is the same defect as B — redaction throwing out of the log call — and because leaving it would make this PR's own claim that redaction never throws on the record it is given untrue.
Verification
eslint --max-warnings 0andtscclean, fullnpm run buildgreen includingattwand the CJS smoke test.mainone at a time, to check the tests actually pin the behaviour rather than merely passing alongside it:src/redact-values.tssrc/serialize-error-format.tssrc/redact-format.tssrc/index.tsThe last one is honest rather than covered.
index.tsonly passeserrorSerializerthrough toredactFormat, and once A lands,serializeErrorFormatleaves no live error for redaction to substitute anywhere in thecreateLoggerpipeline — so the line has no observable effect through the logger. It stays, commented as such, because it matters for a caller composingredactFormatthemselves, and it keeps the shapes aligned if a future path ever does let one through. That standalone path is what the newredact-format.spec.tscovers.dist, with a realAbortSignal.timeout()rejection and a downstream consumer's deployed logger config:The record is written, redaction still applies, and execution continues past the log call.
Commits
965dbddDOMExceptionfixf1e8116.nsprcstays empty. Kept separate so the 700-line lockfile diff does not bury the review0df8681SPLATwalk made non-destructive,errorSerializerthreaded into redaction, README wording onAbortSignalcorrecteddfaea570ebee6bDOMExceptionpassed as the second argument0de240684ee15eSPLATmemoization, README example corrected6675a20SPLATwalk per argument, and C4cfacccredactFormatcomposed on its own, after checking which reverts actually fail34b7a4cSPLATargument keeps its prototype and every own key the rebuild did not replaceNotes
2.1.0, not a patch. A record that is itself anErrornow reaches transports as a plain object rather than anErrorinstance. A custom transport that testedinfo instanceof Errorshould read the serializedname/message/stackinstead.redactValuesWithandredactFormatalso gain optional additive arguments. Both are called out in the README under Error serialization.DOMExceptionsubsection, an accurate account of the three ways anErrorreaches a log call, and an upgrade note.DOMException→Errorconversion before logging can drop it after upgrading.🤖 Generated with Claude Code