@bymax-one/nest-logger 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,142 @@ 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
+
14
150
  ## [1.2.0] - 2026-08-13
15
151
 
16
152
  Remediation of the P0 findings from the observability audit
@@ -657,7 +793,8 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
657
793
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
658
794
  `release.yml`, Dependabot, and issue templates
659
795
 
660
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...HEAD
796
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...HEAD
797
+ [1.2.1]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...v1.2.1
661
798
  [1.2.0]: https://github.com/bymaxone/nest-logger/compare/v1.1.0...v1.2.0
662
799
  [1.1.0]: https://github.com/bymaxone/nest-logger/compare/v1.0.8...v1.1.0
663
800
  [1.0.8]: https://github.com/bymaxone/nest-logger/compare/v1.0.7...v1.0.8
package/README.md CHANGED
@@ -73,7 +73,10 @@ pnpm add @bymax-one/nest-logger
73
73
 
74
74
  ### 🔍 Observability & Context
75
75
 
76
- - ✅ **OpenTelemetry Correlation** — optional `@opentelemetry/api` peer; injects `traceId`/`spanId`/`traceFlags` into every log via a Pino mixin when an active span is detected
76
+ - ✅ **OpenTelemetry Correlation** — optional `@opentelemetry/api` peer; injects `traceId`/`spanId`/`traceFlags` into every log via a Pino mixin when an active span is detected. Resolution is anchored at the library's own module path, so a Docker `WORKDIR`, a pnpm workspace or a monorepo launched from the repo root cannot silently switch correlation off
77
+ - ✅ **Stable Resource Identity** — `service.name`/`.namespace`/`.version`/`.instance.id` and `deployment.environment.name`, all **Stable** in Semantic Conventions v1.44.0, resolved from one deterministic precedence shared with the OTel SDK
78
+ - ✅ **Semconv Error Fields** — opt-in `exception.type`/`.message`/`.stacktrace` and low-cardinality `error.type`, additive beside the legacy `err.*`, with full `Error.cause` chains
79
+ - ✅ **Machine-Readable Event Names** — `event.name` derived from `logKey` following OTel naming rules
77
80
  - ✅ **AsyncLocalStorage Context** — `requestId`, `tenantId`, `userId` flow automatically through the request lifecycle without prop drilling
78
81
  - ✅ **HTTP Access Log, before guards** — logs every request including the ones an interceptor cannot see (401/403/429 guard rejections, 404 unmatched routes), with URL normalization (UUIDs and numeric IDs replaced by `:id`) and the query string stripped
79
82
  - ✅ **Exception Filter** — captures NestJS `HttpException` and unexpected errors with structured output
@@ -361,6 +364,153 @@ void bootstrap()
361
364
 
362
365
  Once active, every log entry automatically carries `traceId`, `spanId`, and `traceFlags`. Click the `traceId` in Grafana to jump directly to the correlated span in Tempo or Honeycomb.
363
366
 
