@bymax-one/nest-logger 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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`
@@ -75,7 +75,7 @@ pnpm add @bymax-one/nest-logger
75
75
 
76
76
  - ✅ **OpenTelemetry Correlation** — optional `@opentelemetry/api` peer; injects `traceId`/`spanId`/`traceFlags` into every log via a Pino mixin when an active span is detected
77
77
  - ✅ **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`)
78
+ - ✅ **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
79
  - ✅ **Exception Filter** — captures NestJS `HttpException` and unexpected errors with structured output
80
80
 
81
81
  ### 🔌 Destinations
@@ -245,7 +245,7 @@ Output (JSON, production):
245
245
 
246
246
  ```json
247
247
  {
248
- "level": 30,
248
+ "level": "info",
249
249
  "time": "2026-05-28T10:12:44.512Z",
250
250
  "service": { "name": "my-app", "version": "abc123" },
251
251
  "logKey": "PAYMENT_REFUND_SUCCESS",
@@ -272,19 +272,48 @@ Output (pretty-print, development):
272
272
 
273
273
  ### 5. HTTP logging (automatic)
274
274
 
275
- Enable `http.isEnabled: true` in the module options. The `HttpLoggingInterceptor` is registered globally and emits:
275
+ 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
276
 
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 |
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_REQUEST_ABORTED` | Connection closed before the response was delivered |
286
285
 
287
- URLs are automatically normalized — `/users/550e8400-e29b-41d4-a716-446655440000` becomes `/users/:id` so Loki/Grafana cardinality stays bounded.
286
+ `HTTP_REQUEST_START` is emitted **before guards**, so it carries no `userId` — authentication runs
287
+ in a guard, and at that point there is no principal yet. The acting user is on the **terminal**
288
+ entry, where the guard has populated it. Both entries carry the same `requestId`, so joining on
289
+ that gives the whole request.
290
+ | `HTTP_EXCEPTION_HANDLED` | `HttpException` caught by the filter |
291
+ | `HTTP_EXCEPTION_UNHANDLED` | Unexpected error caught by the filter |
292
+
293
+ 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.
294
+
295
+ > [!IMPORTANT]
296
+ > **Why middleware and not an interceptor.** NestJS runs middleware → guards → interceptors →
297
+ > handler, so an interceptor never observes a request a guard rejected, and never observes one that
298
+ > matched no route. Measured against a real backend: **401, 403, 429 from a throttler and 404 for an
299
+ > unknown path produced no log line at all** — not even `HTTP_REQUEST_START` — so brute force,
300
+ > credential stuffing and route enumeration were invisible, and invisible without a `requestId` to
301
+ > correlate them by. The access log therefore runs before guards.
302
+ >
303
+ > A consumer who does not wire the middleware keeps the previous interceptor-based behaviour rather
304
+ > than losing HTTP logs — including its blind spot.
305
+
306
+ **Delivery is reported separately from status.** `HTTP_REQUEST_ABORTED` is emitted when the
307
+ connection closed before the response was flushed, and it **keeps the real status the server
308
+ produced** rather than inventing one: nginx's `499` is not an HTTP status (IANA leaves 452–499
309
+ unassigned), so recording it would assert a code the protocol has no name for and break any
310
+ consumer grouping by class.
311
+
312
+ Its limit is worth stating: `writableFinished` distinguishes a response still in flight from one
313
+ handed to the operating system. A **fast handler whose client hangs up after the bytes were
314
+ flushed** is reported as the success it was — the server completed and flushed it, and whether the
315
+ peer read it is not knowable from the server side. What this catches is the case that matters
316
+ operationally: the slow upstream, the load-balancer timeout, the cancelled request.
288
317
 
289
318
  ### 6. OpenTelemetry correlation
290
319
 
@@ -367,17 +396,18 @@ Full options reference for `BymaxLoggerModule.forRoot(options)`:
367
396
 
368
397
  ### Top-level options
