npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bymax-one/nest-logger

v1.4.0

Published

Structured JSON logging for NestJS based on Pino 10, with optional OpenTelemetry correlation.

Readme


✨ Overview

@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.

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.

The library has zero direct dependencies — all packages arrive as peer dependencies, so you control exact versions and the supply chain surface stays minimal.

Why nest-logger?

  • 🎯 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.
  • 🔌 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.
  • 🔒 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.
  • ⚡ 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.
  • 🔭 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.
pnpm add @bymax-one/nest-logger

🔥 Features

📝 Core Logging

  • Structured JSON — every entry has level, time, service, logKey, msg, and arbitrary metadata fields
  • MODULE_ACTION_RESULT Log Keys — a naming convention enforced by an exported regex for CI validation
  • NestJS LoggerService Bridge — drop-in replacement; all NestJS internal logs flow through Pino
  • Pretty-Print in Dev — opt-in PrettyDevDestination with a configurable view (single-line, hidden fields, message-only) for readable local output (requires optional pino-pretty)
  • Field Size Guard — a serialized field over the ceiling (default 64 KB) is replaced by a compact truncation envelope instead of flooding the sink

🛡️ Security & Privacy

  • 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
  • PCI DSS & MFA Coverage — card data and MFA secrets redacted out of the box, with common HTTP auth headers
  • LGPD-Aware Paths — CPF, CNPJ, and RG redacted by default for Brazilian workloads
  • Append-Only Redact ListDEFAULT_REDACT_PATHS never shrinks without a major version; extend it via redactPaths
  • Validated Trace IDs — OTel identifiers pass isValidTraceId before injection, never raw user input

🔍 Observability & Context

  • OpenTelemetry Correlation — optional @opentelemetry/api peer; injects traceId/spanId/traceFlags into every log via a Pino mixin when an active span is detected. Resolution is anchored at the library's own module path, so a Docker WORKDIR, a pnpm workspace or a monorepo launched from the repo root cannot silently switch correlation off
  • Stable Resource Identityservice.name/.namespace/.version/.instance.id and deployment.environment.name, all Stable in Semantic Conventions v1.44.0, resolved from one deterministic precedence shared with the OTel SDK
  • Semconv Error Fields — opt-in exception.type/.message/.stacktrace and low-cardinality error.type, additive beside the legacy err.*, with full Error.cause chains
  • Machine-Readable Event Namesevent.name derived from logKey following OTel naming rules
  • AsyncLocalStorage ContextrequestId, tenantId, userId flow automatically through the request lifecycle without prop drilling
  • 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
  • Exception Filter — captures NestJS HttpException and unexpected errors with structured output

🔌 Destinations

  • Pluggable Destinations — implement ILogDestination to ship logs to Loki, Postgres, rolling files, or any sink
  • Managed LifecycleonInit() / onShutdown() hooks; a destination that fails onInit() is reported and excluded from shutdown, and boot is never aborted
  • Crash-Proof Writes — every write() is wrapped in a try/catch; a failure is reported on stderr and swallowed, never propagated to the app

🧩 Developer Experience

  • Zero Runtime Dependencies — everything arrives as a peer dependency, so you control versions and supply-chain surface
  • 2 Subpath Exports. for the NestJS server API, ./shared for zero-dependency types and constants
  • Dynamic Module — configure via forRoot() or forRootAsync(), sensible defaults included
  • Strict TypeScriptstrict: true, no any in production code, JSDoc on every public export
  • 100% Coverage + Mutation Tested — statements, branches, functions, and lines gated at 100%, with Stryker as the deeper gate

📦 Subpath Exports

One package, two entry points — import only what your app needs:

| Subpath | Import | Purpose | Dependencies | | ---------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------: | | Server | @bymax-one/nest-logger | NestJS module, logger service, interceptor, filter, decorators, destinations | NestJS 11, pino, rxjs, reflect-metadata | | Shared | @bymax-one/nest-logger/shared | Types, constants, the log-key regex — LogLevel, LogEntry, ServiceMetadata, ResolvedServiceMetadata, EmittedServiceResource, EmittedDeploymentResource | None |

shared (zero deps)
     ↑
  server

The /shared subpath is safe to import in isomorphic code, test helpers, CLI scripts, or shared packages that must not pull in NestJS.


[!TIP] Prefer to learn from a working app? See the nest-logger-example — a full NestJS project wired with this library.

🚀 Quick Start

1. Install

# Using pnpm (recommended)
pnpm add @bymax-one/nest-logger

# Using npm
npm install @bymax-one/nest-logger

# Using yarn
yarn add @bymax-one/nest-logger

[!IMPORTANT] You must also install the required peer dependencies. The library ships "dependencies": {}, so nothing arrives implicitly:

# Server subpath (required)
pnpm add @nestjs/common @nestjs/core pino reflect-metadata rxjs

# Optional — pretty local output via PrettyDevDestination
pnpm add -D pino-pretty

# Optional — OpenTelemetry trace correlation
pnpm add @opentelemetry/api @opentelemetry/sdk-node

⚠️ Under pnpm, -D may not make it absent in production — measure, do not assume. Two consumers who had assumed otherwise measured the peer present in their real production images, by two different build routes: pnpm prune --prod keeps the store entry, and a clean pnpm install --prod --frozen-lockfile in a fresh stage installs it too. In both, the peer was recorded in the lockfile. PrettyDevDestination imports it lazily, relative to the library's own directory under .pnpm/, where it is a sibling — so it resolves.

