@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/CHANGELOG.md +309 -1
- package/README.md +222 -87
- package/dist/server/index.cjs +597 -126
- package/dist/server/index.d.cts +234 -38
- package/dist/server/index.d.ts +234 -38
- package/dist/server/index.mjs +596 -128
- package/dist/shared/index.cjs +15 -1
- package/dist/shared/index.d.cts +48 -6
- package/dist/shared/index.d.ts +48 -6
- package/dist/shared/index.mjs +15 -2
- package/package.json +1 -1
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** —
|
|
47
|
-
- **⚡ On the hot path, so it stays cheap** — Singleton providers, one composed Pino mixin, and a
|
|
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** —
|
|
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
|
|
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":
|
|
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
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
|
371
|
-
| ---------------------------- |
|
|
372
|
-
| `service.name` | `string`
|
|
373
|
-
| `service.version` | `string`
|
|
374
|
-
| `level` | `LogLevel`
|
|
375
|
-
| `isPretty` | `boolean`
|
|
376
|
-
| `redactPaths` | `string[]`
|
|
377
|
-
| `
|
|
378
|
-
| `
|
|
379
|
-
| `
|
|
380
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
│
|
|
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
|
-
|
|
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
|
-
| **⚡
|
|
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
|
|
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
|
-
|
|
|
640
|
-
|
|
|
641
|
-
|
|
|
642
|
-
|
|
|
643
|
-
|
|
|
644
|
-
|
|
|
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
|
-
|
|
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
|
|
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 |
|
|
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
|
|
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/)
|
|
749
|
-
`break` threshold of 95. The
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
|
782
|
-
|
|
|
783
|
-
| `
|
|
784
|
-
| `warnStructured` | `(logKey,
|
|
785
|
-
| `errorStructured` | `(logKey, error,
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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(
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
```
|