@bymax-one/nest-logger 1.4.2 → 1.4.4
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 +134 -1
- package/README.md +193 -92
- package/dist/server/index.cjs +159 -6
- package/dist/server/index.d.cts +74 -22
- package/dist/server/index.d.ts +74 -22
- package/dist/server/index.mjs +157 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
`@bymax-one/nest-logger` replaces ad-hoc `console.log` and legacy Winston setups with a production-grade structured logging pipeline. Every log entry is a JSON object carrying a `logKey` (`MODULE_ACTION_RESULT` convention), `requestId`, `tenantId`, and — when OpenTelemetry is active — the correlated `traceId`/`spanId`.
|
|
37
37
|
|
|
38
|
-
**Why Pino?**
|
|
38
|
+
**Why Pino?** In Pino's own benchmarks (Node 20, one instance — not this package's end-to-end rate), Pino writes ~750,000 logs/sec against Winston's ~110,000, using 3× less CPU and half the RSS under load. The difference is measurable in production billing/payments backends where the logger is on the hot path for every request.
|
|
39
39
|
|
|
40
40
|
The library has **zero direct dependencies** — all packages arrive as peer dependencies, so you control exact versions and the supply chain surface stays minimal.
|
|
41
41
|
|
|
@@ -78,13 +78,13 @@ pnpm add @bymax-one/nest-logger
|
|
|
78
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
79
|
- ✅ **Machine-Readable Event Names** — `event.name` derived from `logKey` following OTel naming rules
|
|
80
80
|
- ✅ **AsyncLocalStorage Context** — `requestId`, `tenantId`, `userId` flow automatically through the request lifecycle without prop drilling
|
|
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 (
|
|
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 (UUID, ULID, nanoid and numeric segments replaced by `:id`) and the query string stripped
|
|
82
82
|
- ✅ **Exception Filter** — captures NestJS `HttpException` and unexpected errors with structured output
|
|
83
83
|
|
|
84
84
|
### 🔌 Destinations
|
|
85
85
|
|
|
86
86
|
- ✅ **Pluggable Destinations** — implement `ILogDestination` to ship logs to Loki, Postgres, rolling files, or any sink
|
|
87
|
-
- ✅ **Managed Lifecycle** — `onInit()` / `onShutdown()` hooks; a destination that fails `onInit()` is reported and
|
|
87
|
+
- ✅ **Managed Lifecycle** — `onInit()` / `onShutdown()` hooks; a destination that fails `onInit()` is reported and removed from the write fan-out and from shutdown, and boot is never aborted
|
|
88
88
|
- ✅ **Crash-Proof Writes** — every `write()` is wrapped in a try/catch; a failure is reported on `stderr` and swallowed, never propagated to the app
|
|
89
89
|
|
|
90
90
|
### 🧩 Developer Experience
|
|
@@ -170,10 +170,13 @@ pnpm add @opentelemetry/api @opentelemetry/sdk-node
|
|
|
170
170
|
>
|
|
171
171
|
> Either way, the rule that survives both layouts: **"it is a devDependency, therefore this path
|
|
172
172
|
> cannot run in production" is not a safe premise, because the premise is about someone else's
|
|
173
|
-
> install command.**
|
|
174
|
-
>
|
|
175
|
-
>
|
|
176
|
-
>
|
|
173
|
+
> install command.** `PrettyDevDestination` refuses to initialize when `NODE_ENV` is exactly
|
|
174
|
+
> `production`: it reports `LOGGER_DESTINATION_INIT_FAILED` on stderr and, when it is the only
|
|
175
|
+
> destination, entries go to stdout as raw NDJSON. In any other environment that ships to a log
|
|
176
|
+
> pipeline (a staging stack with `NODE_ENV=staging`, an unset `NODE_ENV`) it still renders ANSI
|
|
177
|
+
> colour into a pipeline that will fail to parse every line, which is indistinguishable from a
|
|
178
|
+
> service that went quiet. Gate it on an explicit configuration value you can see, not on the
|
|
179
|
+
> packaging.
|
|
177
180
|
|
|
178
181
|
> [!NOTE]
|
|
179
182
|
> `@opentelemetry/api` is resolved lazily when the Pino instance is built. When it is absent the trace mixin silently steps aside — the logger never fails to start, and never warns, over an optional peer.
|
|
@@ -200,7 +203,7 @@ import { BymaxLoggerModule } from '@bymax-one/nest-logger'
|
|
|
200
203
|
export class AppModule {}
|
|
201
204
|
```
|
|
202
205
|
|
|
203
|
-
Stdout is wired automatically — `DefaultStdoutDestination` is
|
|
206
|
+
Stdout is wired automatically — with no `destinations` configured, `DefaultStdoutDestination` is the default sink, so this is all you need to be logging structured JSON. A non-empty `destinations` list replaces it (see [Destinations](#destinations-replace-stdout)).
|
|
204
207
|
|
|
205
208
|
### 3. Async configuration with `ConfigService`
|
|
206
209
|
|
|
@@ -230,6 +233,58 @@ import { BymaxLoggerModule } from '@bymax-one/nest-logger'
|
|
|
230
233
|
export class AppModule {}
|
|
231
234
|
```
|
|
232
235
|
|
|
236
|
+
> [!IMPORTANT]
|
|
237
|
+
> **`forRootAsync` does not register the exception filter.** With `http.isEnabled` it registers the
|
|
238
|
+
> access log (`HTTP_REQUEST_START`, `_SUCCESS`, `_CLIENT_ERROR`, `_SERVER_ERROR`, `_ABORTED`), and the
|
|
239
|
+
> failing request's error still reaches its terminal entry. The two entries the filter writes,
|
|
240
|
+
> `HTTP_EXCEPTION_HANDLED` and `HTTP_EXCEPTION_UNHANDLED`, are not emitted, and
|
|
241
|
+
> `http.shouldCaptureExceptions` has no effect on this path. `forRoot` registers the filter itself.
|
|
242
|
+
> To get the same entries from `forRootAsync`, register it:
|
|
243
|
+
>
|
|
244
|
+
> ```typescript
|
|
245
|
+
> import { Module } from '@nestjs/common'
|
|
246
|
+
> import { APP_FILTER } from '@nestjs/core'
|
|
247
|
+
> import { HttpExceptionFilter } from '@bymax-one/nest-logger'
|
|
248
|
+
>
|
|
249
|
+
> @Module({ providers: [{ provide: APP_FILTER, useClass: HttpExceptionFilter }] })
|
|
250
|
+
> export class LoggingFilterModule {}
|
|
251
|
+
> ```
|
|
252
|
+
>
|
|
253
|
+
> It is left to you on purpose: the filter is a global catch-all that writes the response itself, and
|
|
254
|
+
> a library adding one from asynchronously resolved options would put it in the pipeline of every
|
|
255
|
+
> consumer, including those with HTTP logging off or with their own filters.
|
|
256
|
+
|
|
257
|
+
`useFactory` is one of three forms. When the options depend on a provider already in the DI graph, a class can produce them instead. It implements `BymaxLoggerModuleOptionsFactory`; pass it as `useClass` to have the module instantiate it, or as `useExisting` to reuse an instance another module already provides:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// logger-options.factory.ts
|
|
261
|
+
import { Injectable } from '@nestjs/common'
|
|
262
|
+
import { ConfigService } from '@nestjs/config'
|
|
263
|
+
import type {
|
|
264
|
+
BymaxLoggerModuleOptions,
|
|
265
|
+
BymaxLoggerModuleOptionsFactory
|
|
266
|
+
} from '@bymax-one/nest-logger'
|
|
267
|
+
|
|
268
|
+
@Injectable()
|
|
269
|
+
export class LoggerOptionsFactory implements BymaxLoggerModuleOptionsFactory {
|
|
270
|
+
constructor(private readonly cfg: ConfigService) {}
|
|
271
|
+
|
|
272
|
+
createLoggerOptions(): BymaxLoggerModuleOptions {
|
|
273
|
+
return {
|
|
274
|
+
service: {
|
|
275
|
+
name: this.cfg.getOrThrow<string>('OTEL_SERVICE_NAME'),
|
|
276
|
+
version: this.cfg.getOrThrow<string>('RELEASE_SHA')
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// app.module.ts: imports: [BymaxLoggerModule.forRootAsync({ imports: [ConfigModule], useClass: LoggerOptionsFactory })]
|
|
283
|
+
// or, to reuse an instance another module exports: { imports: [OptionsModule], useExisting: LoggerOptionsFactory }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`createLoggerOptions()` may return the options or a promise of them. An error thrown from it, or from `useFactory`, fails module initialisation, and the options it returns go through the same validation as `forRoot`.
|
|
287
|
+
|
|
233
288
|
### 4. Inject the logger in a service
|
|
234
289
|
|
|
235
290
|
```typescript
|
|
@@ -270,38 +325,37 @@ export class PaymentsService {
|
|
|
270
325
|
}
|
|
271
326
|
```
|
|
272
327
|
|
|
273
|
-
Output (JSON, production):
|
|
328
|
+
Output (JSON, production) for the `PAYMENT_REFUND_SUCCESS` call above, with the options from step 2:
|
|
274
329
|
|
|
275
330
|
```json
|
|
276
331
|
{
|
|
277
332
|
"level": "info",
|
|
278
333
|
"time": "2026-05-28T10:12:44.512Z",
|
|
279
|
-
"service": { "name": "my-app", "version": "
|
|
280
|
-
"
|
|
281
|
-
"msg": "Refund processed",
|
|
334
|
+
"service": { "name": "my-app", "version": "1.0.0" },
|
|
335
|
+
"deployment": { "environment": { "name": "production" } },
|
|
282
336
|
"context": "PaymentsService",
|
|
283
|
-
"requestId": "r_7f3a9b",
|
|
284
|
-
"tenantId": "t_acme",
|
|
285
|
-
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
286
|
-
"spanId": "00f067aa0ba902b7",
|
|
287
337
|
"paymentId": "pi_xyz",
|
|
288
|
-
"stripeRefundId": "re_abc"
|
|
338
|
+
"stripeRefundId": "re_abc",
|
|
339
|
+
"logKey": "PAYMENT_REFUND_SUCCESS",
|
|
340
|
+
"userId": "u_42",
|
|
341
|
+
"event.name": "payment.refund.success",
|
|
342
|
+
"msg": "Refund processed"
|
|
289
343
|
}
|
|
290
344
|
```
|
|
291
345
|
|
|
346
|
+
`requestId` and `tenantId` are added when the call runs inside an HTTP request that passed through the request-ID middleware (`tenantId` only when the request carries the tenant header), and `traceId` and `spanId` when an OpenTelemetry span is active (see [OpenTelemetry correlation](#6-opentelemetry-correlation)). Outside a request, as in the flow above, they are absent.
|
|
347
|
+
|
|
292
348
|
Output (pretty-print, development):
|
|
293
349
|
|
|
294
350
|
```
|
|
295
351
|
[10:12:44.512] INFO (my-app): PAYMENT_REFUND_SUCCESS
|
|
296
|
-
requestId: "r_7f3a9b"
|
|
297
|
-
tenantId: "t_acme"
|
|
298
352
|
paymentId: "pi_xyz"
|
|
299
353
|
stripeRefundId: "re_abc"
|
|
300
354
|
```
|
|
301
355
|
|
|
302
356
|
### 5. HTTP logging (automatic)
|
|
303
357
|
|
|
304
|
-
Enable `http.isEnabled: true` in the module options and wire the access log — `applyAccessLog(app)` in `main.ts` (recommended, see [Requests rejected before routing](#requests-rejected-before-routing)) or `applyRequestIdMiddleware(consumer)` (see [Context propagation](#7-context-propagation-with-logcontextservice)). The access log is recorded from **middleware**, which emits:
|
|
358
|
+
Enable `http.isEnabled: true` in the module options and wire the access log — `applyAccessLog(app)` in `main.ts` (recommended, see [Requests rejected before routing](#requests-rejected-before-routing)) or `applyRequestIdMiddleware(consumer)` (see [Context propagation](#7-context-propagation-with-logcontextservice)). The access log is recorded from **middleware**, which emits the six `HTTP_REQUEST_*` keys below; the exception filter adds the last two:
|
|
305
359
|
|
|
306
360
|
| Log key | When |
|
|
307
361
|
| --------------------------- | --------------------------------------------------- |
|
|
@@ -311,13 +365,13 @@ Enable `http.isEnabled: true` in the module options and wire the access log —
|
|
|
311
365
|
| `HTTP_REQUEST_CLIENT_ERROR` | 4xx response |
|
|
312
366
|
| `HTTP_REQUEST_SERVER_ERROR` | 5xx response |
|
|
313
367
|
| `HTTP_REQUEST_ABORTED` | Connection closed before the response was delivered |
|
|
368
|
+
| `HTTP_EXCEPTION_HANDLED` | `HttpException` caught by the filter |
|
|
369
|
+
| `HTTP_EXCEPTION_UNHANDLED` | Unexpected error caught by the filter |
|
|
314
370
|
|
|
315
371
|
`HTTP_REQUEST_START` is emitted **before guards**, so it carries no `userId` — authentication runs
|
|
316
372
|
in a guard, and at that point there is no principal yet. The acting user is on the **terminal**
|
|
317
373
|
entry, where the guard has populated it. Both entries carry the same `requestId`, so joining on
|
|
318
374
|
that gives the whole request.
|
|
319
|
-
| `HTTP_EXCEPTION_HANDLED` | `HttpException` caught by the filter |
|
|
320
|
-
| `HTTP_EXCEPTION_UNHANDLED` | Unexpected error caught by the filter |
|
|
321
375
|
|
|
322
376
|
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.
|
|
323
377
|
|
|
@@ -528,7 +582,7 @@ BymaxLoggerModule.forRoot({
|
|
|
528
582
|
name: 'checkout-api', // service.name
|
|
529
583
|
version: '2.14.3', // service.version
|
|
530
584
|
namespace: 'payments', // service.namespace
|
|
531
|
-
instanceId: process.env.POD_UID
|
|
585
|
+
instanceId: process.env.POD_UID!, // service.instance.id
|
|
532
586
|
environment: 'production' // deployment.environment.name
|
|
533
587
|
}
|
|
534
588
|
})
|
|
@@ -700,6 +754,17 @@ export class RequestIdMiddleware implements NestMiddleware {
|
|
|
700
754
|
|
|
701
755
|
Any log emitted inside the `run()` scope — regardless of nesting depth — automatically includes `requestId` with no prop drilling.
|
|
702
756
|
|
|
757
|
+
#### Inbound `x-request-id` and tenant header
|
|
758
|
+
|
|
759
|
+
The bundled `RequestIdMiddleware` does not trust the headers it reads. A request id or tenant id is accepted only when it is a string of **at most 256 characters** drawn from `A-Z a-z 0-9 . _ : / + = -`. Anything else is rejected and replaced, not truncated or cleaned: an oversized value, an empty one, one carrying a space, a control character or any of `< > " '`,, and a value that is not a string.
|
|
760
|
+
|
|
761
|
+
| Header | Rejected value becomes |
|
|
762
|
+
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
763
|
+
| `x-request-id` | a fresh UUID, or none when `http.shouldGenerateRequestId` is `false` (an enclosing scope's `requestId`, if any, is then inherited) |
|
|
764
|
+
| the tenant header (`http.tenantIdHeader`) | no `tenantId` from the header; a tenant is never minted |
|
|
765
|
+
|
|
766
|
+
An accepted id is echoed back on the response `x-request-id` header and written to every entry the request produces, which is why the set is narrow. The example above reads the header raw to stay short; use `RequestIdMiddleware` through `applyAccessLog(app)` or `applyRequestIdMiddleware(consumer)` unless you apply the same rule yourself.
|
|
767
|
+
|
|
703
768
|
---
|
|
704
769
|
|
|
705
770
|
## ⚙️ Configuration
|
|
@@ -708,40 +773,47 @@ Full options reference for `BymaxLoggerModule.forRoot(options)`:
|
|
|
708
773
|
|
|
709
774
|
### Top-level options
|
|
710
775
|
|
|
711
|
-
| Option | Type
|
|
712
|
-
| ---------------------------- |
|
|
713
|
-
| `service.name` | `string`
|
|
714
|
-
| `service.version` | `string`
|
|
715
|
-
| `service.namespace` | `string`
|
|
716
|
-
| `service.instanceId` | `string`
|
|
717
|
-
| `service.environment` | `string`
|
|
718
|
-
| `resourceFormat` | `'nested' \| 'flat'`
|
|
719
|
-
| `eventNameField` | `string \| false`
|
|
720
|
-
| `errorFormat` | `'pino' \| 'semconv' \| 'both'`
|
|
721
|
-
| `level` | `LogLevel`
|
|
722
|
-
| `redactPaths` | `string[]`
|
|
723
|
-
| `redactStrategy` | `'names' \| 'paths'`
|
|
724
|
-
| `shouldDisableDefaultRedact` | `boolean`
|
|
725
|
-
| `redactCensor` | `string`
|
|
726
|
-
| `maxEntrySizeBytes` | `number`
|
|
727
|
-
| `
|
|
776
|
+
| Option | Type | Default | Description |
|
|
777
|
+
| ---------------------------- | --------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
778
|
+
| `service.name` | `string` | **Required** | Service name emitted in every log entry |
|
|
779
|
+
| `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
|
|
780
|
+
| `service.namespace` | `string` | — | OTel `service.namespace` (**Stable**) — the group the service belongs to |
|
|
781
|
+
| `service.instanceId` | `string` | — | OTel `service.instance.id` (**Stable**). **Never generated** — supply it from the platform. See [Resource identity](#6b-resource-identity) |
|
|
782
|
+
| `service.environment` | `string` | `NODE_ENV` | OTel `deployment.environment.name` (**Stable**). The deprecated `deployment.environment` is never emitted |
|
|
783
|
+
| `resourceFormat` | `'nested' \| 'flat'` | `'nested'` | Shape of the identity fields. `'flat'` emits the dotted attribute names verbatim |
|
|
784
|
+
| `eventNameField` | `string \| false` | `'event.name'` | Field carrying the derived event name (`PAYMENT_FAILED` → `payment.failed`). `false` disables it |
|
|
785
|
+
| `errorFormat` | `'pino' \| 'semconv' \| 'both'` | `'pino'` | `'both'` adds `exception.*` and `error.type` beside `err.*`; `'semconv'` replaces them |
|
|
786
|
+
| `level` | `LogLevel` | `'info'` when `NODE_ENV=production`, else `'debug'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
|
|
787
|
+
| `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths, applied on top of the default coverage |
|
|
788
|
+
| `redactStrategy` | `'names' \| 'paths'` | `'names'` | Engine behind the DEFAULT set. `'paths'` restores the pre-1.2 `fast-redact` expansion (four-level ceiling, ~100× slower) |
|
|
789
|
+
| `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the default PII coverage entirely. ⚠️ Emits `LOGGER_BOOTSTRAP_WARNING` at startup — document why |
|
|
790
|
+
| `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
|
|
791
|
+
| `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
|
|
792
|
+
| `isGlobal` | `boolean` | `true` | Register the module as `@Global()` |
|
|
793
|
+
| `shouldUseAsNestLogger` | `boolean` | `true` | **Deprecated, not read.** Call `BymaxLoggerModule.useNestLogger(app)` in `main.ts` instead |
|
|
794
|
+
| `serializers` | `Record<string, (input: unknown) => unknown>` | `{}` | Custom Pino serializers, merged with the defaults |
|
|
795
|
+
| `timestamp` | `() => string` | `() => new Date().toISOString()` | Custom timestamp function |
|
|
796
|
+
| `destinations` | `ILogDestination[]` | `[new DefaultStdoutDestination()]` | The sinks entries are written to. ⚠️ A non-empty list **replaces** that default — see [Destinations](#destinations-replace-stdout) |
|
|
728
797
|
|
|
729
798
|
### `http` options
|
|
730
799
|
|
|
731
|
-
| Option | Type | Default
|
|
732
|
-
| ------------------------------ | ---------- |
|
|
733
|
-
| `http.isEnabled` | `boolean` | `false`
|
|
734
|
-
| `http.shouldCaptureExceptions` | `boolean` | `true`
|
|
735
|
-
| `http.shouldGenerateRequestId` | `boolean` | `true`
|
|
736
|
-
| `http.excludePaths` | `RegExp[]` | `[/^\/health$/, /^\/metrics$/]` | Paths that bypass HTTP logging. Use anchored, linear-time regexes (ReDoS-safe)
|
|
737
|
-
| `http.tenantIdHeader` | `string` | `'x-tenant-id'`
|
|
800
|
+
| Option | Type | Default | Description |
|
|
801
|
+
| ------------------------------ | ---------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
802
|
+
| `http.isEnabled` | `boolean` | `false` | Register `HttpLoggingInterceptor` (and, on `forRoot`, `HttpExceptionFilter`) globally |
|
|
803
|
+
| `http.shouldCaptureExceptions` | `boolean` | `true` | Capture unhandled HTTP exceptions and emit `HTTP_EXCEPTION_UNHANDLED`. `forRoot` only: see the note under [Async configuration](#3-async-configuration-with-configservice) |
|
|
804
|
+
| `http.shouldGenerateRequestId` | `boolean` | `true` | Generate a `requestId` when the inbound request header is absent |
|
|
805
|
+
| `http.excludePaths` | `RegExp[]` | `[/^\/health$/, /^\/health\/live$/, /^\/health\/ready$/, /^\/metrics$/]` | Paths that bypass HTTP logging. Use anchored, linear-time regexes (ReDoS-safe) |
|
|
806
|
+
| `http.tenantIdHeader` | `string` | `'x-tenant-id'` | Request header carrying the tenant identifier |
|
|
738
807
|
|
|
739
808
|
### `otel` options
|
|
740
809
|
|
|
741
|
-
| Option | Type | Default
|
|
742
|
-
| ----------------------------------- | ----------------------------- |
|
|
743
|
-
| `otel.shouldAutoInjectTraceContext` | `boolean` | `true`
|
|
744
|
-
| `otel.fieldFormat` | `'camelCase' \| 'snake_case'` | `'camelCase'`
|
|
810
|
+
| Option | Type | Default | Description |
|
|
811
|
+
| ----------------------------------- | ----------------------------- | -------------- | ---------------------------------------------------------------------------------------------------- |
|
|
812
|
+
| `otel.shouldAutoInjectTraceContext` | `boolean` | `true` | Detect `@opentelemetry/api` and inject `traceId`/`spanId` via Pino mixin |
|
|
813
|
+
| `otel.fieldFormat` | `'camelCase' \| 'snake_case'` | `'camelCase'` | Field names in log entries: `traceId`/`spanId` vs `trace_id`/`span_id` |
|
|
814
|
+
| `otel.traceIdField` | `string` | `'traceId'` | Field name for the trace ID; `fieldFormat: 'snake_case'` derives `trace_id` unless this is set |
|
|
815
|
+
| `otel.spanIdField` | `string` | `'spanId'` | Field name for the span ID; `fieldFormat: 'snake_case'` derives `span_id` unless this is set |
|
|
816
|
+
| `otel.traceFlagsField` | `string` | `'traceFlags'` | Field name for the trace flags; `fieldFormat: 'snake_case'` derives `trace_flags` unless this is set |
|
|
745
817
|
|
|
746
818
|
---
|
|
747
819
|
|
|
@@ -1015,7 +1087,7 @@ HTTP Request
|
|
|
1015
1087
|
RequestIdMiddleware ← runs FIRST (NestJS middleware precedes
|
|
1016
1088
|
│ guards AND interceptors) and opens the
|
|
1017
1089
|
│ AsyncLocalStorage scope
|
|
1018
|
-
│ { requestId, tenantId
|
|
1090
|
+
│ { requestId, tenantId }
|
|
1019
1091
|
▼
|
|
1020
1092
|
HttpAccessLogMiddleware ← emits HTTP_REQUEST_START inside that scope, and
|
|
1021
1093
|
│ arms the terminal entry on the response's
|
|
@@ -1030,12 +1102,12 @@ HttpLoggingInterceptor ← records the thrown error for the terminal en
|
|
|
1030
1102
|
▼
|
|
1031
1103
|
Application Service
|
|
1032
1104
|
│
|
|
1033
|
-
└── PinoLoggerService.info(logKey,
|
|
1105
|
+
└── PinoLoggerService.info(logKey, message, userId?, metadata?)
|
|
1034
1106
|
│
|
|
1035
1107
|
▼
|
|
1036
1108
|
Pino logger
|
|
1037
1109
|
│
|
|
1038
|
-
|
|
1110
|
+
createTraceContextMixin() ← runs per log entry
|
|
1039
1111
|
├── ALS store → { requestId, tenantId, userId }
|
|
1040
1112
|
└── OTel span → { traceId, spanId, traceFlags }
|
|
1041
1113
|
│
|
|
@@ -1044,7 +1116,7 @@ Application Service
|
|
|
1044
1116
|
│
|
|
1045
1117
|
▼
|
|
1046
1118
|
┌─────────────────────────────┐
|
|
1047
|
-
│ DefaultStdoutDestination │ ←
|
|
1119
|
+
│ DefaultStdoutDestination │ ← default; replaced by `destinations`
|
|
1048
1120
|
│ LokiDestination │ ← optional
|
|
1049
1121
|
│ PrismaLogDestination │ ← optional
|
|
1050
1122
|
│ RollingFileDestination │ ← optional
|
|
@@ -1055,7 +1127,7 @@ Application Service
|
|
|
1055
1127
|
|
|
1056
1128
|
| Principle | Description |
|
|
1057
1129
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1058
|
-
| **🪶 Singleton Scope** | `AsyncLocalStorage` delivers per-request context at zero latency overhead — NestJS `Scope.REQUEST`
|
|
1130
|
+
| **🪶 Singleton Scope** | `AsyncLocalStorage` delivers per-request context at zero latency overhead — NestJS's own docs put the cost of `Scope.REQUEST` at up to ~5% latency, unacceptable on a logger that runs for every request |
|
|
1059
1131
|
| **🧬 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 |
|
|
1060
1132
|
| **⚡ 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 |
|
|
1061
1133
|
| **🔌 Interface-Driven Sinks** | `ILogDestination` is a contract — Loki, Postgres, rolling files, or anything else is a consumer implementation, never a dependency of this package |
|
|
@@ -1162,7 +1234,7 @@ Trace context is never copied verbatim into a log entry. The OTel mixin reads th
|
|
|
1162
1234
|
|
|
1163
1235
|
### Destination failures are contained
|
|
1164
1236
|
|
|
1165
|
-
Every `write()` runs inside a try/catch. A destination that throws or rejects produces a `LOGGER_DESTINATION_WRITE_FAILED` line on `stderr` — never back through the logger, which would turn a broken sink into a write → log → write feedback loop — and the entry is dropped for that sink only. A destination whose `onInit()` rejects is reported as `LOGGER_DESTINATION_INIT_FAILED` and
|
|
1237
|
+
Every `write()` runs inside a try/catch. A destination that throws or rejects produces a `LOGGER_DESTINATION_WRITE_FAILED` line on `stderr` — never back through the logger, which would turn a broken sink into a write → log → write feedback loop — and the entry is dropped for that sink only. A destination whose `onInit()` rejects is reported as `LOGGER_DESTINATION_INIT_FAILED` and removed from the write fan-out and the shutdown sequence, so it receives no entries and never blocks boot. A logging backend going down degrades logging — it never takes the application with it.
|
|
1166
1238
|
|
|
1167
1239
|
### What redaction covers, and what it cannot
|
|
1168
1240
|
|
|
@@ -1205,7 +1277,7 @@ The rule that follows is short: **a value that matters is a field, not text.** A
|
|
|
1205
1277
|
|
|
1206
1278
|
`node app | head` closes the read end of stdout while the application is still writing. The stream reports that as `EPIPE` **asynchronously**, through its `'error'` event, after `write()` has already returned — so a `try/catch` around the write catches nothing, and because Node attaches no default handler to `process.stdout`, the emit becomes an uncaught exception and the process dies. Measured, not assumed: `0` listeners, no synchronous throw, `UNCAUGHT:EPIPE`, exit 42.
|
|
1207
1279
|
|
|
1208
|
-
Every write this library makes to a process stream — the default stdout sink, the last-resort rescue, the pre-init drain, the `stderr` failure reports — installs a swallowing `'error'` listener on first use. **The side effect is worth knowing:** that listener is on the shared `process.stdout`, so after the first log line an EPIPE raised by _your_ writes stops being an uncaught exception too. The alternative is the crash above. Your own `'error'` listener is never removed or replaced — this only ever adds one.
|
|
1280
|
+
Every write this library makes to a process stream — the default stdout sink, the last-resort rescue, the pre-init drain, the `stderr` failure reports — installs a swallowing `'error'` listener on first use. The one exception is the stream `pino-pretty` writes through inside `PrettyDevDestination`, which attaches `'error'` listeners of its own. **The side effect is worth knowing:** that listener is on the shared `process.stdout`, so after the first log line an EPIPE raised by _your_ writes stops being an uncaught exception too. The alternative is the crash above. Your own `'error'` listener is never removed or replaced — this only ever adds one.
|
|
1209
1281
|
|
|
1210
1282
|
If you write to `process.stdout` from a custom `ILogDestination`, do the same in your own sink; the [destinations guide](./docs/guidelines/DESTINATIONS-IMPLEMENTATION-GUIDELINES.md) has the exact pattern.
|
|
1211
1283
|
|
|
@@ -1228,20 +1300,20 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
|
|
|
1228
1300
|
|
|
1229
1301
|
## 🛡️ Security Table
|
|
1230
1302
|
|
|
1231
|
-
| Layer | Implementation
|
|
1232
|
-
| ------------------- |
|
|
1233
|
-
| PII Redaction | 32 field names censored at ANY depth in one snapshotting walk — ~3.6 µs/entry
|
|
1234
|
-
| Credentials | `password`, `passwordHash`, `token`, `accessToken`, `refreshToken`, `apiKey`, `apiSecret`, `privateKey`
|
|
1235
|
-
| MFA Secrets | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` — redacted to the 100-level ceiling
|
|
1236
|
-
| PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry`
|
|
1237
|
-
| LGPD Documents | `cpf`, `cnpj`, `rg`, plus `email` as a conservative default
|
|
1238
|
-
| HTTP Headers | `authorization`, `cookie`, `x-api-key`, `x-auth-token`, `set-cookie` — absolute paths on `req` / `res`
|
|
1239
|
-
| Redact List | `DEFAULT_REDACT_PATHS` is append-only; removal requires a major version
|
|
1240
|
-
| Trace Injection | `isValidTraceId` / `isValidSpanId` validated before any identifier is written
|
|
1241
|
-
| URL Cardinality | UUIDs and numeric IDs normalized to `:id` — bounds label cardinality and keeps raw identifiers out of the path
|
|
1242
|
-
| Destination Failure | Every `write()` try/catch-wrapped; a failure is reported on `stderr` and swallowed, never propagated to the caller
|
|
1243
|
-
| Field Size | Per-field UTF-8 cap at `maxEntrySizeBytes` (64 KB default); oversized values become a truncation envelope
|
|
1244
|
-
| Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing
|
|
1303
|
+
| Layer | Implementation |
|
|
1304
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
1305
|
+
| PII Redaction | 32 field names censored at ANY depth in one snapshotting walk — ~3.6 µs/entry |
|
|
1306
|
+
| Credentials | `password`, `passwordHash`, `token`, `accessToken`, `refreshToken`, `apiKey`, `apiSecret`, `privateKey` |
|
|
1307
|
+
| MFA Secrets | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` — redacted to the 100-level ceiling |
|
|
1308
|
+
| PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
|
|
1309
|
+
| LGPD Documents | `cpf`, `cnpj`, `rg`, plus `email` as a conservative default |
|
|
1310
|
+
| HTTP Headers | `authorization`, `cookie`, `x-api-key`, `x-auth-token`, `set-cookie` — absolute paths on `req` / `res` |
|
|
1311
|
+
| Redact List | `DEFAULT_REDACT_PATHS` is append-only; removal requires a major version |
|
|
1312
|
+
| Trace Injection | `isValidTraceId` / `isValidSpanId` validated before any identifier is written |
|
|
1313
|
+
| URL Cardinality | UUIDs, ULIDs, nanoids and numeric IDs normalized to `:id` — bounds label cardinality and keeps raw identifiers out of the path |
|
|
1314
|
+
| Destination Failure | Every `write()` try/catch-wrapped; a failure is reported on `stderr` and swallowed, never propagated to the caller |
|
|
1315
|
+
| Field Size | Per-field UTF-8 cap at `maxEntrySizeBytes` (64 KB default); oversized values become a truncation envelope |
|
|
1316
|
+
| Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing |
|
|
1245
1317
|
|
|
1246
1318
|
> [!IMPORTANT]
|
|
1247
1319
|
> Redaction protects the fields it knows about. Any new field carrying a secret or a personal document must be added to `redactPaths` — the default list covers the standard set, not your domain's.
|
|
@@ -1256,8 +1328,8 @@ When integrating `@bymax-one/nest-logger` in production, verify each of the foll
|
|
|
1256
1328
|
[](https://nodejs.org)
|
|
1257
1329
|
[](https://jestjs.io)
|
|
1258
1330
|
[](https://stryker-mutator.io)
|
|
1259
|
-
[](https://pnpm.io)
|
|
1332
|
+
[](https://tsup.egoist.dev)
|
|
1261
1333
|
|
|
1262
1334
|
---
|
|
1263
1335
|
|
|
@@ -1341,6 +1413,7 @@ There are **two** APIs on this class, and they do not share a signature shape.
|
|
|
1341
1413
|
| `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields. **A nested `run()` REPLACES the enclosing context** — see below |
|
|
1342
1414
|
| `runMerged` | `(store, callback)` | Opens a scope that EXTENDS the enclosing one: outer fields are carried in, `store` overrides them key by key |
|
|
1343
1415
|
| `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 |
|
|
1416
|
+
| `get` | `(key)` | Reads one field from the current scope, or `undefined` when unset or outside a `run()` call |
|
|
1344
1417
|
| `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
|
|
1345
1418
|
|
|
1346
1419
|
> [!IMPORTANT]
|
|
@@ -1357,11 +1430,26 @@ There are **two** APIs on this class, and they do not share a signature shape.
|
|
|
1357
1430
|
|
|
1358
1431
|
### Decorators
|
|
1359
1432
|
|
|
1360
|
-
| Decorator | Target | Description
|
|
1361
|
-
| ------------------------------- | ----------------- |
|
|
1362
|
-
| `@InjectLogger(context?)` | Constructor param | Injects `PinoLoggerService` pre-bound to the given context string
|
|
1363
|
-
| `@LogContext(name)` | Class | Records a context label as class metadata
|
|
1364
|
-
| `@LogPerformance(thresholdMs?)` | Method | Logs `METHOD_EXECUTION` on completion; `METHOD_SLOW_EXECUTION` if duration exceeds threshold
|
|
1433
|
+
| Decorator | Target | Description |
|
|
1434
|
+
| ------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1435
|
+
| `@InjectLogger(context?)` | Constructor param | Injects `PinoLoggerService` pre-bound to the given context string |
|
|
1436
|
+
| `@LogContext(name)` | Class | **Deprecated.** Records a context label as class metadata that nothing in this package reads, so the label is never applied, and it does NOT open a `logContext.run()` scope. Use `@InjectLogger(MyClass.name)` for a per-class label |
|
|
1437
|
+
| `@LogPerformance(thresholdMs?)` | Method | Logs `METHOD_EXECUTION` on completion; `METHOD_SLOW_EXECUTION` if duration exceeds threshold |
|
|
1438
|
+
|
|
1439
|
+
### Injection tokens and helpers
|
|
1440
|
+
|
|
1441
|
+
Every token is a `Symbol`, never a string, so it cannot collide with one of yours. Inject with an explicit `@Inject(TOKEN)`: the package is built without decorator metadata, and so is any consumer built the same way.
|
|
1442
|
+
|
|
1443
|
+
| Export | Provides | Use it to |
|
|
1444
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1445
|
+
| `LOGGER_OPTIONS_TOKEN` | the resolved `ResolvedBymaxLoggerModuleOptions` | read the effective configuration, defaults applied |
|
|
1446
|
+
| `LOGGER_PINO_INSTANCE_TOKEN` | the underlying Pino instance | hand it to a library that wants a Pino logger |
|
|
1447
|
+
| `LOGGER_DESTINATIONS_TOKEN` | the destinations array, `readonly ILogDestination[]` | inspect what is registered; do not mutate it |
|
|
1448
|
+
| `LOGGER_REDACTOR_TOKEN` | the by-name redaction function, `(value: unknown) => unknown`. With `redactStrategy: 'paths'` it returns its input unchanged, because Pino's path redaction then runs at write time instead | apply the module's by-name redaction to a value of your own under the default `'names'` strategy; do not treat it as a sanitization boundary under `'paths'`. |
|
|
1449
|
+
| `LOG_CONTEXT_TOKEN` | the `LogContextService` instance (an alias of the class) | inject the context by token |
|
|
1450
|
+
| `REDACT_COMMON_FIELDS` | the default sensitive field **names**, matched at any depth | extend the defaults by reference instead of copying the list |
|
|
1451
|
+
| `DEFAULT_REDACT_PATHS` | the legacy path expansion of those names | only with `redactStrategy: 'paths'` |
|
|
1452
|
+
| `isValidTraceId(id)` | `true` for 32 lowercase hex characters that are not all zeros | apply the rule the trace mixin applies before it injects an id |
|
|
1365
1453
|
|
|
1366
1454
|
### Level maps (from `@bymax-one/nest-logger`)
|
|
1367
1455
|
|
|
@@ -1482,19 +1570,31 @@ interface ILogDestination {
|
|
|
1482
1570
|
|
|
1483
1571
|
## 🚨 Error Code Catalog
|
|
1484
1572
|
|
|
1485
|
-
|
|
1486
|
-
| --------------------------------- | ---------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
1487
|
-
| `LOGGER_INVALID_OPTIONS` | Thrown `Error` at init | `service`, `service.name`, or `service.version` missing or empty | Add the required fields |
|
|
1488
|
-
| `LOGGER_INVALID_LEVEL` | Thrown `Error` at init | `level` is not a valid Pino value | Use `'fatal'\|'error'\|'warn'\|'info'\|'debug'\|'trace'` |
|
|
1489
|
-
| `LOGGER_PRETTY_UNAVAILABLE` | Thrown `Error` at `onInit()` | `PrettyDevDestination` registered but `pino-pretty` not installed | Install `pino-pretty` or drop the destination |
|
|
1490
|
-
| `LOGGER_OTEL_API_UNAVAILABLE` | Nothing emitted | `@opentelemetry/api` cannot be resolved | None — the trace mixin steps aside silently |
|
|
1491
|
-
| `LOGGER_DESTINATION_INIT_FAILED` | Structured error log | `destination.onInit()` rejects | Sink is excluded from shutdown; boot continues, writes stay fail-soft |
|
|
1492
|
-
| `LOGGER_DESTINATION_WRITE_FAILED` | NDJSON line on `stderr` | `destination.write()` throws or rejects | Entry dropped for that destination; others continue |
|
|
1493
|
-
| `LOGGER_CONTEXT_OUT_OF_SCOPE` | Thrown `Error` | `LogContextService.set()` called outside `run()` | Wrap in `logContext.run({ ... }, () => ...)` |
|
|
1494
|
-
| `LOGGER_ENTRY_TRUNCATED` | `_logKey` in the envelope | A serialized field exceeds `maxEntrySizeBytes` | Reduce the payload or raise the ceiling |
|
|
1573
|
+
Two kinds of failure, and they surface differently. A **thrown `Error`** carries no code string: it carries a message prefixed with the emitting class, which is what you match on. A **log key** is a code you can filter on: it is written to `stderr` as a structured line, or into the envelope as `_logKey`.
|
|
1495
1574
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1575
|
+
### Thrown errors
|
|
1576
|
+
|
|
1577
|
+
| Message starts with | Thrown from | When | Action |
|
|
1578
|
+
| -------------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
1579
|
+
| `[BymaxLoggerModule] options.` | `forRoot` / `forRootAsync`, at module setup | any option fails validation: a missing `service`, a `level` outside the set, a bad type | Fix the option the message names; it prints the path and what it received |
|
|
1580
|
+
| `[BymaxLoggerModule] useNestLogger(app) called but` | `BymaxLoggerModule.useNestLogger` | the module was never imported into the application | Import `BymaxLoggerModule` before calling it |
|
|
1581
|
+
| `[BymaxLoggerModule] applyAccessLog(app) called but` | `applyAccessLog` | the module was never imported into the application | Import `BymaxLoggerModule` before calling it |
|
|
1582
|
+
| `[PrettyDevDestination] pino-pretty is not installed` | `PrettyDevDestination.onInit` | the destination is registered and the optional peer is missing | Install `pino-pretty` or drop the destination |
|
|
1583
|
+
| `[PrettyDevDestination] refused in production` | `PrettyDevDestination.onInit` | `NODE_ENV` is exactly `production` | Use `DefaultStdoutDestination` in production |
|
|
1584
|
+
| `[LogContextService] set() called outside run() scope` | `LogContextService.set` | called with no active `run()` | Wrap the work in `logContext.run({ ... }, () => ...)` |
|
|
1585
|
+
| `[LogContextService] Refusing to set unsafe context key` | `LogContextService.set` | the key is `__proto__`, `constructor` or `prototype` | Use an ordinary key |
|
|
1586
|
+
|
|
1587
|
+
The two `PrettyDevDestination` errors are thrown inside `onInit`, which the registry contains: the boot continues and the failure is reported as `LOGGER_DESTINATION_INIT_FAILED` below.
|
|
1588
|
+
|
|
1589
|
+
### Log keys
|
|
1590
|
+
|
|
1591
|
+
| Code | Surfaces as | When | Action |
|
|
1592
|
+
| --------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
1593
|
+
| `LOGGER_DESTINATION_INIT_FAILED` | NDJSON line on `stderr` | `destination.onInit()` throws or rejects | Sink is removed from the write fan-out and shutdown; boot continues |
|
|
1594
|
+
| `LOGGER_DESTINATION_WRITE_FAILED` | NDJSON line on `stderr` | `destination.write()` throws or rejects | Entry dropped for that destination; others continue |
|
|
1595
|
+
| `LOGGER_BOOTSTRAP_WARNING` | log entry with `reason: 'OTEL_API_UNAVAILABLE'` | `otel.shouldAutoInjectTraceContext` is on and `@opentelemetry/api` cannot be resolved | Install `@opentelemetry/api`, or turn the option off; until then `traceId` and `spanId` are absent |
|
|
1596
|
+
| `LOGGER_ENTRY_TRUNCATED` | `_logKey` in the envelope | a serialized field exceeds `maxEntrySizeBytes` | Reduce the payload or raise the ceiling |
|
|
1597
|
+
| `LOGGER_REDACTION_FAILED` | `_logKey` in the envelope, with `_redactionFailed: true` | a record cannot be walked by the redactor (a throwing getter, a hostile proxy) | The record is dropped whole, never partly emitted; the message and ambient context stay. Find the value that broke the walk |
|
|
1498
1598
|
|
|
1499
1599
|
---
|
|
1500
1600
|
|
|
@@ -1536,7 +1636,8 @@ By design, the following are out of scope:
|
|
|
1536
1636
|
- ❌ **Log aggregation UI** — Grafana, Datadog, Honeycomb
|
|
1537
1637
|
- ❌ **Sentry SDK** — install `@sentry/node` in the consumer if desired
|
|
1538
1638
|
- ❌ **Immutable audit logs** — future `@bymax-one/nest-audit`
|
|
1539
|
-
- ❌ **File rotation built-in** — use `RollingFileDestination`
|
|
1639
|
+
- ❌ **File rotation built-in** — use the `RollingFileDestination` example above, built on `pino-roll`
|
|
1640
|
+
- ❌ **Fastify** — the HTTP integration targets the Express adapter (`@nestjs/platform-express`); a Fastify adapter is not supported
|
|
1540
1641
|
- ❌ **`pino-http`** — the `HttpLoggingInterceptor` is a NestJS-native replacement; install `pino-http` independently if you prefer it (produces duplicate logs if both are active)
|
|
1541
1642
|
|
|
1542
1643
|
---
|