That is two measurements, not a law of pnpm: whether a production install always carries an optional peer, however the lockfile was produced, has not been tested here. The advice below does not depend on it.

The clean-install case is worth stating separately, because "we do a fresh prod install, not a prune" reads like an exemption and is not one. It is what one of those consumers concluded before measuring their own image.

On other package managers this is different, and the difference cuts the other way: under npm's or yarn's flat node_modules, a pruned devDependency really is gone. If you install with those, do not read this warning as universal.

Either way, the rule that survives both layouts: "it is a devDependency, therefore this path cannot run in production" is not a safe premise, because the premise is about someone else's install command. A PrettyDevDestination left registered in production will not crash and will not warn — it renders ANSI colour into a log pipeline that has silently failed to parse every line since the deploy, which is indistinguishable from a service that went quiet. Gate it on an explicit configuration value you can see, not on the packaging.

[!NOTE] @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.

[!NOTE] The published declarations depend on no HTTP framework's types. The HTTP interceptor, the exception filter and the request-id middleware are typed by structural contracts (LoggableRequest, LoggableResponse, NextHandler) that an Express request and response satisfy as-is, so nothing extra is needed even when you compile with skipLibCheck: false. The emitted .d.ts imports only @nestjs/common, pino and rxjs.

2. Register the module

// app.module.ts
import { Module } from '@nestjs/common'
import { BymaxLoggerModule } from '@bymax-one/nest-logger'

@Module({
  imports: [
    BymaxLoggerModule.forRoot({
      service: { name: 'my-app', version: '1.0.0' },
      level: 'info',
      http: { isEnabled: true }
    })
  ]
})
export class AppModule {}

Stdout is wired automatically — DefaultStdoutDestination is always active, so this is all you need to be logging structured JSON.

3. Async configuration with ConfigService

// app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { BymaxLoggerModule } from '@bymax-one/nest-logger'

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    BymaxLoggerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) => ({
        service: {
          name: cfg.getOrThrow<string>('OTEL_SERVICE_NAME'),
          version: cfg.getOrThrow<string>('RELEASE_SHA')
        },
        level: cfg.get('LOG_LEVEL') ?? 'info',
        http: { isEnabled: true }
      })
    })
  ]
})
export class AppModule {}

4. Inject the logger in a service

// payments.service.ts
import { Injectable } from '@nestjs/common'
import { InjectLogger, PinoLoggerService } from '@bymax-one/nest-logger'

@Injectable()
export class PaymentsService {
  constructor(
    @InjectLogger(PaymentsService.name)
    private readonly logger: PinoLoggerService
  ) {}

  async refund(paymentId: string, amount: number, requestedBy: string) {
    this.logger.info('PAYMENT_REFUND_REQUESTED', 'Refund requested', requestedBy, {
      paymentId,
      amount
    })

    try {
      const result = await this.stripe.refunds.create({ payment_intent: paymentId, amount })
      this.logger.info('PAYMENT_REFUND_SUCCESS', 'Refund processed', requestedBy, {
        paymentId,
        stripeRefundId: result.id
      })
      return result
    } catch (err) {
      this.logger.errorStructured(
        'PAYMENT_REFUND_FAILED',
        err instanceof Error ? err : new Error(String(err)),
        requestedBy,
        { paymentId, amount }
      )
      throw err
    }
  }
}

Output (JSON, production):

{
  "level": "info",
  "time": "2026-05-28T10:12:44.512Z",
  "service": { "name": "my-app", "version": "abc123" },
  "logKey": "PAYMENT_REFUND_SUCCESS",
  "msg": "Refund processed",
  "context": "PaymentsService",
  "requestId": "r_7f3a9b",
  "tenantId": "t_acme",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "paymentId": "pi_xyz",
  "stripeRefundId": "re_abc"
}

Output (pretty-print, development):

[10:12:44.512] INFO (my-app): PAYMENT_REFUND_SUCCESS
    requestId: "r_7f3a9b"
    tenantId: "t_acme"
    paymentId: "pi_xyz"
    stripeRefundId: "re_abc"

5. HTTP logging (automatic)

Enable http.isEnabled: true in the module options and wire the access log — applyAccessLog(app) in main.ts (recommended, see Requests rejected before routing) or applyRequestIdMiddleware(consumer) (see Context propagation). The access log is recorded from middleware, which emits:

| Log key | When | | --------------------------- | --------------------------------------------------- | | HTTP_REQUEST_START | Request received | | HTTP_REQUEST_SUCCESS | 2xx response | | HTTP_REQUEST_REDIRECT | 3xx response | | HTTP_REQUEST_CLIENT_ERROR | 4xx response | | HTTP_REQUEST_SERVER_ERROR | 5xx response | | HTTP_REQUEST_ABORTED | Connection closed before the response was delivered |

HTTP_REQUEST_START is emitted before guards, so it carries no userId — authentication runs in a guard, and at that point there is no principal yet. The acting user is on the terminal entry, where the guard has populated it. Both entries carry the same requestId, so joining on that gives the whole request. | HTTP_EXCEPTION_HANDLED | HttpException caught by the filter | | HTTP_EXCEPTION_UNHANDLED | Unexpected error caught by the filter |

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.

