@bymax-one/nest-logger 1.0.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 ADDED
@@ -0,0 +1,797 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/%40bymax--one-nest--logger-000000?style=for-the-badge&logo=nestjs&logoColor=E0234E" alt="@bymax-one/nest-logger" />
3
+ </p>
4
+
5
+ <h1 align="center">@bymax-one/nest-logger</h1>
6
+
7
+ <p align="center">
8
+ <strong>Structured JSON logging for NestJS</strong><br />
9
+ <sub>Pino 10 · OpenTelemetry · PII Redaction · Multi-Destination · Multi-Tenant · Zero Runtime Dependencies</sub>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@bymax-one/nest-logger"><img src="https://img.shields.io/npm/v/@bymax-one/nest-logger?style=flat-square&colorA=000000&colorB=000000" alt="npm version" /></a>
14
+ <a href="https://www.npmjs.com/package/@bymax-one/nest-logger"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-logger?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
+ <a href="https://github.com/bymaxone/nest-logger/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-logger/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
+ <a href="https://github.com/bymaxone/nest-logger/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
17
+ <a href="https://github.com/bymaxone/nest-logger/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-97.42%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
18
+ <a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-logger"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-logger/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
+ <a href="https://github.com/bymaxone/nest-logger/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-logger?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
20
+ <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
21
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-24%2B-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" /></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="https://github.com/bymaxone/nest-logger">GitHub</a> ·
26
+ <a href="https://github.com/bymaxone/nest-logger/issues">Issues</a> ·
27
+ <a href="#-quick-start">Quick Start</a> ·
28
+ <a href="#-api-reference">API Reference</a> ·
29
+ <a href="https://github.com/bymaxone/nest-logger-example">Example App</a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## ✨ Overview
35
+
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
+
38
+ **Why Pino?** At ~750,000 logs/sec (vs Winston's ~110,000), Pino consumes 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
+
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
+
42
+ ---
43
+
44
+ ## 🔥 Features
45
+
46
+ - **Structured JSON** — every entry has `level`, `time`, `service`, `logKey`, `msg`, and arbitrary metadata fields
47
+ - **PII redaction by default** — 113 paths covering passwords, tokens, generic secrets, PCI DSS card data, MFA secrets, CPF/CNPJ/RG (LGPD), and common HTTP headers — powered by `fast-redact`
48
+ - **OpenTelemetry correlation** — optional `@opentelemetry/api` peer; injects `traceId`/`spanId`/`traceFlags` into every log via a Pino mixin when an active span is detected
49
+ - **AsyncLocalStorage context** — `requestId`, `tenantId`, `userId` flow automatically through the request lifecycle without prop drilling
50
+ - **HTTP logging interceptor** — auto-logs all HTTP requests/responses with URL normalization (UUIDs and numeric IDs replaced by `:id` placeholder)
51
+ - **Exception filter** — captures NestJS `HttpException` and unexpected errors with structured output
52
+ - **Pluggable destinations** — implement `ILogDestination` to ship logs to Loki, Postgres, rolling files, or any sink
53
+ - **NestJS `LoggerService` bridge** — drop-in replacement; all NestJS internal logs flow through Pino
54
+ - **`MODULE_ACTION_RESULT` log key convention** — enforced by an exported regex for CI validation
55
+ - **Pretty-print in dev** — auto-enabled when `NODE_ENV !== 'production'` (requires optional `pino-pretty`)
56
+ - **Entry size guard** — truncates oversized entries (default 64 KB) with a structured warning instead of silently dropping them
57
+ - **Zero-downtime destination lifecycle** — `onInit()` / `onShutdown()` hooks; a failing destination is removed without affecting others
58
+
59
+ ---
60
+
61
+ ## 📦 Subpath Exports
62
+
63
+ The package ships two entry points:
64
+
65
+ | Import path | Contents | Deps |
66
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
67
+ | `@bymax-one/nest-logger` | Full server API: `BymaxLoggerModule`, `PinoLoggerService`, `LogContextService`, destinations, decorators, interceptors | NestJS + Pino peer deps |
68
+ | `@bymax-one/nest-logger/shared` | Zero-dependency utilities: `LOG_KEYS_CONVENTION_REGEX`, `RESERVED_LOG_KEYS`, types (`LogLevel`, `LogEntry`, `ServiceMetadata`) | None |
69
+
70
+ The `/shared` subpath is safe to import in isomorphic code, test helpers, CLI scripts, or shared packages that must not pull in NestJS.
71
+
72
+ ---
73
+
74
+ ## 📥 Installation
75
+
76
+ ```bash
77
+ # Install the library
78
+ pnpm add @bymax-one/nest-logger
79
+
80
+ # Required peer dependencies
81
+ pnpm add pino reflect-metadata
82
+
83
+ # Optional — pretty dev output
84
+ pnpm add -D pino-pretty
85
+
86
+ # Optional — OpenTelemetry correlation
87
+ pnpm add @opentelemetry/api @opentelemetry/sdk-node
88
+ ```
89
+
90
+ ---
91
+
92
+ ## 🚀 Quick Start
93
+
94
+ ### Step 1 — Register the module
95
+
96
+ ```typescript
97
+ // app.module.ts
98
+ import { Module } from '@nestjs/common'
99
+ import { BymaxLoggerModule } from '@bymax-one/nest-logger'
100
+
101
+ @Module({
102
+ imports: [
103
+ BymaxLoggerModule.forRoot({
104
+ service: { name: 'my-app', version: '1.0.0' },
105
+ level: 'info',
106
+ http: { isEnabled: true }
107
+ })
108
+ ]
109
+ })
110
+ export class AppModule {}
111
+ ```
112
+
113
+ `isPretty` defaults to `true` when `NODE_ENV !== 'production'` — no extra config needed in development.
114
+
115
+ ### Step 2 — Async configuration with `ConfigService`
116
+
117
+ ```typescript
118
+ // app.module.ts
119
+ import { Module } from '@nestjs/common'
120
+ import { ConfigModule, ConfigService } from '@nestjs/config'
121
+ import { BymaxLoggerModule } from '@bymax-one/nest-logger'
122
+
123
+ @Module({
124
+ imports: [
125
+ ConfigModule.forRoot({ isGlobal: true }),
126
+ BymaxLoggerModule.forRootAsync({
127
+ imports: [ConfigModule],
128
+ inject: [ConfigService],
129
+ useFactory: (cfg: ConfigService) => ({
130
+ service: {
131
+ name: cfg.getOrThrow<string>('OTEL_SERVICE_NAME'),
132
+ version: cfg.getOrThrow<string>('RELEASE_SHA')
133
+ },
134
+ level: cfg.get('LOG_LEVEL') ?? 'info',
135
+ http: { isEnabled: true }
136
+ })
137
+ })
138
+ ]
139
+ })
140
+ export class AppModule {}
141
+ ```
142
+
143
+ ### Step 3 — Inject the logger in a service
144
+
145
+ ```typescript
146
+ // payments.service.ts
147
+ import { Injectable } from '@nestjs/common'
148
+ import { InjectLogger, PinoLoggerService } from '@bymax-one/nest-logger'
149
+
150
+ @Injectable()
151
+ export class PaymentsService {
152
+ constructor(
153
+ @InjectLogger(PaymentsService.name)
154
+ private readonly logger: PinoLoggerService
155
+ ) {}
156
+
157
+ async refund(paymentId: string, amount: number, requestedBy: string) {
158
+ this.logger.info('PAYMENT_REFUND_REQUESTED', 'Refund requested', requestedBy, {
159
+ paymentId,
160
+ amount
161
+ })
162
+
163
+ try {
164
+ const result = await this.stripe.refunds.create({ payment_intent: paymentId, amount })
165
+ this.logger.info('PAYMENT_REFUND_SUCCESS', 'Refund processed', requestedBy, {
166
+ paymentId,
167
+ stripeRefundId: result.id
168
+ })
169
+ return result
170
+ } catch (err) {
171
+ this.logger.errorStructured(
172
+ 'PAYMENT_REFUND_FAILED',
173
+ err instanceof Error ? err : new Error(String(err)),
174
+ requestedBy,
175
+ { paymentId, amount }
176
+ )
177
+ throw err
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ Output (JSON, production):
184
+
185
+ ```json
186
+ {
187
+ "level": 30,
188
+ "time": "2026-05-28T10:12:44.512Z",
189
+ "service": { "name": "my-app", "version": "abc123" },
190
+ "logKey": "PAYMENT_REFUND_SUCCESS",
191
+ "msg": "Refund processed",
192
+ "context": "PaymentsService",
193
+ "requestId": "r_7f3a9b",
194
+ "tenantId": "t_acme",
195
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
196
+ "spanId": "00f067aa0ba902b7",
197
+ "paymentId": "pi_xyz",
198
+ "stripeRefundId": "re_abc"
199
+ }
200
+ ```
201
+
202
+ Output (pretty-print, development):
203
+
204
+ ```
205
+ [10:12:44.512] INFO (my-app): PAYMENT_REFUND_SUCCESS
206
+ requestId: "r_7f3a9b"
207
+ tenantId: "t_acme"
208
+ paymentId: "pi_xyz"
209
+ stripeRefundId: "re_abc"
210
+ ```
211
+
212
+ ### Step 4 — HTTP logging (automatic)
213
+
214
+ Enable `http.isEnabled: true` in the module options. The `HttpLoggingInterceptor` is registered globally and emits:
215
+
216
+ | Log key | When |
217
+ | --------------------------- | ------------------------------------- |
218
+ | `HTTP_REQUEST_START` | Request received |
219
+ | `HTTP_REQUEST_SUCCESS` | 2xx response |
220
+ | `HTTP_REQUEST_REDIRECT` | 3xx response |
221
+ | `HTTP_REQUEST_CLIENT_ERROR` | 4xx response |
222
+ | `HTTP_REQUEST_SERVER_ERROR` | 5xx response |
223
+ | `HTTP_EXCEPTION_HANDLED` | `HttpException` caught by the filter |
224
+ | `HTTP_EXCEPTION_UNHANDLED` | Unexpected error caught by the filter |
225
+
226
+ URLs are automatically normalized — `/users/550e8400-e29b-41d4-a716-446655440000` becomes `/users/:id` so Loki/Grafana cardinality stays bounded.
227
+
228
+ ### Step 5 — OpenTelemetry correlation
229
+
230
+ Initialize the OTel SDK **before** importing NestJS — this is critical:
231
+
232
+ ```typescript
233
+ // main.ts (top of file — before any NestJS import)
234
+ import { NodeSDK } from '@opentelemetry/sdk-node'
235
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
236
+ import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
237
+ import { resourceFromAttributes } from '@opentelemetry/resources'
238
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
239
+
240
+ const sdk = new NodeSDK({
241
+ resource: resourceFromAttributes({
242
+ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'my-app',
243
+ [ATTR_SERVICE_VERSION]: process.env.RELEASE_SHA ?? 'dev',
244
+ 'deployment.environment': process.env.NODE_ENV ?? 'development'
245
+ }),
246
+ traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_TRACE_ENDPOINT }),
247
+ instrumentations: [
248
+ getNodeAutoInstrumentations({
249
+ '@opentelemetry/instrumentation-fs': { isEnabled: false } // noisy
250
+ })
251
+ ]
252
+ })
253
+
254
+ sdk.start()
255
+
256
+ process.on('SIGTERM', () => {
257
+ void sdk.shutdown().finally(() => process.exit(0))
258
+ })
259
+
260
+ // NestJS imports come AFTER sdk.start()
261
+ import { NestFactory } from '@nestjs/core'
262
+ import { AppModule } from './app.module'
263
+
264
+ async function bootstrap() {
265
+ const app = await NestFactory.create(AppModule, { bufferLogs: true })
266
+ await app.listen(3000)
267
+ }
268
+
269
+ void bootstrap()
270
+ ```
271
+
272
+ Once active, every log entry automatically carries `traceId`, `spanId`, and `traceFlags`. Click the `traceId` in Grafana to jump directly to the correlated span in Tempo or Honeycomb.
273
+
274
+ ### Step 6 — Context propagation with `LogContextService`
275
+
276
+ ```typescript
277
+ // request-id.middleware.ts
278
+ import { Injectable, NestMiddleware } from '@nestjs/common'
279
+ import { LogContextService } from '@bymax-one/nest-logger'
280
+ import { randomUUID } from 'node:crypto'
281
+
282
+ @Injectable()
283
+ export class RequestIdMiddleware implements NestMiddleware {
284
+ constructor(private readonly logContext: LogContextService) {}
285
+
286
+ use(req: Request, res: Response, next: () => void) {
287
+ const requestId = req.headers['x-request-id'] ?? `r_${randomUUID()}`
288
+ this.logContext.run({ requestId }, next)
289
+ }
290
+ }
291
+ ```
292
+
293
+ Any log emitted inside the `run()` scope — regardless of nesting depth — automatically includes `requestId` with no prop drilling.
294
+
295
+ ---
296
+
297
+ ## ⚙️ Configuration
298
+
299
+ Full options reference for `BymaxLoggerModule.forRoot(options)`:
300
+
301
+ ### Top-level options
302
+
303
+ | Option | Type | Default | Description |
304
+ | ---------------------------- | -------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
305
+ | `service.name` | `string` | **Required** | Service name emitted in every log entry |
306
+ | `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
307
+ | `level` | `LogLevel` | `'info'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
308
+ | `isPretty` | `boolean` | `!isProduction` | ⚠️ Reserved — not auto-wired. For pretty output add `new PrettyDevDestination()` to `destinations` (needs `pino-pretty`) |
309
+ | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths merged with the defaults |
310
+ | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the 113 default PII paths. ⚠️ Emits a bootstrap warning — document why |
311
+ | `redactCensor` | `string \| function` | `'[REDACTED]'` | Replacement value for redacted fields. A function receives the raw value |
312
+ | `maxEntrySizeBytes` | `number` | `65536` | Entries larger than this are replaced by a structured `LOGGER_ENTRY_TRUNCATED` warning |
313
+ | `destinations` | `ILogDestination[]` | `[]` | Additional sinks (Loki, Postgres, rolling file, …) alongside default stdout |
314
+
315
+ ### `http` options
316
+
317
+ | Option | Type | Default | Description |
318
+ | ------------------------------ | ---------- | ------------------------------- | ------------------------------------------------------------------------------------- |
319
+ | `http.isEnabled` | `boolean` | `false` | Register `HttpLoggingInterceptor` (and, on `forRoot`, `HttpExceptionFilter`) globally |
320
+ | `http.shouldCaptureExceptions` | `boolean` | `true` | Capture unhandled HTTP exceptions and emit `HTTP_EXCEPTION_UNHANDLED` |
321
+ | `http.shouldGenerateRequestId` | `boolean` | `true` | Generate a `requestId` when the inbound request header is absent |
322
+ | `http.excludePaths` | `RegExp[]` | `[/^\/health$/, /^\/metrics$/]` | Paths that bypass HTTP logging. Use anchored, linear-time regexes (ReDoS-safe) |
323
+ | `http.tenantIdHeader` | `string` | `'x-tenant-id'` | Request header carrying the tenant identifier |
324
+
325
+ ### `otel` options
326
+
327
+ | Option | Type | Default | Description |
328
+ | ----------------------------------- | ----------------------------- | ------------- | ------------------------------------------------------------------------ |
329
+ | `otel.shouldAutoInjectTraceContext` | `boolean` | `true` | Detect `@opentelemetry/api` and inject `traceId`/`spanId` via Pino mixin |
330
+ | `otel.fieldFormat` | `'camelCase' \| 'snake_case'` | `'camelCase'` | Field names in log entries: `traceId`/`spanId` vs `trace_id`/`span_id` |
331
+
332
+ ---
333
+
334
+ ## 🔐 PII Redaction
335
+
336
+ ### Default paths
337
+
338
+ The library ships **113 redact paths** compiled at initialization into a single `fast-redact` function (< 3% throughput impact). These cover:
339
+
340
+ | Category | Fields |
341
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
342
+ | Passwords | `password`, `passwordHash`, `passwordConfirm`, `newPassword`, `oldPassword` |
343
+ | Tokens | `token`, `accessToken`, `refreshToken`, `idToken`, `apiKey`, `apiSecret` |
344
+ | MFA | `mfaSecret`, `mfaRecoveryCodes`, `totpSecret` |
345
+ | Generic secrets | `secret`, `clientSecret`, `signingSecret`, `privateKey` |
346
+ | Payment / PCI DSS | `cardNumber`, `cardCvv`, `cvv`, `cvc`, `cardExpiry` |
347
+ | Personal documents (LGPD) | `cpf`, `cnpj`, `rg` |
348
+ | Conservative PII | `email` |
349
+ | HTTP headers (absolute) | `req.headers.authorization`, `req.headers.cookie`, `req.headers["x-api-key"]`, `req.headers["x-auth-token"]`, `res.headers["set-cookie"]` |
350
+
351
+ Every field is listed at wildcard depths 1–4 (`*.field`, `*.*.field`, `*.*.*.field`, `*.*.*.*.field`) because `fast-redact`'s `*` matches a single level only — not recursive.
352
+
353
+ ### Extending the defaults
354
+
355
+ ```typescript
356
+ BymaxLoggerModule.forRoot({
357
+ service: { name: 'my-app', version: '1.0.0' },
358
+ redactPaths: [
359
+ '*.internalSecret', // depth-1 wildcard
360
+ 'body.creditCard.*', // all fields inside a subobject
361
+ 'payload.user.taxId' // exact path
362
+ ]
363
+ })
364
+ ```
365
+
366
+ The extra paths are **merged** with the defaults — never replacing them.
367
+
368
+ ### Disabling defaults (not recommended)
369
+
370
+ ```typescript
371
+ BymaxLoggerModule.forRoot({
372
+ service: { name: 'my-app', version: '1.0.0' },
373
+ shouldDisableDefaultRedact: true, // ⚠️ emits LOGGER_BOOTSTRAP_WARNING
374
+ redactPaths: ['*.password'] // you own the full list
375
+ })
376
+ ```
377
+
378
+ A `LOGGER_BOOTSTRAP_WARNING` entry is emitted on startup so security reviews can audit when PII protection was intentionally reduced.
379
+
380
+ ---
381
+
382
+ ## 🔑 Log Key Convention
383
+
384
+ All structured log calls must use the `MODULE_ACTION_RESULT` format:
385
+
386
+ ```
387
+ USER_LOGIN_SUCCESS AUTH_REGISTER_FAILED
388
+ PAYMENT_REFUND_PROCESSED WEBHOOK_STRIPE_RECEIVED
389
+ HTTP_REQUEST_CLIENT_ERROR METHOD_SLOW_EXECUTION
390
+ ```
391
+
392
+ The regex is exported from the `/shared` entry for CI validation:
393
+
394
+ ```typescript
395
+ import { LOG_KEYS_CONVENTION_REGEX } from '@bymax-one/nest-logger/shared'
396
+
397
+ function assertValidLogKey(key: string) {
398
+ if (!LOG_KEYS_CONVENTION_REGEX.test(key)) {
399
+ throw new Error(`Invalid log key: "${key}". Expected MODULE_ACTION_RESULT format.`)
400
+ }
401
+ }
402
+ ```
403
+
404
+ ### Reserved keys
405
+
406
+ The following keys are used internally by the library — do not reuse them in application code:
407
+
408
+ `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`
409
+
410
+ All reserved keys are exported as the `RESERVED_LOG_KEYS` constant from `@bymax-one/nest-logger/shared`.
411
+
412
+ ---
413
+
414
+ ## 🧩 Custom Destinations
415
+
416
+ Implement `ILogDestination` to ship logs to any sink:
417
+
418
+ ```typescript
419
+ import type { ILogDestination } from '@bymax-one/nest-logger'
420
+ import type { LogEntry } from '@bymax-one/nest-logger/shared'
421
+
422
+ export class LokiDestination implements ILogDestination {
423
+ readonly name = 'loki'
424
+ readonly minLevel = 'info' as const
425
+
426
+ private readonly url: string
427
+ private readonly headers: Record<string, string>
428
+ private readonly labels: Record<string, string>
429
+ private buffer: LogEntry[] = []
430
+ private flushTimer?: NodeJS.Timeout
431
+
432
+ constructor(opts: {
433
+ url: string
434
+ username: string
435
+ password: string
436
+ labels: Record<string, string>
437
+ }) {
438
+ this.url = `${opts.url}/loki/api/v1/push`
439
+ this.labels = opts.labels
440
+ const credentials = Buffer.from(`${opts.username}:${opts.password}`).toString('base64')
441
+ this.headers = {
442
+ 'Content-Type': 'application/json',
443
+ Authorization: `Basic ${credentials}`
444
+ }
445
+ }
446
+
447
+ async onInit(): Promise<void> {
448
+ this.flushTimer = setInterval(() => void this.flush(), 5_000)
449
+ }
450
+
451
+ async onShutdown(): Promise<void> {
452
+ clearInterval(this.flushTimer)
453
+ await this.flush()
454
+ }
455
+
456
+ write(entry: LogEntry): void {
457
+ this.buffer.push(entry)
458
+ if (this.buffer.length >= 100) void this.flush()
459
+ }
460
+
461
+ private async flush(): Promise<void> {
462
+ if (this.buffer.length === 0) return
463
+ const batch = this.buffer.splice(0)
464
+ const body = JSON.stringify({
465
+ streams: [
466
+ {
467
+ stream: this.labels,
468
+ values: batch.map((e) => [String(BigInt(e.time) * 1_000_000n), JSON.stringify(e)])
469
+ }
470
+ ]
471
+ })
472
+ await fetch(this.url, { method: 'POST', headers: this.headers, body })
473
+ }
474
+ }
475
+ ```
476
+
477
+ Then pass it via `destinations`:
478
+
479
+ ```typescript
480
+ BymaxLoggerModule.forRootAsync({
481
+ inject: [ConfigService],
482
+ useFactory: (cfg: ConfigService) => ({
483
+ service: { name: cfg.getOrThrow('OTEL_SERVICE_NAME'), version: cfg.getOrThrow('RELEASE_SHA') },
484
+ level: 'info',
485
+ destinations: [
486
+ new LokiDestination({
487
+ url: cfg.getOrThrow('LOKI_URL'),
488
+ username: cfg.getOrThrow('LOKI_USER'),
489
+ password: cfg.getOrThrow('LOKI_PASSWORD'),
490
+ labels: { service: cfg.getOrThrow('OTEL_SERVICE_NAME') }
491
+ })
492
+ ]
493
+ })
494
+ })
495
+ ```
496
+
497
+ ### Postgres destination (Prisma)
498
+
499
+ ```typescript
500
+ import type { ILogDestination } from '@bymax-one/nest-logger'
501
+ import type { LogEntry } from '@bymax-one/nest-logger/shared'
502
+ import type { PrismaClient } from '@prisma/client'
503
+
504
+ export class PrismaLogDestination implements ILogDestination {
505
+ readonly name = 'prisma-postgres'
506
+ readonly minLevel = 'warn' as const // only persist warnings and above
507
+
508
+ constructor(private readonly prisma: PrismaClient) {}
509
+
510
+ write(entry: LogEntry): void {
511
+ void this.prisma.applicationLog.create({
512
+ data: {
513
+ level: entry.level,
514
+ logKey: entry.logKey,
515
+ message: entry.msg,
516
+ payload: entry,
517
+ createdAt: new Date(entry.time)
518
+ }
519
+ })
520
+ }
521
+ }
522
+ ```
523
+
524
+ ### Rolling file destination (`pino-roll`)
525
+
526
+ ```typescript
527
+ import { createStream } from 'pino-roll'
528
+ import type { ILogDestination } from '@bymax-one/nest-logger'
529
+ import type { LogEntry } from '@bymax-one/nest-logger/shared'
530
+
531
+ export class RollingFileDestination implements ILogDestination {
532
+ readonly name = 'rolling-file'
533
+ private stream?: Awaited<ReturnType<typeof createStream>>
534
+
535
+ async onInit(): Promise<void> {
536
+ this.stream = await createStream('logs/app.log', {
537
+ frequency: 'daily',
538
+ mkdir: true,
539
+ size: '50m'
540
+ })
541
+ }
542
+
543
+ async onShutdown(): Promise<void> {
544
+ await new Promise<void>((resolve) => this.stream?.end(resolve))
545
+ }
546
+
547
+ write(entry: LogEntry): void {
548
+ this.stream?.write(JSON.stringify(entry) + '\n')
549
+ }
550
+ }
551
+ ```
552
+
553
+ ---
554
+
555
+ ## 🏗️ Architecture
556
+
557
+ ```
558
+ HTTP Request
559
+ │
560
+ ▼
561
+ HttpLoggingInterceptor ← emits HTTP_REQUEST_START
562
+ │
563
+ ▼
564
+ RequestIdMiddleware ← opens AsyncLocalStorage scope
565
+ │ { requestId, tenantId, userId }
566
+ ▼
567
+ Application Service
568
+ │
569
+ └── PinoLoggerService.info(logKey, msg, context, metadata)
570
+ │
571
+ ▼
572
+ Pino logger
573
+ │
574
+ composedMixin() ← runs per-log (O(1))
575
+ ├── ALS store → { requestId, tenantId, userId }
576
+ └── OTel span → { traceId, spanId, traceFlags }
577
+ │
578
+ ▼
579
+ fast-redact ← compiled redact function (113 paths)
580
+ │
581
+ ▼
582
+ ┌─────────────────────────────┐
583
+ │ DefaultStdoutDestination │ ← always active
584
+ │ LokiDestination │ ← optional
585
+ │ PrismaLogDestination │ ← optional
586
+ │ RollingFileDestination │ ← optional
587
+ └─────────────────────────────┘
588
+ ```
589
+
590
+ **Key design decisions:**
591
+
592
+ - **Singleton scope, not `Scope.REQUEST`** — `AsyncLocalStorage` delivers per-request context at zero latency overhead. NestJS request scope adds ~5% latency on the injection graph; unacceptable on a logger that runs every request.
593
+ - **One Pino mixin** — ALS context and OTel trace context are composed into a single mixin. Merge order is deterministic: ALS fields first, then OTel (OTel wins on name conflicts because an active span is the authoritative trace identity).
594
+ - **Zero direct dependencies** — the library ships with `"dependencies": {}`. All packages arrive as peer dependencies so consumers control exact versions.
595
+ - **`fast-redact` compiled at init** — 113 paths compile once at module bootstrap into a specialized JS function. No per-log regex matching.
596
+
597
+ ---
598
+
599
+ ## 📖 API Reference
600
+
601
+ ### `PinoLoggerService`
602
+
603
+ Implements NestJS `LoggerService`. All methods are available via `@InjectLogger()`.
604
+
605
+ | Method | Signature | Notes |
606
+ | ----------------- | ---------------------------------- | -------------------------------------- |
607
+ | `info` | `(logKey, msg, context?, meta?)` | Structured info log |
608
+ | `warn` | `(logKey, msg, context?, meta?)` | Structured warn log |
609
+ | `debug` | `(logKey, msg, context?, meta?)` | Structured debug log |
610
+ | `error` | `(logKey, msg, context?, meta?)` | Structured error log (string message) |
611
+ | `fatal` | `(logKey, msg, context?, meta?)` | Structured fatal log |
612
+ | `warnStructured` | `(logKey, error, context?, meta?)` | Warn with `Error` object (serialized) |
613
+ | `errorStructured` | `(logKey, error, context?, meta?)` | Error with `Error` object (serialized) |
614
+ | `fatalStructured` | `(logKey, error, context?, meta?)` | Fatal with `Error` object (serialized) |
615
+ | `log` | `(msg, context?)` | NestJS `LoggerService` bridge → `info` |
616
+ | `verbose` | `(msg, context?)` | NestJS bridge → `trace` |
617
+
618
+ ### `LogContextService`
619
+
620
+ | Method | Signature | Description |
621
+ | ---------- | ------------------- | --------------------------------------------------------------------------- |
622
+ | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields |
623
+ | `set` | `(key, value)` | Adds/updates a field in the current scope. Throws if called outside `run()` |
624
+ | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
625
+
626
+ ### Decorators
627
+
628
+ | Decorator | Target | Description |
629
+ | ------------------------------- | ----------------- | --------------------------------------------------------------------------------------------- |
630
+ | `@InjectLogger(context?)` | Constructor param | Injects `PinoLoggerService` pre-bound to the given context string |
631
+ | `@LogContext(store)` | Method | Wraps the method in `logContext.run(store, ...)` — all downstream logs carry the given fields |
632
+ | `@LogPerformance(thresholdMs?)` | Method | Logs `METHOD_EXECUTION` on completion; `METHOD_SLOW_EXECUTION` if duration exceeds threshold |
633
+
634
+ ### Types (from `@bymax-one/nest-logger/shared`)
635
+
636
+ ```typescript
637
+ type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
638
+
639
+ type ServiceMetadata = { name: string; version: string }
640
+
641
+ type LogEntry = {
642
+ level: number
643
+ time: number
644
+ service: ServiceMetadata
645
+ logKey: string
646
+ msg: string
647
+ [key: string]: unknown
648
+ }
649
+ ```
650
+
651
+ ### `ILogDestination`
652
+
653
+ ```typescript
654
+ interface ILogDestination {
655
+ readonly name: string
656
+ readonly minLevel?: LogLevel
657
+ write(entry: LogEntry): void
658
+ onInit?(): Promise<void>
659
+ onShutdown?(): Promise<void>
660
+ }
661
+ ```
662
+
663
+ ---
664
+
665
+ ## 🚨 Error Code Catalog
666
+
667
+ | Code | Severity | When | Action |
668
+ | --------------------------------- | ----------------- | -------------------------------------------------------- | --------------------------------------------------------------------- |
669
+ | `LOGGER_INVALID_OPTIONS` | Throws at init | `service.name` or `service.version` missing | Add the required fields |
670
+ | `LOGGER_INVALID_LEVEL` | Throws at init | `level` is not a valid Pino value | Use `'fatal'\|'error'\|'warn'\|'info'\|'debug'\|'trace'` |
671
+ | `LOGGER_PRETTY_UNAVAILABLE` | Warn on bootstrap | `isPretty: true` but `pino-pretty` not installed | Install `pino-pretty` or set `isPretty: false` |
672
+ | `LOGGER_OTEL_API_UNAVAILABLE` | Info on bootstrap | OTel mixin active but `@opentelemetry/api` not installed | Install the package or set `otel.shouldAutoInjectTraceContext: false` |
673
+ | `LOGGER_DESTINATION_INIT_FAILED` | Error (logged) | `destination.onInit()` rejects | Destination is removed; other destinations continue |
674
+ | `LOGGER_DESTINATION_WRITE_FAILED` | Warn (logged) | `destination.write()` throws | Entry skipped for that destination; others continue |
675
+ | `LOGGER_CONTEXT_OUT_OF_SCOPE` | Throws | `LogContextService.set()` called outside `run()` | Wrap in `logContext.run({ ... }, () => ...)` |
676
+ | `LOGGER_ENTRY_TRUNCATED` | Warn (meta-log) | Entry exceeds `maxEntrySizeBytes` | Reduce metadata or raise the limit |
677
+
678
+ ---
679
+
680
+ ## 🪜 Level Mapping
681
+
682
+ | Pino | Pino string | NestJS | Typical use |
683
+ | ---- | ----------- | --------- | ----------------------------------------- |
684
+ | 60 | `fatal` | — | Process about to exit |
685
+ | 50 | `error` | `error` | Failure requiring human attention |
686
+ | 40 | `warn` | `warn` | Recoverable anomaly |
687
+ | 30 | `info` | `log` | Significant business events |
688
+ | 20 | `debug` | `debug` | Implementation detail for troubleshooting |
689
+ | 10 | `trace` | `verbose` | Ultra-granular (rarely in prod) |
690
+
691
+ ---
692
+
693
+ ## 🧪 Testing & Quality
694
+
695
+ ```bash
696
+ pnpm test # unit tests (Jest)
697
+ pnpm test:cov # coverage report
698
+ pnpm test:e2e # end-to-end tests (supertest)
699
+ pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
700
+ pnpm mutation # Stryker mutation testing (95% break gate)
701
+ pnpm typecheck # tsc strict check (all tsconfig variants)
702
+ pnpm lint # ESLint
703
+ ```
704
+
705
+ Coverage gate: **100%** on statements, branches, functions, and lines. Mutation: **97.42%** current vs the **95%** break gate (Stryker `thresholds.break: 95`); 97.42% is the theoretical maximum — all 10 surviving mutants are documented equivalents. See [mutation_testing_results.md](docs/mutation_testing_results.md).
706
+
707
+ ---
708
+
709
+ ## 🧱 Tech Stack
710
+
711
+ [![Pino](https://img.shields.io/badge/Pino-10-green?style=flat-square)](https://getpino.io)
712
+ [![NestJS](https://img.shields.io/badge/NestJS-11-E0234E?style=flat-square&logo=nestjs)](https://nestjs.com)
713
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
714
+ [![Node.js](https://img.shields.io/badge/Node.js-24-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
715
+ [![Jest](https://img.shields.io/badge/Jest-30-C21325?style=flat-square&logo=jest)](https://jestjs.io)
716
+ [![Stryker](https://img.shields.io/badge/Stryker-Mutation_Testing-red?style=flat-square)](https://stryker-mutator.io)
717
+ [![pnpm](https://img.shields.io/badge/pnpm-10-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
718
+ [![tsup](https://img.shields.io/badge/tsup-8-orange?style=flat-square)](https://tsup.egoist.dev)
719
+
720
+ ---
721
+
722
+ ## ⚡ Performance
723
+
724
+ Pino official benchmarks (Node 24, 100k logs):
725
+
726
+ | Logger | Logs/sec | Avg latency | RSS | CPU |
727
+ | ---------------------- | ------------ | ----------- | ---------- | ------ |
728
+ | **Pino 10 (JSON)** | **~750,000** | **1.3 µs** | **~45 MB** | **8%** |
729
+ | Pino 10 (pretty) | ~120,000 | 8 µs | ~50 MB | 14% |
730
+ | Winston 3 (JSON) | ~110,000 | 9 µs | ~85 MB | 22% |
731
+ | Winston 3 (transports) | ~75,000 | 13 µs | ~95 MB | 28% |
732
+
733
+ At high throughput (~100k req/s), Pino consumes 3× less CPU and ~2× less RSS than Winston.
734
+
735
+ ---
736
+
737
+ ## 🚫 What This Library Does NOT Do
738
+
739
+ By design, the following are out of scope:
740
+
741
+ - ❌ **OTel SDK initialization** — consumer's responsibility in `main.ts`
742
+ - ❌ **Custom metrics** (OTel Metrics API) — use `@opentelemetry/api` directly
743
+ - ❌ **Alerting** (PagerDuty, Slack) — configure your OTLP backend (Grafana Alertmanager)
744
+ - ❌ **Log aggregation UI** — Grafana, Datadog, Honeycomb
745
+ - ❌ **Sentry SDK** — install `@sentry/node` in the consumer if desired
746
+ - ❌ **Immutable audit logs** — future `@bymax-one/nest-audit`
747
+ - ❌ **File rotation built-in** — use `RollingFileDestination` with `pino-roll`
748
+ - ❌ **`pino-http`** — the `HttpLoggingInterceptor` is a NestJS-native replacement; install `pino-http` independently if you prefer it (produces duplicate logs if both are active)
749
+
750
+ ---
751
+
752
+ ## 🤝 Contributing
753
+
754
+ This library is part of the `@bymax-one` monorepo. Development follows the Bymax coding standards:
755
+
756
+ - TypeScript strict (`noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
757
+ - 100% test coverage gate
758
+ - Conventional Commits enforced by commitlint + husky
759
+ - No direct dependencies — peer deps only
760
+ - All boolean identifiers prefixed with `is / has / should / can`
761
+ - All log keys in `MODULE_ACTION_RESULT` format
762
+
763
+ ```bash
764
+ # Clone the repository
765
+ git clone https://github.com/bymaxone/nest-logger.git
766
+ cd nest-logger
767
+
768
+ # Install dependencies
769
+ pnpm install
770
+
771
+ # Run tests
772
+ pnpm test
773
+
774
+ # Build
775
+ pnpm build
776
+
777
+ # Type check
778
+ pnpm typecheck
779
+ ```
780
+
781
+ ---
782
+
783
+ ## 🔒 Security Policy
784
+
785
+ If you discover a security vulnerability, please **do not** open a public issue. Instead, email us at **support@bymax.one** with details. We take security seriously and will respond promptly.
786
+
787
+ ---
788
+
789
+ ## 📄 License
790
+
791
+ [MIT](./LICENSE) © [Bymax One](https://github.com/bymaxone)
792
+
793
+ ---
794
+
795
+ <p align="center">
796
+ <sub>Built with ❤️ by <a href="https://github.com/bymaxone">Bymax One</a></sub>
797
+ </p>