@bymax-one/nest-logger 1.4.6 → 1.4.7

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,41 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.4.7] - 2026-11-01
15
+
16
+ ### Fixed
17
+
18
+ - **`errorStructured` keeps its `logKey` when the `Error` cannot be read.** A hostile `Error` (a Proxy whose
19
+ `ownKeys` throws, an own getter that throws, a `stack` or `message` getter that throws) made the whole
20
+ record fail the name redaction, so the entry arrived as `LOGGER_REDACTION_FAILED` and lost the caller's
21
+ `logKey`, `userId` and metadata. The `Error` node is now isolated: when it cannot be walked it is replaced
22
+ by its sanitized form and walked again, so the name redaction still applies to whatever can be read. An
23
+ unreadable `stack` or `cause` no longer degrades the node to `SanitizeFailed`; it keeps `name` and
24
+ `message` and carries `_errorFieldsIncomplete: true`. A hostile value that is not an `Error` still fails
25
+ the whole record, as before.
26
+ - **A stale comment in `HttpExceptionFilter` is corrected.** It said the cause chain was dropped at
27
+ `errorStructured`; the entry keeps the cause chain and `AggregateError.errors`, and the response body
28
+ that rides along as an own field is name-redacted. A new spec pins both.
29
+
30
+ #### Apply to a derived backend
31
+
32
+ Nothing to change in code. What can differ at run time:
33
+
34
+ - An alert on `LOGGER_REDACTION_FAILED` that fired for an unreadable `Error` stops firing for it. The event
35
+ now arrives under its own `logKey`, with the `err` node carrying `_errorFieldsIncomplete: true`. To find
36
+ these entries going forward, filter on `err._errorFieldsIncomplete: true` or `err.type: 'SanitizeFailed'`. `LOGGER_REDACTION_FAILED` is
37
+ left for a record that really cannot be read, such as hostile metadata.
38
+ - An error whose `stack` or `cause` getter throws used to reach the log as `{ type: 'SanitizeFailed' }`; it
39
+ now keeps its `type` and `message` and carries the marker. Code that matched `SanitizeFailed` for that
40
+ case should match the marker instead.
41
+
42
+ ### Tests
43
+
44
+ - An `Error` that cannot be read has a failing-first spec at the redactor and through the real Pino
45
+ pipeline, including a readable `password` next to a throwing getter and a hostile `message` getter, with
46
+ no clear secret in the line. Two existing cases that pinned the old degradation (`SanitizeFailed` for a
47
+ throwing `stack` getter, the record-level envelope for a hostile `Error`) now pin the new behavior.
48
+
14
49
  ## [1.4.6] - 2026-11-01
15
50
 
16
51
  ### Fixed
@@ -2276,7 +2311,8 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
2276
2311
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
2277
2312
  `release.yml`, Dependabot, and issue templates
2278
2313
 
2279
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.4.6...HEAD
2314
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.4.7...HEAD
2315
+ [1.4.7]: https://github.com/bymaxone/nest-logger/compare/v1.4.6...v1.4.7
2280
2316
  [1.4.6]: https://github.com/bymaxone/nest-logger/compare/v1.4.5...v1.4.6
2281
2317
  [1.4.5]: https://github.com/bymaxone/nest-logger/compare/v1.4.4...v1.4.5
2282
2318
  [1.4.4]: https://github.com/bymaxone/nest-logger/compare/v1.4.3...v1.4.4
package/README.md CHANGED
@@ -729,6 +729,8 @@ Pino's standard serializer did.
729
729
 
730
730
  When those properties cannot be read (a Proxy whose `ownKeys` throws, or a getter that throws), none of them is copied and the error node carries `_errorFieldsIncomplete: true`, so an entry without its `code` is never mistaken for a complete one.
731
731
 