[!IMPORTANT] Why middleware and not an interceptor. NestJS runs middleware → guards → interceptors → handler, so an interceptor never observes a request a guard rejected, and never observes one that matched no route. Measured against a real backend: 401, 403, 429 from a throttler and 404 for an unknown path produced no log line at all — not even HTTP_REQUEST_START — so brute force, credential stuffing and route enumeration were invisible, and invisible without a requestId to correlate them by. The access log therefore runs before guards.

A consumer who does not wire the middleware keeps the previous interceptor-based behaviour rather than losing HTTP logs — including its blind spot.

Requests rejected before routing

Middleware wired through configure(consumer) is not early enough for every request. NestJS registers the body parser one line before it registers module middleware:

// @nestjs/core/nest-application.js
useBodyParser && this.registerParserMiddleware()
await this.registerModules() // everything from configure(consumer)

Express dispatches in registration order, so when the parser rejects a body it calls next(err) and every remaining non-error handler is skipped — module middleware, guards, interceptors and the route handler all never run. A POST carrying truncated JSON therefore produces no access log at all, and the same hole covers a payload over the body limit and an unsupported content type: exactly the requests a client got wrong or an attacker shaped.

What you get today depends on your wiring, and neither answer is good:

| Wiring | A body the parser rejects produces | | ------------------------------------------------------ | --------------------------------------------------------- | | forRoot with shouldCaptureExceptions (the default) | one HTTP_EXCEPTION_HANDLED line, no requestId | | forRootAsync, or shouldCaptureExceptions: false | nothing at all |

The first row is the one worth reading twice. The line exists, so a dashboard counting entries looks healthy — but the correlation scope is mounted behind the parser too, so it never opened: the client can send x-request-id: measure-me and the entry carries no requestId to join it to. A 400 you cannot trace to the caller is the same defect as a missing 400 wearing a healthier face.

applyAccessLog(app) closes both rows. INestApplication.use() delegates straight to the HTTP adapter and init() (where the parser is registered) does not run until listen(), so mounting from main.ts lands ahead of the parser — the same reason helmet and cookie-parser are mounted this way:

// main.ts
import { NestFactory } from '@nestjs/core'
import { BymaxLoggerModule, applyAccessLog } from '@bymax-one/nest-logger'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true })
  BymaxLoggerModule.useNestLogger(app)

  // BEFORE the app initializes. init() is where the parser is mounted, and
  // listen() only triggers init() when you have not called it yourself — so a
  // serverless entry point or a test that does `await app.init()` first must
  // mount before THAT, not merely before listen().
  applyAccessLog(app)

  await app.listen(3000)
}

void bootstrap()

The access log needs nothing else to work there — it emits from the response's 'close' event, reading statusCode and writableFinished, so it never depends on a route matching, a handler running or a filter catching anything. One line in, one line out, for the parser rejection, the unmatched-route 404, the guard rejection and the aborted connection alike.

applyRequestIdMiddleware(consumer) remains supported and unchanged; it is the right choice when correlation should be scoped to a route subtree rather than the whole application. Wiring both is safe — the correlation middleware adopts an id already in scope instead of minting a second one, and the access log stands down when it finds the request's lifecycle already claimed, so nothing is minted twice and nothing is logged twice.

Delivery is reported separately from status. HTTP_REQUEST_ABORTED is emitted when the connection closed before the response was flushed, and it keeps the real status the server produced rather than inventing one: nginx's 499 is not an HTTP status (IANA leaves 452–499 unassigned), so recording it would assert a code the protocol has no name for and break any consumer grouping by class.

Its limit is worth stating: writableFinished distinguishes a response still in flight from one handed to the operating system. A fast handler whose client hangs up after the bytes were flushed is reported as the success it was — the server completed and flushed it, and whether the peer read it is not knowable from the server side. What this catches is the case that matters operationally: the slow upstream, the load-balancer timeout, the cancelled request.

6. OpenTelemetry correlation

Initialize the OTel SDK before importing NestJS — this is critical:

// main.ts (top of file — before any NestJS import)
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'my-app',
    [ATTR_SERVICE_VERSION]: process.env.RELEASE_SHA ?? 'dev',
    'deployment.environment': process.env.NODE_ENV ?? 'development'
  }),
  traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_TRACE_ENDPOINT }),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { isEnabled: false } // noisy
    })
  ]
})

sdk.start()

process.on('SIGTERM', () => {
  void sdk.shutdown().finally(() => process.exit(0))
})

// NestJS imports come AFTER sdk.start()
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true })
  await app.listen(3000)
}

void bootstrap()

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.

Without OpenTelemetry installed, everything above simply does not happen — no error, no warning on every log, no crash. The peer is optional and the logger is fully usable on its own. The one case that is reported is a misconfiguration: if trace injection is enabled and @opentelemetry/api cannot be resolved, a single LOGGER_BOOTSTRAP_WARNING naming OTEL_API_UNAVAILABLE is emitted at startup. Absence of traceId would otherwise be indistinguishable from "no active span".

The logger observes the trace context; it never creates spans, installs a context manager, or parses traceparent by hand. When the API is present but no span is active, no identifiers are emitted — none are invented.

