@bymax-one/nest-logger 1.1.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/README.md CHANGED
@@ -43,8 +43,8 @@ The library has **zero direct dependencies** — all packages arrive as peer dep
43
43
 
44
44
  - **🎯 One module, the whole pipeline** — Logger service, HTTP interceptor, exception filter, context propagation, redaction, and destinations arrive in a single `forRoot()`. No gluing together `pino-http`, a redaction layer, and a transport by hand.
45
45
  - **🔌 Your sinks, your rules** — The library defines `ILogDestination`. You implement it for Loki, Postgres, a rolling file, or anything else. No vendor lock-in, no hidden transport dependencies.
46
- - **🔒 Redacted by default** — 140 redact paths compile at bootstrap covering the standard set: passwords, tokens, PCI DSS card data, MFA secrets, and LGPD documents. Domain-specific field names are yours to add via `redactPaths`.
47
- - **⚡ On the hot path, so it stays cheap** — Singleton providers, one composed Pino mixin, and a pre-compiled redactor. No `Scope.REQUEST`, no per-log regex matching.
46
+ - **🔒 Redacted by default** — 32 sensitive field names are censored **down to 100 levels of nesting**, covering passwords, tokens, PCI DSS card data, MFA secrets, LGPD documents and credential-bearing HTTP headers. One recursive walk per entry, not a path list. Domain-specific names are yours to add via `redactPaths`.
47
+ - **⚡ On the hot path, so it stays cheap** — Singleton providers, one composed Pino mixin, and a single-pass redactor. No `Scope.REQUEST`, no path matching.
48
48
  - **🔭 Correlated when you need it** — When an OpenTelemetry span is active, `traceId`/`spanId`/`traceFlags` land in every entry. When the peer is absent, the mixin steps aside at zero cost.
49
49
 
50
50
  ```
@@ -65,7 +65,7 @@ pnpm add @bymax-one/nest-logger
65
65
 
66
66
  ### 🛡️ Security & Privacy
67
67
 
68
- - ✅ **PII Redaction by Default** — 140 paths covering passwords, tokens, and generic secrets — powered by `fast-redact`
68
+ - ✅ **PII Redaction by Default** — 32 field names censored wherever they appear, to a 100-level nesting ceiling past which nested objects are dropped, in a single snapshotting walk
69
69
  - ✅ **PCI DSS & MFA Coverage** — card data and MFA secrets redacted out of the box, with common HTTP auth headers
70
70
  - ✅ **LGPD-Aware Paths** — CPF, CNPJ, and RG redacted by default for Brazilian workloads
71
71
  - ✅ **Append-Only Redact List** — `DEFAULT_REDACT_PATHS` never shrinks without a major version; extend it via `redactPaths`
@@ -73,9 +73,12 @@ 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
- - ✅ **HTTP Logging Interceptor** — auto-logs all HTTP requests/responses with URL normalization (UUIDs and numeric IDs replaced by `:id`)
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
80
83
 
81
84
  ### 🔌 Destinations
@@ -245,7 +248,7 @@ Output (JSON, production):
245
248
 
246
249
  ```json
