@bymax-one/nest-logger 1.2.9 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,52 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.3.0] - 2026-08-18
15
+
16
+ ### Fixed
17
+
18
+ - **A frozen `Error` lost every field, including its own message.** A consumer that freezes its
19
+ errors — deliberate and documented practice, so nobody mutates an issue list after the throw —
20
+ hands over properties that are neither writable nor configurable. The redactor's clone inherited
21
+ those descriptors, and rewriting any key whose walked value is a fresh object (a censored secret,
22
+ or simply the structural copy the walk makes of a nested array) could not redefine it. That
23
+ failure reached the fail-closed guard and dropped the **whole** record, so the entry became
24
+ `_redactionFailed: true` and nothing else — total silent loss on the error that crashed the
25
+ process, which is the one entry worth logging.
26
+
27
+ A frozen error carrying only scalars never showed it, because redefining a property with the same
28
+ primitive succeeds; it took a nested object to expose it. Reported against 1.2.7 by a consumer and
29
+ reproduced unchanged on 1.2.9.
30
+
31
+ The clone is transient — built, serialized, discarded — so it is now built with relaxed
32
+ descriptors and the write always lands. **The caller's error is never touched**, and freezing
33
+ remains the caller's guarantee about the caller's own object.
34
+
35
+ A non-configurable secret is now **censored** rather than dropped: same protection, without
36
+ losing the surrounding fields. The frozen error also keeps its **stack** again — V8 exposes it as
37
+ an own property bound to the original's internal state, so the clone pins a resolved copy, and on
38
+ a frozen error that pin was failing silently too. The case that did NOT drop the record was
39
+ therefore already reaching the sink with its trace erased, which is the same defect wearing a
40
+ healthy face; a consumer measured that independently. `LOGGER_REDACTION_FAILED` is unchanged for the case it was written
41
+ for — a record the walk cannot READ, such as a throwing getter or a hostile proxy.
42
+
43
+ ### Documentation
44
+
45
+ - **The limit of field-based redaction now ships.** A value interpolated into the message has no
46
+ field name for any strategy to match — not an uncovered one, none — so no configuration closes
47
+ that gap and the rule is "a value that matters is a field, not text". It was stated only in
48
+ `docs/OBSERVABILITY-CONTRACT.md`, which is not in `files`: it reached whoever had the repository
49
+ and nobody who installs from npm. The decision it governs is made at a call site in a derived
50
+ backend whose only copy of this contract is what the registry delivers, so it is in the README's
51
+ security model now, beside the case it sharpens — that one covers a secret inside a field's
52
+ **value**, this one a value with no field at all.
53
+
54
+ - **The `minLevel` entry below, under 1.2.9, now names the direction of its change.** The behaviour
55
+ changed in that release and its record stays there; what shipped here is the paragraph explaining
56
+ what upgrading does. A sink configured with an unrecognised level was silent and starts delivering
57
+ at the module level, which arrives as a step change in ingestion cost and reads as a noise
58
+ regression unless the note says which way it moves. Two consumers asked for this independently.
59
+
14
60
  ## [1.2.9] - 2026-08-16
15
61
 
16
62
  > **`1.2.8` was merged but never published.** The defect below was found in it after the merge and
@@ -33,6 +79,15 @@ heading here.
33
79
  `validateOptions` already held `options.level` to that same list; this is the check reaching
34
80
  the one place it had not.
35
81
 
82
+ **On upgrade this changes your log volume, upward, and it will not look like a fix.** A sink
83
+ that was configured with an unrecognised level and was therefore silent starts delivering at
84
+ the module level the moment you install this version. Read as a graph, that is a step change in
85
+ ingestion cost arriving with a patch; read correctly, it is entries that were always supposed
86
+ to be delivered and never were. If you see one, check that destination's `minLevel` before
87
+ treating it as a regression — the value has to be one of `'trace'`, `'debug'`, `'info'`,
88
+ `'warn'`, `'error'` or `'fatal'`. `'verbose'` and `'log'` are NestJS level names, not Pino
89
+ ones, and are the likely culprits for anyone arriving from `LoggerService`.
90
+
36
91
  - **A write returning a thenable that is not `instanceof Promise` no longer takes the synchronous
