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.
| Concern | Default Nest logger | nestjs-pino |
|---|---|---|
| Output format | Colorized text | NDJSON (one object per line) |
| Throughput | Synchronous | Asynchronous, very high |
| Request context | Manual | Automatic per-request child logger |
| Correlation IDs | None | Built in via genReqId |
| Secret redaction | None | Declarative 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: trueoption holds back early log lines untiluseLoggeris 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-prettyfor local development—pretty-printing adds overhead and breaks log shippers. - Always declare
redactpaths forauthorization,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
levelandNODE_ENVfrom configuration so you can raise verbosity per environment without redeploying code. - Reuse an inbound
x-request-idwhen 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.