Structured Logging

Replace Nest's console logger with a JSON one, set levels from the environment, and keep secrets out of the output.

Syntaxnew ConsoleLogger({ json: true, logLevels: ['log'], redact: ['password'] })

Nest already logs: the startup lines come from ConsoleLogger, the framework's own implementation of the LoggerService interface in @nestjs/common. Coloured, human-readable text suits a terminal and defeats a log collector, which wants one JSON object per line. Both are the same class with different options.

Levels

There are six, ordered from least to most severe: verbose, debug, log, warn, error, fatal. Enabling one enables every level at least as severe, so ['warn'] also prints error and fatal. These two lines do the same thing:

logger: ['error', 'warn']
logger: new ConsoleLogger({ logLevels: ['error', 'warn'] })

filterLogLevels('>=warn') builds that array from a string; it has been exported from @nestjs/common since Nest 11.1, not Nest 12. What Nest 12 adds is the fallback: ConsoleLogger reads the NEST_LOG_LEVEL variable when no levels are passed.

Secrets

Nest 12 adds redact. Give it key names, matched at any depth, or dotted paths such as 'user.token'; matches are replaced with [REDACTED] and the original object is not mutated.

What trips people up

A logger with injected dependencies cannot be passed to NestFactory.create(), because the injector does not exist yet. Create the app with bufferLogs: true, then call app.useLogger(app.get(MyLogger)): lines logged during bootstrap are held and flushed once initialisation finishes, since autoFlushLogs defaults to true.

Example

Example · typescript
// src/main.ts
import { ConsoleLogger, filterLogLevels } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule, {
    logger: new ConsoleLogger({
      // One JSON object per line: level, pid, timestamp, message, context, params.
      json: process.env.NODE_ENV === 'production',
      // Accepts 'warn', '>=warn' or 'warn,error'. filterLogLevels is Nest 11.1+.
      logLevels: filterLogLevels(process.env.LOG_LEVEL ?? '>=log'),
      // Nest 12+. Masks these keys in structured params and non-string messages.
      redact: ['password', 'authorization', 'user.token'],
      prefix: 'orders-api', // ignored while json is enabled
    }),
  });

  await app.listen(process.env.PORT ?? 3000);
}

void bootstrap();

When to use it

  • Ship one JSON object per line to Loki, Datadog or CloudWatch without adding a second logging library.
  • Turn debug logging on for a single deployment by setting LOG_LEVEL='>=debug'.
  • Stop an Authorization header or password field ever reaching log storage.
  • Tag every line with the service name so one aggregator can hold ten services.

More examples

A logger the injector builds

Reach for this when the logger needs configuration or any other provider: a destination, a sampling rate, a tenant name. bufferLogs holds the framework's own startup lines until useLogger() has run, so they come out in the same format as everything else instead of being lost or printed twice.

Example · typescript
// src/logging/app-logger.service.ts
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AppLogger extends ConsoleLogger {
  constructor(config: ConfigService) {
    // ConsoleLogger's (context, options) constructor overload.
    super('orders-api', {
      json: config.get<string>('LOG_FORMAT') === 'json',
      redact: ['password', 'authorization'],
    });
  }

  // Text mode already prints an absolute timestamp, locale-formatted; this
  // replaces it with ISO-8601. (The trailing "+12ms" difference is a separate
  // thing, off unless you pass timestamp: true.) JSON mode has its own
  // timestamp field.
  protected getTimestamp(): string {
    return new Date().toISOString();
  }
}

// src/logging/logger.module.ts
import { Module } from '@nestjs/common';
import { AppLogger } from './app-logger.service.js';

// A provider is any value the injector constructs and hands to other
// constructors; exporting it lets importing modules inject it too.
@Module({ providers: [AppLogger], exports: [AppLogger] })
export class LoggerModule {}

// src/main.ts (excerpt)
const app = await NestFactory.create(AppModule, { bufferLogs: true });
// app.get() resolves a provider from the root injector, so LoggerModule
// must be imported somewhere in AppModule's tree.
app.useLogger(app.get(AppLogger));
await app.listen(3000);

Message, context and structured params

This is the shape of 90% of application logging: one instance per class, a short stable message, and the variable parts as an object. Querying logs for orderId="42" only works if the id was a field; it cannot be done reliably against an interpolated sentence.

Example · typescript
// src/orders/orders.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrdersRepository } from './orders.repository.js';

@Injectable()
export class OrdersService {
  // Logger is not a provider. It is a thin class that forwards every call to
  // whichever LoggerService the application was given, so it works before and
  // after useLogger(). The argument becomes the "context" field on each line.
  private readonly logger = new Logger(OrdersService.name);

  constructor(private readonly orders: OrdersRepository) {}

  async cancel(orderId: string, actorId: string): Promise<void> {
    const order = await this.orders.findById(orderId);

    if (!order) {
      this.logger.warn('cancel rejected: unknown order', { orderId, actorId });
      throw new NotFoundException(`Order ${orderId} not found`);
    }

    await this.orders.markCancelled(orderId);

    // A plain object after the message is treated as structured metadata
    // (structuredParams, on by default in Nest 12) and lands in a "params"
    // field in JSON mode, so it stays queryable instead of becoming prose.
    this.logger.log('order cancelled', {
      orderId,
      actorId,
      totalCents: order.totalCents,
    });
  }
}

error(): the stack argument, longhand and shorthand

Both forms appear in real codebases, and the positional one is the usual source of log lines whose context reads like a stack trace. Pass the Error when you log JSON: the serialised error field keeps the cause chain, which a stack string drops.

Example · typescript
// src/payments/payments.service.ts
import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class PaymentsService {
  private readonly logger = new Logger(PaymentsService.name);

  async charge(invoiceId: string, cents: number): Promise<void> {
    try {
      await this.callGateway(invoiceId, cents);
    } catch (error) {
      // Longhand: error(message, stack, context). The two trailing strings
      // are read positionally, which is why a forgotten stack silently
      // becomes the context field.
      this.logger.error('charge failed', (error as Error).stack, PaymentsService.name);

      // Shorthand for the same intent: pass the Error itself. In JSON mode
      // Nest 12 pulls it out into an "error" object (name, message, stack,
      // cause) and keeps the trailing object as params. Use one or the other.
      this.logger.error('charge failed', error, { invoiceId, cents });

      throw error;
    }
  }

  private async callGateway(invoiceId: string, cents: number): Promise<void> {
    const response = await fetch('https://payments.internal/charges', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ invoiceId, cents }),
    });

    if (!response.ok) {
      throw new Error(`gateway returned ${response.status}`);
    }
  }
}

Discussion

  • Be the first to comment on this lesson.