37
92
  path, where its rejection escaped and its entry could be discarded.** `instanceof` is realm-local:
38
93
  it answers `false` for a promise built in another realm — a worker, a `vm` context — and for any
@@ -1739,7 +1794,8 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
1739
1794
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
1740
1795
  `release.yml`, Dependabot, and issue templates
1741
1796
 
1742
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.9...HEAD
1797
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.3.0...HEAD
1798
+ [1.3.0]: https://github.com/bymaxone/nest-logger/compare/v1.2.9...v1.3.0
1743
1799
  [1.2.9]: https://github.com/bymaxone/nest-logger/compare/v1.2.7...v1.2.9
1744
1800
 
1745
1801
  <!-- 1.2.8 has no link: it was merged but never tagged, so there is no v1.2.8 to compare against. See its section above. -->
package/README.md CHANGED
@@ -1070,6 +1070,28 @@ There is no name rule that reaches the second one, and scanning inside values fo
1070
1070
 
1071
1071
  `redactStrategy` governs **your** payload — the record you log, your child bindings, and the output of any serializer you supply. The built-in `err` serializer is the library's own output and is always name-walked, including under `'paths'`, because `fast-redact`'s four-wildcard ceiling cannot reach the depth a `cause` chain serializes to.
1072
1072
 
1073
+ #### A value interpolated into the message has no name to redact
1074
+
1075
+ The case above is a secret inside a field's **value**. The sharper version is a value interpolated into the message itself, where there is no field at all:
1076
+
1077
+ ```ts
1078
+ // Both identifiers are now message text: there is no `userId` field and no
1079
+ // `familyId` field for any strategy to match, only a string that happens to
1080
+ // contain them.
1081
+ logger.warn(`reissueTokens: family revoked userId=${userId} familyId=${familyId}`)
1082
+
1083
+ // Same facts, as fields. The logKey is yours — `RESERVED_LOG_KEYS` holds the
1084
+ // library's own keys, not your domain's — and follows MODULE_ACTION_RESULT:
1085
+ // module AUTH, action REUSE, result DETECTED.
1086
+ logger.warnStructured('AUTH_REUSE_DETECTED', 'token family revoked after reuse detection', userId, {
1087
+ familyId
1088
+ })
1089
+ ```
1090
+
1091
+ Redaction matches names. An interpolated value does not have one — not an uncovered name, none — so there is nothing for any strategy to match, and no configuration you can add will change that. This is not a gap to be closed later: scanning message text for secret-shaped substrings would mean rewriting what you asked to be logged, which is the wrong trade in both directions.
1092
+
1093
+ The rule that follows is short: **a value that matters is a field, not text.** A field can be redacted, queried, aggregated and correlated; the same value inside `msg` can only be read by a human who happens to be looking. That applies most to the values you would least like to interpolate — tokens, identifiers, addresses — and it is the one part of this contract the library cannot enforce for you, because the decision is made at the call site.
1094
+
1073
1095
  ### A closed pipe does not kill the process
1074
1096
 
1075
1097
  `node app | head` closes the read end of stdout while the application is still writing. The stream reports that as `EPIPE` **asynchronously**, through its `'error'` event, after `write()` has already returned — so a `try/catch` around the write catches nothing, and because Node attaches no default handler to `process.stdout`, the emit becomes an uncaught exception and the process dies. Measured, not assumed: `0` listeners, no synchronous throw, `UNCAUGHT:EPIPE`, exit 42.
@@ -1224,10 +1224,11 @@ function resolveToJson(value) {
1224
1224
  };
1225
1225
  }
