@bymax-one/nest-logger 1.1.0 → 1.2.1

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,449 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.1] - 2026-08-13
15
+
16
+ ### Security
17
+
18
+ - **A log message can no longer forge log lines in re-rendered output** (CodeQL
19
+ `js/log-injection`, alert #61). Every message argument handed to Pino can carry caller- or
20
+ user-provided text — a thrown value recorded off an HTTP request, a variadic NestJS line, a
21
+ structured message — and while the NDJSON transport neutralizes an embedded LF or CR through
22
+ JSON escaping (measured; it does NOT cover U+2028/U+2029 — see the entry below),
23
+ `pino-pretty` — shipped here as `PrettyDevDestination` — and
24
+ any destination that re-renders the parsed message print real newlines, where a break forges what
25
+ looks like a separate entry (also measured: the forged line is indistinguishable from a genuine
26
+ one). Line and paragraph separators (`\r`, `\n`, U+2028, U+2029) are now replaced with the
27
+ literal `\n` sequence on **every** message sink: `error()` for both the `Error` and the string
28
+ path, the structured `info`/`warn` calls, and the NestJS variadic bridge. Structured fields are
29
+ untouched — `err.message` keeps the verbatim text — so no information is lost, only the
30
+ human-readable `msg` is pinned to one line.
31
+ - **Terminal control characters can no longer drive the renderer either.** Pinning the line
32
+ terminators is not enough on a terminal: `ESC E` is ANSI NEL (next line), `ESC [` opens a control
33
+ sequence, and vertical tab, form feed and C1 NEL (U+0085) all move the cursor. Measured on the
34
+ built bundle — the raw `ESC` byte reached the terminal through `pino-pretty` and forged a line
35
+ exactly like a newline did. Every C0 control except TAB, plus DEL and the C1 range, is now
36
+ rendered as its readable `\uXXXX` escape — one form for every character, four hex digits.
37
+ Escaping the ESC byte is what disarms every sequence built on it, rather than chasing sequences
38
+ one by one.
39
+ - **This also protects the raw NDJSON line, not only re-rendering destinations.** JSON escaping
40
+ covers C0 and nothing more: `JSON.stringify` and Pino's serializer emit DEL, the C1 range
41
+ (U+0085 NEL among them), U+2028 and U+2029 **verbatim** — measured on the emitted bytes. A human
42
+ reading raw NDJSON in a terminal was therefore exposed to the same cursor movement as a
43
+ `pino-pretty` reader. Both paths are now covered. The boundary is unchanged: structured values
44
+ are data and are not rewritten, so a control character placed in a metadata field still reaches
45
+ the terminal — only `msg` and the stack carry the guarantee.
46
+ - **The scrubbed stack is escaped too.** `pino-pretty` prints `err.stack` RAW rather than as a JSON
47
+ string, and a stack's first line repeats the error message — so escaping only `msg` left the
48
+ identical attack working through `err.stack` and `exception.stacktrace`. Control characters in
49
+ the stack are now escaped; its newlines are preserved, because a stack is legitimately
50
+ multi-line.
51
+
52
+ P1 of the observability audit ([`docs/observability_audit.md`](./docs/observability_audit.md)):
53
+ stable OpenTelemetry resource identity, robust trace correlation, semconv-aligned error fields and
54
+ machine-readable event names. Every convention adopted here was verified **Stable** against
55
+ Semantic Conventions **v1.44.0** on 2026-08-13; nothing Development or Experimental is emitted by
56
+ default, and no runtime dependency was added.
57
+
58
+ Design rationale is in [ADR 0001](./docs/adr/0001-resource-identity-and-otel-correlation.md); the
59
+ complete field inventory is in
60
+ [`docs/semantic-convention-mapping.md`](./docs/semantic-convention-mapping.md).
61
+
62
+ ### Fixed
63
+
64
+ - **The HTTP terminal entry is emitted in the live async context when one exists.** The `'close'`
65
+ listener used `AsyncResource.bind` alone, which captures the context at REGISTRATION time — so
66
+ when instrumentation opened a span downstream of the middleware, the terminal entry was
67
+ attributed to the wrong span, silently, because a plausible trace id was still present. Measured
68
+ (and confirmed independently against a real OTel ContextManager): the live read covers the
69
+ normal path, the bound context covers the aborted path, and neither alone covers both. The
70
+ listener now reads live-first with the bound context as fallback; the ALS store doubles as the
71
+ liveness probe.
72
+ - **A thrown non-object no longer spreads into the `err` object.** The serializer's own-property
73
+ copy ran on any value, and `Object.entries` on a thrown STRING spreads its characters as indexed
74
+ keys — a record carried `err: {"0":"t","1":"h",…}` beside the `UnknownError` envelope; a thrown
75
+ array spread its elements the same way. Own properties are now copied only off a plain
76
+ non-array object.
77
+ - **Trace correlation no longer switches off because of the working directory.** `@opentelemetry/api`
78
+ was resolved from `process.cwd()` alone, so a Docker `WORKDIR` that is not the app root, a
79
+ pnpm/Yarn workspace with hoisted `node_modules`, a monorepo launched from the repository root or a
80
+ serverless bundle silently disabled ALL trace correlation. Measured with `cwd = /`: the old anchor
81
+ fails to resolve, the new one succeeds. Resolution is now anchored at the library's own module
82
+ path, falling back to the working directory.
83
+ - **`err.type` is no longer `"Object"` for a typed exception.** `pino.stdSerializers.err` derives
84
+ the type from the value's CONSTRUCTOR, so an error already normalized into a plain object — which
85
+ is exactly what `HttpExceptionFilter` produces — was reported as `Object`. The fix is
86
+ architectural: nothing pre-serializes any more and the `err` serializer reads the real type. A
87
+ second layer of the same defect is fixed too — `sanitizeError` only accepted `instanceof Error`,
88
+ so the same plain object came out as `UnknownError`.
89
+ - **`Error.cause` chains reach the log.** `sanitizeError` computed the chain and the service
90
+ discarded it, so modern JavaScript error chaining was invisible. `AggregateError` members are
91
+ carried as `err.errors`. Both are depth- and width-bounded, circular-safe, and redacted.
92
+
93
+ ### Added
94
+
95
+ - **Stable resource identity.** `service.namespace`, `service.instanceId` and `service.environment`
96
+ options, emitted as `service.namespace`, `service.instance.id` and `deployment.environment.name`.
97
+ Resolution precedence is deterministic: explicit options → `OTEL_SERVICE_NAME` →
98
+ `OTEL_RESOURCE_ATTRIBUTES` → `NODE_ENV`. The order of the two OTel variables is required by the
99
+ specification, so the logger and the SDK reading the same environment agree by construction.
100
+ `service.instance.id` is **never generated** — a UUID minted here would be the logger's rather
101
+ than the OTel Resource's, and would change on every restart while looking authoritative.
102
+ - **`resourceFormat: 'nested' | 'flat'`** (default `'nested'`). `'flat'` emits the dotted attribute
103
+ names verbatim, which a collector maps onto resource attributes directly.
104
+ - **`errorFormat: 'pino' | 'semconv' | 'both'`** (default `'pino'`). Adds the Stable
105
+ `exception.type` / `exception.message` / `exception.stacktrace` and a low-cardinality
106
+ `error.type`. `'both'` is the migration path; `'semconv'` replaces the legacy `err` object and is
107
+ an explicit choice, never a default.
108
+ - **`eventNameField`** (default `'event.name'`). Derives an OTel-conforming event name from
109
+ `logKey` — `PAYMENT_FAILED` → `payment.failed` — following the naming rules. `logKey` is never
110
+ renamed or removed. `false` disables it.
111
+ - **`LOGGER_BOOTSTRAP_WARNING` for an unavailable OTel API.** When trace injection is enabled and
112
+ `@opentelemetry/api` cannot be resolved, one warning naming `OTEL_API_UNAVAILABLE` is emitted at
113
+ boot. A missing `traceId` used to be indistinguishable from "no active span".
114
+ - **`ResolvedServiceMetadata`** exported from `@bymax-one/nest-logger/shared` — the identity after
115
+ precedence, and the contract a future `@bymax-one/nest-observability` consumes.
116
+
117
+ ### Changed
118
+
119
+ - An error's own enumerable properties (`code`, `statusCode`, domain fields) are still carried onto
120
+ the serialized `err`, as Pino's standard serializer did — with the fields the serializer derives
121
+ itself excluded, so a plain error-like object no longer emits `name` beside the `type` derived
122
+ from it.
123
+ - `deployment.environment.name` is emitted from `NODE_ENV` when nothing else supplies it, so most
124
+ applications gain the attribute without configuration. The **deprecated** `deployment.environment`
125
+ spelling is never emitted.
126
+
127
+ ### Performance
128
+
129
+ Measured on the same machine as the 1.2.0 figures, shipped configuration:
130
+
131
+ | Path | 1.2.0 | P1 | Delta |
132
+ | ------------------------------------------- | --------------: | -------------: | ----: |
133
+ | Shipped config throughput | ~278,000 logs/s | 263,768 logs/s | −5% |
134
+ | Retention vs. bare service (budget ≥ 0.20×) | 0.349× | 0.323× | −7% |
135
+
136
+ The cost is almost entirely `event.name` derivation: 949,560 vs 1,089,561 ops/sec on the info path
137
+ (~13% there, ~7% of the full path). Resource identity costs ~2%, because it is resolved once and
138
+ precomputed into Pino's `base`. `errorFormat: 'semconv'` is **faster** than the legacy shape
139
+ (330,289 vs 245,463 ops/sec) since it emits flat strings instead of walking a cause chain. Set
140
+ `eventNameField: false` to pay none of it.
141
+
142
+ ### Not adopted
143
+
144
+ - **`@opentelemetry/api-logs`** — still `0.221.0`. A 0.x package will not become a dependency of a
145
+ library that ships `"dependencies": {}`.
146
+ - **`event.name` as an OTLP attribute** — the attribute is Deprecated; the value belongs in the
147
+ LogRecord `EventName` field, and the emitted key is the JSON carrier for that mapping.
148
+ - **`deployment.id` / `.name` / `.status`** — Development stability.
149
+
150
+ ## [1.2.0] - 2026-08-13
151
+
152
+ Remediation of the P0 findings from the observability audit
153
+ ([`docs/observability_audit.md`](./docs/observability_audit.md)): a credential leak in the
154
+ default redaction set, a correlation field silently dropped from every structured entry, a
155
+ published type that never matched the runtime, and reserved log keys that were documented as
156
+ signals but never written. The redaction engine was replaced in the process, which made the
157
+ shipped logging path **~50× faster**.
158
+
159
+ A second pass moved the HTTP access log from an interceptor to middleware, because an interceptor
160
+ cannot see a request a guard rejected: 401, 403, 429 and 404 produced no log line at all.
161
+
162
+ ### Security
163
+
164
+ - **Guard rejections and unmatched routes are logged.** NestJS runs
165
+ middleware → guards → interceptors → handler, so the interceptor-based access log never observed
166
+ a request rejected by a guard or one that matched no route. Measured against a real backend over
167
+ 20 minutes of traffic: lines existed for 200/201/400, and **zero** for 401 — brute force,
168
+ credential stuffing and route enumeration were invisible, and invisible without a `requestId` to
169
+ correlate them by. `HttpAccessLogMiddleware` now records the access log before guards run, so
170
+ every request produces `HTTP_REQUEST_START` and a terminal entry carrying `ip`, `userAgent` and
171
+ the correlation context.
172
+ - **The correlation scope no longer skips the prefixed root route.** `applyRequestIdMiddleware`
173
+ defaulted to the route pattern `'*'`, and under NestJS 11 (Express 5 / path-to-regexp v8) a
174
+ wildcard is a named parameter with segment-count semantics. Measured: both `'*'` and `'{*splat}'`
175
+ stop matching once the app calls `setGlobalPrefix`, so `GET /api` silently had no correlation
176
+ scope and, with the change above, no access log either. The default is now `'/'`, which MOUNTS at
177
+ the root rather than matching a pattern. The failure mode was absence — no error, just a request
178
+ that never appeared.
179
+
180
+ ### Fixed
181
+
182
+ - **An aborted request is no longer logged as a success.** Destroying the socket does not cancel
183
+ the handler: it runs to completion, writes to a dead connection, the observable completes, and
184
+ the interceptor reported the `200` it intended. Delivery is now reported on its own axis —
185
+ `HTTP_REQUEST_ABORTED`, emitted from the response's `'close'` event when `writableFinished` is
186
+ false — while the status stays whatever the server produced. Inventing one was rejected: nginx's
187
+ `499` is not an HTTP status (IANA leaves 452–499 unassigned), so recording it would assert a code
188
+ the protocol has no name for and break any consumer grouping by class. The limit is stated in the
189
+ README: a fast handler whose client hangs up after the bytes were flushed is still reported as
190
+ the success it was, because the server cannot know whether the peer read them.
191
+
192
+ ### Breaking
193
+
194
+ - **`HttpAccessLogMiddleware` must be wired for the new coverage.** It is registered by
195
+ `applyRequestIdMiddleware(consumer)`, which most consumers already call — no code change needed
196
+ for them. A consumer who never wired that helper keeps the previous interceptor-based behaviour
197
+ (including its blind spot) rather than losing HTTP logs. **Migration:** call
198
+ `applyRequestIdMiddleware(consumer)` in your module's `configure()` hook.
199
+ - **The default middleware route changed from `'*'` to `'/'`.** A consumer passing an explicit
200
+ route is unaffected. One relying on the default now also gets the prefixed root route, which is
201
+ the fix. **Migration:** none; if you deliberately excluded the root, pass an explicit route.
202
+ - **`LoggableResponse` gained `writableFinished` and `on('close', …)`.** An Express response
203
+ satisfies both already. **Migration:** only a consumer who hand-implements the interface — for a
204
+ test double, say — needs to add them.
205
+ - **`HTTP_REQUEST_START` no longer carries `userId`.** The entry is now emitted before guards run,
206
+ which is the whole point of the change — and authentication runs in a guard, so at that moment
207
+ there is no principal to read. The previous interceptor-based START ran after guards and did
208
+ include it. The acting user is still logged: it is read at the terminal entry
209
+ (`HTTP_REQUEST_SUCCESS` / `_REDIRECT` / `_CLIENT_ERROR` / `_SERVER_ERROR` / `_ABORTED`), where
210
+ the guard has populated it. **Migration:** query the terminal entry for `userId`, not START. A
211
+ dashboard joining on START's `userId` should join on `requestId` instead, which both entries
212
+ carry.
213
+ - **The logged URL now includes the app's global prefix.** The middleware is mounted, and a mounted
214
+ middleware sees `url` relative to its mount point, so the entry is built from `originalUrl`.
215
+ Under `setGlobalPrefix('api')` a request for `/api/users/7` used to be logged by the interceptor
216
+ as `/api/users/:id`; that is preserved. **Migration:** none — this keeps the previous value. An
217
+ `excludePaths` pattern is likewise matched against the full path, as before.
218
+ - **New reserved log key `HTTP_REQUEST_ABORTED`.** A consumer asserting an exhaustive list of
219
+ `RESERVED_LOG_KEYS` must add it. **Migration:** add the key to the assertion.
220
+
221
+ ### Security
222
+
223
+ - **Credential-bearing HTTP headers are redacted by name.** `authorization`, `cookie`,
224
+ `set-cookie`, `x-api-key` and `x-auth-token` were covered ONLY by the absolute paths
225
+ `req.headers.*` / `res.headers.*`. A headers bag logged under any other key — for example
226
+ `logger.info(key, msg, userId, { headers: req.headers })` — wrote the bearer token in clear.
227
+ They are now first-class field names, caught wherever they appear.
228
+ - **The four-level nesting cap is gone, and what replaces it fails closed.** The previous wildcard
229
+ expansion reached `*.*.*.*.field` and anything deeper was emitted in clear. The walk now reaches
230
+ 100 levels, and past that a CONTAINER is DROPPED rather than passed through — a traversal ceiling
231
+ exists only so a pathological self-similar structure cannot exhaust the call stack, and it can
232
+ never become a leak the way the old one was. A primitive at the boundary is still emitted, and
233
+ that is not a gap: its key was matched by the parent at level 100, the last container walked.
234
+ - **Prototype-polluting metadata keys are dropped instead of silently swapping the copy.** A log
235
+ call's `metadata` was copied with `Reflect.set`, which does NOT create an own property for
236
+ `__proto__` — the write walks the prototype chain, finds `Object.prototype`'s inherited setter
237
+ and invokes it. An own `__proto__` (what `JSON.parse` of an untrusted body produces) therefore
238
+ vanished from the entry AND swapped the copy's prototype for the caller's value. `__proto__`,
239
+ `constructor` and `prototype` are now dropped, from the same constant the ALS context path
240
+ already enforced, so the two guards cannot drift. `Object.prototype` itself was never reachable,
241
+ which is what kept this a correctness bug rather than a pollution vulnerability.
242
+ - **An accessor `toJSON` can no longer smuggle a value through the binary fast path.** That path
243
+ hands back the ORIGINAL reference, and `JSON.stringify` reads `toJSON` again from it — so a
244
+ GETTER returning `Buffer.prototype.toJSON` to the walk took the fast path, and the stringifier
245
+ then read a factory synthesizing `{ password }`, in clear, past both hooks. Identity proves
246
+ nothing about the next read unless the property is a data property, so an accessor no longer
247
+ selects any path that returns a caller-controlled reference. `toJSON` is now resolved along the
248
+ prototype chain exactly ONCE per value, where the walk used to read it twice.
249
+ - **A `toJSON` chain can no longer exhaust the stack.** Following the method's output did not
250
+ advance the depth counter, so a chain where each `toJSON()` returns a fresh object carrying
251
+ another one recursed forever — nothing repeats, so the ancestor set never matched, and the
252
+ ceiling was never reached. The root catch contained it as the fail-closed envelope rather than a
253
+ crash, but it cost the whole record; the ceiling costs only the pathological value.
254
+ - **A `toJSON` can no longer rename a field around the matcher.** Only the method's OUTPUT was
255
+ inspected, so `{ password, toJSON: () => ({ value: this.password }) }` emitted the secret under
256
+ `value` — a name nobody declared sensitive. When the SOURCE carries a sensitive own key the
257
+ method is no longer trusted and the whole value is censored. This over-redacts an object that
258
+ holds a sensitive key AND correctly omits it, deliberately: the alternative — invoking `toJSON`
259
+ against a sanitized copy — throws on every method that reads an internal slot instead of an own
260
+ property, which is `Date`, `Decimal` and Luxon.
261
+ - **A terminal array index in `redactPaths` no longer covers nothing.** `tokens[0]` fed the leaf
262
+ `0` to the walk, which compares NAMES and never array positions — so the element stayed raw
263
+ through the size-bounded `_preview`, while any object key literally named `0` was censored
264
+ instead. An unquoted numeric segment is now read as an index and skipped like a wildcard, so
265
+ `tokens[0]` covers `tokens`: broader than the path, in the safe direction. The quoted form
266
+ `["0"]` stays a name.
267
+ - **Base bindings are redacted.** `service` is consumer-supplied and `applyDefaults` keeps whatever
268
+ it was handed, so a `{ name, version, apiKey }` reached the sink in clear once base stopped going
269
+ through the path expansion that had covered it via `*.*.apiKey`. Redacted at
270
+ `formatters.bindings`, which runs once at logger construction — no per-entry cost — and which
271
+ preserves a consumer's extra non-sensitive metadata rather than trimming base to the two declared
272
+ fields.
273
+ - **Child-logger bindings are redacted.** `PinoLoggerService.child(bindings)` accepts any record,
274
+ and Pino pre-serializes child bindings into the instance's `chindings` fragment before any
275
+ formatter runs — so no factory hook can reach them. `logger.child({ password })` stamped the
276
+ value in clear on every entry that child emitted. Redaction is applied in `child()` itself.
277
+ - **An `Error` is redacted wherever it is logged, not only under a key with a serializer.** The
278
+ walk skipped `Error` instances to preserve the instance Pino's `err` serializer keys off, and
279
+ the compensating serializer hook only fires for keys that actually have one — so
280
+ `{ failure: err }` carrying an `apiKey` reached the sink in clear. Errors are now cloned through
281
+ their prototype and descriptors, which censors the enumerable properties while keeping
282
+ `instanceof`, `message` and `stack` intact.
283
+ - **Sensitive field names are matched case-insensitively.** HTTP header names are
284
+ case-insensitive by spec, and only INBOUND Node headers arrive lower-cased — a hand-built or
285
+ outbound bag carrying `Authorization`, `Cookie` or `X-API-Key` was left in clear while the
286
+ documentation claimed header coverage. Matching now lower-cases the key, which also covers
287
+ `Password` / `Email` and errs toward redacting.
288
+ - **An array with a custom `toJSON()` is redacted.** `JSON.stringify` gives the method precedence
289
+ over array serialization, so an array whose `toJSON()` synthesized a secret was walked as an
290
+ ordinary array — finding nothing in its elements — and then emitted the secret. The `toJSON`
291
+ branch now runs before the array branch.
292
+ - **A secret synthesized by `toJSON()` is redacted.** A value with `toJSON` decides its own
293
+ serialized form, so the walk cannot inspect its own properties — but skipping it let
294
+ `{ toJSON: () => ({ accessToken }) }` emit the token untouched. The walk now redacts the
295
+ method's output, substituting it only when something was actually censored so a clean `Date` or
296
+ `Decimal` is still passed through by reference.
297
+ - **Binary values are fast-pathed by IDENTITY, not by shape.** `ArrayBuffer.isView` was too wide:
298
+ it also matched extended views, and three shapes went straight through both hooks — an own
299
+ `toJSON` synthesizing a payload, and an enumerable property on a `Uint8Array` or `DataView`,
300
+ neither of which has a `toJSON` to hide it. Narrowing it to "a view with no OWN `toJSON`" was
301
+ still too wide, because a SUBCLASS can define one on its own prototype. The check is now an
302
+ identity comparison against `Buffer.prototype.toJSON` itself, the one function known to produce a
303
+ `{ type, data }` output that cannot carry a caller's key — which keeps a real binary payload from
304
+ being recursed over byte by byte, and inspects everything else.
305
+ - **Arrays are indexed numerically rather than iterated.** `for...of` runs the array's
306
+ `Symbol.iterator`, which a caller can override, while `JSON.stringify` reads `length` and
307
+ numeric indices. An iterator that never returned `done` hung the log call — a loop that does not
308
+ end cannot be caught by the never-throw guard — and one yielding values unrelated to the indices
309
+ made the walk inspect something the array does not hold. Reading the way the serializer reads
310
+ also makes holes render identically to the native output.
311
+ - **A callable carrying `toJSON` is inspected.** `JSON.stringify` applies a callable `toJSON` to a
312
+ FUNCTION object too — the spec runs that step BEFORE the "callable serializes to undefined" rule
313
+ — so `Object.assign(() => {}, { toJSON: () => ({ accessToken }) })` emitted its payload with
314
+ nothing having inspected it. An ordinary function is still handed back untouched, so it keeps
315
+ being omitted from the output rather than becoming `{}`.
316
+ - **The root of a log record never honours `toJSON`.** Pino ITERATES that object rather than
317
+ serializing it, so honouring the method replaced the whole record with its return value:
318
+ `logger.info(key, msg, userId, { toJSON: () => 'x' })` emitted `{"0":"x"}` and lost `logKey`,
319
+ `userId` and every other field. Nested values and serializer outputs do reach `JSON.stringify`
320
+ and keep it. The same applies to `child()` bindings, which Pino also iterates.
321
+ - **An object that reports no keys is snapshot, not passed through.** The empty-key fast path
322
+ returned the original reference, which a Proxy exploits: answer `[]` to the walk's
323
+ `Object.keys`, then expose an enumerable secret when Pino serializes the reference.
324
+ - **A censor that cannot be written fails closed.** `Reflect.defineProperty` reports failure by
325
+ returning `false` rather than throwing, so an `Error` carrying a non-configurable enumerable
326
+ secret kept its raw value while the redaction silently no-opped. A failed write is now a
327
+ traversal failure.
328
+ - **A stateful `toJSON()` or getter cannot differ between inspection and serialization.** The walk
329
+ probed `toJSON()` once and, when the result was clean, returned the original object — leaving
330
+ `JSON.stringify` to call the method a second time (with the property key, which the probe does
331
+ not pass). The inspected result is now what gets serialized, and the same applies to accessors.
332
+ - **Hostile metadata and hostile errors no longer crash the caller.** The never-throw guarantee
333
+ now starts at the FIRST read of caller-controlled data: `Object.keys` fires a Proxy's `ownKeys`
334
+ trap and `Reflect.get` fires a getter, both before anything reached the redaction pipeline, so
335
+ `logger.info(key, msg, undefined, hostileMetadata)` threw outright. Unreadable metadata is
336
+ dropped whole and marked with the redaction-failure envelope while the entry keeps its real
337
+ `logKey`, message and correlation ids. The same guard covers the error path — `name`, `message`
338
+ and `stack` are ordinary properties a caller can redefine as throwing accessors, and `message`
339
+ is read outside the serializer.
340
+ - **A record whose getter throws is dropped WHOLE, not up to the failing property.** The mixin
341
+ merge used `Object.assign` on the mixin's own object, which copies key by key — so everything
342
+ read before the hostile getter was already written into it, and the failure path emitted that
343
+ prefix while claiming to drop the record. Merging into a disposable target keeps the partial
344
+ writes in the value that is discarded.
345
+ - **A throwing getter no longer crashes the log call.** Pino merges the mixin result with the
346
+ caller's object before `formatters.log` runs, and the default strategy's `Object.assign` invokes
347
+ every own getter — so a hostile getter threw before the redactor's fail-closed envelope could
348
+ apply. The factory now owns the merge, and the never-throw guarantee holds through the real
349
+ pipeline.
350
+ - **Bootstrap entries are emitted after destination initialization.** They were written from an
351
+ eagerly instantiated provider factory, which runs before `DestinationRegistry.onModuleInit()` —
352
+ so a sink that only accepts writes once its own `onInit()` has run could drop them, including
353
+ `LOGGER_BOOTSTRAP_WARNING`. They now come from the registry that owns that initialization, which
354
+ makes the ordering structural. Note for test authors: `Test.createTestingModule().compile()`
355
+ builds the graph, `init()` runs the lifecycle hooks — the bootstrap entries appear after `init()`.
356
+ - **`LOGGER_BOOTSTRAP_WARNING` is emitted when `shouldDisableDefaultRedact` is on.** The README
357
+ has always described this entry as the audit trail proving when PII protection was
358
+ intentionally reduced. It was never written, so a deployment running without redaction was
359
+ indistinguishable from a protected one.
360
+
361
+ ### Changed
362
+
363
+ - **Default redaction is now a single name-based recursive walk** instead of 140 compiled
364
+ `fast-redact` paths. A value is censored when its key name is in `REDACT_COMMON_FIELDS`, at any
365
+ depth, in one snapshotting traversal that mutates nothing of the caller's and reads every value
366
+ exactly once.
367
+ Circular references collapse to `[Circular]`; a record that cannot be walked
368
+ degrades to a marked, data-free envelope rather than being emitted unredacted.
369
+
370
+ Measured on the full production path (`forRoot({ service })`, no other option set):
371
+ **9,311 → ~274,000 logs/s**, ~107 µs → ~3.6 µs per entry. Every value is read exactly once and
372
+ pinned into a fresh structure, so what reaches the sink is guaranteed to be what was inspected —
373
+ an earlier copy-on-write draft returned clean subtrees by reference and let `JSON.stringify`
374
+ re-evaluate their accessors, which a stateful getter can answer differently.
375
+
376
+ - **`redactPaths` is unchanged** — consumer paths are still `fast-redact` paths, applied on top of
377
+ the default coverage. `fast-redact` is now configured only when there are consumer paths to
378
+ apply.
379
+ - **Bundle-size budget raised** 13.5 → 16.0 KiB brotli for the server subpath, and the benchmark's
380
+ throughput floor raised 0.004 → 0.20; the old floor was calibrated to the wildcard engine and
381
+ could no longer fail on anything short of a 100× regression.
382
+
383
+ ### Added
384
+
385
+ - **`redactStrategy: 'names' | 'paths'`** (default `'names'`). `'paths'` restores the pre-1.2
386
+ engine for a consumer depending on exact `fast-redact` path semantics — with its four-level
387
+ ceiling and its cost. Expect it to be removed in a future major.
388
+ - **`PINO_LEVEL_NUMBERS` / `PINO_LEVEL_NAMES` are exported** from the server subpath, so a
389
+ destination writing a numeric level column can convert the string label without hard-coding the
390
+ mapping.
391
+ - **`RESERVED_LOG_KEYS.LOGGER_REDACTION_FAILED`** — the marker on the envelope substituted for a
392
+ record whose traversal threw.
393
+ - **`RESERVED_LOG_KEYS_NOT_EMITTED`** — the keys that are reserved but intentionally never
394
+ written, each with its reason. A unit test now asserts that every other declared key has a
395
+ writer in production source, so a key can no longer be declared, documented as a signal, and
396
+ silently never emitted.
397
+
398
+ ### Fixed
399
+
400
+ - **AsyncLocalStorage context reaches structured entries.** `emitStructured` / `errorStructured` /
401
+ the NestJS-variadic path wrote `userId` and `context` as own properties even when they were
402
+ `undefined`, and Pino's default `mixinMergeStrategy` (`Object.assign(mixinResult, mergeObject)`)
403
+ let that `undefined` overwrite the value the trace mixin had just read from the ALS store. The
404
+ key then vanished during serialization, so `logContext.set('userId', …)` — the documented way to
405
+ attach the authenticated user once per request — never reached a log unless every call site
406
+ repeated it. `requestId` and `tenantId` only ever survived because those names are not written
407
+ by this class. Precedence is now explicit argument > ALS store > field absent.
408
+ - **A caller's `metadata` can never occupy `logKey`, `userId` or `context`.** The invariant was
409
+ previously enforced by overwriting those fields after the spread — the same unconditional write
410
+ that clobbered the ALS context. Now that they are written only when defined, the owned names are
411
+ stripped from `metadata` on the way in, so a metadata bag cannot forge the acting user that the
412
+ mixin read from the authenticated request scope.
413
+ - **`RESERVED_LOG_KEYS_NOT_EMITTED` is exported** from both subpaths, so the `{@link}` in
414
+ `RESERVED_LOG_KEYS`'s documentation resolves in the published declarations.
415
+ - **`LOGGER_SHUTDOWN_OK` is emitted** at the start of `onApplicationShutdown`, before the
416
+ destinations are torn down, with an event-loop barrier so an async sink's write is not raced by
417
+ its own teardown (the authoritative contract remains `ILogDestination.onShutdown`, which MUST
418
+ flush pending writes). It is the bookend to `LOGGER_BOOTSTRAP_OK`: its absence in a log
419
+ stream is how an operator tells a graceful shutdown from a killed process.
420
+ - **A consumer path's leaf name reaches the walk, so it cannot leak through a truncation preview.**
421
+ Consumer `redactPaths` are applied by Pino's stringifier, which runs AFTER the per-field size
422
+ bound — so a field covered only by a path was still raw when the 200-character `_preview` was
423
+ built. The leaf name is now matched by the walk as well, which closes that and covers the same
424
+ name on every other surface the walk reaches. Deliberately broader than the path itself:
425
+ `redactPaths: ['user.ssn']` censors `ssn` wherever it appears.
426
+ - **An oversized field's truncation `_preview` is redacted.** Redaction now runs before the size
427
+ bound, so the 200-character preview of a truncated value carries `[REDACTED]` instead of the
428
+ head of a secret.
429
+
430
+ ### Documentation
431
+
432
+ - **`README.md` API reference corrected.** The table described `warn` / `debug` / `error` / `fatal`
433
+ as structured methods taking a log key — they are the NestJS variadic bridge — documented a
434
+ `fatalStructured` that does not exist, labelled the structured API's third parameter `context`
435
+ when it is `userId`, and described `@LogContext` as a method decorator that opens a
436
+ `logContext.run()` scope when it is a class decorator that only records metadata.
437
+ - **The Loki and Prisma destination examples now run.** The Loki example called
438
+ `BigInt(entry.time)` on an ISO 8601 string, which throws; the Prisma example wrote the string
439
+ level label into a numeric column. Both conversions are now correct and are exercised by
440
+ `test/e2e/log-entry-contract.e2e-spec.ts`.
441
+ - **The architecture diagram no longer inverts the pipeline** — `RequestIdMiddleware` runs before
442
+ `HttpLoggingInterceptor`, which is why the interceptor's entries carry a `requestId` at all.
443
+
444
+ ### Breaking
445
+
446
+ - **`LogEntry.level` is `LogLevel` (a string) and `LogEntry.time` is `string`.** They were declared
447
+ `number` and `string | number`; the runtime has always emitted the Pino string label and an ISO
448
+ 8601 string. This is a **type-level** break only — code relying on the old declaration was
449
+ already failing at runtime — but it can newly fail to compile. Convert with the now-exported
450
+ `PINO_LEVEL_NUMBERS` and `Date.parse`.
451
+
452
+ Shipped as a minor rather than a major deliberately: the library has no consumers yet, and
453
+ SemVer's major exists to protect the consumers a break would reach. Breaks are documented here
454
+ under this heading with their migration path, which is what carries the signal while the version
455
+ number does not.
456
+
14
457
  ## [1.1.0] - 2026-08-11
15
458
 
16
459
  Coordinated ecosystem release aligning every `@bymax-one/*` package after the ioredis 6 /
@@ -350,7 +793,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
350
793
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
351
794
  `release.yml`, Dependabot, and issue templates
352
795
 
353
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.1.0...HEAD
796
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...HEAD
797
+ [1.2.1]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...v1.2.1
798
+ [1.2.0]: https://github.com/bymaxone/nest-logger/compare/v1.1.0...v1.2.0
354
799
  [1.1.0]: https://github.com/bymaxone/nest-logger/compare/v1.0.8...v1.1.0
355
800
  [1.0.8]: https://github.com/bymaxone/nest-logger/compare/v1.0.7...v1.0.8
356
801
  [1.0.7]: https://github.com/bymaxone/nest-logger/compare/v1.0.6...v1.0.7