Skip to content
ExponaBLOG
NESTJS · DAY 054 / 100

Structured logging with Pino: what developers should know

Fast JSON logs with request context.

Expona Admin7 min read

The logging problem in production

Most NestJS teams start with the default console logger, fire up their application, and watch log lines flow past their terminal. Within weeks of hitting production, they're drowning in unstructured text, searching logs by hand, and unable to correlate which request caused which error. Structured logging with Pino: what developers should know starts with a simple truth: your logs are data, and data should be queryable.

In this article, we'll walk through how to replace NestJS's default logger with Pino, a production-grade JSON logging library that was built for speed and structure. We'll cover request ID propagation, object-based logging, and secret redaction—the three pillars of logging that actually helps you troubleshoot.

Why Pino over the default logger

The default NestJS logger works fine for development, but it has real limits. It outputs plain text, offers no built-in request correlation, and provides no structured fields. When you search logs later, you're searching strings. When you want to correlate a user's API calls across your infrastructure, you're out of luck.

Pino solves this by shipping JSON logs out of the box. Each log entry is an object with fields like level, timestamp, msg, and any custom fields you add. Pino also runs at a fraction of the CPU cost of other loggers—it was designed by engineers who run high-throughput services.

Installing nestjs-pino drops Pino into your NestJS application as a first-class logger. Instead of replacing the logger after boot, the module integrates at NestJS's core so every log line—from your code, from third-party modules, from the framework itself—flows through Pino.

typescript
npm install nestjs-pino pino pino-http

Setup is straightforward. In your main module:

typescript
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        level: 'info',
      },
    }),
  ],
})
export class AppModule {}

That's it. Every HTTP request now gets a unique ID, every log line carries it, and every entry is JSON. Your logs are now queryable.

Request IDs: automatic correlation across your stack

One of Pino's killer features is automatic request ID tracking. When a request arrives, Pino generates a unique ID (or uses one from a header if you're forwarding from a reverse proxy). That ID is attached to every log line that happens during that request's lifetime.

In a microservices setup, this is essential. Your API receives a request, logs a message with ID req-abc123. It calls your database service, which logs with the same ID. It writes to a cache layer, same ID. Later, when a user reports a problem, you search your logs for req-abc123 and see the entire request's journey in one place.

The configuration is minimal. Pino handles it automatically:

typescript
LoggerModule.forRoot({
  pinoHttp: {
    level: 'info',
    transport: {
      target: 'pino-pretty',
      options: {
        colorize: true,
        singleLine: false,
      },
    },
  },
})

When you hit an endpoint, Pino logs the HTTP request with a UUID, then attaches that UUID to every subsequent log in your controller, service, and database layer. If you forward the request ID in your HTTP headers when calling downstream services, those services can use the same ID. Correlation becomes automatic.

To access the request ID in your services, inject Pino's request object:

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

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

  createOrder(userId: string, items: any[]) {
    this.logger.info({ userId, itemCount: items.length }, 'Order creation started');
    // Order logic
    this.logger.info({ userId, orderId: 'ord-789' }, 'Order created');
  }
}

Each log call automatically inherits the request ID from the current HTTP context.

Log objects, not strings: searchable fields

Here's where structured logging changes your troubleshooting workflow. Instead of writing:

typescript
this.logger.info(`User ${userId} created lead for company ${companyId} with source ${source}`);

Write:

typescript
this.logger.info({ userId, companyId, source }, 'Lead created');

The second approach sends JSON to your logs. Your logging system (Datadog, ELK, CloudWatch, Splunk) now sees userId, companyId, and source as separate searchable fields, not text embedded in a string.

This matters enormously. Say a customer reports that leads from a specific company stopped being created yesterday. With string logs, you search for the company name and manually parse each line. With structured fields, you run a query like companyId = "acme-corp" AND level = "error" in seconds.

Here's a practical example from an e-commerce service:

typescript
@Injectable()
export class CartService {
  constructor(
    private readonly logger: PinoLogger,
    private readonly repository: CartRepository,
  ) {}