[!NOTE] Resolution of the optional peer is anchored at the library's own module path, falling back to process.cwd(). Anchoring only at the working directory — as versions before this did — silently disabled correlation whenever the process was launched from somewhere other than the application root: a Docker WORKDIR, a pnpm/Yarn workspace with hoisted node_modules, a monorepo started at the repository root, a serverless bundle.

6b. Resource identity

Every entry carries the service identity, using attributes that are Stable in Semantic Conventions v1.44.0:

BymaxLoggerModule.forRoot({
  service: {
    name: 'checkout-api', // service.name
    version: '2.14.3', // service.version
    namespace: 'payments', // service.namespace
    instanceId: process.env.POD_UID, // service.instance.id
    environment: 'production' // deployment.environment.name
  }
})
{
  "level": "info",
  "time": "2026-08-13T10:00:00.000Z",
  "service": {
    "name": "checkout-api",
    "version": "2.14.3",
    "namespace": "payments",
    "instance": { "id": "pod-7f3a" }
  },
  "deployment": { "environment": { "name": "production" } },
  "logKey": "PAYMENT_FAILED",
  "event.name": "payment.failed",
  "msg": "Payment failed"
}

Set resourceFormat: 'flat' to emit the dotted attribute names verbatim ("service.instance.id": "pod-7f3a"), which is what a collector mapping log fields onto resource attributes reads directly.

Precedence

Resolved once at startup, in this order:

  1. explicit service options
  2. OTEL_SERVICE_NAME (name only)
  3. OTEL_RESOURCE_ATTRIBUTES (service.namespace=payments,service.version=2.14.3,…)
  4. NODE_ENV (environment only)

Explicit configuration wins because it is the most specific statement you can make. The order of the two OpenTelemetry variables is required by the specification, not chosen: "If service.name is also provided in OTEL_RESOURCE_ATTRIBUTES, then OTEL_SERVICE_NAME takes precedence."

Reading the same variables the SDK reads is what makes logs and traces agree without the logger depending on the SDK. Configure the environment once and both signals describe the same service.

[!IMPORTANT] service.instance.id is never generated. The specification requires the triplet (service.namespace, service.name, service.instance.id) to be globally unique and recommends a random UUID — but a UUID minted by the logger would be the logger's, not the OpenTelemetry Resource's, so logs and traces would claim different instances of the same process. It would also change on every restart while looking authoritative. Supply it from the platform (Kubernetes pod UID, ECS task ARN, VM instance id) or through OTEL_RESOURCE_ATTRIBUTES. Omitted is honest; plausible-but-wrong is not.

There is no OTEL_SERVICE_VERSION variable in the specification. Version comes from configuration or from OTEL_RESOURCE_ATTRIBUTES.

The deprecated deployment.environment spelling is never emitted, in any mode.

6c. Event names

Structured entries carry a machine-readable event name derived from logKey, following OTel naming rules — lowercase, dot-namespaced:

| logKey | event.name | | ---------------------------- | ---------------------------- | | PAYMENT_FAILED | payment.failed | | USER_AUTHENTICATION_FAILED | user.authentication.failed |

logKey is never renamed or removed — this is additive. Calls through the NestJS variadic bridge (logger.log, logger.warn, …) carry no log key and correctly get no event name: an ordinary diagnostic line is not an Event.

Keep event names low cardinality. payment.failed is an event; payment.failed.918231781 is an identifier wearing an event's clothes and will multiply your series count. Identifiers belong in their own fields.

[!NOTE] The value is meant to be mapped onto the EventName field of the OpenTelemetry LogRecord, which is Stable in the Logs Data Model. The same-named event.name attribute is Deprecated precisely because the value belongs in that top-level field instead. A JSON log line has no way to express that distinction — every key is just a key — so the carrier key is configurable via eventNameField, and your collector decides where it lands. What it must not do is carry it into an OTLP attributes map under the deprecated name.

Set eventNameField: false to emit nothing.

6d. Error fields

errorFormat controls the shape:

// errorFormat: 'pino'  (default — unchanged from 1.2.0)
{ "err": { "type": "PaymentDeclined", "message": "card declined", "stack": "…",
           "cause": { "name": "Error", "message": "gateway timeout" } } }

// errorFormat: 'both'  (recommended while migrating)
{ "err": { "type": "PaymentDeclined", … },
  "exception.type": "PaymentDeclined",
  "exception.message": "card declined",
  "exception.stacktrace": "…",
  "error.type": "PaymentDeclined" }

// errorFormat: 'semconv'  (explicit migration — err is removed)
{ "exception.type": "PaymentDeclined", "exception.message": "card declined",
  "exception.stacktrace": "…", "error.type": "PaymentDeclined" }

All four attributes are Stable. error.type carries the class name and is low cardinality by construction — never a message, never an identifier — because the spec requires it to be aggregatable.

Error.cause chains are preserved, depth- and width-bounded, circular-safe, and redacted like any other field. AggregateError members reach the entry as err.errors. There is no OpenTelemetry attribute for a cause chain, so these stay namespaced under err rather than inventing an exception.cause the spec does not define.

An error's own enumerable properties travel with it at every depthcode on a Node system error, statusCode from an HTTP layer, whatever domain fields you attached — including on a nested cause and on each AggregateError member:

{
  "err": {
    "type": "Error",
    "message": "bootstrap failed",
    "code": "EBOOT",
    "cause": {
      "name": "Error",
      "message": "config invalid",
      "code": "BYMAX_CONFIG_VALIDATION",
      "issues": [{ "variable": "DATABASE_URL", "code": "invalid_url" }]
    }
  }
}

This matters because code is usually the field you alert on. Until 1.2.7 those properties survived only on the error handed to the log call and were dropped the moment the same error was wrapped as someone else's cause — which is what any handler that wraps what it caught does before logging it. They never shadow a field the serializer derives, and they pass through the same name-based redaction and size bound as any other serializer output.

An error's own enumerable properties (code, statusCode, domain fields) are carried through, as Pino's standard serializer did.

7. Context propagation with LogContextService

// request-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common'
import {
  LogContextService,
  type LoggableRequest,
  type LoggableResponse
} from '@bymax-one/nest-logger'
import { randomUUID } from 'node:crypto'

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  constructor(private readonly logContext: LogContextService) {}

  use(req: LoggableRequest, res: LoggableResponse, next: () => void) {
    // A header can arrive repeated, so its type is `string | string[]`.
    const raw = req.headers['x-request-id']
    const requestId = (Array.isArray(raw) ? raw[0] : raw) ?? `r_${randomUUID()}`
    this.logContext.run({ requestId }, next)
  }
}

Any log emitted inside the run() scope — regardless of nesting depth — automatically includes requestId with no prop drilling.


⚙️ Configuration

Full options reference for BymaxLoggerModule.forRoot(options):

Top-level options

| Option | Type | Default | Description | | ---------------------------- | ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | service.name | string | Required | Service name emitted in every log entry | | service.version | string | Required | Release version/SHA emitted in every log entry | | service.namespace | string | — | OTel service.namespace (Stable) — the group the service belongs to | | service.instanceId | string | — | OTel service.instance.id (Stable). Never generated — supply it from the platform. See Resource identity | | service.environment | string | NODE_ENV | OTel deployment.environment.name (Stable). The deprecated deployment.environment is never emitted | | resourceFormat | 'nested' \| 'flat' | 'nested' | Shape of the identity fields. 'flat' emits the dotted attribute names verbatim | | eventNameField | string \| false | 'event.name' | Field carrying the derived event name (PAYMENT_FAILEDpayment.failed). false disables it | | errorFormat | 'pino' \| 'semconv' \| 'both' | 'pino' | 'both' adds exception.* and error.type beside err.*; 'semconv' replaces them | | level | LogLevel | 'info' | Minimum log level. One of fatal \| error \| warn \| info \| debug \| trace | | redactPaths | string[] | [] | Additional fast-redact paths, applied on top of the default coverage | | redactStrategy | 'names' \| 'paths' | 'names' | Engine behind the DEFAULT set. 'paths' restores the pre-1.2 fast-redact expansion (four-level ceiling, ~100× slower) | | shouldDisableDefaultRedact | boolean | false | Skip the default PII coverage entirely. ⚠️ Emits LOGGER_BOOTSTRAP_WARNING at startup — document why | | redactCensor | string | '[REDACTED]' | Replacement value written in place of every redacted field | | maxEntrySizeBytes | number | 65536 | UTF-8 byte ceiling per serialized field (err + any custom serializer); over it the value becomes a truncation envelope | | destinations | ILogDestination[] | [new DefaultStdoutDestination()] | The sinks entries are written to. ⚠️ A non-empty list replaces that default — see Destinations |

http options

| Option | Type | Default | Description | | ------------------------------ | ---------- | ------------------------------- | ------------------------------------------------------------------------------------- | | http.isEnabled | boolean | false | Register HttpLoggingInterceptor (and, on forRoot, HttpExceptionFilter) globally | | http.shouldCaptureExceptions | boolean | true | Capture unhandled HTTP exceptions and emit HTTP_EXCEPTION_UNHANDLED | | http.shouldGenerateRequestId | boolean | true | Generate a requestId when the inbound request header is absent | | http.excludePaths | RegExp[] | [/^\/health$/, /^\/metrics$/] | Paths that bypass HTTP logging. Use anchored, linear-time regexes (ReDoS-safe) | | http.tenantIdHeader | string | 'x-tenant-id' | Request header carrying the tenant identifier |

otel options

| Option | Type | Default | Description | | ----------------------------------- | ----------------------------- | ------------- | ------------------------------------------------------------------------ | | otel.shouldAutoInjectTraceContext | boolean | true | Detect @opentelemetry/api and inject traceId/spanId via Pino mixin | | otel.fieldFormat | 'camelCase' \| 'snake_case' | 'camelCase' | Field names in log entries: traceId/spanId vs trace_id/span_id |


🔑 Log Key Convention

All structured log calls must use the MODULE_ACTION_RESULT format:

USER_LOGIN_SUCCESS         AUTH_REGISTER_FAILED
PAYMENT_REFUND_PROCESSED   WEBHOOK_STRIPE_RECEIVED
HTTP_REQUEST_CLIENT_ERROR  METHOD_SLOW_EXECUTION

The regex is exported from the /shared entry for CI validation:

import { LOG_KEYS_CONVENTION_REGEX } from '@bymax-one/nest-logger/shared'