369
398
 
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 |
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 |
381
411
 
382
412
  ### `http` options
383
413
 
@@ -424,10 +454,16 @@ function assertValidLogKey(key: string) {
424
454
 
425
455
  The following keys are used internally by the library — do not reuse them in application code:
426
456
 
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`
457
+ `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
458
 
429
459
  All reserved keys are exported as the `RESERVED_LOG_KEYS` constant from `@bymax-one/nest-logger/shared`.
430
460
 
461
+ `HTTP_REQUEST_COMPLETED` is reserved but deliberately never emitted — the four status-specific
462
+ terminal keys already carry the same `duration`, so a generic "completed" entry would double the
463
+ access-log volume to say nothing new. The reserved-but-unwritten set is exported as
464
+ `RESERVED_LOG_KEYS_NOT_EMITTED`, and a test asserts that every OTHER declared key has a writer in
465
+ the source, so a key can no longer be documented as a signal and then silently never emitted.
466
+
431
467
  ---
432
468
 
433
469
  ## 🧩 Custom Destinations
@@ -488,7 +524,9 @@ export class LokiDestination implements ILogDestination {
488
524
  {
489
525
  stream: this.labels,
490
526
  values: batch.map((line) => [
491
- String(BigInt((JSON.parse(line) as LogEntry).time) * 1_000_000n),
527
+ // `time` is an ISO 8601 string — parse it to epoch ms, then scale to
528
+ // the nanoseconds Loki expects. `BigInt(entry.time)` throws on an ISO string.
529
+ String(BigInt(Date.parse((JSON.parse(line) as LogEntry).time)) * 1_000_000n),
492
530
  line.trimEnd()
493
531
  ])
494
532
  }
@@ -522,6 +560,7 @@ BymaxLoggerModule.forRootAsync({
522
560
  ### Postgres destination (Prisma)
523
561
 
524
562
  ```typescript
563
+ import { PINO_LEVEL_NUMBERS } from '@bymax-one/nest-logger'
525
564
  import type { ILogDestination } from '@bymax-one/nest-logger'
526
565
  import type { LogEntry } from '@bymax-one/nest-logger/shared'
527
566
  import type { PrismaClient } from '@prisma/client'
@@ -538,7 +577,8 @@ export class PrismaLogDestination implements ILogDestination {
538
577
  const entry = JSON.parse(payload) as LogEntry
539
578
  void this.prisma.applicationLog.create({
540
579
  data: {
541
- level: entry.level,
580
+ // `entry.level` is the Pino LABEL ('info'). Map it when the column is numeric.
581
+ level: PINO_LEVEL_NUMBERS[entry.level],
542
582
  logKey: entry.logKey,
543
583
  message: entry.msg,
544
584
  payload: entry,
@@ -587,11 +627,21 @@ export class RollingFileDestination implements ILogDestination {
587
627
  HTTP Request
588
628
  │
589
629
  ▼
590
- HttpLoggingInterceptor ← emits HTTP_REQUEST_START
630
+ RequestIdMiddleware ← runs FIRST (NestJS middleware precedes
631
+ │ guards AND interceptors) and opens the
632
+ │ AsyncLocalStorage scope
633
+ │ { requestId, tenantId, userId }
634
+ ▼
635
+ HttpAccessLogMiddleware ← emits HTTP_REQUEST_START inside that scope, and
636
+ │ arms the terminal entry on the response's
637
+ │ 'close' event. BEFORE guards, so a 401/403/429
638
+ │ rejection is logged too.
639
+ ▼
640
+ Guards (consumer's auth) ← may reject here; no interceptor ever runs
591
641
  │
592
642
  ▼
593
- RequestIdMiddleware ← opens AsyncLocalStorage scope
594
- │ { requestId, tenantId, userId }
643
+ HttpLoggingInterceptor ← records the thrown error for the terminal entry
644
+ │
595
645
  ▼
596
646
  Application Service
597
647
  │
@@ -605,7 +655,7 @@ Application Service
605
655
  └── OTel span → { traceId, spanId, traceFlags }
606
656
  │
607
657
  ▼
608
- fast-redact ← compiled redact function (140 paths)
658
+ name redactor ← one recursive walk, any depth
609
659
  │
610
660
  ▼
611
661
  ┌─────────────────────────────┐
@@ -618,13 +668,13 @@ Application Service
618
668
 
619
669
  ### Design Principles
620
670
 
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 |
671
+ | Principle | Description |
672
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
673
+ | **🪶 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 |
674
+ | **🧬 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 |
675
+ | **⚡ 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 |
676
+ | **🔌 Interface-Driven Sinks** | `ILogDestination` is a contract — Loki, Postgres, rolling files, or anything else is a consumer implementation, never a dependency of this package |
677
+ | **🌳 Zero Runtime Deps** | `"dependencies": {}` — every package arrives as a peer dependency, so consumers pin exact versions and the supply-chain surface stays theirs |
628
678
 
629
679
  ---
630
680
 
@@ -634,20 +684,45 @@ A logger sees every payload the application handles, so the security posture is
634
684
 
635
685
  ### Redaction by default
636
686
 
637
- The library ships **140 redact paths** compiled at initialization into a single `fast-redact` function (< 3% throughput impact). These cover:
687
+ 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:
688
+
689
+ | Category | Fields |
690
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
691
+ | HTTP auth headers | `authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token` |
692
+ | Passwords | `password`, `passwordHash`, `passwordConfirm`, `newPassword`, `oldPassword` |
693
+ | Tokens | `token`, `accessToken`, `refreshToken`, `idToken`, `apiKey`, `apiSecret` |
694
+ | MFA | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` |
695
+ | Generic secrets | `secret`, `clientSecret`, `signingSecret`, `privateKey` |
696
+ | Payment / PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
697
+ | Personal documents (LGPD) | `cpf`, `cnpj`, `rg` |
698
+ | Conservative PII | `email` |
699
+ | 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 |
700
+
701
+ #### How it works, and why it changed in `1.2.0`
702
+
703
+ 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.
704
+
705
+ 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.
706
+
707
+ > [!WARNING]
708
+ > Redaction matches **key names**. A secret placed under a name you have not declared sensitive is
709
+ > emitted — `logger.log(key, msg, userId, { renamed: user.password })` writes it in clear, and so
710
+ > does a `toJSON` that renames nested state. That is a property of name-based redaction, not a
711
+ > defect: no name matcher can follow a value through a rename. Declare the name, or keep the value
712
+ > out of the log.
713
+
714
+ 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:
638
715
 
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"]` |
716
+ | | depth-1–4 paths (pre-`1.2.0`) | name walk (`1.2.0`) |
717
+ | -------------------------------- | -------------------------------------------: | ----------------------------------: |
718
+ | Throughput, full production path | **9,311 logs/s** | **274,227 logs/s** |
719
+ | Cost per entry | ~107 µs | ~3.6 µs |
720
+ | Nesting covered | 4 levels — deeper **leaked** | 100 levels — deeper objects dropped |
721
+ | `{ headers: { authorization } }` | **leaked** (only `req.headers.*` was pinned) | censored |
649
722
 
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.
723
+ `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.
724
+
725
+ **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`.
651
726
 
652
727
  ### Extending the defaults
653
728
 
@@ -664,6 +739,20 @@ BymaxLoggerModule.forRoot({
664
739
 
665
740
  The extra paths are **merged** with the defaults — never replacing them.
666
741
 
742
+ > [!IMPORTANT]
743
+ > A consumer path's LEAF NAME is also fed to the name walk, so `redactPaths: ['user.ssn']`
744
+ > censors `ssn` wherever it appears, not only under `user`. That is broader than the path you
745
+ > wrote, deliberately: consumer paths are applied by Pino's stringifier, which runs after the
746
+ > per-field size bound, so without this a field covered only by a path could still surface inside
747
+ > a truncation envelope's `_preview`. It errs toward redacting a name you have already declared
748
+ > secret.
749
+ >
750
+ > An **array index is not a name**, so an unquoted numeric segment is skipped and the path falls
751
+ > back to the nearest name: `redactPaths: ['tokens[0]']` censors the whole `tokens` array. The
752
+ > walk matches key names and never array positions, so feeding it `0` would cover nothing while
753
+ > censoring any object key literally named `0`. The quoted form `['obj["0"]']` stays a name,
754
+ > since an object key named `0` IS matched by the walk.
755
+
667
756
  ### Disabling defaults (not recommended)
668
757
 
669
758
  ```typescript
@@ -699,7 +788,7 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
699
788
  - `shouldDisableDefaultRedact` stays `false` — if it is on, the `LOGGER_BOOTSTRAP_WARNING` must be justified in a security review
700
789
  - Every custom field carrying a secret or personal document is added to `redactPaths` at the depths it actually appears
701
790
  - `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`
791
+ - Custom destinations never re-serialize the raw request object; they receive the already-redacted, already-serialized NDJSON line
703
792
  - Destination credentials (Loki, Postgres, OTLP) come from the environment, never from values written into module options in source control
704
793
  - Log retention at the sink matches your data-retention policy — redaction bounds what is stored, not how long
705
794
 
@@ -709,9 +798,9 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
709
798
 
710
799
  | Layer | Implementation |
711
800
  | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
712
- | PII Redaction | 140 `fast-redact` paths compiled once at bootstrap — no per-log regex matching |
801
+ | PII Redaction | 32 field names censored at ANY depth in one snapshotting walk — ~3.6 µs/entry |
713
802
  | Credentials | `password`, `passwordHash`, `token`, `accessToken`, `refreshToken`, `apiKey`, `apiSecret`, `privateKey` |
714
- | MFA Secrets | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` — redacted at wildcard depths 1–4 |
803
+ | MFA Secrets | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` — redacted to the 100-level ceiling |
715
804
  | PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
716
805
  | LGPD Documents | `cpf`, `cnpj`, `rg`, plus `email` as a conservative default |
717
806
  | HTTP Headers | `authorization`, `cookie`, `x-api-key`, `x-auth-token`, `set-cookie` — absolute paths on `req` / `res` |
@@ -745,11 +834,11 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
745
834
  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
835
 
747
836
  - ✅ **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))
837
+ - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) on a cold run
838
+ against a `break` threshold of 95, with **zero** surviving mutants. The handful that no test can
839
+ kill are suppressed on the line they apply to and each carries its reason; a rule against those
840
+ comments was retired once their cost was measured at **+0.10 kB brotli**, roughly a tenth of
841
+ what it had been assumed to be (see the [report](./docs/mutation_testing_results.md))
753
842
  - ✅ **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
843
  - ✅ **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
844
 
@@ -774,34 +863,72 @@ pnpm lint # ESLint
774
863
 
775
864
  Implements NestJS `LoggerService`. All methods are available via `@InjectLogger()`.
776
865
 
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` |
866
+ There are **two** APIs on this class, and they do not share a signature shape.
867
+
868
+ **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.
869
+
870
+ | Method | Signature | Notes |
871
+ | ----------------- | --------------------------------- | ---------------------------------------------------------------- |
872
+ | `info` | `(logKey, msg, userId?, meta?)` | Structured info log |
873
+ | `warnStructured` | `(logKey, msg, userId?, meta?)` | Structured warn log — takes a **message string** |
874
+ | `errorStructured` | `(logKey, error, userId?, meta?)` | Structured error log — takes an **`Error`**, serialized to `err` |
875
+
876
+ > [!NOTE]
877
+ > `meta` carries arbitrary fields, with two reserved groups dropped on the way in. `logKey`,
878
+ > `userId` and `context` belong to the payload — the entry states who acted and where, read from
879
+ > the authenticated ALS scope, so a metadata bag cannot forge them. `__proto__`, `constructor`
880
+ > and `prototype` are dropped as well: they name a prototype chain rather than a field, and an
881
+ > own `__proto__` (what `JSON.parse` of a request body produces) would otherwise be lost anyway.
882
+
883
+ **NestJS `LoggerService` bridge** — the variadic contract NestJS itself calls, so `app.useLogger()` works. These take a message, not a log key.
884
+
885
+ | Method | Signature | Notes |
886
+ | --------- | -------------------------------------- | ---------------------------------------------- |
887
+ | `log` | `(message, ...optionalParams)` | → Pino `info`; trailing string is the context |
888
+ | `warn` | `(message, ...optionalParams)` | → Pino `warn` |
889
+ | `debug` | `(message, ...optionalParams)` | → Pino `debug` |
890
+ | `verbose` | `(message, ...optionalParams)` | → Pino `trace` |
891
+ | `fatal` | `(message, ...optionalParams)` | → Pino `fatal` |
892
+ | `error` | `(message \| Error, stack?, context?)` | An `Error` routes to the serialized `err` path |
893
+
894
+ **Helpers**
895
+
896
+ | Method | Signature | Notes |
897
+ | -------------- | ------------ | ----------------------------------------------------------------- |
898
+ | `child` | `(bindings)` | New `PinoLoggerService` over a Pino child logger |
899
+ | `setContext` | `(context)` | Global label — ⚠️ singleton; prefer `@InjectLogger(MyClass.name)` |
900
+ | `getRawLogger` | `()` | The underlying Pino instance |
901
+
902
+ > [!NOTE]
903
+ > 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
904
 
790
905
  ### `LogContextService`
791
906
 
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 |
907
+ | Method | Signature | Description |
908
+ | ---------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
909
+ | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields |
910
+ | `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 |
911
+ | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
797
912
 
798
913
  ### Decorators
799
914
 
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 |
915
+ | Decorator | Target | Description |
916
+ | ------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
917
+ | `@InjectLogger(context?)` | Constructor param | Injects `PinoLoggerService` pre-bound to the given context string |
918
+ | `@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 |
919
+ | `@LogPerformance(thresholdMs?)` | Method | Logs `METHOD_EXECUTION` on completion; `METHOD_SLOW_EXECUTION` if duration exceeds threshold |
920
+
921
+ ### Level maps (from `@bymax-one/nest-logger`)
922
+
923
+ `LogEntry.level` is the Pino string LABEL, not the numeric code. A destination writing a numeric
924
+ column converts with the exported maps instead of hard-coding them:
925
+
926
+ ```typescript
927
+ import { PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS } from '@bymax-one/nest-logger'
928
+
929
+ PINO_LEVEL_NUMBERS['info'] // 30 — label → numeric code
930
+ PINO_LEVEL_NAMES[30] // 'info' — numeric code → label
931
+ ```
805
932
 
806
933
  ### Types (from `@bymax-one/nest-logger/shared`)
807
934
 
@@ -811,11 +938,19 @@ type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
811
938
  type ServiceMetadata = { name: string; version: string }
812
939
 
813
940
  type LogEntry = {
814
- level: number
815
- time: number
816
- service: ServiceMetadata
817
- logKey: string
941
+ /** Pino string label — `'info'`, not `30`. Convert with `PINO_LEVEL_NUMBERS`. */
942
+ level: LogLevel
943
+ /** ISO 8601 UTC string — `Date.parse(entry.time)` for epoch milliseconds. */
944
+ time: string
818
945
  msg: string
946
+ logKey?: string
947
+ service?: ServiceMetadata
948
+ context?: string
949
+ requestId?: string
950
+ tenantId?: string
951
+ userId?: string
952
+ traceId?: string
953
+ spanId?: string
819
954
  [key: string]: unknown
820
955
  }
821
956
  ```