@bymax-one/nest-logger 1.2.3 → 1.2.5

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,8 +11,154 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.5] - 2026-08-14
15
+
16
+ ### Fixed
17
+
18
+ - **An `Error` handed to the NestJS logger methods is no longer silently dropped.** Reported from a
19
+ real run rather than a unit test. `@bymax-one/nest-auth` logs the cause the way every caller does,
20
+ an SMTP `STARTTLS 502` failed, and this is what came out:
21
+
22
+ ```js
23
+ // the call site, nest-auth
24
+ this.logger.error(`delivery failed for "${subject}"`, error)
25
+ ```
26
+
27
+ ```json
28
+ {
29
+ "level": "error",
30
+ "context": "DefaultAuthEmailProvider",
31
+ "msg": "delivery failed for \"Verify your email address\""
32
+ }
33
+ ```
34
+
35
+ No `err`, no `stack`, no warning. The cause existed the whole time and was discarded. The dispatch
36
+ is fire-and-forget, so the HTTP response was `204` while nothing had been delivered: **the log was
37
+ the only surface where that failure could appear, and it appeared without its reason.** In CI you
38
+ can raise the level and re-run; in production nobody runs at `debug`, so the reason was never
39
+ written at all.
40
+
41
+ Neither side was obviously wrong, which is what made it survive. NestJS types the slot as
42
+ `error(message, stack?: string, context?: string)`, so this library read a string and discarded
43
+ anything else — implementing the contract faithfully. Passing the `Error` itself is what callers
44
+ actually do, because every other logger in the ecosystem accepts it.
45
+
46
+ **Measured before the fix: eleven of the twelve level/position combinations lost the `Error`.**
47
+ Only `error(err)` survived — every other level (`log`, `warn`, `debug`, `verbose`, `fatal`) routes
48
+ through a shared helper that handled no `Error` in either position. `fatal` was the worst of them,
49
+ being the level a caller reaches for when the process is about to die.
50
+
51
+ An `Error` anywhere in the variadic tail is now attached as `err`, the same field the leading-`Error`
52
+ branch already wrote, at every level. A trailing _string_ is still the `stack`, and `stack` is never
53
+ populated from an `Error` — the `err` serializer derives it, so writing both would emit the same
54
+ trace under two names.
55
+
56
+ **Detection is structural, not `instanceof`.** The first version of this fix used
57
+ `instanceof Error`, which is realm-local — so it would still have dropped a genuine `Error` built in
58
+ a `vm` context, and one already normalized into a plain `{ name, message, stack }`, which is what
59
+ `HttpExceptionFilter` produces and what any error crossing a process or worker boundary becomes.
60
+ That is the population most likely to be carrying the reason something failed, and this repository
61
+ had already recorded the same lesson in `sanitizeError` ("too narrow, and the gap was a live
62
+ defect"). The bridge now shares `isErrorLike` with `sanitizeError`, so the two cannot disagree about
63
+ what an error is. The bar stays deliberately narrow — string `name` AND string `message` — so an
64
+ ordinary metadata object is not mistaken for a cause.
65
+
66
+ **Two output changes to expect, since "additive" would overstate it.** Nothing is removed or
67
+ renamed, and a caller passing a string stack is untouched. But a caller who was passing an `Error`
68
+ necessarily sees a difference, because that is the defect:
69
+
70
+ - entries that silently dropped a cause now carry an `err` object — a **new field** on lines that
71
+ previously had none, which a strict log-pipeline schema will see;
72
+ - `warn(new Error('x'))` and the other non-`error` levels now emit `msg: "x"` instead of
73
+ `msg: "Error: x"`, matching what `error(new Error('x'))` already produced. A query grepping for
74
+ the `"Error: "` prefix on those levels will stop matching.
75
+
76
+ Both are the point of the change rather than side effects, but neither is invisible.
77
+
78
+ Scope, as measured by the reporting consumer across the ten `@bymax-one/*` packages that backend
79
+ installs at its pinned versions: **37 affected call sites, all of them in `nest-auth`**, none
80
+ elsewhere. Two are security-relevant — a breach check that fails open and logs that it admitted a
81
+ password it could not verify, and the `onRefreshTokenReuseDetected` hook, the tripwire for a stolen
82
+ token. Both said something went wrong and threw away what.
83
+
84
+ ### Documentation
85
+
86
+ - **The `pnpm prune --prod` note named the wrong mechanism.** It described the peer surviving as a
87
+ _residue_ of an incomplete prune. A second consumer whose Dockerfile never prunes — a clean
88
+ `pnpm install --prod --frozen-lockfile` in a fresh runtime stage — measured the peer present anyway.
89
+ In both images the peer was recorded in the lockfile and pnpm placed it in the store for a
90
+ production-only install. The distinction matters because "we do a clean prod install, not a prune"
91
+ reads like an exemption and is not one; it is what that consumer expected before measuring their
92
+ own image.
93
+
94
+ The note is now phrased as **two measurements rather than a law of pnpm** — whether a prod install
95
+ always carries an optional peer, however the lockfile was produced, has not been tested here. The
96
+ first version of this note over-generalized from the evidence, which is the same class of error as
97
+ the two corrections it documents.
98
+
99
+ The note now also says what is different **elsewhere**: under npm's or yarn's flat `node_modules` a
100
+ pruned devDependency really is gone, so the warning must not be read as universal. And it records
101
+ the probe that nearly produced the opposite conclusion — `require.resolve` from the library's
102
+ app-visible path returns `MODULE_NOT_FOUND` while the lazy import still succeeds, because the
103
+ library resolves from its real `.pnpm/` path. Verify by behaviour, not from a path the library
104
+ never uses.
105
+
106
+ ## [1.2.4] - 2026-08-14
107
+
108
+ ### Documentation
109
+
110
+ - **The `1.2.3` notes below were amended after release: they were missing a `### Security` section.**
111
+ The terminal-escaping hardening described there shipped **in 1.2.3** — `toSingleLineMessage` is in
112
+ that bundle — but the release notes did not mention it, so a reader auditing the CHANGELOG for
113
+ security-relevant changes would have missed it. Found by a consumer reading the bundle diff rather
114
+ than the notes. The section was added to `[1.2.3]` where it belongs chronologically, and this entry
115
+ exists so anyone who already read those notes learns they changed. No code moved; the amendment is
116
+ the correction.
117
+
118
+ - **An optional peer dependency is NOT absent from a pruned production install.** Two consumers
119
+ independently reasoned that because `pino-pretty` is a `devDependency`, `pnpm prune --prod` strips
120
+ it and `PrettyDevDestination` therefore cannot run in production. That is false under pnpm, and one
121
+ of them disproved it inside a real production image: `prune --prod` removes the **top-level link**
122
+ while the package stays in the store, linked beside this library as the resolved optional peer, so
123
+ the lazy `import('pino-pretty')` inside `onInit` resolves relative to the library's own directory
124
+ and succeeds.
125
+
126
+ The practical consequence is documented in the README and the destinations guideline: do not treat
127
+ "it is a devDependency" as proof that a code path is unreachable in production. A `PrettyDevDestination`
128
+ left registered in a production deployment will not crash and will not warn — it will render ANSI
129
+ colour into a log pipeline that has silently failed to parse every line since the deploy.
130
+
131
+ ### Tests
132
+
133
+ - **The rescue path now asserts the audit signal it was written to protect.** The end-to-end case for
134
+ the total-init-failure fallback names `LOGGER_BOOTSTRAP_WARNING` — the signal that exists so a
135
+ security review can see PII redaction was disabled — as its justification, then asserted
136
+ `LOGGER_BOOTSTRAP_OK` instead. The guarantee was covered transitively (same path, and `warn` clears
137
+ any filter `info` clears), but the test did not pin the promise it cited, so it could have been
138
+ weakened without anyone noticing what was surrendered. A dedicated case now boots with
139
+ `shouldDisableDefaultRedact: true` and asserts the warning survives the rescue, with its flag and
140
+ level. Verified to fail when the rescuer election is moved after `announceBootstrap()`.
141
+
14
142
  ## [1.2.3] - 2026-08-14
15
143
 
144
+ ### Security
145
+
146
+ - **Control characters in a destination failure report are escaped before reaching a terminal.**
147
+ The `LOGGER_DESTINATION_INIT_FAILED` and `LOGGER_DESTINATION_WRITE_FAILED` lines are written
148
+ straight to `process.stderr` and read by a human in a terminal, and `JSON.stringify` escapes C0 and
149
+ nothing else — DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 survive verbatim, which is
150
+ enough to drive a terminal or forge what looks like a separate log entry. The destination `name` is
151
+ consumer-chosen and the failure text is frequently remote-derived, so both are attacker-influenced
152
+ surfaces. `destination`, `err.type`, `err.message` and `msg` now pass through `toSingleLineMessage`,
153
+ the same per-sink escaping the rest of the library applies.
154
+
155
+ - **A rejected value that throws while being coerced can no longer abort application bootstrap.**
156
+ The failure reporter normalized the thrown value _outside_ its own guard, so `String(cause)` on a
157
+ value with a throwing `toString`/`Symbol.toPrimitive` — or the `name`/`message` reads on an `Error`
158
+ with hostile getters — escaped the reporter. It runs inside the registry catch that exists to keep a
159
+ bad sink from mattering, so the escape aborted boot: the failure handler became a harder failure
160
+ than the one it was handling. Coercion now happens inside the guard.
161
+
16
162
  ### Fixed
17
163
 
18
164
  - **A destination that fails to initialize no longer silences the entire application.** Reported
@@ -868,7 +1014,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
868
1014
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
869
1015
  `release.yml`, Dependabot, and issue templates
870
1016
 
871
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...HEAD
1017
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.5...HEAD
1018
+ [1.2.5]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...v1.2.5
1019
+ [1.2.4]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...v1.2.4
872
1020
  [1.2.3]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...v1.2.3
873
1021
  [1.2.2]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...v1.2.2
874
1022
  [1.2.1]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...v1.2.1
package/README.md CHANGED
@@ -149,6 +149,32 @@ pnpm add @opentelemetry/api @opentelemetry/sdk-node
149
149
 
150
150
  ```
151
151
 
152
+ > ⚠️ **Under pnpm, `-D` may not make it absent in production — measure, do not assume.** Two
153
+ > consumers who had assumed otherwise measured the peer **present** in their real production images,
154
+ > by two different build routes: `pnpm prune --prod` keeps the store entry, and a clean
155
+ > `pnpm install --prod --frozen-lockfile` in a fresh stage installs it too. In both, the peer was
156
+ > recorded in the lockfile. `PrettyDevDestination` imports it lazily, relative to the library's own
157
+ > directory under `.pnpm/`, where it is a sibling — so it resolves.
158
+ >
159
+ > That is two measurements, not a law of pnpm: whether a production install always carries an optional
160
+ > peer, however the lockfile was produced, has not been tested here. The advice below does not depend
161
+ > on it.
162
+ >
163
+ > The clean-install case is worth stating separately, because "we do a fresh prod install, not a
164
+ > prune" reads like an exemption and is not one. It is what one of those consumers concluded before
165
+ > measuring their own image.
166
+ >
167
+ > **On other package managers this is different**, and the difference cuts the other way: under npm's
168
+ > or yarn's flat `node_modules`, a pruned devDependency really is gone. If you install with those,
169
+ > do not read this warning as universal.
170
+ >
171
+ > Either way, the rule that survives both layouts: **"it is a devDependency, therefore this path
172
+ > cannot run in production" is not a safe premise, because the premise is about someone else's
173
+ > install command.** A `PrettyDevDestination` left registered in production will not crash and will
174
+ > not warn — it renders ANSI colour into a log pipeline that has silently failed to parse every line
175
+ > since the deploy, which is indistinguishable from a service that went quiet. Gate it on an explicit
176
+ > configuration value you can see, not on the packaging.
177
+
152
178
  > [!NOTE]
153
179
  > `@opentelemetry/api` is resolved lazily when the Pino instance is built. When it is absent the trace mixin silently steps aside — the logger never fails to start, and never warns, over an optional peer.
154
180
 
@@ -228,6 +228,75 @@ function escapeControlCharacters(text) {
228
228
  return text.replace(UNSAFE_IN_MULTILINE, toEscapeSequence);
229
229
  }
230
230
 
231
+ // src/server/utils/sanitize-error.util.ts
232
+ var DEFAULT_MAX_CAUSE_DEPTH = 3;
233
+ var MAX_AGGREGATE_ERRORS = 10;
234
+ var CIRCULAR_SENTINEL = "[Circular]";
235
+ function sanitizeError(err, options) {
236
+ const maxCauseDepth = DEFAULT_MAX_CAUSE_DEPTH;
237
+ return toSanitizedError(err, /* @__PURE__ */ new WeakSet(), 0, maxCauseDepth);
238
+ }
239
+ function isErrorLike(value) {
240
+ if (typeof value !== "object" || value === null) {
241
+ return false;
242
+ }
243
+ const candidate = value;
244
+ return typeof candidate.name === "string" && typeof candidate.message === "string";
245
+ }
246
+ function toSanitizedError(value, seen, depth2, maxDepth) {
247
+ try {
248
+ if (!isErrorLike(value)) {
249
+ return { name: "UnknownError", message: String(value) };
250
+ }
251
+ seen.add(value);
252
+ const result = { name: value.name, message: value.message };
253
+ if (value.stack !== void 0) {
254
+ result.stack = scrubStack(value.stack);
255
+ }
256
+ const isAtDepthLimit = depth2 >= maxDepth;
257
+ if (value.cause !== void 0) {
258
+ result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(value.cause, seen, depth2 + 1, maxDepth);
259
+ }
260
+ const aggregated = readAggregatedErrors(value);
261
+ if (aggregated !== void 0) {
262
+ const capped = aggregated.slice(0, MAX_AGGREGATE_ERRORS);
263
+ result.errors = capped.map(
264
+ (inner) => isAtDepthLimit ? createDepthMarker() : toSanitizedChild(inner, seen, depth2 + 1, maxDepth)
265
+ );
266
+ if (aggregated.length > MAX_AGGREGATE_ERRORS) {
267
+ result.errors.push(createWidthMarker(aggregated.length - MAX_AGGREGATE_ERRORS));
268
+ }
269
+ }
270
+ return result;
271
+ } catch {
272
+ return { name: "SanitizeFailed", message: "Failed to sanitize the thrown value" };
273
+ }
274
+ }
275
+ function toSanitizedChild(value, seen, depth2, maxDepth) {
276
+ if (isObject(value) && seen.has(value)) {
277
+ return CIRCULAR_SENTINEL;
278
+ }
279
+ return toSanitizedError(value, seen, depth2, maxDepth);
280
+ }
281
+ function readAggregatedErrors(value) {
282
+ const candidate = value.errors;
283
+ return Array.isArray(candidate) ? candidate : void 0;
284
+ }
285
+ function scrubStack(stack) {
286
+ return escapeControlCharacters(
287
+ stack.split("\n").filter((line) => !line.includes("node_modules")).join("\n")
288
+ );
289
+ }
290
+ function isObject(value) {
291
+ return typeof value === "object" && value !== null;
292
+ }
293
+ function createDepthMarker() {
294
+ return { _truncated: true, _reason: "cause-depth-exceeded" };
295
+ }
296
+ function createWidthMarker(omitted) {
297
+ return { _truncated: true, _reason: "aggregate-width-exceeded", _omitted: omitted };
298
+ }
299
+
231
300
  // src/server/services/pino-logger.service.ts
232
301
  var OWNED_PAYLOAD_KEYS = ["logKey", "userId", "context"];
233
302
  function withoutOwnedKeys(metadata) {
@@ -256,6 +325,9 @@ function safeErrorMessage(error) {
256
325
  return "Unreadable error message";
257
326
  }
258
327
  }
328
+ function findErrorParam(optionalParams) {
329
+ return optionalParams.find((param) => isErrorLike(param));
330
+ }
259
331
  function assignIfDefined(payload, key, value) {
260
332
  if (value !== void 0) {
261
333
  Reflect.defineProperty(payload, key, { value, enumerable: true });
@@ -292,7 +364,7 @@ exports.PinoLoggerService = class PinoLoggerService {
292
364
  * @param optionalParams - NestJS trailing params (`stack` at index 0, `context` at index 1).
293
365
  */
294
366
  error(message, ...optionalParams) {
295
- if (message instanceof Error) {
367
+ if (isErrorLike(message)) {
296
368
  const payload2 = { err: message };
297
369
  assignIfDefined(payload2, "context", this.resolveContext(optionalParams));
298
370
  this.pino.error(payload2, safeErrorMessage(message));
@@ -303,6 +375,7 @@ exports.PinoLoggerService = class PinoLoggerService {
303
375
  const payload = {};
304
376
  assignIfDefined(payload, "context", context);
305
377
  assignIfDefined(payload, "stack", stack);
378
+ assignIfDefined(payload, "err", findErrorParam(optionalParams));
306
379
  this.pino.error(
307
380
  payload,
308
381
  toSingleLineMessage(typeof message === "string" ? message : String(message))
@@ -461,7 +534,9 @@ exports.PinoLoggerService = class PinoLoggerService {
461
534
  emitNestStyle(level, message, optionalParams) {
462
535
  const payload = {};
463
536
  assignIfDefined(payload, "context", this.resolveContext(optionalParams));
464
- const msg = toSingleLineMessage(typeof message === "string" ? message : String(message));
537
+ const leading = isErrorLike(message) ? message : void 0;
538
+ assignIfDefined(payload, "err", leading ?? findErrorParam(optionalParams));
539
+ const msg = leading === void 0 ? toSingleLineMessage(typeof message === "string" ? message : String(message)) : safeErrorMessage(leading);
465
540
  switch (level) {
466
541
  case "info":
467
542
  this.pino.info(payload, msg);
@@ -548,75 +623,6 @@ function stripQueryString(url) {
548
623
  return queryStart === -1 ? url : url.slice(0, queryStart);
549
624
  }
550
625
 
551
- // src/server/utils/sanitize-error.util.ts
552
- var DEFAULT_MAX_CAUSE_DEPTH = 3;
553
- var MAX_AGGREGATE_ERRORS = 10;
554
- var CIRCULAR_SENTINEL = "[Circular]";
555
- function sanitizeError(err, options) {
556
- const maxCauseDepth = DEFAULT_MAX_CAUSE_DEPTH;
557
- return toSanitizedError(err, /* @__PURE__ */ new WeakSet(), 0, maxCauseDepth);
558
- }
559
- function isErrorLike(value) {
560
- if (typeof value !== "object" || value === null) {
561
- return false;
562
- }
563
- const candidate = value;
564
- return typeof candidate.name === "string" && typeof candidate.message === "string";
565
- }
566
- function toSanitizedError(value, seen, depth2, maxDepth) {
567
- try {
568
- if (!isErrorLike(value)) {
569
- return { name: "UnknownError", message: String(value) };
570
- }
571
- seen.add(value);
572
- const result = { name: value.name, message: value.message };
573
- if (value.stack !== void 0) {
574
- result.stack = scrubStack(value.stack);
575
- }
576
- const isAtDepthLimit = depth2 >= maxDepth;
577
- if (value.cause !== void 0) {
578
- result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(value.cause, seen, depth2 + 1, maxDepth);
579
- }
580
- const aggregated = readAggregatedErrors(value);
581
- if (aggregated !== void 0) {
582
- const capped = aggregated.slice(0, MAX_AGGREGATE_ERRORS);
583
- result.errors = capped.map(
584
- (inner) => isAtDepthLimit ? createDepthMarker() : toSanitizedChild(inner, seen, depth2 + 1, maxDepth)
585
- );
586
- if (aggregated.length > MAX_AGGREGATE_ERRORS) {
587
- result.errors.push(createWidthMarker(aggregated.length - MAX_AGGREGATE_ERRORS));
588
- }
589
- }
590
- return result;
591
- } catch {
592
- return { name: "SanitizeFailed", message: "Failed to sanitize the thrown value" };
593
- }
594
- }
595
- function toSanitizedChild(value, seen, depth2, maxDepth) {
596
- if (isObject(value) && seen.has(value)) {
597
- return CIRCULAR_SENTINEL;
598
- }
599
- return toSanitizedError(value, seen, depth2, maxDepth);
600
- }
601
- function readAggregatedErrors(value) {
602
- const candidate = value.errors;
603
- return Array.isArray(candidate) ? candidate : void 0;
604
- }
605
- function scrubStack(stack) {
606
- return escapeControlCharacters(
607
- stack.split("\n").filter((line) => !line.includes("node_modules")).join("\n")
608
- );
609
- }
610
- function isObject(value) {
611
- return typeof value === "object" && value !== null;
612
- }
613
- function createDepthMarker() {
614
- return { _truncated: true, _reason: "cause-depth-exceeded" };
615
- }
616
- function createWidthMarker(omitted) {
617
- return { _truncated: true, _reason: "aggregate-width-exceeded", _omitted: omitted };
618
- }
619
-
620
626
  // src/server/filters/http-exception.filter.ts
621
627
  var INTERNAL_ERROR_BODY = {
622
628
  statusCode: common.HttpStatus.INTERNAL_SERVER_ERROR,
@@ -225,6 +225,75 @@ function escapeControlCharacters(text) {
225
225
  return text.replace(UNSAFE_IN_MULTILINE, toEscapeSequence);
226
226
  }
227
227
 
228
+ // src/server/utils/sanitize-error.util.ts
229
+ var DEFAULT_MAX_CAUSE_DEPTH = 3;
230
+ var MAX_AGGREGATE_ERRORS = 10;
231
+ var CIRCULAR_SENTINEL = "[Circular]";
232
+ function sanitizeError(err, options) {
233
+ const maxCauseDepth = DEFAULT_MAX_CAUSE_DEPTH;
234
+ return toSanitizedError(err, /* @__PURE__ */ new WeakSet(), 0, maxCauseDepth);
235
+ }
236
+ function isErrorLike(value) {
237
+ if (typeof value !== "object" || value === null) {
238
+ return false;
239
+ }
240
+ const candidate = value;
241
+ return typeof candidate.name === "string" && typeof candidate.message === "string";
242
+ }
243
+ function toSanitizedError(value, seen, depth2, maxDepth) {
244
+ try {
245
+ if (!isErrorLike(value)) {
246
+ return { name: "UnknownError", message: String(value) };
247
+ }
248
+ seen.add(value);
249
+ const result = { name: value.name, message: value.message };
250
+ if (value.stack !== void 0) {
251
+ result.stack = scrubStack(value.stack);
252
+ }
253
+ const isAtDepthLimit = depth2 >= maxDepth;
254
+ if (value.cause !== void 0) {
255
+ result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(value.cause, seen, depth2 + 1, maxDepth);
256
+ }
257
+ const aggregated = readAggregatedErrors(value);
258
+ if (aggregated !== void 0) {
259
+ const capped = aggregated.slice(0, MAX_AGGREGATE_ERRORS);
260
+ result.errors = capped.map(
261
+ (inner) => isAtDepthLimit ? createDepthMarker() : toSanitizedChild(inner, seen, depth2 + 1, maxDepth)
262
+ );
263
+ if (aggregated.length > MAX_AGGREGATE_ERRORS) {
264
+ result.errors.push(createWidthMarker(aggregated.length - MAX_AGGREGATE_ERRORS));
265
+ }
266
+ }
267
+ return result;
268
+ } catch {
269
+ return { name: "SanitizeFailed", message: "Failed to sanitize the thrown value" };
270
+ }
271
+ }
272
+ function toSanitizedChild(value, seen, depth2, maxDepth) {
273
+ if (isObject(value) && seen.has(value)) {
274
+ return CIRCULAR_SENTINEL;
275
+ }
276
+ return toSanitizedError(value, seen, depth2, maxDepth);
277
+ }
278
+ function readAggregatedErrors(value) {
279
+ const candidate = value.errors;
280
+ return Array.isArray(candidate) ? candidate : void 0;
281
+ }
282
+ function scrubStack(stack) {
283
+ return escapeControlCharacters(
284
+ stack.split("\n").filter((line) => !line.includes("node_modules")).join("\n")
285
+ );
286
+ }
287
+ function isObject(value) {
288
+ return typeof value === "object" && value !== null;
289
+ }
290
+ function createDepthMarker() {
291
+ return { _truncated: true, _reason: "cause-depth-exceeded" };
292
+ }
293
+ function createWidthMarker(omitted) {
294
+ return { _truncated: true, _reason: "aggregate-width-exceeded", _omitted: omitted };
295
+ }
296
+
228
297
  // src/server/services/pino-logger.service.ts
229
298
  var OWNED_PAYLOAD_KEYS = ["logKey", "userId", "context"];
230
299
  function withoutOwnedKeys(metadata) {
@@ -253,6 +322,9 @@ function safeErrorMessage(error) {
253
322
  return "Unreadable error message";
254
323
  }
255
324
  }
325
+ function findErrorParam(optionalParams) {
326
+ return optionalParams.find((param) => isErrorLike(param));
327
+ }
256
328
  function assignIfDefined(payload, key, value) {
257
329
  if (value !== void 0) {
258
330
  Reflect.defineProperty(payload, key, { value, enumerable: true });
@@ -289,7 +361,7 @@ var PinoLoggerService = class {
289
361
  * @param optionalParams - NestJS trailing params (`stack` at index 0, `context` at index 1).
290
362
  */
291
363
  error(message, ...optionalParams) {
292
- if (message instanceof Error) {
364
+ if (isErrorLike(message)) {
293
365
  const payload2 = { err: message };
294
366
  assignIfDefined(payload2, "context", this.resolveContext(optionalParams));
295
367
  this.pino.error(payload2, safeErrorMessage(message));
@@ -300,6 +372,7 @@ var PinoLoggerService = class {
300
372
  const payload = {};
301
373
  assignIfDefined(payload, "context", context);
302
374
  assignIfDefined(payload, "stack", stack);
375
+ assignIfDefined(payload, "err", findErrorParam(optionalParams));
303
376
  this.pino.error(
304
377
  payload,
305
378
  toSingleLineMessage(typeof message === "string" ? message : String(message))
@@ -458,7 +531,9 @@ var PinoLoggerService = class {
458
531
  emitNestStyle(level, message, optionalParams) {
459
532
  const payload = {};
460
533
  assignIfDefined(payload, "context", this.resolveContext(optionalParams));
461
- const msg = toSingleLineMessage(typeof message === "string" ? message : String(message));
534
+ const leading = isErrorLike(message) ? message : void 0;
535
+ assignIfDefined(payload, "err", leading ?? findErrorParam(optionalParams));
536
+ const msg = leading === void 0 ? toSingleLineMessage(typeof message === "string" ? message : String(message)) : safeErrorMessage(leading);
462
537
  switch (level) {
463
538
  case "info":
464
539
  this.pino.info(payload, msg);
@@ -545,75 +620,6 @@ function stripQueryString(url) {
545
620
  return queryStart === -1 ? url : url.slice(0, queryStart);
546
621
  }
547
622
 
548
- // src/server/utils/sanitize-error.util.ts
549
- var DEFAULT_MAX_CAUSE_DEPTH = 3;
550
- var MAX_AGGREGATE_ERRORS = 10;
551
- var CIRCULAR_SENTINEL = "[Circular]";
552
- function sanitizeError(err, options) {
553
- const maxCauseDepth = DEFAULT_MAX_CAUSE_DEPTH;
554
- return toSanitizedError(err, /* @__PURE__ */ new WeakSet(), 0, maxCauseDepth);
555
- }
556
- function isErrorLike(value) {
557
- if (typeof value !== "object" || value === null) {
558
- return false;
559
- }
560
- const candidate = value;
561
- return typeof candidate.name === "string" && typeof candidate.message === "string";
562
- }
563
- function toSanitizedError(value, seen, depth2, maxDepth) {
564
- try {
565
- if (!isErrorLike(value)) {
566
- return { name: "UnknownError", message: String(value) };
567
- }
568
- seen.add(value);
569
- const result = { name: value.name, message: value.message };
570
- if (value.stack !== void 0) {
571
- result.stack = scrubStack(value.stack);
572
- }
573
- const isAtDepthLimit = depth2 >= maxDepth;
574
- if (value.cause !== void 0) {
575
- result.cause = isAtDepthLimit ? createDepthMarker() : toSanitizedChild(value.cause, seen, depth2 + 1, maxDepth);
576
- }
577
- const aggregated = readAggregatedErrors(value);
578
- if (aggregated !== void 0) {
579
- const capped = aggregated.slice(0, MAX_AGGREGATE_ERRORS);
580
- result.errors = capped.map(
581
- (inner) => isAtDepthLimit ? createDepthMarker() : toSanitizedChild(inner, seen, depth2 + 1, maxDepth)
582
- );
583
- if (aggregated.length > MAX_AGGREGATE_ERRORS) {
584
- result.errors.push(createWidthMarker(aggregated.length - MAX_AGGREGATE_ERRORS));
585
- }
586
- }
587
- return result;
588
- } catch {
589
- return { name: "SanitizeFailed", message: "Failed to sanitize the thrown value" };
590
- }
591
- }
592
- function toSanitizedChild(value, seen, depth2, maxDepth) {
593
- if (isObject(value) && seen.has(value)) {
594
- return CIRCULAR_SENTINEL;
595
- }
596
- return toSanitizedError(value, seen, depth2, maxDepth);
597
- }
598
- function readAggregatedErrors(value) {
599
- const candidate = value.errors;
600
- return Array.isArray(candidate) ? candidate : void 0;
601
- }
602
- function scrubStack(stack) {
603
- return escapeControlCharacters(
604
- stack.split("\n").filter((line) => !line.includes("node_modules")).join("\n")
605
- );
606
- }
607
- function isObject(value) {
608
- return typeof value === "object" && value !== null;
609
- }
610
- function createDepthMarker() {
611
- return { _truncated: true, _reason: "cause-depth-exceeded" };
612
- }
613
- function createWidthMarker(omitted) {
614
- return { _truncated: true, _reason: "aggregate-width-exceeded", _omitted: omitted };
615
- }
616
-
617
623
  // src/server/filters/http-exception.filter.ts
618
624
  var INTERNAL_ERROR_BODY = {
619
625
  statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-logger",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
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",