function assertValidLogKey(key: string) {
  if (!LOG_KEYS_CONVENTION_REGEX.test(key)) {
    throw new Error(`Invalid log key: "${key}". Expected MODULE_ACTION_RESULT format.`)
  }
}

Reserved keys

The following keys are used internally by the library — do not reuse them in application code:

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

All reserved keys are exported as the RESERVED_LOG_KEYS constant from @bymax-one/nest-logger/shared.

HTTP_REQUEST_COMPLETED is reserved but deliberately never emitted — the four status-specific terminal keys already carry the same duration, so a generic "completed" entry would double the access-log volume to say nothing new. The reserved-but-unwritten set is exported as RESERVED_LOG_KEYS_NOT_EMITTED, and a test asserts that every OTHER declared key has a writer in the source, so a key can no longer be documented as a signal and then silently never emitted.


🧩 Custom Destinations

Implement ILogDestination to ship logs to any sink:

import type { ILogDestination } from '@bymax-one/nest-logger'
import type { LogEntry } from '@bymax-one/nest-logger/shared'

export class LokiDestination implements ILogDestination {
  readonly name = 'loki'
  readonly minLevel = 'info' as const

  private readonly url: string
  private readonly headers: Record<string, string>
  private readonly labels: Record<string, string>
  // Serialized lines, not objects — `write` receives the payload already encoded.
  private buffer: string[] = []
  private flushTimer?: NodeJS.Timeout

  constructor(opts: {
    url: string
    username: string
    password: string
    labels: Record<string, string>
  }) {
    this.url = `${opts.url}/loki/api/v1/push`
    this.labels = opts.labels
    const credentials = Buffer.from(`${opts.username}:${opts.password}`).toString('base64')
    this.headers = {
      'Content-Type': 'application/json',
      Authorization: `Basic ${credentials}`
    }
  }

  async onInit(): Promise<void> {
    this.flushTimer = setInterval(() => void this.flush(), 5_000)
  }

  async onShutdown(): Promise<void> {
    clearInterval(this.flushTimer)
    await this.flush()
  }

  // `payload` already IS the serialized entry, newline-terminated. Buffer it as
  // it arrives; parse only where a field is genuinely needed, as `flush` does.
  write(payload: string): void {
    this.buffer.push(payload)
    if (this.buffer.length >= 100) void this.flush()
  }

  private async flush(): Promise<void> {
    if (this.buffer.length === 0) return
    const batch = this.buffer.splice(0)
    const body = JSON.stringify({
      streams: [
        {
          stream: this.labels,
          values: batch.map((line) => [
            // `time` is an ISO 8601 string — parse it to epoch ms, then scale to
            // the nanoseconds Loki expects. `BigInt(entry.time)` throws on an ISO string.
            String(BigInt(Date.parse((JSON.parse(line) as LogEntry).time)) * 1_000_000n),
            line.trimEnd()
          ])
        }
      ]
    })
    await fetch(this.url, { method: 'POST', headers: this.headers, body })
  }
}

Then pass it via destinations:

BymaxLoggerModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (cfg: ConfigService) => ({
    service: { name: cfg.getOrThrow('OTEL_SERVICE_NAME'), version: cfg.getOrThrow('RELEASE_SHA') },
    level: 'info',
    destinations: [
      new LokiDestination({
        url: cfg.getOrThrow('LOKI_URL'),
        username: cfg.getOrThrow('LOKI_USER'),
        password: cfg.getOrThrow('LOKI_PASSWORD'),
        labels: { service: cfg.getOrThrow('OTEL_SERVICE_NAME') }
      })
    ]
  })
})

Destinations replace stdout

A non-empty destinations replaces DefaultStdoutDestination — it does not add to it. That is deliberate: a file-only or socket-only deployment has to be able to turn structured stdout off. To keep stdout alongside a custom sink, list it explicitly:

destinations: [new DefaultStdoutDestination(), new LokiDestination({ ... })]

The consequence worth knowing: a sink you supply may be the only one the application has, so its failure is the application's silence. Two guarantees cover that, and neither requires anything from you:

  • A destination that fails onInit is reported as LOGGER_DESTINATION_INIT_FAILED on stderr — not through the logger, whose sinks are the ones that just failed — and receives no entries.
  • If every destination fails to initialize, entries fall back to raw NDJSON on stdout. Degraded and ugly, but visible; nothing is lost, including the bootstrap entries.

So the common accident — adding new PrettyDevDestination() without installing the optional pino-pretty — costs you colours and a line on stderr telling you why, never your logs.

Choosing how the dev terminal renders

PrettyDevDestination takes a view. Every field defaults to what it rendered before, so new PrettyDevDestination() is unchanged — the options exist because the default view is deliberately verbose, and one entry can be seven lines.

// One line per entry, with the fields your project repeats on every line hidden.
new PrettyDevDestination({
  view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event\\.name' }
})

// Message only, with the context pulled back into the line.
// See the messageFormat warning below before interpolating any other field.
new PrettyDevDestination({
  view: { hideObject: true, messageFormat: '[{context}] {msg}' }
})

| Field | Default | Notes | | --------------- | ------------------------ | ---------------------------------------------------------------------------- | | singleLine | false | The single biggest change to how a terminal reads | | ignore | 'pid,hostname,service' | Display-only — what a real sink receives is untouched | | hideObject | false | Hides the record entirely; see the caveat below | | messageFormat | — | e.g. '[{context}] {msg}'; how to keep one field visible under hideObject | | translateTime | 'SYS:HH:MM:ss.l' | Or false for the raw timestamp | | colorize | true | ANSI colour |