  async addItem(userId: string, productId: string, quantity: number) {
    this.logger.debug(
      { userId, productId, quantity },
      'Adding item to cart',
    );

    const product = await this.repository.getProduct(productId);
    if (!product) {
      this.logger.warn(
        { userId, productId, availableInventory: 0 },
        'Product not found',
      );
      throw new NotFoundException();
    }

    const cart = await this.repository.addToCart(userId, productId, quantity);
    this.logger.info(
      {
        userId,
        productId,
        quantity,
        cartTotal: cart.total,
        cartItemCount: cart.items.length,
      },
      'Item added to cart',
    );
  }
}

When you query your logs later, you can filter by any field: all carts for a user, all errors on a product, all cart totals above a threshold. String logs can't give you this flexibility.

Redacting secrets: protecting sensitive data

The moment you add fields to logs, you need to think about secrets. Authorization headers, API keys, passwords, payment tokens—these should never hit your logs, even in development.

Pino's redact option lets you define paths that should be scrubbed before logging. Any field matching those paths gets replaced with [REDACTED].

typescript
LoggerModule.forRoot({
  pinoHttp: {
    level: 'info',
    redact: [
      'req.headers.authorization',
      'req.headers["x-api-key"]',
      'res.headers["set-cookie"]',
    ],
  },
})

Now, if you log request headers, the authorization token is automatically redacted. A real-world scenario:

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

  validateToken(token: string) {
    this.logger.debug({ tokenLength: token.length }, 'Token validation started');
    // Never log the token itself
    const payload = this.decode(token);
    this.logger.info(
      { userId: payload.sub, tokenExpiry: payload.exp },
      'Token validated',
    );
  }
}

Notice we log the token length and expiry, but never the token itself. Pino's redaction is a safety net for cases where you accidentally pass sensitive data into a log object. Add any field path that might contain secrets:

typescript
redact: [
  'req.headers.authorization',
  'req.headers["x-api-key"]',
  'password',
  'creditCard',
  'ssn',
  'apiSecret',
  'res.headers["set-cookie"]',
]

Redaction happens on the worker thread before logs leave your application, so secrets never touch your disk or logging service.

Configuring Pino for different environments

Development and production need different logging strategies. In development, you want readable logs. In production, you want fast JSON.

Use environment variables to switch:

typescript
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        level: process.env.LOG_LEVEL || 'info',
        ...(process.env.NODE_ENV === 'development' && {
          transport: {
            target: 'pino-pretty',
            options: {
              colorize: true,
              singleLine: false,
              translateTime: 'SYS:standard',
            },
          },
        }),
        redact: [
          'req.headers.authorization',
          'req.headers["x-api-key"]',
          'password',
        ],
      },
    }),
  ],
})
export class AppModule {}

In development, pino-pretty formats JSON as readable text with colors. In production, raw JSON flows to your logging platform. Both setups use the same redaction rules.

Common mistakes

  • Logging too much. Every field you add increases log volume and cost. Log what you need to debug, not everything. A product ID is useful; the entire product object usually is not.
  • Forgetting to redact custom fields. You redacted authorization but not apiKey. Review all fields before deploying. Think about what could leak.
  • Logging secrets in the message string. this.logger.info(`Token ${token} validated`) still leaks the token even if you redact fields. Keep the message template static and pass secrets only as field values, then redact those fields.
  • Using different log levels across services. If one service logs at debug and another at info, correlation becomes harder. Set LOG_LEVEL consistently across your fleet.
  • Not forwarding request IDs downstream. If you call another service, pass the request ID in a header so that service can pick it up. Otherwise, you lose correlation.

Key takeaways

  • Replace the default NestJS logger with Pino via nestjs-pino to get JSON logs, request correlation, and production performance.
  • Pino automatically assigns a request ID to every HTTP request and attaches it to all logs within that request's scope.
  • Log structured objects with named fields instead of interpolated strings. This makes your logs queryable in any logging platform.
  • Use the redact configuration to automatically scrub sensitive fields like authorization headers and passwords before logs leave your application.
  • Configure Pino differently for development (readable output) and production (raw JSON) using environment variables.
  • Keep log messages static and pass dynamic data as structured fields; this makes aggregation and search much easier.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles