@bymax-one/nest-logger 1.2.0 → 1.2.2

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,163 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.2] - 2026-08-13
15
+
16
+ ### Added
17
+
18
+ - **`EmittedServiceResource` and `EmittedDeploymentResource` are exported.** `LogEntry['service']`
19
+ and `LogEntry['deployment']` have been typed by these interfaces since 1.2.1, but neither was
20
+ reexported from `@bymax-one/nest-logger/shared`, so a consumer could hold `entry.service` and had
21
+ no way to write its type — a function taking one had to inline the shape or reach for
22
+ `LogEntry['service']`. Reported by a consumer adopting 1.2.1.
23
+ - **Every shared type is now reexported from the server subpath, not a subset.** `LogEntry` was
24
+ already there while the types of its own properties were not, which is the same defect one level
25
+ up. `ResolvedServiceMetadata` and `ReservedLogKey` join them for the same reason.
26
+ - **`LogEntry` declares `'event.name'`.** The field has been emitted since 1.2.1 and documented as
27
+ part of the entry, but only the index signature covered it — so a consumer reading
28
+ `entry['event.name']` got `unknown` and needed a cast. A documented field that cannot be read
29
+ without a cast is not really published. The declaration describes the default key; a renamed
30
+ `eventNameField` still arrives through the index signature.
31
+
32
+ Type-only change: `dist/*.mjs` and `dist/*.cjs` are byte-identical to 1.2.1, since `export type`
33
+ erases at compile time. Only the `.d.ts` files differ.
34
+
35
+ ## [1.2.1] - 2026-08-13
36
+
37
+ ### Security
38
+
39
+ - **A log message can no longer forge log lines in re-rendered output** (CodeQL
40
+ `js/log-injection`, alert #61). Every message argument handed to Pino can carry caller- or
41
+ user-provided text — a thrown value recorded off an HTTP request, a variadic NestJS line, a
42
+ structured message — and while the NDJSON transport neutralizes an embedded LF or CR through
43
+ JSON escaping (measured; it does NOT cover U+2028/U+2029 — see the entry below),
44
+ `pino-pretty` — shipped here as `PrettyDevDestination` — and
45
+ any destination that re-renders the parsed message print real newlines, where a break forges what
46
+ looks like a separate entry (also measured: the forged line is indistinguishable from a genuine
47
+ one). Line and paragraph separators (`\r`, `\n`, U+2028, U+2029) are now replaced with the
48
+ literal `\n` sequence on **every** message sink: `error()` for both the `Error` and the string
49
+ path, the structured `info`/`warn` calls, and the NestJS variadic bridge. Structured fields are
50
+ untouched — `err.message` keeps the verbatim text — so no information is lost, only the
51
+ human-readable `msg` is pinned to one line.
52
+ - **Terminal control characters can no longer drive the renderer either.** Pinning the line
53
+ terminators is not enough on a terminal: `ESC E` is ANSI NEL (next line), `ESC [` opens a control
54
+ sequence, and vertical tab, form feed and C1 NEL (U+0085) all move the cursor. Measured on the
55
+ built bundle — the raw `ESC` byte reached the terminal through `pino-pretty` and forged a line
56
+ exactly like a newline did. Every C0 control except TAB, plus DEL and the C1 range, is now
57
+ rendered as its readable `\uXXXX` escape — one form for every character, four hex digits.
58
+ Escaping the ESC byte is what disarms every sequence built on it, rather than chasing sequences
59
+ one by one.
60
+ - **This also protects the raw NDJSON line, not only re-rendering destinations.** JSON escaping
61
+ covers C0 and nothing more: `JSON.stringify` and Pino's serializer emit DEL, the C1 range
62
+ (U+0085 NEL among them), U+2028 and U+2029 **verbatim** — measured on the emitted bytes. A human
63
+ reading raw NDJSON in a terminal was therefore exposed to the same cursor movement as a
64
+ `pino-pretty` reader. Both paths are now covered. The boundary is unchanged: structured values
65
+ are data and are not rewritten, so a control character placed in a metadata field still reaches
66
+ the terminal — only `msg` and the stack carry the guarantee.
67
+ - **The scrubbed stack is escaped too.** `pino-pretty` prints `err.stack` RAW rather than as a JSON
68
+ string, and a stack's first line repeats the error message — so escaping only `msg` left the
69
+ identical attack working through `err.stack` and `exception.stacktrace`. Control characters in
70
+ the stack are now escaped; its newlines are preserved, because a stack is legitimately
71
+ multi-line.
72
+
73
+ P1 of the observability audit ([`docs/observability_audit.md`](./docs/observability_audit.md)):
74
+ stable OpenTelemetry resource identity, robust trace correlation, semconv-aligned error fields and
75
+ machine-readable event names. Every convention adopted here was verified **Stable** against
76
+ Semantic Conventions **v1.44.0** on 2026-08-13; nothing Development or Experimental is emitted by
77
+ default, and no runtime dependency was added.
78
+
79
+ Design rationale is in [ADR 0001](./docs/adr/0001-resource-identity-and-otel-correlation.md); the
80
+ complete field inventory is in
81
+ [`docs/semantic-convention-mapping.md`](./docs/semantic-convention-mapping.md).
82
+
83
+ ### Fixed
84
+
85
+ - **The HTTP terminal entry is emitted in the live async context when one exists.** The `'close'`
86
+ listener used `AsyncResource.bind` alone, which captures the context at REGISTRATION time — so
87
+ when instrumentation opened a span downstream of the middleware, the terminal entry was
88
+ attributed to the wrong span, silently, because a plausible trace id was still present. Measured
89
+ (and confirmed independently against a real OTel ContextManager): the live read covers the
90
+ normal path, the bound context covers the aborted path, and neither alone covers both. The
91
+ listener now reads live-first with the bound context as fallback; the ALS store doubles as the
92
+ liveness probe.
93
+ - **A thrown non-object no longer spreads into the `err` object.** The serializer's own-property
94
+ copy ran on any value, and `Object.entries` on a thrown STRING spreads its characters as indexed
95
+ keys — a record carried `err: {"0":"t","1":"h",…}` beside the `UnknownError` envelope; a thrown
96
+ array spread its elements the same way. Own properties are now copied only off a plain
97
+ non-array object.
98
+ - **Trace correlation no longer switches off because of the working directory.** `@opentelemetry/api`
99
+ was resolved from `process.cwd()` alone, so a Docker `WORKDIR` that is not the app root, a
100
+ pnpm/Yarn workspace with hoisted `node_modules`, a monorepo launched from the repository root or a
101
+ serverless bundle silently disabled ALL trace correlation. Measured with `cwd = /`: the old anchor
102
+ fails to resolve, the new one succeeds. Resolution is now anchored at the library's own module
103
+ path, falling back to the working directory.
104
+ - **`err.type` is no longer `"Object"` for a typed exception.** `pino.stdSerializers.err` derives
105
+ the type from the value's CONSTRUCTOR, so an error already normalized into a plain object — which
106
+ is exactly what `HttpExceptionFilter` produces — was reported as `Object`. The fix is
107
+ architectural: nothing pre-serializes any more and the `err` serializer reads the real type. A
108
+ second layer of the same defect is fixed too — `sanitizeError` only accepted `instanceof Error`,
109
+ so the same plain object came out as `UnknownError`.
110
+ - **`Error.cause` chains reach the log.** `sanitizeError` computed the chain and the service
111
+ discarded it, so modern JavaScript error chaining was invisible. `AggregateError` members are
112
+ carried as `err.errors`. Both are depth- and width-bounded, circular-safe, and redacted.
113
+
114
+ ### Added
115
+
116
+ - **Stable resource identity.** `service.namespace`, `service.instanceId` and `service.environment`
117
+ options, emitted as `service.namespace`, `service.instance.id` and `deployment.environment.name`.
118
+ Resolution precedence is deterministic: explicit options → `OTEL_SERVICE_NAME` →
119
+ `OTEL_RESOURCE_ATTRIBUTES` → `NODE_ENV`. The order of the two OTel variables is required by the
120
+ specification, so the logger and the SDK reading the same environment agree by construction.
121
+ `service.instance.id` is **never generated** — a UUID minted here would be the logger's rather
122
+ than the OTel Resource's, and would change on every restart while looking authoritative.
123
+ - **`resourceFormat: 'nested' | 'flat'`** (default `'nested'`). `'flat'` emits the dotted attribute
124
+ names verbatim, which a collector maps onto resource attributes directly.
125
+ - **`errorFormat: 'pino' | 'semconv' | 'both'`** (default `'pino'`). Adds the Stable
126
+ `exception.type` / `exception.message` / `exception.stacktrace` and a low-cardinality
127
+ `error.type`. `'both'` is the migration path; `'semconv'` replaces the legacy `err` object and is
128
+ an explicit choice, never a default.
129
+ - **`eventNameField`** (default `'event.name'`). Derives an OTel-conforming event name from
130
+ `logKey` — `PAYMENT_FAILED` → `payment.failed` — following the naming rules. `logKey` is never
131
+ renamed or removed. `false` disables it.
132
+ - **`LOGGER_BOOTSTRAP_WARNING` for an unavailable OTel API.** When trace injection is enabled and
133
+ `@opentelemetry/api` cannot be resolved, one warning naming `OTEL_API_UNAVAILABLE` is emitted at
134
+ boot. A missing `traceId` used to be indistinguishable from "no active span".
135
+ - **`ResolvedServiceMetadata`** exported from `@bymax-one/nest-logger/shared` — the identity after
136
+ precedence, and the contract a future `@bymax-one/nest-observability` consumes.
137
+
138
+ ### Changed
139
+
140
+ - An error's own enumerable properties (`code`, `statusCode`, domain fields) are still carried onto
141
+ the serialized `err`, as Pino's standard serializer did — with the fields the serializer derives
142
+ itself excluded, so a plain error-like object no longer emits `name` beside the `type` derived
143
+ from it.
144
+ - `deployment.environment.name` is emitted from `NODE_ENV` when nothing else supplies it, so most
145
+ applications gain the attribute without configuration. The **deprecated** `deployment.environment`
146
+ spelling is never emitted.
147
+
148
+ ### Performance
149
+
150
+ Measured on the same machine as the 1.2.0 figures, shipped configuration:
151
+
152
+ | Path | 1.2.0 | P1 | Delta |
153
+ | ------------------------------------------- | --------------: | -------------: | ----: |
154
+ | Shipped config throughput | ~278,000 logs/s | 263,768 logs/s | −5% |
155
+ | Retention vs. bare service (budget ≥ 0.20×) | 0.349× | 0.323× | −7% |
156
+
157
+ The cost is almost entirely `event.name` derivation: 949,560 vs 1,089,561 ops/sec on the info path
158
+ (~13% there, ~7% of the full path). Resource identity costs ~2%, because it is resolved once and
159
+ precomputed into Pino's `base`. `errorFormat: 'semconv'` is **faster** than the legacy shape
160
+ (330,289 vs 245,463 ops/sec) since it emits flat strings instead of walking a cause chain. Set
161
+ `eventNameField: false` to pay none of it.
162
+
163
+ ### Not adopted
164
+
165
+ - **`@opentelemetry/api-logs`** — still `0.221.0`. A 0.x package will not become a dependency of a
166
+ library that ships `"dependencies": {}`.
167
+ - **`event.name` as an OTLP attribute** — the attribute is Deprecated; the value belongs in the
168
+ LogRecord `EventName` field, and the emitted key is the JSON carrier for that mapping.
169
+ - **`deployment.id` / `.name` / `.status`** — Development stability.
170
+
14
171
  ## [1.2.0] - 2026-08-13
15
172
 
16
173
  Remediation of the P0 findings from the observability audit
@@ -657,7 +814,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
657
814
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
658
815
  `release.yml`, Dependabot, and issue templates
659
816
 
660
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...HEAD
817
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...HEAD
818
+ [1.2.2]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...v1.2.2
819
+ [1.2.1]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...v1.2.1
661
820
  [1.2.0]: https://github.com/bymaxone/nest-logger/compare/v1.1.0...v1.2.0
662
821
  [1.1.0]: https://github.com/bymaxone/nest-logger/compare/v1.0.8...v1.1.0
663
822
  [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
@@ -98,10 +101,10 @@ pnpm add @bymax-one/nest-logger
98
101
 
99
102
  One package, two entry points — import only what your app needs:
100
103
 
101
- | Subpath | Import | Purpose | Dependencies |
102
- | ---------- | ------------------------------- | ------------------------------------------------------------------------------- | :-------------------------------------: |
103
- | **Server** | `@bymax-one/nest-logger` | NestJS module, logger service, interceptor, filter, decorators, destinations | NestJS 11, pino, rxjs, reflect-metadata |
104
- | **Shared** | `@bymax-one/nest-logger/shared` | Types, constants, the log-key regex — `LogLevel`, `LogEntry`, `ServiceMetadata` | None |
104
+ | Subpath | Import | Purpose | Dependencies |
105
+ | ---------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------: |
106
+ | **Server** | `@bymax-one/nest-logger` | NestJS module, logger service, interceptor, filter, decorators, destinations | NestJS 11, pino, rxjs, reflect-metadata |
107
+ | **Shared** | `@bymax-one/nest-logger/shared` | Types, constants, the log-key regex — `LogLevel`, `LogEntry`, `ServiceMetadata`, `ResolvedServiceMetadata`, `EmittedServiceResource`, `EmittedDeploymentResource` | None |
105
108
 
106
109
  ```
107
110
  shared (zero deps)
@@ -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
@@ -935,7 +1093,28 @@ PINO_LEVEL_NAMES[30] // 'info' — numeric code → label
935
1093
  ```typescript
936
1094
  type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
937
1095
 
938
- type ServiceMetadata = { name: string; version: string }
1096
+ // What you CONFIGURE.
1097
+ type ServiceMetadata = {
1098
+ readonly name: string
1099
+ readonly version: string
1100
+ readonly namespace?: string
1101
+ readonly instanceId?: string
1102
+ readonly environment?: string
1103
+ }
1104
+
1105
+ // What is EMITTED. Deliberately a different type: `instanceId` nests as
1106
+ // `service.instance.id`, and the environment is not under `service` at all.
1107
+ // Reusing the configuration type here would advertise `entry.service.instanceId`,
1108
+ // which no entry carries.
1109
+ type EmittedServiceResource = {
1110
+ readonly name: string
1111
+ readonly version: string
1112
+ readonly namespace?: string
1113
+ readonly instance?: { readonly id: string }
1114
+ readonly [key: string]: unknown
1115
+ }
1116
+
1117
+ type EmittedDeploymentResource = { readonly environment: { readonly name: string } }
939
1118
 
940
1119
  type LogEntry = {
941
1120
  /** Pino string label — `'info'`, not `30`. Convert with `PINO_LEVEL_NUMBERS`. */
@@ -944,7 +1123,12 @@ type LogEntry = {
944
1123
  time: string
945
1124
  msg: string
946
1125
  logKey?: string
947
- service?: ServiceMetadata
1126
+ /** Present under `resourceFormat: 'nested'` (the default). */
1127
+ service?: EmittedServiceResource
1128
+ /** Present when an environment resolved — `deployment.environment.name`. */
1129
+ deployment?: EmittedDeploymentResource
1130
+ /** Derived from `logKey` unless `eventNameField: false`. */
1131
+ 'event.name'?: string
948
1132
  context?: string
949
1133
  requestId?: string
950
1134
  tenantId?: string
@@ -955,6 +1139,41 @@ type LogEntry = {
955
1139
  }
956
1140
  ```
957
1141
 
1142
+ Under `resourceFormat: 'flat'` there is no `service` object at all — the values arrive as dotted
1143
+ top-level keys (`"service.instance.id"`), which the index signature already admits.
1144
+
1145
+ The emitted types are exported, so a helper that receives one can be typed without inlining its
1146
+ shape:
1147
+
1148
+ ```typescript
1149
+ import type {
1150
+ EmittedDeploymentResource,
1151
+ EmittedServiceResource,
1152
+ LogEntry
1153
+ } from '@bymax-one/nest-logger/shared'
1154
+
1155
+ /** Build the identity triplet a backend joins on, from a parsed entry. */
1156
+ function identityOf(entry: LogEntry): string {
1157
+ const service: EmittedServiceResource | undefined = entry.service
1158
+ const deployment: EmittedDeploymentResource | undefined = entry.deployment
1159
+ if (service === undefined) {
1160
+ return 'unknown'
1161
+ }
1162
+ // `service.namespace` is part of the identity, not decoration: OTel allows two
1163
+ // services to share a name when their namespaces differ, so a key built without
1164
+ // it collapses them into one.
1165
+ const namespace = service.namespace ?? 'no-namespace'
1166
+ const instance = service.instance?.id ?? 'no-instance'
1167
+ const environment = deployment?.environment.name ?? 'no-environment'
1168
+ return `${namespace}/${service.name}@${service.version}/${instance}/${environment}`
1169
+ }
1170
+
1171
+ /** `event.name` is declared, so it reads as a string without a cast. */
1172
+ function eventOf(entry: LogEntry): string | undefined {
1173
+ return entry['event.name']
1174
+ }
1175
+ ```
1176
+
958
1177
  ### `ILogDestination`
959
1178
 
960
1179
  ```typescript