732
+ An `Error` that cannot be read at all does not cost the record its `logKey`, `userId` or metadata: the node is replaced by its sanitized form, which keeps `name` and `message` and carries the same marker unless `name` or `message` itself is unreadable, in which case it is `{ type: 'SanitizeFailed' }`. Filter on `err._errorFieldsIncomplete: true` or `SanitizeFailed` to find these entries.
733
+
732
734
  ### 7. Context propagation with `LogContextService`
733
735
 
734
736
  ```typescript
@@ -1599,7 +1601,7 @@ The `PrettyDevDestination` errors are thrown inside `onInit`, which the registry
1599
1601
  | `LOGGER_OTEL_API_BROKEN` | NDJSON line on `stderr`, once | `@opentelemetry/api` is installed but throws while loading, or exports no usable `trace` namespace | Reinstall it, or turn `otel.shouldAutoInjectTraceContext` off; until then `traceId` and `spanId` are absent |
1600
1602
  | `LOGGER_BOOTSTRAP_WARNING` | log entry with `reason: 'OTEL_API_UNAVAILABLE'` | `otel.shouldAutoInjectTraceContext` is on and `@opentelemetry/api` cannot be resolved | Install `@opentelemetry/api`, or turn the option off; until then `traceId` and `spanId` are absent |
1601
1603
  | `LOGGER_ENTRY_TRUNCATED` | `_logKey` in the envelope | a serialized field exceeds `maxEntrySizeBytes` | Reduce the payload or raise the ceiling |
1602
- | `LOGGER_REDACTION_FAILED` | `_logKey` in the envelope, with `_redactionFailed: true` | a record cannot be walked by the redactor (a throwing getter, a hostile proxy) | The record is dropped whole, never partly emitted; the message and ambient context stay. Find the value that broke the walk |
1604
+ | `LOGGER_REDACTION_FAILED` | `_logKey` in the envelope, with `_redactionFailed: true` | a record cannot be walked by the redactor (a throwing getter, a hostile proxy in the metadata) | The record is dropped whole, never partly emitted; the message and ambient context stay. Find the value that broke the walk |
1603
1605
 
1604
1606
  ---
1605
1607
 
@@ -399,12 +399,14 @@ function toSanitizedError(value, seen, depth2, maxDepth) {
399
399
  }
400
400
  seen.add(value);
401
401
  const result = { name: value.name, message: value.message };
402
- if (value.stack !== void 0) {
403
- result.stack = scrubStack(value.stack);
402
+ const stack = readOptional(() => value.stack, result);
403
+ if (stack !== void 0) {
404
+ result.stack = scrubStack(stack);
404
405
  }
405
406
  const isAtDepthLimit = depth2 >= maxDepth;
406
- if (value.cause !== void 0) {
407
- result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(value.cause, seen, depth2 + 1, maxDepth);
407
+ const cause = readOptional(() => value.cause, result);
408
+ if (cause !== void 0) {
409
+ result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(cause, seen, depth2 + 1, maxDepth);
408
410
  }
409
411
  const aggregated = readAggregatedErrors(value);
410
412
  if (aggregated !== void 0) {
@@ -431,7 +433,18 @@ function assignOwnErrorFields(target, source) {
431
433
  }
432
434
  }
433
435
  } catch {
434
- Reflect.defineProperty(target, "_errorFieldsIncomplete", { value: true, enumerable: true });
436
+ markIncomplete(target);
437
+ }
438
+ }
439
+ function markIncomplete(target) {
440
+ Reflect.defineProperty(target, "_errorFieldsIncomplete", { value: true, enumerable: true });
441
+ }
442
+ function readOptional(read, node) {
443
+ try {
444
+ return read();
445
+ } catch {
446
+ markIncomplete(node);
447
+ return void 0;
435
448
  }
436
449
  }
437
450
  function toSanitizedChild(value, seen, depth2, maxDepth) {
@@ -1485,6 +1498,19 @@ function walk(value, sensitive, censor, depth2, ancestors, honorToJson = true) {
1485
1498
  if (ancestors.has(value)) {
1486
1499
  return REDACT_CIRCULAR;
1487
1500
  }
1501
+ if (value instanceof Error) {
1502
+ return walkError(value, sensitive, censor, depth2, ancestors, honorToJson);
1503
+ }
1504
+ return walkObject(value, sensitive, censor, depth2, ancestors, honorToJson);
1505
+ }
1506
+ function walkError(error, sensitive, censor, depth2, ancestors, honorToJson) {
1507
+ try {
1508
+ return walkObject(error, sensitive, censor, depth2, ancestors, honorToJson);
1509
+ } catch {
1510
+ return walk(sanitizeError(error), sensitive, censor, depth2, ancestors, honorToJson);
1511
+ }
1512
+ }
1513
+ function walkObject(value, sensitive, censor, depth2, ancestors, honorToJson) {
1488
1514
  const resolvedToJson = resolveToJson(value);
1489
1515
  if (!resolvedToJson.fromAccessor && resolvedToJson.method === BUFFER_TO_JSON) {
1490
1516
  return value;
@@ -618,66 +618,6 @@ declare const RESERVED_LOG_KEYS_NOT_EMITTED: Readonly<{
618
618
  */
619
619
  type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS];
620
620
 
621
- /**
622
- * Name-based recursive redaction.
623
- *
624
- * Layer: server/utils — the engine behind the library's DEFAULT PII protection.
625
- * It walks a log record once and censors every value whose KEY NAME is in the
626
- * sensitive set, to a bounded depth (see {@link REDACT_MAX_TRAVERSAL_DEPTH}).
627
- *
628
- * Why this replaced path matching. The previous default compiled 140
629
- * `fast-redact` paths, 108 of them multi-level wildcards (`*.password`,
630
- * `*.*.password`, …). `fast-redact` walks every key at every listed level on
631
- * every log call, so the cost grew with the PATH COUNT, not with the payload:
632
- * measured at ~107 µs per entry (≈9.3 k logs/s) against ~0.9 µs with redaction
633
- * off. A single traversal is O(nodes) — the same order Pino's serializer already
634
- * pays. The shipped configuration measures 462 k logs/s against 9.3 k, for
635
- * byte-identical output on every payload the old paths covered.
636
- *
637
- * It is also STRICTLY SAFER than the paths it replaces:
638
- * - depth 100 instead of four, and past it the value is DROPPED rather than
639
- * passed through, so the ceiling cannot become a leak the way the old one
640
- * was (anything below the wildcard list's fourth level was emitted in clear);
641
- * - a sensitive key is caught wherever it appears, so a headers bag logged as
642
- * `{ headers: { authorization } }` is covered, not only the exact
643
- * `req.headers.authorization` shape the absolute paths pinned.
644
- *
645
- * Three value kinds need special handling, and getting any of them wrong is a
646
- * leak rather than a cosmetic issue:
647
- * - `Error` — copied through its prototype and descriptors, so it stays an
648
- * `Error` (Pino's `err` serializer keys off the instance, and `message` /
649
- * `stack` are own but NON-enumerable). Skipping errors entirely left an
650
- * error under any key WITHOUT a serializer serializing its own enumerable
651
- * properties in clear.
652
- * - anything carrying `toJSON` (`Date`, `Decimal`, Luxon, Prisma types) — the
653
- * method decides the serialized form, so the walk inspects its OUTPUT.
654
- * Skipping these let a `toJSON` SYNTHESIZE a secret straight past both hooks.
655
- * - `ArrayBuffer` views (`Buffer`, typed arrays) — indexed byte containers,
656
- * returned untouched.
657
- *
658
- * What this file cannot reach: bindings passed to `child()`. Pino pre-serializes
659
- * them into the instance's `chindings` fragment before any formatter runs, so
660
- * `PinoLoggerService.child()` applies this redactor itself.
661
- *
662
- * Contract: SNAPSHOT and NEVER-THROW. Nothing the caller passed is mutated; the
663
- * result is a fresh structure built from values read exactly ONCE. That is a
664
- * deliberate trade against an earlier copy-on-write design which returned a
665
- * clean subtree by reference: doing so left every accessor in it to be evaluated
666
- * a SECOND time by `JSON.stringify`, and a stateful getter (or `toJSON`) can
667
- * answer clean to the walk and `{ password }` to the serializer. Inspection
668
- * cannot close that window — only reading once and pinning the result can. It
669
- * costs ~26 % of the entry throughput, measured, and the walk is still ~36x
670
- * faster than the path expansion it replaced.
671
- *
672
- * Any internal failure — a throwing getter, a hostile proxy, a value the walk
673
- * cannot READ — degrades the whole record to a marked, secret-free envelope rather
674
- * than crashing the request that produced it. Writing the censor is not among those
675
- * failures: every copy is built so the write cannot fail, which is why a frozen
676
- * error keeps its fields instead of costing the record.
677
- *
678
- * @see {@link createNameRedactor}
679
- */
680
-
681
621
  /**
682
622
  * A configured redactor.
683
623
  *
@@ -618,66 +618,6 @@ declare const RESERVED_LOG_KEYS_NOT_EMITTED: Readonly<{
618
618
  */
619
619
  type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS];
620
620
 
621
- /**
622
- * Name-based recursive redaction.
623
- *
624
- * Layer: server/utils — the engine behind the library's DEFAULT PII protection.
625
- * It walks a log record once and censors every value whose KEY NAME is in the
626
- * sensitive set, to a bounded depth (see {@link REDACT_MAX_TRAVERSAL_DEPTH}).
627
- *
628
- * Why this replaced path matching. The previous default compiled 140
629
- * `fast-redact` paths, 108 of them multi-level wildcards (`*.password`,
630
- * `*.*.password`, …). `fast-redact` walks every key at every listed level on
631
- * every log call, so the cost grew with the PATH COUNT, not with the payload:
632
- * measured at ~107 µs per entry (≈9.3 k logs/s) against ~0.9 µs with redaction
633
- * off. A single traversal is O(nodes) — the same order Pino's serializer already
634
- * pays. The shipped configuration measures 462 k logs/s against 9.3 k, for
635
- * byte-identical output on every payload the old paths covered.
636
- *
637
- * It is also STRICTLY SAFER than the paths it replaces:
638
- * - depth 100 instead of four, and past it the value is DROPPED rather than
639
- * passed through, so the ceiling cannot become a leak the way the old one
640
- * was (anything below the wildcard list's fourth level was emitted in clear);
641
- * - a sensitive key is caught wherever it appears, so a headers bag logged as
642
- * `{ headers: { authorization } }` is covered, not only the exact
643
- * `req.headers.authorization` shape the absolute paths pinned.
644
- *
645
- * Three value kinds need special handling, and getting any of them wrong is a
646
- * leak rather than a cosmetic issue:
647
- * - `Error` — copied through its prototype and descriptors, so it stays an
648
- * `Error` (Pino's `err` serializer keys off the instance, and `message` /
649
- * `stack` are own but NON-enumerable). Skipping errors entirely left an
650
- * error under any key WITHOUT a serializer serializing its own enumerable
651
- * properties in clear.
652
- * - anything carrying `toJSON` (`Date`, `Decimal`, Luxon, Prisma types) — the
653
- * method decides the serialized form, so the walk inspects its OUTPUT.
654
- * Skipping these let a `toJSON` SYNTHESIZE a secret straight past both hooks.
655
- * - `ArrayBuffer` views (`Buffer`, typed arrays) — indexed byte containers,
656
- * returned untouched.
657
- *
658
- * What this file cannot reach: bindings passed to `child()`. Pino pre-serializes
659
- * them into the instance's `chindings` fragment before any formatter runs, so
660
- * `PinoLoggerService.child()` applies this redactor itself.
661
- *
662
- * Contract: SNAPSHOT and NEVER-THROW. Nothing the caller passed is mutated; the
663
- * result is a fresh structure built from values read exactly ONCE. That is a
664
- * deliberate trade against an earlier copy-on-write design which returned a
665
- * clean subtree by reference: doing so left every accessor in it to be evaluated
666
- * a SECOND time by `JSON.stringify`, and a stateful getter (or `toJSON`) can
667
- * answer clean to the walk and `{ password }` to the serializer. Inspection
668
- * cannot close that window — only reading once and pinning the result can. It
669
- * costs ~26 % of the entry throughput, measured, and the walk is still ~36x
670
- * faster than the path expansion it replaced.
671
- *
672
- * Any internal failure — a throwing getter, a hostile proxy, a value the walk
673
- * cannot READ — degrades the whole record to a marked, secret-free envelope rather
674
- * than crashing the request that produced it. Writing the censor is not among those
675
- * failures: every copy is built so the write cannot fail, which is why a frozen
676
- * error keeps its fields instead of costing the record.
677
- *
678
- * @see {@link createNameRedactor}
679
- */
680
-
681
621
  /**
682
622
  * A configured redactor.
683
623
  *
@@ -396,12 +396,14 @@ function toSanitizedError(value, seen, depth2, maxDepth) {
396
396
  }
397
397
  seen.add(value);
398
398
  const result = { name: value.name, message: value.message };
399
- if (value.stack !== void 0) {
400
- result.stack = scrubStack(value.stack);
399
+ const stack = readOptional(() => value.stack, result);
400
+ if (stack !== void 0) {
401
+ result.stack = scrubStack(stack);
401
402
  }
402
403
  const isAtDepthLimit = depth2 >= maxDepth;
403
- if (value.cause !== void 0) {
404
- result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(value.cause, seen, depth2 + 1, maxDepth);
404
+ const cause = readOptional(() => value.cause, result);
405
+ if (cause !== void 0) {
406
+ result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(cause, seen, depth2 + 1, maxDepth);
405
407
  }
406
408
  const aggregated = readAggregatedErrors(value);
407
409
  if (aggregated !== void 0) {
@@ -428,7 +430,18 @@ function assignOwnErrorFields(target, source) {
428
430
  }
429
431
  }
430
432
  } catch {
431
- Reflect.defineProperty(target, "_errorFieldsIncomplete", { value: true, enumerable: true });
433
+ markIncomplete(target);
434
+ }
435
+ }
436
+ function markIncomplete(target) {
437
+ Reflect.defineProperty(target, "_errorFieldsIncomplete", { value: true, enumerable: true });
438
+ }
439
+ function readOptional(read, node) {
440
+ try {
441
+ return read();
442
+ } catch {
443
+ markIncomplete(node);
444
+ return void 0;
432
445
  }
433
446
  }
434
447
  function toSanitizedChild(value, seen, depth2, maxDepth) {
@@ -1482,6 +1495,19 @@ function walk(value, sensitive, censor, depth2, ancestors, honorToJson = true) {
1482
1495
  if (ancestors.has(value)) {
1483
1496
  return REDACT_CIRCULAR;
1484
1497
  }
1498
+ if (value instanceof Error) {
1499
+ return walkError(value, sensitive, censor, depth2, ancestors, honorToJson);
1500
+ }
1501
+ return walkObject(value, sensitive, censor, depth2, ancestors, honorToJson);
1502
+ }
1503
+ function walkError(error, sensitive, censor, depth2, ancestors, honorToJson) {
1504
+ try {
1505
+ return walkObject(error, sensitive, censor, depth2, ancestors, honorToJson);
1506
+ } catch {
1507
+ return walk(sanitizeError(error), sensitive, censor, depth2, ancestors, honorToJson);
1508
+ }
1509
+ }
1510
+ function walkObject(value, sensitive, censor, depth2, ancestors, honorToJson) {
1485
1511
  const resolvedToJson = resolveToJson(value);
1486
1512
  if (!resolvedToJson.fromAccessor && resolvedToJson.method === BUFFER_TO_JSON) {
1487
1513
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-logger",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "description": "Structured JSON logging for NestJS based on Pino 10, with optional OpenTelemetry correlation.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",