367
+ **Without OpenTelemetry installed, everything above simply does not happen** — no error, no warning
368
+ on every log, no crash. The peer is optional and the logger is fully usable on its own. The one
369
+ case that _is_ reported is a misconfiguration: if trace injection is enabled and
370
+ `@opentelemetry/api` cannot be resolved, a single `LOGGER_BOOTSTRAP_WARNING` naming
371
+ `OTEL_API_UNAVAILABLE` is emitted at startup. Absence of `traceId` would otherwise be
372
+ indistinguishable from "no active span".
373
+
374
+ The logger **observes** the trace context; it never creates spans, installs a context manager, or
375
+ parses `traceparent` by hand. When the API is present but no span is active, no identifiers are
376
+ emitted — none are invented.
377
+
378
+ > [!NOTE]
379
+ > Resolution of the optional peer is anchored at the **library's own module path**, falling back to
380
+ > `process.cwd()`. Anchoring only at the working directory — as versions before this did — silently
381
+ > disabled correlation whenever the process was launched from somewhere other than the application
382
+ > root: a Docker `WORKDIR`, a pnpm/Yarn workspace with hoisted `node_modules`, a monorepo started at
383
+ > the repository root, a serverless bundle.
384
+
385
+ ### 6b. Resource identity
386
+
387
+ Every entry carries the service identity, using attributes that are **Stable** in Semantic
388
+ Conventions v1.44.0:
389
+
390
+ ```typescript
391
+ BymaxLoggerModule.forRoot({
392
+ service: {
393
+ name: 'checkout-api', // service.name
394
+ version: '2.14.3', // service.version
395
+ namespace: 'payments', // service.namespace
396
+ instanceId: process.env.POD_UID, // service.instance.id
397
+ environment: 'production' // deployment.environment.name
398
+ }
399
+ })
400
+ ```
401
+
402
+ ```json
403
+ {
404
+ "level": "info",
405
+ "time": "2026-08-13T10:00:00.000Z",
406
+ "service": {
407
+ "name": "checkout-api",
408
+ "version": "2.14.3",
409
+ "namespace": "payments",
410
+ "instance": { "id": "pod-7f3a" }
411
+ },
412
+ "deployment": { "environment": { "name": "production" } },
413
+ "logKey": "PAYMENT_FAILED",
414
+ "event.name": "payment.failed",
415
+ "msg": "Payment failed"
416
+ }
417
+ ```
418
+
419
+ Set `resourceFormat: 'flat'` to emit the dotted attribute names verbatim
420
+ (`"service.instance.id": "pod-7f3a"`), which is what a collector mapping log fields onto resource
421
+ attributes reads directly.
422
+
423
+ #### Precedence
424
+
425
+ Resolved once at startup, in this order:
426
+
427
+ 1. **explicit `service` options**
428
+ 2. **`OTEL_SERVICE_NAME`** (name only)
429
+ 3. **`OTEL_RESOURCE_ATTRIBUTES`** (`service.namespace=payments,service.version=2.14.3,…`)
430
+ 4. **`NODE_ENV`** (environment only)
431
+
432
+ Explicit configuration wins because it is the most specific statement you can make. The order of
433
+ the two OpenTelemetry variables is required by the specification, not chosen: _"If `service.name` is
434
+ also provided in `OTEL_RESOURCE_ATTRIBUTES`, then `OTEL_SERVICE_NAME` takes precedence."_
435
+
436
+ Reading the same variables the SDK reads is what makes **logs and traces agree** without the logger
437
+ depending on the SDK. Configure the environment once and both signals describe the same service.
438
+
439
+ > [!IMPORTANT]
440
+ > **`service.instance.id` is never generated.** The specification requires the triplet
441
+ > (`service.namespace`, `service.name`, `service.instance.id`) to be globally unique and recommends
442
+ > a random UUID — but a UUID minted by the logger would be the _logger's_, not the OpenTelemetry
443
+ > Resource's, so logs and traces would claim different instances of the same process. It would also
444
+ > change on every restart while looking authoritative. Supply it from the platform (Kubernetes pod
445
+ > UID, ECS task ARN, VM instance id) or through `OTEL_RESOURCE_ATTRIBUTES`. Omitted is honest;
446
+ > plausible-but-wrong is not.
447
+ >
448
+ > There is **no `OTEL_SERVICE_VERSION`** variable in the specification. Version comes from
449
+ > configuration or from `OTEL_RESOURCE_ATTRIBUTES`.
450
+
451
+ The deprecated `deployment.environment` spelling is **never** emitted, in any mode.
452
+
453
+ ### 6c. Event names
454
+
455
+ Structured entries carry a machine-readable event name derived from `logKey`, following OTel naming
456
+ rules — lowercase, dot-namespaced:
457
+
458
+ | `logKey` | `event.name` |
459
+ | ---------------------------- | ---------------------------- |
460
+ | `PAYMENT_FAILED` | `payment.failed` |
461
+ | `USER_AUTHENTICATION_FAILED` | `user.authentication.failed` |
462
+
463
+ `logKey` is **never renamed or removed** — this is additive. Calls through the NestJS variadic
464
+ bridge (`logger.log`, `logger.warn`, …) carry no log key and correctly get no event name: an
465
+ ordinary diagnostic line is not an Event.
466
+
467
+ Keep event names **low cardinality**. `payment.failed` is an event; `payment.failed.918231781` is an
468
+ identifier wearing an event's clothes and will multiply your series count. Identifiers belong in
469
+ their own fields.
470
+
471
+ > [!NOTE]
472
+ > The value is meant to be mapped onto the **`EventName`** field of the OpenTelemetry LogRecord,
473
+ > which is Stable in the Logs Data Model. The same-named `event.name` **attribute** is _Deprecated_
474
+ > precisely because the value belongs in that top-level field instead. A JSON log line has no way to
475
+ > express that distinction — every key is just a key — so the carrier key is configurable via
476
+ > `eventNameField`, and your collector decides where it lands. What it must not do is carry it into
477
+ > an OTLP _attributes_ map under the deprecated name.
478
+
479
+ Set `eventNameField: false` to emit nothing.
480
+
481
+ ### 6d. Error fields
482
+
483
+ `errorFormat` controls the shape:
484
+
485
+ ```json
486
+ // errorFormat: 'pino' (default — unchanged from 1.2.0)
487
+ { "err": { "type": "PaymentDeclined", "message": "card declined", "stack": "…",
488
+ "cause": { "name": "Error", "message": "gateway timeout" } } }
489
+
490
+ // errorFormat: 'both' (recommended while migrating)
491
+ { "err": { "type": "PaymentDeclined", … },
492
+ "exception.type": "PaymentDeclined",
493
+ "exception.message": "card declined",
494
+ "exception.stacktrace": "…",
495
+ "error.type": "PaymentDeclined" }
496
+
497
+ // errorFormat: 'semconv' (explicit migration — err is removed)
498
+ { "exception.type": "PaymentDeclined", "exception.message": "card declined",
499
+ "exception.stacktrace": "…", "error.type": "PaymentDeclined" }
500
+ ```
501
+
502
+ All four attributes are **Stable**. `error.type` carries the **class name** and is low cardinality
503
+ by construction — never a message, never an identifier — because the spec requires it to be
504
+ aggregatable.
505
+
506
+ **`Error.cause` chains are preserved**, depth- and width-bounded, circular-safe, and redacted like
507
+ any other field. `AggregateError` members reach the entry as `err.errors`. There is no
508
+ OpenTelemetry attribute for a cause chain, so these stay namespaced under `err` rather than
509
+ inventing an `exception.cause` the spec does not define.
510
+
511
+ An error's own enumerable properties (`code`, `statusCode`, domain fields) are carried through, as
512
+ Pino's standard serializer did.
513
+
364
514
  ### 7. Context propagation with `LogContextService`
