@bymax-one/nest-logger 1.2.7 → 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,513 @@ 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
+
14
521
  ## [1.2.7] - 2026-08-15
15
522
 
16
523
  ### Security
@@ -79,7 +586,8 @@ heading here.
79
586
  - **An error's own properties now survive at every depth of the `cause` chain, not just at the top.**
80
587
  `code`, `statusCode` and any domain field an application attaches were copied onto the error handed
81
588
  to the log call and **dropped the moment that same error was wrapped as someone else's `cause`** —
82
- which is what a `catch` in `main.ts` does routinely. Measured on the published `1.2.6`:
589
+ which is what any handler that wraps what it caught does before logging it. Measured on the
590
+ published `1.2.6`:
83
591
 
84
592
  ```json
85
593
  {
@@ -1231,7 +1739,11 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
1231
1739
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
1232
1740
  `release.yml`, Dependabot, and issue templates
1233
1741
 
1234
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.7...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
+
1235
1747
  [1.2.7]: https://github.com/bymaxone/nest-logger/compare/v1.2.6...v1.2.7
1236
1748
  [1.2.6]: https://github.com/bymaxone/nest-logger/compare/v1.2.5...v1.2.6
1237
1749
  [1.2.5]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...v1.2.5
package/README.md CHANGED
@@ -556,7 +556,8 @@ error, `statusCode` from an HTTP layer, whatever domain fields you attached —
556
556
 
557
557
  This matters because `code` is usually the field you alert on. Until 1.2.7 those properties survived
558
558
  only on the error handed to the log call and were dropped the moment the same error was wrapped as
559
- someone else's `cause` — which is what a `catch` in `main.ts` does routinely. They never shadow a
559
+ someone else's `cause` — which is what any handler that wraps what it caught does before
560
+ logging it. They never shadow a
560
561
  field the serializer derives, and they pass through the same name-based redaction and size bound as
561
562
  any other serializer output.
562
563
 
@@ -790,6 +791,7 @@ new PrettyDevDestination({
790
791
  })
791
792
 
792
793
  // Message only, with the context pulled back into the line.
794
+ // See the messageFormat warning below before interpolating any other field.
793
795
  new PrettyDevDestination({
794
796
  view: { hideObject: true, messageFormat: '[{context}] {msg}' }
795
797
  })
@@ -804,7 +806,7 @@ new PrettyDevDestination({
804
806
  | `translateTime` | `'SYS:HH:MM:ss.l'` | Or `false` for the raw timestamp |
805
807
  | `colorize` | `true` | ANSI colour |
806
808
 
807
- > **`hideObject` hides it everywhere.** In pretty mode there is no JSON copy behind the rendering, because `destinations` **replaces** stdout — so a field hidden here, `logKey` included, is not visible anywhere. That is what the option is for; `messageFormat` is how you pull a specific field back.
809
+ > **`hideObject` hides it from THIS destination.** Any other destination you registered still receives the complete entry — the option is display-only and scoped to the pretty renderer. When pretty is your **only** destination that scope becomes total: `destinations` **replaces** stdout, so there is no JSON copy behind the rendering and a hidden field, `logKey` included, is not visible anywhere. That is what the option is for; `messageFormat` is how you pull a specific field back.
808
810
 
809
811
  > **A dot in `ignore` means "nested path", so a field name that CONTAINS a dot must escape it.** This library emits `event.name` as a literal top-level key, so the obvious spelling hides nothing at all — `pino-pretty` looks for an `event` object with a `name` inside, finds neither, and says nothing. Measured on one entry with only the option changing:
810
812
  >
@@ -817,6 +819,16 @@ new PrettyDevDestination({
817
819
 
818
820
  The shape is exported as `PrettyViewOptions` when you want to build the view separately — it is this library's own interface, not a re-export of `pino-pretty`'s `PrettyOptions`, so type-checking without the optional peer installed still resolves.
819
821
 
822
+ > **`messageFormat` interpolates every placeholder except `{msg}` raw — that can forge a terminal entry.** This library normalizes line separators and control characters in `msg` and in the stack, never in metadata, because escaping data would mean rewriting what you asked to be logged. `pino-pretty` substitutes whatever the field holds, so a newline in an interpolated field splits one entry into two and the second reads like a genuine record. Measured, with a newline in `context`:
823
+ >
824
+ > ```
825
+ > lines produced by ONE entry: 2
826
+ > 1: "[10:35:14.484] INFO: [Auth"
827
+ > 2: "[10:00:00.000] INFO: FORGED admin promoted] real entry …"
828
+ > ```
829
+ >
830
+ > Interpolate only fields your own code sets. Never one carrying user input — `{tenantId}`, `{userId}`, a header, a query value. If you need one of those visible, leave it in the record instead. That closes **this** path and no more: a metadata value is not safe terminal text either, since `JSON.stringify` escapes only C0 and emits DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 verbatim — see _One entry, one line_ below, where the same boundary is stated. Only `msg` and the stack carry that guarantee.
831
+
820
832
  > **`destination` is not exposed, by design.** The library owns where entries go — a redirected stream would route around the fan-out and the last-resort rescue above. It is absent from the type and applied after your options are merged, so it cannot be overridden from untyped JavaScript either.
821
833
 
822
834
  **The first entries of a boot are held, not lost.** The transform cannot exist until `onInit` — loading the optional peer is async — so everything NestJS emits while instantiating providers arrives before it. Those entries are buffered and then rendered through the transform in arrival order. If the peer is missing, the bound is reached, or the app shuts down before init, they are written as raw NDJSON instead: degraded, never dropped.
@@ -1043,6 +1055,21 @@ Trace context is never copied verbatim into a log entry. The OTel mixin reads th
1043
1055
 
1044
1056
  Every `write()` runs inside a try/catch. A destination that throws or rejects produces a `LOGGER_DESTINATION_WRITE_FAILED` line on `stderr` — never back through the logger, which would turn a broken sink into a write → log → write feedback loop — and the entry is dropped for that sink only. A destination whose `onInit()` rejects is reported as `LOGGER_DESTINATION_INIT_FAILED` and excluded from the shutdown sequence without blocking boot; it stays in the write fan-out, where the same fail-soft wrapper contains it. A logging backend going down degrades logging — it never takes the application with it.
1045
1057
 
1058
+ ### What redaction covers, and what it cannot
1059
+
1060
+ Redaction here matches **field names**, not values. Under the default `redactStrategy: 'names'` a secret stored under a covered name is censored at any depth — under the legacy `'paths'` strategy the four-level `fast-redact` ceiling applies instead, and a deeper field is not covered. Either way, the same secret **embedded inside a string** under an uncovered name travels intact. Measured:
1061
+
1062
+ ```json
1063
+ {
1064
+ "password": "[REDACTED]",
1065
+ "renderedBody": "Hi Ana, your reset code is 481920. Expires in 10 minutes."
1066
+ }
1067
+ ```
1068
+
1069
+ There is no name rule that reaches the second one, and scanning inside values for secret-shaped text would mean rewriting what you asked to be logged — wrong in both directions, silently. **The boundary is yours to hold:** do not put rendered bodies, raw provider responses or whole request payloads into a field, and be aware that attaching an upstream error as `cause` carries whatever that error carries. Since 1.2.7 preserves an error's own fields at every depth of the chain, a cause that quotes something it should not quote is now more visible, not less.
1070
+
1071
+ `redactStrategy` governs **your** payload — the record you log, your child bindings, and the output of any serializer you supply. The built-in `err` serializer is the library's own output and is always name-walked, including under `'paths'`, because `fast-redact`'s four-wildcard ceiling cannot reach the depth a `cause` chain serializes to.
1072
+
1046
1073
  ### A closed pipe does not kill the process
1047
1074
 
1048
1075
  `node app | head` closes the read end of stdout while the application is still writing. The stream reports that as `EPIPE` **asynchronously**, through its `'error'` event, after `write()` has already returned — so a `try/catch` around the write catches nothing, and because Node attaches no default handler to `process.stdout`, the emit becomes an uncaught exception and the process dies. Measured, not assumed: `0` listeners, no synchronous throw, `UNCAUGHT:EPIPE`, exit 42.
@@ -1297,9 +1324,13 @@ interface ILogDestination {
1297
1324
  readonly name: string
1298
1325
  readonly minLevel?: LogLevel
1299
1326
  /** `payload` is a newline-terminated JSON entry, UTF-8 encoded — not an object. */
1300
- write(payload: string): void | Promise<void>
1301
- onInit?(): Promise<void>
1302
- onShutdown?(): Promise<void>
1327
+ write(payload: string): void | PromiseLike<void>
1328
+ onInit?(): void | PromiseLike<void>
1329
+ /** Called once after EVERY destination's `onInit` settled. Only useful if you buffer. */
1330
+ onRegistryReady?(status: {
1331
+ readonly heldEntriesDeliveredElsewhere: boolean
1332
+ }): void | PromiseLike<void>
1333
+ onShutdown?(): void | PromiseLike<void>
1303
1334
  }
1304
1335
  ```
1305
1336