Skip to content
NestJS ns config 4 min read

Structured Logging with Pino

NestJS ships with a perfectly serviceable text logger, but production systems need machine-readable logs that flow into Elasticsearch, Loki, or Datadog without fragile regex parsing. Pino is one of the fastest Node.js loggers available, emitting newline-delimited JSON with minimal overhead. The nestjs-pino package wires Pino into the Nest lifecycle so that every log line is structured, every request carries a correlation ID, and secrets are redacted before they ever touch disk.

Why structured logging matters

Plain text logs are easy to read on your laptop and miserable to query at scale. When an incident hits at 3 a.m., you want to filter by requestId, userId, or statusCode—not grep through gigabytes of free-form strings. Structured logging treats every log entry as a typed object, so your aggregation layer can index, filter, and alert on individual fields.

ConcernDefault Nest loggernestjs-pino
Output formatColorized textNDJSON (one object per line)
ThroughputSynchronousAsynchronous, very high
Request contextManualAutomatic per-request child logger
Correlation IDsNoneBuilt in via genReqId
Secret redactionNoneDeclarative redact paths

Installation

Install Pino, the Nest adapter, and the optional pretty-printer for local development.

npm install nestjs-pino pino pino-http
npm install --save-dev pino-pretty

Wiring up the module

Register LoggerModule once at the application root. The pino-http middleware it installs creates a child logger for every incoming request and attaches it to the request object, so any log written during that request automatically inherits the request context.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { IncomingMessage } from 'node:http';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    LoggerModule.forRoot({
      pino: {
        level: process.env.LOG_LEVEL ?? 'info',
        // Pretty output in dev, raw NDJSON in production.
        transport:
          process.env.NODE_ENV !== 'production'
            ? { target: 'pino-pretty', options: { singleLine: true } }
            : undefined,
        // Reuse an inbound correlation id or mint a new one.
        genReqId: (req: IncomingMessage) =>
          (req.headers['x-request-id'] as string) ?? randomUUID(),
        // Strip secrets before anything is serialized.
        redact: {
          paths: [
            'req.headers.authorization',
            'req.headers.cookie',
            'req.body.password',
            'res.headers["set-cookie"]',
          ],
          censor: '[REDACTED]',
        },
        // Keep request logs lean.
        customProps: () => ({ context: 'HTTP' }),
      },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Next, tell Nest to use Pino as the application-wide logger so framework messages (bootstrap, route mapping, exceptions) also flow through it.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';

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

The bufferLogs: true option holds back early log lines until useLogger is wired, so even bootstrap messages are emitted as structured JSON instead of the default text format.

Logging from a service

Inject the nestjs-pino PinoLogger (or the standard Logger) into any provider. Because the logger is request-scoped under the hood, the correlation ID is included automatically—you never have to thread it through manually.

// src/app.service.ts
import { Injectable } from '@nestjs/common';
import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';

@Injectable()
export class AppService {
  constructor(
    @InjectPinoLogger(AppService.name)
    private readonly logger: PinoLogger,
  ) {}

  placeOrder(userId: string, total: number): { ok: true } {
    this.logger.info({ userId, total }, 'order placed');
    return { ok: true };
  }
}
// src/app.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { AppService } from './app.service';

@Controller('orders')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post()
  create(@Body() body: { userId: string; total: number }) {
    return this.appService.placeOrder(body.userId, body.total);
  }
}

A single request now produces correlated, structured lines. Note the shared reqId linking the application log to the auto-generated HTTP completion log.

Output:

{"level":30,"time":1718323200123,"reqId":"3f9c1e2a-...","context":"AppService","userId":"u_42","total":99.5,"msg":"order placed"}
{"level":30,"time":1718323200125,"reqId":"3f9c1e2a-...","context":"HTTP","req":{"method":"POST","url":"/orders","headers":{"authorization":"[REDACTED]"}},"res":{"statusCode":201},"responseTime":4,"msg":"request completed"}

Propagating the correlation ID downstream

To trace a request across microservices, forward the ID on outbound calls. Read it from the request-scoped logger bindings and set it as a header.

import { Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';

@Injectable()
export class PaymentClient {
  constructor(private readonly logger: PinoLogger) {}

  async charge(amount: number): Promise<Response> {
    const { reqId } = this.logger.logger.bindings();
    return fetch('https://payments.internal/charge', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'x-request-id': String(reqId),
      },
      body: JSON.stringify({ amount }),
    });
  }
}

Because the downstream service uses the same genReqId (reusing x-request-id), the entire call chain shares one ID across service boundaries.

Best Practices

  • Emit raw NDJSON in production and reserve pino-pretty for local development—pretty-printing adds overhead and breaks log shippers.
  • Always declare redact paths for authorization, cookie, and any password or token fields; redaction is opt-in, not automatic.
  • Pass structured context as the first argument (logger.info({ userId }, 'msg')) so fields are indexed, rather than interpolating them into the message string.
  • Drive level and NODE_ENV from configuration so you can raise verbosity per environment without redeploying code.
  • Reuse an inbound x-request-id when present so correlation IDs survive across gateways and microservices.
  • Avoid logging large payloads or entire request bodies—log identifiers and sizes instead to keep throughput high and storage costs down.
Last updated June 14, 2026
Was this helpful?