@bymax-one/nest-logger 1.2.6 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,615 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.9] - 2026-08-16
15
+
16
+ > **`1.2.8` was merged but never published.** The defect below was found in it after the merge and
17
+ > before any tag existed, so the version was left untagged rather than released and superseded. npm
18
+ > therefore goes `1.2.7` → `1.2.9`; the `1.2.8` section stays as the record of what landed, because
19
+ > it is what `main` contains.
20
+
21
+ ### Fixed
22
+
23
+ - **A destination whose `minLevel` was not one of the six levels received NOTHING, silently.**
24
+ `readonly minLevel?: LogLevel` is a compile-time claim, and a JavaScript consumer — or a
25
+ miscast one — can return any string; `'verbose'` is a real NestJS level name and a natural
26
+ thing to write. `pino.multistream` does not reject an unrecognised level: it builds without
27
+ complaint and the entry then matches nothing. Measured on a sink configured that way: **0 of 3
28
+ emitted entries delivered**, while the registry recorded it as covering the module level,
29
+ because `LOG_LEVEL_PRIORITY.indexOf` returned `-1` and lost the comparison. A destination
30
+ believed healthy and receiving, receiving nothing, is the worst failure this library has.
31
+ `safeMinLevel` now validates the value against the canonical list and treats an unrecognised
32
+ one as absent, so the destination falls back to the module level and keeps receiving.
33
+ `validateOptions` already held `options.level` to that same list; this is the check reaching
34
+ the one place it had not.
35
+
36
+ - **A write returning a thenable that is not `instanceof Promise` no longer takes the synchronous
37
+ path, where its rejection escaped and its entry could be discarded.** `instanceof` is realm-local:
38
+ it answers `false` for a promise built in another realm — a worker, a `vm` context — and for any
39
+ structurally valid thenable. Measured, both:
40
+
41
+ ```
42
+ thenable instanceof Promise? false
43
+ promise from another realm instanceof Promise? false
44
+ Promise.resolve() assimilates both? true
45
+ ```
46
+
47
+ Such a write took the branch meant for synchronous sinks: the stream callback fired immediately, a
48
+ later rejection surfaced as an **unhandled rejection** instead of a reported
49
+ `LOGGER_DESTINATION_WRITE_FAILED`, and the write was never counted as in flight — so `1.2.8`'s
50
+ readiness check could tell a buffering destination that delivery was proven and let it discard its
51
+ only copy of an entry that was about to fail. Losing an entry is the one outcome this path does not
52
+ accept.
53
+
54
+ The branch is now on `undefined`, which is what the declared `void | PromiseLike<void>` actually
55
+ distinguishes, and the result goes through `Promise.resolve` — which assimilates both shapes.
56
+ Synchronous writes keep the synchronous path, so back-pressure is unchanged.
57
+
58
+ This library learned the realm-local lesson once already, from `instanceof Error`, which is why
59
+ `isErrorLike` exists. The same mistake sat one file away.
60
+
61
+ ### Breaking
62
+
63
+ - **`ILogDestination` declares `void | PromiseLike<void>` instead of `void | Promise<void>`,** on
64
+ `write`, `onInit`, `onRegistryReady` and `onShutdown`. The fix above handles a thenable at runtime;
65
+ the public type still forbade one, so a TypeScript consumer could not return the very shape the
66
+ release claims to support — the tests needed casts precisely because of that gap, and the casts are
67
+ gone.
68
+
69
+ **This is source-breaking for CALLERS, and an earlier draft of this note wrongly said it was not.**
70
+ Widening what an implementation may return necessarily narrows what a caller receives: `PromiseLike`
71
+ exposes only `then`, so code that called `.catch()` or `.finally()` on the result no longer type-checks.
72
+
73
+ The call has to be narrowed first, in both versions: `write` has always been able to return
74
+ `void`, so `.catch()` was never available on the bare call.
75
+
76
+ ```ts
77
+ const result = destination.write(payload)
78
+
79
+ // before — `result` narrowed to `Promise<void>`, so `.catch` was there
80
+ if (result !== undefined) result.catch(handle)
81
+
82
+ // after — it narrows to `PromiseLike<void>`; assimilate to get `.catch` back
83
+ if (result !== undefined) Promise.resolve(result).catch(handle)
84
+ ```
85
+
86
+ There is no type that gives implementations the freedom and callers a full `Promise`; this release
87
+ chooses the implementations, because a destination that cannot be written in the first place is the
88
+ worse failure.
89
+
90
+ ### Fixed
91
+
92
+ - **A throwing `minLevel` getter could abort application bootstrap.** `effectiveLevelOf` reads the
93
+ consumer-defined `minLevel`, and `readonly minLevel?: LogLevel` does not stop it being a getter.
94
+ It is read on BOTH branches of the init loop — including inside the catch that exists so a failing
95
+ destination cannot stop the application from starting — so a throw there took the app down at
96
+ start-up and stranded every destination after it. The read is guarded inside `effectiveLevelOf`,
97
+ which covers every call site at once, and falls back to the module level: what a destination
98
+ without a `minLevel` gets anyway. Same defect class as the `name` getter, in the sibling property.
99
+
100
+ The registry was not even the FIRST reader. `pino-factory` builds the multistream entry from
101
+ `destination.minLevel` at provider-construction time, outside any guard and before any lifecycle
102
+ hook — so a hostile getter failed the factory and the application never started, earlier than any
103
+ fail-soft path could contain it. Found by sweeping every consumer-defined property read in
104
+ `src/server` rather than by fixing the site that was reported: eight of the nine were already
105
+ inside a `try`, and that ninth one made the registry fix unreachable in practice. Both sites share
106
+ one `safeMinLevel` now, and it PINS the first answer per destination.
107
+
108
+ Guarding the read stops the throw but not the DISAGREEMENT. Nothing makes the getter pure, and two
109
+ independent consumers read it: the factory, which fixes the multistream entry's level, and the
110
+ registry, which records the same destination's health level. A stateful getter answering `info` to
111
+ one and `error` to the other would let an `error` sink be credited with covering held `info`
112
+ entries, and a buffering destination would discard its only copy of them — a loss path, not an
113
+ inconsistency. Pinning makes the two agree by construction; `readonly minLevel?: LogLevel` says it
114
+ should not change anyway.
115
+
116
+ - **The shutdown reporter could be made to forge a second stderr record.** Its guard used
117
+ `escapeControlCharacters`, which preserves newlines ON PURPOSE because a stack trace is
118
+ legitimately multi-line — but it was applied to `cause.message` when no stack existed, and to any
119
+ non-Error thrown value. A destination rejecting with `'failed\n[forged entry]'` therefore wrote a
120
+ second raw line that an operator reads as a genuine record. The two escapers are not
121
+ interchangeable: a stack goes through `escapeControlCharacters`, a message or a non-Error value
122
+ through `toSingleLineMessage`. The regression asserts the emitted text is ONE line, not merely that
123
+ an escape appears in it.
124
+
125
+ - **A failing `onShutdown` could abort the teardown of every destination behind it.** The registry
126
+ built its stderr report by coercing the thrown value ABOVE the guard: reading `stack`/`message` on
127
+ an `Error` with hostile getters, or `String(cause)` on a value with a throwing `Symbol.toPrimitive`,
128
+ throws from inside the very `catch` that exists to keep one bad sink from mattering. The throw then
129
+ propagated out of `onApplicationShutdown`, and every destination still queued lost its flush —
130
+ turning one destination's bad error object into lost entries everywhere.
131
+
132
+ The coercion is now inside its own guard, falls back to `UnknownError` when the value cannot be
133
+ read at all, and the text is escaped like every other terminal-bound path. The regression test
134
+ asserts that the SECOND destination still shuts down, not merely that nothing threw: continuing the
135
+ teardown is the property that matters. Reverting the guard makes it fail — verified, not assumed.
136
+
137
+ **The same gap existed at every other reporting call site, and the first fix only closed one of
138
+ them.** `reportWriteFailure(destination.name, …)` in the write adapter and `reportInitFailure`
139
+ in the registry both read the name at the call site — inside the catch — so a hostile getter turned
140
+ a contained write failure into an unhandled rejection, and a failed `onInit` into an aborted
141
+ bootstrap, contradicting the comment one line above it that the library never throws to abort boot.
142
+ A shared `safeDestinationName` now performs that read under a guard, and every reporter uses it.
143
+
144
+ The destination's own `name` is read inside a guard as well, and a separate one:
145
+ `readonly name: string` does not stop a consumer implementing it as a getter, and reading it at the
146
+ call site would have left it inside the `catch` with the same cost. That second gap was found by
147
+ reviewing the fix for the first — the correction had reproduced the defect one layer up.
148
+
149
+ The control-character escaping on this path is now asserted rather than assumed. Every shutdown
150
+ test used plain ASCII, so replacing the escaper with an identity function would have left them all
151
+ green — a 100% mutation score does not cover a transformation no test observes. The new case emits
152
+ a stack containing an ANSI escape and a C1 character, and asserts that those are neutralized while
153
+ the stack's own newlines survive.
154
+
155
+ Found by a review comment on the DOCUMENTATION examples. The examples mirrored the implementation
156
+ faithfully, which is exactly why the defect was worth chasing back into `src/`: a guarded writer
157
+ never guarded the arguments handed to it, and that had been true in three places.
158
+
159
+ ### Documentation
160
+
161
+ - **Snippets that referenced symbols which were not there are now caught by a parser, not by
162
+ eye.** Five review rounds each found another documented example using `safeMinLevel`,
163
+ `LOGGER_OPTIONS_TOKEN`, `PREVIEW_LENGTH`, `this.reportShutdownFailure` or an outright invented
164
+ `RESERVED_LOG_KEYS.LOGGER_DESTINATION_SHUTDOWN_FAILED` with nothing to resolve them. `pnpm
165
+ check:docs` parses every TypeScript block — both `ts` and `typescript` fences — and fails on
166
+ three things: a symbol this package declares in `src/` used without being imported or declared,
167
+ a `this.member` the class shown does not define, and a `CONSTANT.KEY` the real constant does not
168
+ have. It reads the syntax tree rather than the text, so a name in a comment is a mention and not
169
+ a use. It runs in CI and in `prepublishOnly`, because a gate nothing executes is not a gate.
170
+
171
+ The 43 pre-existing cases in the planning documents sit in a baseline that shrinks and does not
172
+ grow: regenerating writes the intersection with what still reproduces, so a defect introduced in
173
+ the same edit is never adopted, and an entry that stops reproducing fails too rather than
174
+ lingering. Widening what the check looks at is the one case where the list legitimately grows,
175
+ and it takes a separate `--adopt-new` flag so that decision is stated rather than taken
176
+ silently.
177
+
178
+ - **The destinations guide credited the wrong component with building the fan-out.** It said
179
+ `DestinationRegistry` wraps each destination into the `pino.multistream` array; that happens in
180
+ `buildPinoInstance` (`src/server/pino-factory.ts:574`), and the registry owns the lifecycle hooks
181
+ and the health record instead. The mistake was not only prose — the snippet's relative imports
182
+ were written as `../utils/...`, which resolve only from `services/`, so following the guide gave
183
+ paths that do not exist from where the code actually lives.
184
+
185
+ - **The specification gave the wrong reason for reporting init failures on stderr.** It said Pino
186
+ was not yet wired at that point. It is — the instance is built during provider construction,
187
+ which is why the same method emits `LOGGER_BOOTSTRAP_OK` through the logger a few lines later.
188
+ The actual reason is the fan-out: the logger writes to the very set containing the sink that
189
+ just failed. An implementer reading the old text would have inferred the wrong lifecycle order.
190
+
191
+ - **The shipped `README.md` no longer teaches the old contract.** Its `ILogDestination` reference
192
+ still declared `Promise<void>` on `write`, `onInit` and `onShutdown`, and omitted `onRegistryReady`
193
+ entirely — so the one document most consumers read described types incompatible with the interface
194
+ and hid a hook from anyone who buffers. It ships inside the package, which makes it the copy that
195
+ matters most.
196
+
197
+ - **Overflow was only reported when a later flush failed.** A request that stalls long enough to
198
+ overflow the cap and then SUCCEEDS runs no catch at all, so the discarded entries were lost with
199
+ nothing said — the opposite of the policy stated one paragraph above. Both examples report the
200
+ dropped count on every settlement, not only from the failure path.
201
+
202
+ - **A guarded WRITER does not guard the arguments handed to it.** The documented adapters called
203
+ `writeStderrSafely` with an interpolated `${String(err)}`, and that coercion runs before the call:
204
+ an unknown rejection value with a hostile `Symbol.toPrimitive`, or an `Error` whose `message` getter
205
+ throws, takes the handler down before `callback()` — an unhandled rejection produced from inside the
206
+ containment. The previous release note credited the safe writer with a protection it never had.
207
+ Every documented adapter now uses `reportDestinationFailure`, which does the coercion inside its own
208
+ guard and escapes control characters.
209
+
210
+ - **A raced timeout is not a timeout.** The database example briefly carried a `Promise.race`
211
+ deadline, which settles the AWAIT without cancelling the query: the batch would be requeued while
212
+ the original `createMany` was still running, the next flush would start a second one, and a slow
213
+ database would accumulate concurrent inserts of the same batch — duplicates, not just delay. The
214
+ bound belongs at the driver, where it aborts the statement server-side (`statement_timeout` on the
215
+ connection string), and the example says so instead of demonstrating a race. The HTTP example keeps
216
+ `AbortSignal.timeout`, which does cancel.
217
+
218
+ - **Retaining a failed batch forever is a memory leak.** Requeueing on failure while `write` keeps
219
+ appending is an unbounded queue: a sustained outage ends in an OOM, which loses every held entry
220
+ AND the application. Both worked examples now cap the buffer and drop the OLDEST beyond it — the
221
+ newest entries describe the incident — and report the dropped count rather than discarding
222
+ silently. A destination that cannot lose anything is told to use a durable spool, not a buffer.
223
+
224
+ - **Four defects in the specification's own examples**, all from wiring `DestinationHealth` through
225
+ them: the factory call site still passed the old three arguments, so a `LogLevel` landed where the
226
+ health tracker was expected; the adapter never checked `isFailed`/`shouldRescue`, though the
227
+ fan-out builds a stream for every registered destination and that branch is what keeps a
228
+ failed-init sink out of it; the new provider dependency omitted the explicit `@Inject` this
229
+ repository requires because tsup strips decorator metadata; and the shutdown loop contained
230
+ failures with `Promise.allSettled` while reporting none of them, contradicting the guarantee the
231
+ destination examples state.
232
+
233
+ - **The consumer-facing stderr guard reintroduced a defect the library had already fixed.** It
234
+ remembered `guarded = true` in a boolean, which goes stale the moment anything else removes the
235
+ listener — a test doing cleanup is enough — and every later EPIPE is uncaught again. It also
236
+ treated ANY existing `'error'` listener as proof the stream was safe, though a consumer's handler
237
+ may rethrow. `safe-stdio.util.ts` arrived at the right shape after exactly this: check whether OUR
238
+ listener is in `stream.listeners('error')`, and only ever add. The documented helper now does the
239
+ same.
240
+
241
+ - **The Loki example discarded its batch on every HTTP error.** `fetch` rejects only on a
242
+ network-level failure; 401, 429 and 500 resolve normally, so the retention path added for it never
243
+ ran on the failures a log sink actually sees. It checks `response.ok` and throws now. Its timer
244
+ also still detached the flush directly, which the same commit had fixed only in the Postgres
245
+ example.
246
+
247
+ - **A background flush was invisible to shutdown.** Both examples now chain background flushes into
248
+ one tracked promise and `onShutdown` awaits it before the final drain: a flush that fails requeues
249
+ its batch, and without the await those entries sit in a buffer nobody drains again.
250
+
251
+ - **The specification formatted failure reports inline instead of using `reportDestinationFailure`.**
252
+ An `Error` with a throwing `message` getter — or a non-Error with a throwing coercion hook — makes
253
+ an inline `String(cause)` throw from inside the very handler that exists to contain failures, and
254
+ the inline form also skips the control-character escaping applied to remote-derived text. All four
255
+ reporting sites in the document now go through the helper that does the coercion inside its own
256
+ guard.
257
+
258
+ - **The batch-retention fix introduced a crash path of its own.** Making `flush()` rethrow is right
259
+ for the shutdown caller, which awaits it — and wrong for the two background callers, which detach
260
+ the promise. A detached rejection is an unhandled rejection, which terminates the process on
261
+ Node 24: a loss path traded for a crash path, in the same commit that closed the loss. The two
262
+ detached call sites now go through a `flushInBackground()` that swallows what `flush` already
263
+ retained and reported, while `onShutdown` still awaits `flush` directly, where the rejection has
264
+ somewhere to go.
265
+
266
+ - **The Loki example was still discarding its batch** — the same defect as the Postgres one, one
267
+ section earlier, with `// Fail soft` written over it. Fail-soft means not crashing the host; it
268
+ never meant dropping the entries. It retains and reports now, and the guarded stderr helper both
269
+ examples use is defined once, ahead of its first use, instead of being named and left undefined.
270
+
271
+ - **The documented adapters recorded a failure only after the promise rejected**, leaving the
272
+ in-flight window the implementation closes with `markWritePending`/`markWriteSettled`. Readiness
273
+ can be computed while a write is still pending; without the pending count it reads as silent
274
+ success, and a buffering sink discards its copy moments before that write rejects.
275
+
276
+ - **The specification's registry example referenced members it did not declare** — `this.active`,
277
+ `this.health`, `this.effectiveLevelOf` — so the authoritative document showed an implementation
278
+ that would not compile. It now declares them, and its shutdown loop iterates the ACTIVE subset
279
+ rather than every registered destination, matching the implementation: a sink whose `onInit`
280
+ failed may never have acquired what `onShutdown` would close.
281
+
282
+ - **The documented adapters reported a failed write but never RECORDED it.** The prose added with
283
+ them says the adapter "contains it, records it and reports it"; the examples did the first and the
284
+ third. Recording is the half that prevents the loss: without `markWriteFailed`, readiness still
285
+ counts the sink as having taken the entry and another destination may discard the copy it was
286
+ holding. The examples now take the health tracker and mark the sink before reporting.
287
+
288
+ - **The worked Postgres destination still lost a batch, in the example that demonstrates not losing
289
+ one.** Removing the `try/catch` from its `write` was not enough: the flush it triggers runs after
290
+ that `write` returned, and `flush()` ended in `catch { /* fail-soft */ }`, discarding every entry
291
+ in the batch while the adapter had already recorded them as taken. A background flush is the one
292
+ failure a destination must handle itself — so it now returns the batch to the buffer, reports it,
293
+ and rethrows so the caller sees it. The stderr helper it uses is written out in full, because the
294
+ library's guarded one is internal and a consumer cannot import it.
295
+
296
+ - **The specification contained two incompatible lifecycle contracts.** The `onRegistryReady`
297
+ guarantee added above says the hook runs for every registered destination; the `DestinationRegistry`
298
+ example further down truncated the registered list to the survivors of `onInit` and never called
299
+ the hook at all. The example now keeps the registered set, writes only to the active subset, and
300
+ awaits the hook for every destination with per-destination isolation.
301
+
302
+ - **The guide told destinations to swallow their own write failures — a loss path, in the document
303
+ that defines the fail-soft contract.** "Catch every error in `write` and log it via
304
+ `process.stderr.write`" reads as prudence and is the opposite: a swallowed failure looks like a
305
+ successful write from outside, so `markWriteFailed` never runs for that sink, readiness can then
306
+ credit it with entries it dropped, and ANOTHER destination discards the held copies it was keeping.
307
+ The entry stops existing anywhere — exactly the outcome this release exists to prevent. The advice
308
+ is now to let the failure reach the adapter, which contains it, RECORDS it and reports it. Two
309
+ anti-pattern entries carried the same instruction, and the worked Postgres destination practised
310
+ it; all three are corrected.
311
+
312
+ - **Every reporter in the documented examples wrote to `process.stderr` directly.** The guide
313
+ explains, three sections earlier, that a closed pipe reports EPIPE asynchronously and kills the
314
+ process — which is why `safe-stdio.util.ts` exists. The containment helpers added one commit ago
315
+ ignored that and would have traded a contained destination failure for an uncaught exception. The
316
+ library's own paths now route through `writeStderrSafely` in every example. The consumer-facing
317
+ examples do not, because that helper is internal and unexported; they point at the EPIPE note
318
+ instead, which is the honest instruction for code outside this package.
319
+
320
+ - **The technical specification did not state that `onRegistryReady` runs for a destination whose own
321
+ `onInit` rejected.** It runs for every registered destination, verified against
322
+ `notifyRegistryReady`, and a sink that failed to initialize is exactly the one most likely to be
323
+ holding entries it now has to resolve. Left unstated, an implementer would reasonably assume the
324
+ opposite and strand them.
325
+
326
+ - **The drain contract described a return value `_write` does not have.** It said the adapter signals
327
+ backpressure by `_write` returning `false`; `_write` returns `void`. Deferring its callback is the
328
+ actual mechanism — the chunk stays in flight, the internal buffer grows, and it is the PUBLIC
329
+ `writable.write()` that returns `false` at `highWaterMark`. The conclusion the section drew was
330
+ right and the model under it was not, which is the kind of text a destination author reasons from.
331
+
332
+ - **The documented adapter examples contradicted the fail-soft contract they were illustrating.**
333
+ They routed a failed write into `callback(err)`, and the guide listed that as an advantage — "errors
334
+ are propagated as a callback err (not as an exception that crashes the app)". A `Writable` given an
335
+ error in its callback emits `'error'`, which with no listener terminates the host. The production
336
+ adapter has always done the opposite, and says so in a comment one line above the code: contain the
337
+ failure, report it on stderr, complete the write as successful. Every example, and the two prose
338
+ lines that recommended the pattern, now match it.
339
+
340
+ - **Every documented `destinationToStream` example branched on `result instanceof Promise`** — the
341
+ exact defect fixed above, in the snippets that teach people how to write a destination. A reader
342
+ implementing against the guide, the specification or the plan documents would have reproduced the
343
+ escaped rejection and the lost entry. All of them now branch on `undefined` and assimilate with
344
+ `Promise.resolve`.
345
+
346
+ These two were found only because a review kept pulling the same thread. Correcting the interface
347
+ and the four excerpts that restate it still left the README and every worked example behind: a
348
+ contract lives in more places than the ones that quote its signature.
349
+
350
+ So the rule is now a gate rather than a habit. `check:published` compiled only README blocks that
351
+ **import** the package, on the reasoning that naming the API is not a claim about it — which left a
352
+ block that _re-declares_ an exported type unchecked, and that is exactly where the README drifted.
353
+ It now compiles those too, with three assertions because each covers what the previous one cannot:
354
+ mutual assignability catches a changed return type; key parity catches an omitted member, which is
355
+ assignable in both directions and is how the absent `onRegistryReady` survived; per-member type
356
+ identity catches a changed PARAMETER, which stays bivariant even under `strictFunctionTypes`.
357
+
358
+ Both of the later assertions were added because a measured version of the gate let a real drift
359
+ through: the first passed a README declaring `write(payload: unknown)`, and the second — comparing
360
+ members as `T[K]` — passed one that dropped `readonly`, because indexed access discards property
361
+ modifiers. Each hole was reproduced against the version that had it before being closed, and each
362
+ assertion is verified the same way: introduce the drift, watch the gate name it, restore.
363
+
364
+ - **`heldEntriesDeliveredElsewhere` is documented as best-effort rather than as proof.** The JSDoc
365
+ said `true` requires no write still in flight, which reads as complete accounting — while the
366
+ `1.2.8` note already admitted that writes queued inside the `Writable` adapter are invisible until
367
+ `destination.write()` is called. Both statements shipped in the same release, and they contradict
368
+ each other.
369
+
370
+ Calling it best-effort was not enough on its own: the surrounding prose still framed the decision
371
+ as "discard only on proof", which is the same contradiction one paragraph up. The contract is now
372
+ stated as what it is — a **deduplication signal**, where `true` means the smaller risk is dropping
373
+ the held copy, not that the entry is safe. The library's own destinations dedupe on it because a
374
+ duplicated boot line is a smaller harm than a lost one; a destination that cannot tolerate any loss
375
+ is told to ignore the flag and always emit. The guide, the specification and the JSDoc now say the
376
+ same thing, so a reader meets one story instead of three.
377
+
378
+ Reframing the parameter was still not the end of it: `PrettyDevDestination` went on calling the
379
+ same value "a proven fact" in one comment and instructing the hook to "discard only what is proven
380
+ delivered" in another, and this changelog kept the word too. One class saying both things is worse
381
+ than either alone — "proven" invites a destination author to discard with full confidence, which is
382
+ precisely the loss this release refuses. The vocabulary is now one word everywhere: a signal, with
383
+ the queued-write blind spot named where the decision is taken.
384
+
385
+ ## 1.2.8 - 2026-08-15 (merged, never published)
386
+
387
+ ### Fixed
388
+
389
+ - **A pretty destination registered alongside another sink no longer prints every boot entry twice.**
390
+ The fan-out hands each entry to every registered destination, so entries buffered before
391
+ `PrettyDevDestination.onInit` had ALSO been delivered to the co-destination. When the pretty sink
392
+ then failed to initialize, it drained its buffer raw to stdout, producing a second copy of every
393
+ boot line. Measured on the supported
394
+ `[DefaultStdoutDestination(), PrettyDevDestination()]` pair: two occurrences of one entry.
395
+
396
+ **You were affected only if you registered pretty ALONGSIDE another destination** — the shape a
397
+ derived backend reaches by adding an HTTP or file sink beside pretty for local development. A
398
+ one-element `destinations` list is structurally immune, because the option REPLACES the default
399
+ sink rather than adding to it, so pretty-alone and stdout-alone can never produce the pair.
400
+ Worth stating explicitly: "duplicated boot lines" is not a symptom from which a reader would guess
401
+ the triggering configuration.
402
+
403
+ The destination could not decide this alone: at `onInit` failure it has no way to know what became
404
+ of the entries. The decision moves to a new optional `ILogDestination.onRegistryReady`, which the
405
+ registry calls once every `onInit` has settled.
406
+
407
+ **The policy is: prefer a duplicated line over a lost one.** The hook carries ONE signal — whether
408
+ another live sink appears to have accepted everything this destination accepted — and the
409
+ destination dedupes only on that. `true` requires all of: another destination, initialized, at or
410
+ below this level, with no write failure and nothing still in flight. Anything less certain reads as
411
+ `false`, and the entries are emitted.
412
+
413
+ An earlier draft handed over three hints and let the destination judge. Every judgement had a
414
+ losing branch, and review found them one at a time: trusting "a sink survived" lost entries to a
415
+ sink at a higher level; trusting the level lost them to a sink that was itself the asker; then to
416
+ one whose writes were throwing; then to one whose write had not settled yet. Collapsing to a single
417
+ signal is what removed the class, rather than the four instances.
418
+
419
+ In the configuration that motivated this — pretty beside `DefaultStdoutDestination` — nothing
420
+ duplicates: the stdout sink is live at the same level, the signal reads `true`, and the held copies
421
+ are dropped. Duplication is left wherever the signal cannot be given, which is exactly where
422
+ discarding would risk silence.
423
+
424
+ **One narrow gap is known and left open rather than papered over, and it is the reason the value is
425
+ a signal and not a proof.** The accounting covers writes this library has SEEN — resolved, rejected,
426
+ or in flight. A write still queued inside the `Writable` adapter, behind a slow async destination
427
+ that has not been called yet, is invisible to it: the signal can read `true` while that entry has
428
+ not reached the sink. It will normally arrive (it is
429
+ queued, not lost); the residual risk is a queued write that later fails, and the entry existed only
430
+ in a buffer that was discarded on the strength of a different sink's record. Closing it means
431
+ readiness waiting for every pre-ready stream to drain, which is a larger change than the boot-time
432
+ cosmetic defect this whole path exists to fix. Stated here so a consumer can weigh it, rather than
433
+ implied by a guarantee that does not quite hold.
434
+
435
+ **One case is NOT deduplicated, and the first draft of this note claimed it was.** A shutdown that
436
+ happens before the registry's `onModuleInit` ran — the application aborting mid-bootstrap — still
437
+ drains the buffer raw. The multistream is wired by a provider factory while NestJS assembles the
438
+ graph, so entries can already have reached a co-destination by then, but the readiness hook has not
439
+ run and the destination has no way to learn that. It drains, because between a duplicated boot line
440
+ and a lost one this library picks the duplicate. Narrowing the claim rather than widening the fix:
441
+ making that path registry-aware would require the registry, which by definition has not run.
442
+
443
+ Reported by Copilot on the `1.2.6` pull request, in a review body rather than as an inline
444
+ comment — see the note below.
445
+
446
+ ### Security
447
+
448
+ - **`messageFormat` interpolates every placeholder except `{msg}` raw, and that reopens terminal
449
+ log forging.** This library normalizes line separators and control characters in `msg` and in the
450
+ stack, never in metadata — escaping data would mean rewriting what a consumer asked to be logged.
451
+ `pino-pretty` substitutes whatever the field holds, so a newline in an interpolated field splits
452
+ one entry into two, and the second reads like a genuine record. Measured with a newline in
453
+ `context`:
454
+
455
+ ```
456
+ lines produced by ONE entry: 2
457
+ 1: "[10:35:14.484] INFO: [Auth"
458
+ 2: "[10:00:00.000] INFO: FORGED admin promoted] real entry …"
459
+ ```
460
+
461
+ The option stays — rejecting non-`msg` placeholders would remove the feature, and the interpolation
462
+ happens inside `pino-pretty` where this library cannot escape it. What changes is that the JSDoc
463
+ now says so, and the README no longer recommends the pattern without the warning: interpolate only
464
+ fields your own code sets, never one carrying user input.
465
+
466
+ ### Documentation
467
+
468
+ - **`hideObject` is scoped to the pretty destination, not to the entry.** The doc said a field hidden
469
+ there "is not visible anywhere", which holds only when pretty is the sole destination. Registered
470
+ alongside another sink, that sink still receives the complete entry.
471
+
472
+ - **The `safe-stdio` specs no longer leave `'error'` listeners on the shared process streams.** They
473
+ did so deliberately, with a rationale that the same pull request had already made stale: cleanup
474
+ had once broken the guarantee under test because the module remembered which streams it guarded,
475
+ and `ensureGuarded` was then changed to inspect the actual listener list. Every
476
+ `isolateModulesAsync` load installs a distinct handler, so the file accumulated one per case on a
477
+ stream that outlives the suite — where a later spec asserting an unhandled stream error propagates
478
+ would be silently satisfied. Each block now restores its exact baseline, and asserts that it did.
479
+
480
+ - **A process note, recorded because it cost two releases.** Copilot reports some findings inside a
481
+ `Suppressed comments` block in the review BODY, where they never become review threads. Checking
482
+ `reviewThreads` for `isResolved == false` — the documented way to avoid trusting the page — reports
483
+ zero open and is telling the truth about threads while missing these entirely. Ten such findings
484
+ sat unread on the `1.2.6` pull request, including the two fixed above. Reviews are now read
485
+ alongside threads.
486
+
487
+ - **A hand-built `PinoLoggerService` is not this library's behaviour, and the constructor doc used to
488
+ invite one without saying so.** `new PinoLoggerService(pino({...}, sink))` does not throw — the
489
+ parameter default exists for exactly that — but the Pino instance you built has none of the wiring
490
+ `buildPinoInstance` installs, and the omissions are silent. No `err` serializer, so the `cause`
491
+ chain **disappears entirely**: no marker, no warning, and a shape that reads as "the field was
492
+ never set". Also no name-based redaction, no trace-context mixin, no size bound.
493
+
494
+ Found the honest way: a consumer hand-built one to check whether a `cause` carried its `code`,
495
+ measured absence, and went to read the built `dist` before reporting it — where they found their
496
+ harness was the cause. The doc now names what such an instance lacks and points at booting the
497
+ module for a faithful one.
498
+
499
+ - **What redaction covers, stated as a boundary rather than left to be inferred.** It matches field
500
+ NAMES, not values: a secret under a covered name is censored at any depth, and the same secret
501
+ embedded **inside a string** under an uncovered name travels intact. Scanning values for
502
+ secret-shaped text would mean rewriting what a consumer asked to be logged, wrong in both
503
+ directions and silently — so the boundary is real and belongs in the open. It matters more since
504
+ `1.2.7`: preserving an error's own fields at every depth makes whatever a `cause` carries more
505
+ visible, including what should not be there.
506
+
507
+ The README also now says which redactor governs what — `redactStrategy` covers the consumer's
508
+ payload, their child bindings and any serializer they supply; the built-in `err` serializer is the
509
+ library's own output and is always name-walked, because `fast-redact`'s four-wildcard ceiling
510
+ cannot reach a serialized `cause` chain.
511
+
512
+ - **The `1.2.7` notes used `a catch in main.ts` as the example, and it is the wrong one.** Not false
513
+ as a general statement — a handler that wraps and logs does exist — but it became the canonical
514
+ example of the motivating case, and for the consumer who reported that case it describes a path
515
+ that does not exist: their config validation rejects inside `NestFactory.create`, before the
516
+ logger is attached, and reports through `console.error` on stderr without touching the serializer.
517
+ A reader anchors on the example. Now "any handler that wraps what it caught before logging it".
518
+ Corrected in the README and in the `1.2.7` entry; the `v1.2.7` tag annotation is left as published,
519
+ since rewriting a published tag is worse than the imprecision it would fix.
520
+
521
+ ## [1.2.7] - 2026-08-15
522
+
523
+ ### Security
524
+
525
+ - **Serializer output is redacted by name under `redactStrategy: 'paths'` too.** Carrying an
526
+ error's own properties at every depth of the `cause` chain (below) opened a hole in that legacy
527
+ mode: the name walk is a pass-through there, so redaction fell entirely to `fast-redact`, and
528
+ `DEFAULT_REDACT_PATHS` reaches `*.*.*.*.password` — four wildcard levels. A serialized cause chain
529
+ goes deeper. Measured on the built artifact with a password on the deepest link of
530
+ `err.errors[0].cause.cause`:
531
+
532
+ ```json
533
+ { "cause": { "name": "Error", "message": "deep", "password": "LEAK-deep" } }
534
+ ```
535
+
536
+ Confirmed absent from `1.2.6`, where the field was not carried at all — so this was a regression
537
+ introduced inside this release rather than an inherited one, and it never reached a published
538
+ artifact.
539
+
540
+ `redactStrategy` is the consumer's choice about **their own payload**; what a serializer
541
+ synthesizes is this library's output, and the name walk — which has no depth ceiling — now covers
542
+ it either way. The caller's record is untouched by this: `'paths'` still means exactly what it
543
+ documented, four-level ceiling included, and the e2e case that pins that ceiling is what caught
544
+ the first attempt at this fix collapsing both hooks onto one redactor.
545
+
546
+ Reported by Copilot on the pull request that introduced the deeper copy.
547
+
548
+ - **An own `__proto__` key on an error is now stored as data instead of being applied as a
549
+ prototype.** The own-property copy assigned each key, and `__proto__` is an accessor inherited
550
+ from `Object.prototype` — so the assignment walked the chain, invoked that setter, and replaced
551
+ the sanitized node's prototype. An error-like object arriving from JSON (a worker boundary, a
552
+ queue, an HTTP body) carries `__proto__` as an ordinary own enumerable key, and `Object.entries`
553
+ hands it over like any other.
554
+
555
+ Measured rather than assumed. `Object.prototype` itself is never touched, so this is prototype
556
+ injection into a single log node rather than global pollution, and with a JSON-sourced payload the
557
+ emitted line was unchanged — `JSON.stringify` ignores inherited fields. The live hazard is the
558
+ node: given `{"__proto__": null}` it loses `hasOwnProperty` and `toString`, which then **throw**
559
+ for anything downstream that touches them, inside the one path whose contract is that logging
560
+ never crashes the application.
561
+
562
+ The copy now defines an own data property, which makes the key inert and keeps its value — an
563
+ error that genuinely carries a `__proto__` field gets it logged as the data it is. Reported by
564
+ Copilot on the pull request that introduced the deeper copy.
565
+
566
+ ### Documentation
567
+
568
+ - **`ignore` needs an escaped dot for `event.name`, and every example here taught the opposite.**
569
+ `pino-pretty` reads a dot as a nested path, and this library emits `event.name` as a literal
570
+ top-level key — so `ignore: '…,event.name'` looks for an `event` object with a `name` inside,
571
+ finds neither, and hides nothing without a word of complaint. Measured on one entry with only the
572
+ option changing:
573
+
574
+ ```
575
+ ignore: '…,event.name' → INFO: msg {"logKey":"X","event.name":"x"} ← still there
576
+ ignore: '…,event\\.name' → INFO: msg {"logKey":"X"} ← hidden
577
+ ```
578
+
579
+ Found by a consumer who copied the `1.2.6` example verbatim, configured it, and saw the field
580
+ still on screen. The escaped form is now in the `PrettyViewOptions` JSDoc — where an editor's
581
+ autocomplete shows it, which is where someone actually is when writing the option — in the README,
582
+ and in the `1.2.6` example itself, which is corrected rather than left to keep teaching it.
583
+
584
+ ### Fixed
585
+
586
+ - **An error's own properties now survive at every depth of the `cause` chain, not just at the top.**
587
+ `code`, `statusCode` and any domain field an application attaches were copied onto the error handed
588
+ to the log call and **dropped the moment that same error was wrapped as someone else's `cause`** —
589
+ which is what any handler that wraps what it caught does before logging it. Measured on the
590
+ published `1.2.6`:
591
+
592
+ ```json
593
+ {
594
+ "type": "Error",
595
+ "message": "bootstrap failed",
596
+ "code": "EBOOT",
597
+ "cause": { "name": "Error", "message": "config invalid" }
598
+ }
599
+ ```
600
+
601
+ The inner error carried `code: "BYMAX_CONFIG_VALIDATION"` and an `issues` array holding one entry
602
+ per invalid variable. Both vanished. The reporting consumer framed the severity exactly right:
603
+ because `message` survives and their message is the full aggregated report, **the human stayed
604
+ served while the machine went blind** — the text an operator reads was intact, and the fields an
605
+ alert or a dashboard keys on were gone.
606
+
607
+ Nothing justified the asymmetry. The reason own properties are "part of the contract" at the top —
608
+ `pino.stdSerializers.err` copied them, so dropping them is a silent compatibility loss — is the
609
+ same reason one level down, and a wrapped error is the common case rather than the exotic one.
610
+ They still never shadow a field the serializer derives, and they still pass through the same
611
+ name-based redaction and size bound as any other serializer output.
612
+
613
+ Found by following up an asymmetry noticed while answering a consumer's question about how to pass
614
+ an error through the NestJS bridge — not by a bug report.
615
+
616
+ ### Changed
617
+
618
+ - **The own-property copy lives in one place instead of two.** The `err` serializer read the raw
619
+ thrown value and walked its properties itself, duplicating what the sanitizer now does at every
620
+ node; it copies from the sanitized node instead. Same fields, one walk — and no second copy to
621
+ drift away from the first.
622
+
14
623
  ## [1.2.6] - 2026-08-15
15
624
 
16
625
  ### Added
@@ -30,7 +639,7 @@ heading here.
30
639
 
31
640
  ```ts
32
641
  new PrettyDevDestination({
33
- view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event.name' }
642
+ view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event\\.name' }
34
643
  })
35
644
  ```
36
645
 
@@ -1130,7 +1739,12 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
1130
1739
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
1131
1740
  `release.yml`, Dependabot, and issue templates
1132
1741
 
1133
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.6...HEAD
1742
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.9...HEAD
1743
+ [1.2.9]: https://github.com/bymaxone/nest-logger/compare/v1.2.7...v1.2.9
1744
+
1745
+ <!-- 1.2.8 has no link: it was merged but never tagged, so there is no v1.2.8 to compare against. See its section above. -->
1746
+
1747
+ [1.2.7]: https://github.com/bymaxone/nest-logger/compare/v1.2.6...v1.2.7
1134
1748
  [1.2.6]: https://github.com/bymaxone/nest-logger/compare/v1.2.5...v1.2.6
1135
1749
  [1.2.5]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...v1.2.5
1136
1750
  [1.2.4]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...v1.2.4