hideObject hides it from THIS destination. Any other destination you registered still receives the complete entry — the option is display-only and scoped to the pretty renderer. When pretty is your only destination that scope becomes total: destinations replaces stdout, so there is no JSON copy behind the rendering and a hidden field, logKey included, is not visible anywhere. That is what the option is for; messageFormat is how you pull a specific field back.

A dot in ignore means "nested path", so a field name that CONTAINS a dot must escape it. This library emits event.name as a literal top-level key, so the obvious spelling hides nothing at all — pino-pretty looks for an event object with a name inside, finds neither, and says nothing. Measured on one entry with only the option changing:

ignore: '…,event.name'    → INFO: msg {"logKey":"X","event.name":"x"}   ← still there
ignore: '…,event\\.name'  → INFO: msg {"logKey":"X"}                    ← hidden

In a TypeScript string literal that is 'event\\.name' — one real backslash reaching pino-pretty. service and deployment need no escape; they really are nested objects. Reported by a consumer who copied the example above when it still had the unescaped form.

The shape is exported as PrettyViewOptions when you want to build the view separately — it is this library's own interface, not a re-export of pino-pretty's PrettyOptions, so type-checking without the optional peer installed still resolves.

messageFormat interpolates every placeholder except {msg} raw — that can forge a terminal entry. This library normalizes line separators and control characters in msg and in the stack, never in metadata, because escaping data would mean rewriting what you asked to be logged. pino-pretty substitutes whatever the field holds, so a newline in an interpolated field splits one entry into two and the second reads like a genuine record. Measured, with a newline in context:

lines produced by ONE entry: 2
  1: "[10:35:14.484] INFO: [Auth"
  2: "[10:00:00.000] INFO: FORGED admin promoted] real entry …"

Interpolate only fields your own code sets. Never one carrying user input — {tenantId}, {userId}, a header, a query value. If you need one of those visible, leave it in the record instead. That closes this path and no more: a metadata value is not safe terminal text either, since JSON.stringify escapes only C0 and emits DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 verbatim — see One entry, one line below, where the same boundary is stated. Only msg and the stack carry that guarantee.

destination is not exposed, by design. The library owns where entries go — a redirected stream would route around the fan-out and the last-resort rescue above. It is absent from the type and applied after your options are merged, so it cannot be overridden from untyped JavaScript either.

The first entries of a boot are held, not lost. The transform cannot exist until onInit — loading the optional peer is async — so everything NestJS emits while instantiating providers arrives before it. Those entries are buffered and then rendered through the transform in arrival order. If the peer is missing, the bound is reached, or the app shuts down before init, they are written as raw NDJSON instead: degraded, never dropped.

Postgres destination (Prisma)

import { PINO_LEVEL_NUMBERS } from '@bymax-one/nest-logger'
import type { ILogDestination } from '@bymax-one/nest-logger'
import type { LogEntry } from '@bymax-one/nest-logger/shared'
import type { PrismaClient } from '@prisma/client'

export class PrismaLogDestination implements ILogDestination {
  readonly name = 'prisma-postgres'
  readonly minLevel = 'warn' as const // only persist warnings and above

  constructor(private readonly prisma: PrismaClient) {}

  write(payload: string): void {
    // This destination stores individual columns, so it is one of the few that
    // has to parse. A sink that forwards the line verbatim should not.
    const entry = JSON.parse(payload) as LogEntry
    void this.prisma.applicationLog.create({
      data: {
        // `entry.level` is the Pino LABEL ('info'). Map it when the column is numeric.
        level: PINO_LEVEL_NUMBERS[entry.level],
        logKey: entry.logKey,
        message: entry.msg,
        payload: entry,
        createdAt: new Date(entry.time)
      }
    })
  }
}

Rolling file destination (pino-roll)

import { createStream } from 'pino-roll'
import type { ILogDestination } from '@bymax-one/nest-logger'
import type { LogEntry } from '@bymax-one/nest-logger/shared'

export class RollingFileDestination implements ILogDestination {
  readonly name = 'rolling-file'
  private stream?: Awaited<ReturnType<typeof createStream>>

  async onInit(): Promise<void> {
    this.stream = await createStream('logs/app.log', {
      frequency: 'daily',
      mkdir: true,
      size: '50m'
    })
  }

  async onShutdown(): Promise<void> {
    await new Promise<void>((resolve) => this.stream?.end(resolve))
  }

  write(payload: string): void {
    // Already newline-terminated JSON — re-serializing would double-encode it.
    this.stream?.write(payload)
  }
}

🏗️ Architecture

HTTP Request
    │
    ▼
RequestIdMiddleware             ← runs FIRST (NestJS middleware precedes
    │                              guards AND interceptors) and opens the
    │                              AsyncLocalStorage scope
    │                              { requestId, tenantId, userId }
    ▼
HttpAccessLogMiddleware         ← emits HTTP_REQUEST_START inside that scope, and
    │                              arms the terminal entry on the response's
    │                              'close' event. BEFORE guards, so a 401/403/429
    │                              rejection is logged too.
    ▼
