@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 +446 -1
- package/README.md +381 -88
- package/dist/server/index.cjs +891 -145
- package/dist/server/index.d.cts +361 -51
- package/dist/server/index.d.ts +361 -51
- package/dist/server/index.mjs +896 -150
- package/dist/shared/index.cjs +15 -1
- package/dist/shared/index.d.cts +149 -8
- package/dist/shared/index.d.ts +149 -8
- package/dist/shared/index.mjs +15 -2
- package/package.json +1 -1
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
|
|
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
|