1226
1226
  function cloneError(error) {
1227
- const clone = Object.create(
1228
- Object.getPrototypeOf(error),
1229
- Object.getOwnPropertyDescriptors(error)
1230
- );
1227
+ const descriptors = Object.getOwnPropertyDescriptors(error);
1228
+ for (const descriptor of Object.values(descriptors)) {
1229
+ descriptor.configurable = true;
1230
+ }
1231
+ const clone = Object.create(Object.getPrototypeOf(error), descriptors);
1231
1232
  const { stack } = error;
1232
1233
  if (typeof stack === "string") {
1233
1234
  Reflect.defineProperty(clone, "stack", { value: stack, enumerable: false });
@@ -1235,11 +1236,8 @@ function cloneError(error) {
1235
1236
  return clone;
1236
1237
  }
1237
1238
  var BUFFER_TO_JSON = Buffer.prototype.toJSON;
1238
- var WRITE_FAILED = new Error();
1239
1239
  function defineOwn(target, key, value) {
1240
- if (!Reflect.defineProperty(target, key, { value, enumerable: true })) {
1241
- throw WRITE_FAILED;
1242
- }
1240
+ Reflect.defineProperty(target, key, { value, enumerable: true });
1243
1241
  }
1244
1242
  function walkArray(value, sensitive, censor, depth2, ancestors) {
1245
1243
  const length = value.length;
@@ -639,9 +639,11 @@ type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS]
639
639
  * costs ~26 % of the entry throughput, measured, and the walk is still ~36x
640
640
  * faster than the path expansion it replaced.
641
641
  *
642
- * Any internal failure — including a censor that cannot be written — degrades the
643
- * whole record to a marked, secret-free envelope rather than crashing the request
644
- * that produced it.
642
+ * Any internal failure — a throwing getter, a hostile proxy, a value the walk
643
+ * cannot READ — degrades the whole record to a marked, secret-free envelope rather
644
+ * than crashing the request that produced it. Writing the censor is not among those
645
+ * failures: every copy is built so the write cannot fail, which is why a frozen
646
+ * error keeps its fields instead of costing the record.
645
647
  *
646
648
  * @see {@link createNameRedactor}
647
649
  */
@@ -639,9 +639,11 @@ type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS]
639
639
  * costs ~26 % of the entry throughput, measured, and the walk is still ~36x
640
640
  * faster than the path expansion it replaced.
641
641
  *
642
- * Any internal failure — including a censor that cannot be written — degrades the
643
- * whole record to a marked, secret-free envelope rather than crashing the request
644
- * that produced it.
642
+ * Any internal failure — a throwing getter, a hostile proxy, a value the walk
643
+ * cannot READ — degrades the whole record to a marked, secret-free envelope rather
644
+ * than crashing the request that produced it. Writing the censor is not among those
645
+ * failures: every copy is built so the write cannot fail, which is why a frozen
646
+ * error keeps its fields instead of costing the record.
645
647
  *
646
648
  * @see {@link createNameRedactor}
647
649
  */
@@ -1221,10 +1221,11 @@ function resolveToJson(value) {
1221
1221
  };
1222
1222
  }
1223
1223
  function cloneError(error) {
1224
- const clone = Object.create(
1225
- Object.getPrototypeOf(error),
1226
- Object.getOwnPropertyDescriptors(error)
1227
- );
1224
+ const descriptors = Object.getOwnPropertyDescriptors(error);
1225
+ for (const descriptor of Object.values(descriptors)) {
1226
+ descriptor.configurable = true;
1227
+ }
1228
+ const clone = Object.create(Object.getPrototypeOf(error), descriptors);
1228
1229
  const { stack } = error;
1229
1230
  if (typeof stack === "string") {
1230
1231
  Reflect.defineProperty(clone, "stack", { value: stack, enumerable: false });
@@ -1232,11 +1233,8 @@ function cloneError(error) {
1232
1233
  return clone;
1233
1234
  }
1234
1235
  var BUFFER_TO_JSON = Buffer.prototype.toJSON;
1235
- var WRITE_FAILED = new Error();
1236
1236
  function defineOwn(target, key, value) {
1237
- if (!Reflect.defineProperty(target, key, { value, enumerable: true })) {
1238
- throw WRITE_FAILED;
1239
- }
1237
+ Reflect.defineProperty(target, key, { value, enumerable: true });
1240
1238
  }
1241
1239
  function walkArray(value, sensitive, censor, depth2, ancestors) {
1242
1240
  const length = value.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-logger",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
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",