Guards (consumer's auth)        ← may reject here; no interceptor ever runs
    │
    ▼
HttpLoggingInterceptor          ← records the thrown error for the terminal entry
    │
    ▼
Application Service
    │
    └── PinoLoggerService.info(logKey, msg, context, metadata)
              │
              ▼
         Pino logger
              │
         composedMixin()         ← runs per-log (O(1))
              ├── ALS store      → { requestId, tenantId, userId }
              └── OTel span      → { traceId, spanId, traceFlags }
              │
              ▼
         name redactor           ← one recursive walk, any depth
              │
              ▼
    ┌─────────────────────────────┐
    │  DefaultStdoutDestination   │  ← always active
    │  LokiDestination            │  ← optional
    │  PrismaLogDestination       │  ← optional
    │  RollingFileDestination     │  ← optional
    └─────────────────────────────┘

Design Principles

| Principle | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 🪶 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 | | 🧬 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 | | ⚡ 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 | | 🔌 Interface-Driven Sinks | ILogDestination is a contract — Loki, Postgres, rolling files, or anything else is a consumer implementation, never a dependency of this package | | 🌳 Zero Runtime Deps | "dependencies": {} — every package arrives as a peer dependency, so consumers pin exact versions and the supply-chain surface stays theirs |


🔐 Security Model

A logger sees every payload the application handles, so the security posture is about what never reaches the sink — and about a sink failure never reaching the application.

Redaction by default

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:

| Category | Fields | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | HTTP auth headers | authorization, cookie, set-cookie, x-api-key, x-auth-token | | Passwords | password, passwordHash, passwordConfirm, newPassword, oldPassword | | Tokens | token, accessToken, refreshToken, idToken, apiKey, apiSecret | | MFA | mfaSecret, mfaRecoveryCodes, totpSecret | | Generic secrets | secret, clientSecret, signingSecret, privateKey | | Payment / PCI DSS | cardNumber, cardCvv, cvv, cvc, cardExpiry | | Personal documents (LGPD) | cpf, cnpj, rg | | Conservative PII | email | | 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 |

How it works, and why it changed in 1.2.0

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.

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.

[!WARNING] Redaction matches key names. A secret placed under a name you have not declared sensitive is emitted — logger.log(key, msg, userId, { renamed: user.password }) writes it in clear, and so does a toJSON that renames nested state. That is a property of name-based redaction, not a defect: no name matcher can follow a value through a rename. Declare the name, or keep the value out of the log.

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:

| | depth-1–4 paths (pre-1.2.0) | name walk (1.2.0) | | -------------------------------- | -------------------------------------------: | ----------------------------------: | | Throughput, full production path | 9,311 logs/s | 274,227 logs/s | | Cost per entry | ~107 µs | ~3.6 µs | | Nesting covered | 4 levels — deeper leaked | 100 levels — deeper objects dropped | | { headers: { authorization } } | leaked (only req.headers.* was pinned) | censored |

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.

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`.

One entry, one line. What the message string cannot do is forge an entry. Every message argument passes through a line-separator normalization before it reaches Pino, so \r, \n, U+2028, U+2029 and C1 NEL (U+0085) become the literal two-character sequence \n — and every other control character that can drive a terminal (ESC, vertical tab, form feed, backspace, the rest of C0 except TAB, DEL and the C1 range) becomes its readable \uXXXX escape. This protects two sinks, not one. pino-pretty (shipped here as PrettyDevDestination) and any destination that re-renders the parsed message write those bytes straight to the terminal, where a raw newline or an ESC E produces something indistinguishable from a separate log entry. And the raw NDJSON line is exposed too when a human reads it in a terminal: JSON escaping covers only C0, so JSON.stringify and Pino's serializer emit DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 verbatim — measured on real bytes. Escaping at the sink neutralizes both paths. The scrubbed stack gets the same escaping (its newlines are kept, since a stack is multi-line by design), because pino-pretty prints it raw and its first line repeats the error message. Structured fields are untouched: err.message keeps the original text verbatim. That is the boundary — a control character placed in a metadata value still reaches a terminal, because escaping data would mean rewriting what you asked to be logged. Only msg and the stack carry this guarantee.

Extending the defaults

BymaxLoggerModule.forRoot({
  service: { name: 'my-app', version: '1.0.0' },
  redactPaths: [
    '*.internalSecret', // depth-1 wildcard
    'body.creditCard.*', // all fields inside a subobject
    'payload.user.taxId' // exact path
  ]
})

The extra paths are merged with the defaults — never replacing them.

[!IMPORTANT] A consumer path's LEAF NAME is also fed to the name walk, so redactPaths: ['user.ssn'] censors ssn wherever it appears, not only under user. That is broader than the path you wrote, deliberately: consumer paths are applied by Pino's stringifier, which runs after the per-field size bound, so without this a field covered only by a path could still surface inside a truncation envelope's _preview. It errs toward redacting a name you have already declared secret.

An array index is not a name, so an unquoted numeric segment is skipped and the path falls back to the nearest name: redactPaths: ['tokens[0]'] censors the whole tokens array. The walk matches key names and never array positions, so feeding it 0 would cover nothing while censoring any object key literally named 0. The quoted form ['obj["0"]'] stays a name, since an object key named 0 IS matched by the walk.

Disabling defaults (not recommended)