365
515
 
366
516
  ```typescript
@@ -396,18 +546,24 @@ Full options reference for `BymaxLoggerModule.forRoot(options)`:
396
546
 
397
547
  ### Top-level options
398
548
 
399
- | Option | Type | Default | Description |
400
- | ---------------------------- | -------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
401
- | `service.name` | `string` | **Required** | Service name emitted in every log entry |
402
- | `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
403
- | `level` | `LogLevel` | `'info'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
404
- | `isPretty` | `boolean` | `!isProduction` | ⚠️ Reserved — not auto-wired. For pretty output add `new PrettyDevDestination()` to `destinations` (needs `pino-pretty`) |
405
- | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths, applied on top of the default coverage |
406
- | `redactStrategy` | `'names' \| 'paths'` | `'names'` | Engine behind the DEFAULT set. `'paths'` restores the pre-1.2 `fast-redact` expansion (four-level ceiling, ~100× slower) |
407
- | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the default PII coverage entirely. ⚠️ Emits `LOGGER_BOOTSTRAP_WARNING` at startup — document why |
408
- | `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
409
- | `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
410
- | `destinations` | `ILogDestination[]` | `[]` | Additional sinks (Loki, Postgres, rolling file, …) alongside default stdout |
549
+ | Option | Type | Default | Description |
550
+ | ---------------------------- | ------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
551
+ | `service.name` | `string` | **Required** | Service name emitted in every log entry |
552
+ | `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
553
+ | `service.namespace` | `string` | — | OTel `service.namespace` (**Stable**) — the group the service belongs to |
554
+ | `service.instanceId` | `string` | — | OTel `service.instance.id` (**Stable**). **Never generated** — supply it from the platform. See [Resource identity](#6b-resource-identity) |
555
+ | `service.environment` | `string` | `NODE_ENV` | OTel `deployment.environment.name` (**Stable**). The deprecated `deployment.environment` is never emitted |
556
+ | `resourceFormat` | `'nested' \| 'flat'` | `'nested'` | Shape of the identity fields. `'flat'` emits the dotted attribute names verbatim |
557
+ | `eventNameField` | `string \| false` | `'event.name'` | Field carrying the derived event name (`PAYMENT_FAILED` → `payment.failed`). `false` disables it |
558
+ | `errorFormat` | `'pino' \| 'semconv' \| 'both'` | `'pino'` | `'both'` adds `exception.*` and `error.type` beside `err.*`; `'semconv'` replaces them |
559
+ | `level` | `LogLevel` | `'info'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
560
+ | `isPretty` | `boolean` | `!isProduction` | ⚠️ Reserved — not auto-wired. For pretty output add `new PrettyDevDestination()` to `destinations` (needs `pino-pretty`) |
561
+ | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths, applied on top of the default coverage |
562
+ | `redactStrategy` | `'names' \| 'paths'` | `'names'` | Engine behind the DEFAULT set. `'paths'` restores the pre-1.2 `fast-redact` expansion (four-level ceiling, ~100× slower) |
563
+ | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the default PII coverage entirely. ⚠️ Emits `LOGGER_BOOTSTRAP_WARNING` at startup — document why |
564
+ | `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
565
+ | `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
566
+ | `destinations` | `ILogDestination[]` | `[]` | Additional sinks (Loki, Postgres, rolling file, …) alongside default stdout |
411
567
 
412
568
  ### `http` options
413
569
 
@@ -724,6 +880,8 @@ Before `1.2.0` the same names were expanded into 140 `fast-redact` paths at wild
724
880
 
725
881
  **Not covered, by design:** a secret interpolated into the message STRING (`logger.info(key, \`token=${t}\`)`) — redaction works on structured fields, and no field-based mechanism can scrub a substring. Keep secrets out of `msg`.
726
882
 
883
+ **One entry, one line.** What the message string _cannot_ do is forge an entry. Every message argument passes through a line-separator normalization before it reaches Pino, so `\r`, `\n`, U+2028, U+2029 and C1 NEL (U+0085) become the literal two-character sequence `\n` — and every other control character that can drive a terminal (ESC, vertical tab, form feed, backspace, the rest of C0 except TAB, DEL and the C1 range) becomes its readable `\uXXXX` escape. This protects **two** sinks, not one. `pino-pretty` (shipped here as `PrettyDevDestination`) and any destination that re-renders the parsed message write those bytes straight to the terminal, where a raw newline **or** an `ESC E` produces something indistinguishable from a separate log entry. And the raw NDJSON line is exposed too when a human reads it in a terminal: JSON escaping covers only C0, so `JSON.stringify` and Pino's serializer emit DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 **verbatim** — measured on real bytes. Escaping at the sink neutralizes both paths. The scrubbed stack gets the same escaping (its newlines are kept, since a stack is multi-line by design), because `pino-pretty` prints it raw and its first line repeats the error message. Structured fields are untouched: `err.message` keeps the original text verbatim. That is the boundary — a control character placed in a **metadata value** still reaches a terminal, because escaping data would mean rewriting what you asked to be logged. Only `msg` and the stack carry this guarantee.
884
+
727
885
  ### Extending the defaults
728
886
 
729
887
  ```typescript