247
250
  {
248
- "level": 30,
251
+ "level": "info",
249
252
  "time": "2026-05-28T10:12:44.512Z",
250
253
  "service": { "name": "my-app", "version": "abc123" },
251
254
  "logKey": "PAYMENT_REFUND_SUCCESS",
@@ -272,19 +275,48 @@ Output (pretty-print, development):
272
275
 
273
276
  ### 5. HTTP logging (automatic)
274
277
 
275
- Enable `http.isEnabled: true` in the module options. The `HttpLoggingInterceptor` is registered globally and emits:
278
+ Enable `http.isEnabled: true` in the module options and wire `applyRequestIdMiddleware(consumer)` (see [Context propagation](#7-context-propagation-with-logcontextservice)). The access log is recorded from **middleware**, which emits:
276
279
 
277
- | Log key | When |
278
- | --------------------------- | ------------------------------------- |
279
- | `HTTP_REQUEST_START` | Request received |
280
- | `HTTP_REQUEST_SUCCESS` | 2xx response |
281
- | `HTTP_REQUEST_REDIRECT` | 3xx response |
282
- | `HTTP_REQUEST_CLIENT_ERROR` | 4xx response |
283
- | `HTTP_REQUEST_SERVER_ERROR` | 5xx response |
284
- | `HTTP_EXCEPTION_HANDLED` | `HttpException` caught by the filter |
285
- | `HTTP_EXCEPTION_UNHANDLED` | Unexpected error caught by the filter |
280
+ | Log key | When |
281
+ | --------------------------- | --------------------------------------------------- |
282
+ | `HTTP_REQUEST_START` | Request received |
283
+ | `HTTP_REQUEST_SUCCESS` | 2xx response |
284
+ | `HTTP_REQUEST_REDIRECT` | 3xx response |
285
+ | `HTTP_REQUEST_CLIENT_ERROR` | 4xx response |
286
+ | `HTTP_REQUEST_SERVER_ERROR` | 5xx response |
287
+ | `HTTP_REQUEST_ABORTED` | Connection closed before the response was delivered |
286
288
 
287
- URLs are automatically normalized — `/users/550e8400-e29b-41d4-a716-446655440000` becomes `/users/:id` so Loki/Grafana cardinality stays bounded.
289
+ `HTTP_REQUEST_START` is emitted **before guards**, so it carries no `userId` — authentication runs
290
+ in a guard, and at that point there is no principal yet. The acting user is on the **terminal**
291
+ entry, where the guard has populated it. Both entries carry the same `requestId`, so joining on
292
+ that gives the whole request.
293
+ | `HTTP_EXCEPTION_HANDLED` | `HttpException` caught by the filter |
294
+ | `HTTP_EXCEPTION_UNHANDLED` | Unexpected error caught by the filter |
295
+
296
+ URLs are automatically normalized — `/users/550e8400-e29b-41d4-a716-446655440000` becomes `/users/:id` so Loki/Grafana cardinality stays bounded. The query string is stripped from every logged URL, because a magic-link token or reset code in a query parameter is a secret no key-name redaction can scrub out of a string value.
297
+
298
+ > [!IMPORTANT]
299
+ > **Why middleware and not an interceptor.** NestJS runs middleware → guards → interceptors →
300
+ > handler, so an interceptor never observes a request a guard rejected, and never observes one that
301
+ > matched no route. Measured against a real backend: **401, 403, 429 from a throttler and 404 for an
302
+ > unknown path produced no log line at all** — not even `HTTP_REQUEST_START` — so brute force,
303
+ > credential stuffing and route enumeration were invisible, and invisible without a `requestId` to
304
+ > correlate them by. The access log therefore runs before guards.
305
+ >
306
+ > A consumer who does not wire the middleware keeps the previous interceptor-based behaviour rather
307
+ > than losing HTTP logs — including its blind spot.
308
+
309
+ **Delivery is reported separately from status.** `HTTP_REQUEST_ABORTED` is emitted when the
310
+ connection closed before the response was flushed, and it **keeps the real status the server
311
+ produced** rather than inventing one: nginx's `499` is not an HTTP status (IANA leaves 452–499
312
+ unassigned), so recording it would assert a code the protocol has no name for and break any
313
+ consumer grouping by class.
314
+
315
+ Its limit is worth stating: `writableFinished` distinguishes a response still in flight from one
316
+ handed to the operating system. A **fast handler whose client hangs up after the bytes were
317
+ flushed** is reported as the success it was — the server completed and flushed it, and whether the
318
+ peer read it is not knowable from the server side. What this catches is the case that matters
319
+ operationally: the slow upstream, the load-balancer timeout, the cancelled request.
288
320
 
289
321
  ### 6. OpenTelemetry correlation
290
322
 
@@ -332,6 +364,153 @@ void bootstrap()
332
364
 
333
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.
334
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
+
335
514
  ### 7. Context propagation with `LogContextService`
336
515
 
337
516
  ```typescript
@@ -367,17 +546,24 @@ Full options reference for `BymaxLoggerModule.forRoot(options)`:
367
546
 
368
547
  ### Top-level options
369
548
 
370
- | Option | Type | Default | Description |
371
- | ---------------------------- | ------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
372
- | `service.name` | `string` | **Required** | Service name emitted in every log entry |
373
- | `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
374
- | `level` | `LogLevel` | `'info'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
375
- | `isPretty` | `boolean` | `!isProduction` | ⚠️ Reserved — not auto-wired. For pretty output add `new PrettyDevDestination()` to `destinations` (needs `pino-pretty`) |
376
- | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths merged with the defaults |
377
- | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the 140 default PII paths. ⚠️ Emits a bootstrap warning — document why |
378
- | `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
379
- | `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
380
- | `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 |
381
567
 
382
568
  ### `http` options
383
569
 
@@ -424,10 +610,16 @@ function assertValidLogKey(key: string) {
424
610
 
425
611
  The following keys are used internally by the library — do not reuse them in application code:
426
612
 
427
- `LOGGER_BOOTSTRAP_OK` · `LOGGER_BOOTSTRAP_WARNING` · `LOGGER_SHUTDOWN_OK` · `HTTP_REQUEST_START` · `HTTP_REQUEST_SUCCESS` · `HTTP_REQUEST_REDIRECT` · `HTTP_REQUEST_CLIENT_ERROR` · `HTTP_REQUEST_SERVER_ERROR` · `HTTP_REQUEST_COMPLETED` · `HTTP_EXCEPTION_HANDLED` · `HTTP_EXCEPTION_UNHANDLED` · `METHOD_EXECUTION` · `METHOD_SLOW_EXECUTION` · `LOGGER_DESTINATION_INIT_FAILED` · `LOGGER_DESTINATION_WRITE_FAILED` · `LOGGER_ENTRY_TRUNCATED`
613
+ `LOGGER_BOOTSTRAP_OK` · `LOGGER_BOOTSTRAP_WARNING` · `LOGGER_SHUTDOWN_OK` · `HTTP_REQUEST_START` · `HTTP_REQUEST_SUCCESS` · `HTTP_REQUEST_REDIRECT` · `HTTP_REQUEST_CLIENT_ERROR` · `HTTP_REQUEST_SERVER_ERROR` · `HTTP_REQUEST_ABORTED` · `HTTP_REQUEST_COMPLETED` · `HTTP_EXCEPTION_HANDLED` · `HTTP_EXCEPTION_UNHANDLED` · `METHOD_EXECUTION` · `METHOD_SLOW_EXECUTION` · `LOGGER_DESTINATION_INIT_FAILED` · `LOGGER_DESTINATION_WRITE_FAILED` · `LOGGER_ENTRY_TRUNCATED` · `LOGGER_REDACTION_FAILED`
428
614
 
429
615
  All reserved keys are exported as the `RESERVED_LOG_KEYS` constant from `@bymax-one/nest-logger/shared`.
430
616
 
617
+ `HTTP_REQUEST_COMPLETED` is reserved but deliberately never emitted — the four status-specific
618
+ terminal keys already carry the same `duration`, so a generic "completed" entry would double the
619
+ access-log volume to say nothing new. The reserved-but-unwritten set is exported as
620
+ `RESERVED_LOG_KEYS_NOT_EMITTED`, and a test asserts that every OTHER declared key has a writer in
621
+ the source, so a key can no longer be documented as a signal and then silently never emitted.
622
+
431
623
  ---
432
624
 
433
625
  ## 🧩 Custom Destinations
@@ -488,7 +680,9 @@ export class LokiDestination implements ILogDestination {
488
680
  {
489
681
  stream: this.labels,
490
682
  values: batch.map((line) => [
491
- String(BigInt((JSON.parse(line) as LogEntry).time) * 1_000_000n),
683
+ // `time` is an ISO 8601 string — parse it to epoch ms, then scale to
684
+ // the nanoseconds Loki expects. `BigInt(entry.time)` throws on an ISO string.
685
+ String(BigInt(Date.parse((JSON.parse(line) as LogEntry).time)) * 1_000_000n),
492
686
  line.trimEnd()
493
687
  ])
494
688
  }
@@ -522,6 +716,7 @@ BymaxLoggerModule.forRootAsync({
522
716
  ### Postgres destination (Prisma)
523
717
 
524
718
  ```typescript
719
+ import { PINO_LEVEL_NUMBERS } from '@bymax-one/nest-logger'
525
720
  import type { ILogDestination } from '@bymax-one/nest-logger'
526
721
  import type { LogEntry } from '@bymax-one/nest-logger/shared'
527
722
  import type { PrismaClient } from '@prisma/client'
@@ -538,7 +733,8 @@ export class PrismaLogDestination implements ILogDestination {
538
733
  const entry = JSON.parse(payload) as LogEntry
539
734
  void this.prisma.applicationLog.create({
540
735
  data: {
541
- level: entry.level,
736
+ // `entry.level` is the Pino LABEL ('info'). Map it when the column is numeric.
737
+ level: PINO_LEVEL_NUMBERS[entry.level],
542
738
  logKey: entry.logKey,
543
739
  message: entry.msg,
544
740
  payload: entry,
@@ -587,11 +783,21 @@ export class RollingFileDestination implements ILogDestination {
587
783
  HTTP Request
588
784
  │
589
785
  ▼
590
- HttpLoggingInterceptor ← emits HTTP_REQUEST_START
786
+ RequestIdMiddleware ← runs FIRST (NestJS middleware precedes
787
+ │ guards AND interceptors) and opens the
788
+ │ AsyncLocalStorage scope
789
+ │ { requestId, tenantId, userId }
790
+ ▼
791
+ HttpAccessLogMiddleware ← emits HTTP_REQUEST_START inside that scope, and
792
+ │ arms the terminal entry on the response's
793
+ │ 'close' event. BEFORE guards, so a 401/403/429
794
+ │ rejection is logged too.
795
+ ▼
796
+ Guards (consumer's auth) ← may reject here; no interceptor ever runs
591
797
  │
592
798
  ▼
593
- RequestIdMiddleware ← opens AsyncLocalStorage scope
594
- │ { requestId, tenantId, userId }
799
+ HttpLoggingInterceptor ← records the thrown error for the terminal entry
800
+ │
595
801
  ▼
596
802
  Application Service
597
803
  │
@@ -605,7 +811,7 @@ Application Service
605
811
  └── OTel span → { traceId, spanId, traceFlags }
606
812
  │
607
813
  ▼
608
- fast-redact ← compiled redact function (140 paths)
814
+ name redactor ← one recursive walk, any depth
609
815
  │
610
816
  ▼
611
817
  ┌─────────────────────────────┐
@@ -618,13 +824,13 @@ Application Service
618
824
 
619
825
  ### Design Principles
620
826
 
621
- | Principle | Description |
622
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
623
- | **🪶 Singleton Scope** | `AsyncLocalStorage` delivers per-request context at zero latency overhead — NestJS `Scope.REQUEST` adds ~5% on the injection graph, unacceptable on a logger that runs for every request |
624
- | **🧬 One Composed Mixin** | ALS context and OTel trace context merge into a single Pino mixin with a deterministic order: ALS first, then OTel — an active span is the authoritative trace identity, so it wins on conflicts |
625
- | **⚡ Compiled Redaction** | 140 `fast-redact` paths compile once at module bootstrap into a specialized function; no per-log regex matching, under 3% throughput impact |
626
- | **🔌 Interface-Driven Sinks** | `ILogDestination` is a contract — Loki, Postgres, rolling files, or anything else is a consumer implementation, never a dependency of this package |
627
- | **🌳 Zero Runtime Deps** | `"dependencies": {}` — every package arrives as a peer dependency, so consumers pin exact versions and the supply-chain surface stays theirs |
827
+ | Principle | Description |
828
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
829
+ | **🪶 Singleton Scope** | `AsyncLocalStorage` delivers per-request context at zero latency overhead — NestJS `Scope.REQUEST` adds ~5% on the injection graph, unacceptable on a logger that runs for every request |
830
+ | **🧬 One Composed Mixin** | ALS context and OTel trace context merge into a single Pino mixin with a deterministic order: ALS first, then OTel — an active span is the authoritative trace identity, so it wins on conflicts |
831
+ | **⚡ Single-pass Redaction** | One recursive walk censors any value whose key name is in the sensitive set, to a 100-level ceiling past which nested objects are dropped rather than emitted — O(nodes), the same order the serializer already pays. Replaced 140 `fast-redact` wildcard paths that cost ~107 µs per entry |
832
+ | **🔌 Interface-Driven Sinks** | `ILogDestination` is a contract — Loki, Postgres, rolling files, or anything else is a consumer implementation, never a dependency of this package |
833
+ | **🌳 Zero Runtime Deps** | `"dependencies": {}` — every package arrives as a peer dependency, so consumers pin exact versions and the supply-chain surface stays theirs |
628
834
 
629
835
  ---
630
836
 
@@ -634,20 +840,47 @@ A logger sees every payload the application handles, so the security posture is
634
840
 
635
841
  ### Redaction by default
636
842
 
637
- The library ships **140 redact paths** compiled at initialization into a single `fast-redact` function (< 3% throughput impact). These cover:
843
+ The library censors **32 sensitive field names** wherever they appear in a log record — down to 100 levels of nesting, inside arrays, and inside class instances. Past that ceiling a nested OBJECT is DROPPED rather than emitted, so the limit can never become a leak. (A primitive sitting at the boundary is still emitted: its key was matched by the container at level 100, the last one walked, so it has already been through the matcher.) These cover:
844
+
845
+ | Category | Fields |
846
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
847
+ | HTTP auth headers | `authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token` |
848
+ | Passwords | `password`, `passwordHash`, `passwordConfirm`, `newPassword`, `oldPassword` |
849
+ | Tokens | `token`, `accessToken`, `refreshToken`, `idToken`, `apiKey`, `apiSecret` |
850
+ | MFA | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` |
851
+ | Generic secrets | `secret`, `clientSecret`, `signingSecret`, `privateKey` |
852
+ | Payment / PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
853
+ | Personal documents (LGPD) | `cpf`, `cnpj`, `rg` |
854
+ | Conservative PII | `email` |
855
+ | HTTP headers (absolute) | `req.headers.authorization`, `req.headers.cookie`, `req.headers["x-api-key"]`, `req.headers["x-auth-token"]`, `res.headers["set-cookie"]` — retained for the legacy `'paths'` strategy; the by-name row above already covers these shapes and every other |
856
+
857
+ #### How it works, and why it changed in `1.2.0`
858
+
859
+ Redaction is one **recursive, snapshotting walk** of the record: a value is censored when its KEY NAME is in the set above, wherever that key sits. Nothing the caller passed is mutated, and every value is read exactly once and pinned into a fresh structure — so what reaches the sink is guaranteed to be what was inspected, even when the payload carries accessors or a `toJSON` that could answer differently on a second read.
860
+
861
+ A value with a `toJSON` is inspected through that method's output, because that is what reaches the log. A method can also **rename** what it exposes — `{ password, toJSON: () => ({ value: this.password }) }` would emit the secret under a name nobody declared sensitive — so when the source object itself carries a sensitive key, the method is not trusted and the whole value is censored. This deliberately over-redacts an object that holds a sensitive key and correctly omits it; the alternative (running `toJSON` against a sanitized copy) throws on every method that reads an internal slot rather than an own property, which is `Date`, `Decimal` and Luxon.
638
862
 
639
- | Category | Fields |
640
- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
641
- | Passwords | `password`, `passwordHash`, `passwordConfirm`, `newPassword`, `oldPassword` |
642
- | Tokens | `token`, `accessToken`, `refreshToken`, `idToken`, `apiKey`, `apiSecret` |
643
- | MFA | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` |
644
- | Generic secrets | `secret`, `clientSecret`, `signingSecret`, `privateKey` |
645
- | Payment / PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
646
- | Personal documents (LGPD) | `cpf`, `cnpj`, `rg` |
647
- | Conservative PII | `email` |
648
- | HTTP headers (absolute) | `req.headers.authorization`, `req.headers.cookie`, `req.headers["x-api-key"]`, `req.headers["x-auth-token"]`, `res.headers["set-cookie"]` |
863
+ > [!WARNING]
864
+ > Redaction matches **key names**. A secret placed under a name you have not declared sensitive is
865
+ > emitted — `logger.log(key, msg, userId, { renamed: user.password })` writes it in clear, and so
866
+ > does a `toJSON` that renames nested state. That is a property of name-based redaction, not a
867
+ > defect: no name matcher can follow a value through a rename. Declare the name, or keep the value
868
+ > out of the log.
649
869
 
650
- Every field is listed at wildcard depths 1–4 (`*.field`, `*.*.field`, `*.*.*.field`, `*.*.*.*.field`) because `fast-redact`'s `*` matches a single level only — not recursive.
870
+ Before `1.2.0` the same names were expanded into 140 `fast-redact` paths at wildcard depths 1–4 (`*.field`, `*.*.field`, …), because `fast-redact`'s `*` matches a single level and is not recursive. That approach had two problems, both measured:
871
+
872
+ | | depth-1–4 paths (pre-`1.2.0`) | name walk (`1.2.0`) |
873
+ | -------------------------------- | -------------------------------------------: | ----------------------------------: |
874
+ | Throughput, full production path | **9,311 logs/s** | **274,227 logs/s** |
875
+ | Cost per entry | ~107 µs | ~3.6 µs |
876
+ | Nesting covered | 4 levels — deeper **leaked** | 100 levels — deeper objects dropped |
877
+ | `{ headers: { authorization } }` | **leaked** (only `req.headers.*` was pinned) | censored |
878
+
879
+ `DEFAULT_REDACT_PATHS` is still exported at full fidelity, and `redactStrategy: 'paths'` still feeds it to `fast-redact` for anyone depending on exact path semantics — with the ceiling and the cost that implies. Expect that escape hatch to be removed in a future major.
880
+
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`.
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.
651
884
 
652
885
  ### Extending the defaults
653
886
 
@@ -664,6 +897,20 @@ BymaxLoggerModule.forRoot({
664
897
 
665
898
  The extra paths are **merged** with the defaults — never replacing them.
666
899
 
900
+ > [!IMPORTANT]
901
+ > A consumer path's LEAF NAME is also fed to the name walk, so `redactPaths: ['user.ssn']`
902
+ > censors `ssn` wherever it appears, not only under `user`. That is broader than the path you
903
+ > wrote, deliberately: consumer paths are applied by Pino's stringifier, which runs after the
904
+ > per-field size bound, so without this a field covered only by a path could still surface inside
905
+ > a truncation envelope's `_preview`. It errs toward redacting a name you have already declared
906
+ > secret.
907
+ >
908
+ > An **array index is not a name**, so an unquoted numeric segment is skipped and the path falls
909
+ > back to the nearest name: `redactPaths: ['tokens[0]']` censors the whole `tokens` array. The
910
+ > walk matches key names and never array positions, so feeding it `0` would cover nothing while
911
+ > censoring any object key literally named `0`. The quoted form `['obj["0"]']` stays a name,
912
+ > since an object key named `0` IS matched by the walk.
913
+
667
914
  ### Disabling defaults (not recommended)
668
915
 
669
916
  ```typescript
@@ -699,7 +946,7 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
699
946
  - `shouldDisableDefaultRedact` stays `false` — if it is on, the `LOGGER_BOOTSTRAP_WARNING` must be justified in a security review
700
947
  - Every custom field carrying a secret or personal document is added to `redactPaths` at the depths it actually appears
701
948
  - `http.excludePaths` regexes are anchored and linear-time — an unbounded pattern runs on every request URL (ReDoS)
702
- - Custom destinations never re-serialize the raw request object; they receive the already-redacted `LogEntry`
949
+ - Custom destinations never re-serialize the raw request object; they receive the already-redacted, already-serialized NDJSON line
703
950
  - Destination credentials (Loki, Postgres, OTLP) come from the environment, never from values written into module options in source control
704
951
  - Log retention at the sink matches your data-retention policy — redaction bounds what is stored, not how long
705
952
 
@@ -709,9 +956,9 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
709
956
 
710
957
  | Layer | Implementation |
711
958
  | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
712
- | PII Redaction | 140 `fast-redact` paths compiled once at bootstrap — no per-log regex matching |
959
+ | PII Redaction | 32 field names censored at ANY depth in one snapshotting walk — ~3.6 µs/entry |
713
960
  | Credentials | `password`, `passwordHash`, `token`, `accessToken`, `refreshToken`, `apiKey`, `apiSecret`, `privateKey` |
714
- | MFA Secrets | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` — redacted at wildcard depths 1–4 |
961
+ | MFA Secrets | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` — redacted to the 100-level ceiling |
715
962
  | PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
716
963
  | LGPD Documents | `cpf`, `cnpj`, `rg`, plus `email` as a conservative default |
717
964
  | HTTP Headers | `authorization`, `cookie`, `x-api-key`, `x-auth-token`, `set-cookie` — absolute paths on `req` / `res` |
@@ -745,11 +992,11 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
745
992
  A logger runs on every request of every service that installs it, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression **fails a test**.
746
993
 
747
994
  - ✅ **100% line coverage** — statements, branches, functions, and lines, enforced as a release gate across unit + e2e
748
- - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) against a
749
- `break` threshold of 95. The ten mutants no test can kill each carry their reason on the line
750
- they apply to; a rule against those comments was retired once their cost was measured at
751
- **+0.10 kB brotli** against 0.66 kB of headroom, roughly a tenth of what it had been assumed
752
- to be (see the [report](./docs/mutation_testing_results.md))
995
+ - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) on a cold run
996
+ against a `break` threshold of 95, with **zero** surviving mutants. The handful that no test can
997
+ kill are suppressed on the line they apply to and each carries its reason; a rule against those
998
+ comments was retired once their cost was measured at **+0.10 kB brotli**, roughly a tenth of
999
+ what it had been assumed to be (see the [report](./docs/mutation_testing_results.md))
753
1000
  - ✅ **Every suppression carries its reason** — each `// Stryker disable` names, after the `:` Stryker reads it from, why the mutant it silences cannot be observed; one of them is recorded as what it is, a mutant the suite kills but Stryker fails to attribute, rather than mislabelled an equivalent. `check:mutants` proves those reasons parse, so they reach the report rather than the `Ignored using a comment` fallback, and the score stays an accounting rather than a number
754
1001
  - ✅ **No real I/O in unit tests** — the Pino instance and every destination are mocked; e2e tests exercise the wired module through `@nestjs/testing` and supertest
755
1002
 
@@ -774,34 +1021,72 @@ pnpm lint # ESLint
774
1021
 
775
1022
  Implements NestJS `LoggerService`. All methods are available via `@InjectLogger()`.
776
1023
 
777
- | Method | Signature | Notes |
778
- | ----------------- | ---------------------------------- | -------------------------------------- |
779
- | `info` | `(logKey, msg, context?, meta?)` | Structured info log |
780
- | `warn` | `(logKey, msg, context?, meta?)` | Structured warn log |
781
- | `debug` | `(logKey, msg, context?, meta?)` | Structured debug log |
782
- | `error` | `(logKey, msg, context?, meta?)` | Structured error log (string message) |
783
- | `fatal` | `(logKey, msg, context?, meta?)` | Structured fatal log |
784
- | `warnStructured` | `(logKey, error, context?, meta?)` | Warn with `Error` object (serialized) |
785
- | `errorStructured` | `(logKey, error, context?, meta?)` | Error with `Error` object (serialized) |
786
- | `fatalStructured` | `(logKey, error, context?, meta?)` | Fatal with `Error` object (serialized) |
787
- | `log` | `(msg, context?)` | NestJS `LoggerService` bridge → `info` |
788
- | `verbose` | `(msg, context?)` | NestJS bridge → `trace` |
1024
+ There are **two** APIs on this class, and they do not share a signature shape.
1025
+
1026
+ **Structured API** — the `MODULE_ACTION_RESULT` convention. The third parameter is the acting **`userId`**, not a context label (the context comes from `@InjectLogger(ctx)`), and it may be omitted: when it is, an ambient `userId` from the `LogContextService` scope is used.
1027
+
1028
+ | Method | Signature | Notes |
1029
+ | ----------------- | --------------------------------- | ---------------------------------------------------------------- |
1030
+ | `info` | `(logKey, msg, userId?, meta?)` | Structured info log |
1031
+ | `warnStructured` | `(logKey, msg, userId?, meta?)` | Structured warn log — takes a **message string** |
1032
+ | `errorStructured` | `(logKey, error, userId?, meta?)` | Structured error log — takes an **`Error`**, serialized to `err` |
1033
+
1034
+ > [!NOTE]
1035
+ > `meta` carries arbitrary fields, with two reserved groups dropped on the way in. `logKey`,
1036
+ > `userId` and `context` belong to the payload — the entry states who acted and where, read from
1037
+ > the authenticated ALS scope, so a metadata bag cannot forge them. `__proto__`, `constructor`
1038
+ > and `prototype` are dropped as well: they name a prototype chain rather than a field, and an
1039
+ > own `__proto__` (what `JSON.parse` of a request body produces) would otherwise be lost anyway.
1040
+
1041
+ **NestJS `LoggerService` bridge** — the variadic contract NestJS itself calls, so `app.useLogger()` works. These take a message, not a log key.
1042
+
1043
+ | Method | Signature | Notes |
1044
+ | --------- | -------------------------------------- | ---------------------------------------------- |
1045
+ | `log` | `(message, ...optionalParams)` | → Pino `info`; trailing string is the context |
1046
+ | `warn` | `(message, ...optionalParams)` | → Pino `warn` |
1047
+ | `debug` | `(message, ...optionalParams)` | → Pino `debug` |
1048
+ | `verbose` | `(message, ...optionalParams)` | → Pino `trace` |
1049
+ | `fatal` | `(message, ...optionalParams)` | → Pino `fatal` |
1050
+ | `error` | `(message \| Error, stack?, context?)` | An `Error` routes to the serialized `err` path |
1051
+
1052
+ **Helpers**
1053
+
1054
+ | Method | Signature | Notes |
1055
+ | -------------- | ------------ | ----------------------------------------------------------------- |
1056
+ | `child` | `(bindings)` | New `PinoLoggerService` over a Pino child logger |
1057
+ | `setContext` | `(context)` | Global label — ⚠️ singleton; prefer `@InjectLogger(MyClass.name)` |
1058
+ | `getRawLogger` | `()` | The underlying Pino instance |
1059
+
1060
+ > [!NOTE]
1061
+ > There is no `fatalStructured` / `debugStructured`. Structured `fatal` and `debug` helpers are out of scope until a consumer needs them; use `getRawLogger()` in the meantime.
789
1062
 
790
1063
  ### `LogContextService`
791
1064
 
792
- | Method | Signature | Description |
793
- | ---------- | ------------------- | --------------------------------------------------------------------------- |
794
- | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields |
795
- | `set` | `(key, value)` | Adds/updates a field in the current scope. Throws if called outside `run()` |
796
- | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
1065
+ | Method | Signature | Description |
1066
+ | ---------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
1067
+ | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields |
1068
+ | `set` | `(key, value)` | Adds/updates a field in the current scope. Throws if called outside `run()`. Fields set here reach every subsequent log in the scope |
1069
+ | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
797
1070
 
798
1071
  ### Decorators
799
1072
 
800
- | Decorator | Target | Description |
801
- | ------------------------------- | ----------------- | --------------------------------------------------------------------------------------------- |
802
- | `@InjectLogger(context?)` | Constructor param | Injects `PinoLoggerService` pre-bound to the given context string |
803
- | `@LogContext(store)` | Method | Wraps the method in `logContext.run(store, ...)` — all downstream logs carry the given fields |
804
- | `@LogPerformance(thresholdMs?)` | Method | Logs `METHOD_EXECUTION` on completion; `METHOD_SLOW_EXECUTION` if duration exceeds threshold |
1073
+ | Decorator | Target | Description |
1074
+ | ------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1075
+ | `@InjectLogger(context?)` | Constructor param | Injects `PinoLoggerService` pre-bound to the given context string |
1076
+ | `@LogContext(name)` | Class | Records a context label as class metadata. ⚠️ Metadata only — it does NOT open a `logContext.run()` scope; use `@InjectLogger(MyClass.name)` for a per-class label |
1077
+ | `@LogPerformance(thresholdMs?)` | Method | Logs `METHOD_EXECUTION` on completion; `METHOD_SLOW_EXECUTION` if duration exceeds threshold |
1078
+
1079
+ ### Level maps (from `@bymax-one/nest-logger`)
1080
+
1081
+ `LogEntry.level` is the Pino string LABEL, not the numeric code. A destination writing a numeric
1082
+ column converts with the exported maps instead of hard-coding them:
1083
+
1084
+ ```typescript
1085
+ import { PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS } from '@bymax-one/nest-logger'
1086
+
1087
+ PINO_LEVEL_NUMBERS['info'] // 30 — label → numeric code
1088
+ PINO_LEVEL_NAMES[30] // 'info' — numeric code → label
1089
+ ```
805
1090
 
806
1091
  ### Types (from `@bymax-one/nest-logger/shared`)
807
1092
 
@@ -811,11 +1096,19 @@ type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
811
1096
  type ServiceMetadata = { name: string; version: string }
812
1097
 
813
1098
  type LogEntry = {
814
- level: number
815
- time: number
816
- service: ServiceMetadata
817
- logKey: string
1099
+ /** Pino string label — `'info'`, not `30`. Convert with `PINO_LEVEL_NUMBERS`. */
1100
+ level: LogLevel
1101
+ /** ISO 8601 UTC string — `Date.parse(entry.time)` for epoch milliseconds. */
1102
+ time: string
818
1103
  msg: string
1104
+ logKey?: string
1105
+ service?: ServiceMetadata
1106
+ context?: string
1107
+ requestId?: string
1108
+ tenantId?: string
1109
+ userId?: string
1110
+ traceId?: string
1111
+ spanId?: string
819
1112
  [key: string]: unknown
820
1113
  }
821
1114
  ```