@bymax-one/nest-logger 1.2.7 → 1.3.0

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