Logging
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), TypeScript 6 (6.0.x), Node.js 22 and 24 LTS, Express 5 and Fastify 5, and was checked on 2026-10-02. The third-party integrations use nestjs-pino 5.3 (Pino 10) and nest-winston 2.0 (Winston 3.19), and nestjs-cls 7.0. The examples assume a Nest 12 project (ESM template, Vitest and supertest); process ids and timestamps in the output blocks are shortened. The request-correlation example also works on the Fastify adapter.
Logs are how a running application explains itself. Nest ships a logger that it uses for its own messages (bootstrap, route mapping, unhandled exceptions) and that you can use for yours, and it lets you replace that logger completely without changing the code that calls it. In the Bookshelf application that grows across this section, logging answers three questions: what happened (levels and messages), what it happened to (structured fields such as bookId) and which request it belongs to (a correlation id). This page covers the built-in tools first, then custom and injected loggers, then request correlation, and finally the two libraries most teams reach for, Pino and Winston.
The built-in Logger, ConsoleLogger and LoggerService
Three types in @nestjs/common fit together:
-
LoggerServiceis an interface:log,error,warn,fataland, optionally,debug,verboseandsetLogLevels. Anything that implements it can be Nest’s logger. -
ConsoleLoggeris the default implementation of that interface. It prints to stdout (errorandfatalgo to stderr), colourises text and can print JSON. -
Loggeris the class you use in your own code.new Logger(BooksService.name)creates a logger with a context (printed in square brackets). ALoggerinstance forwards each call to whicheverLoggerServiceis currently installed for the application (theConsoleLoggerunless you replace it), so your code does not depend on a particular implementation.
import { Injectable, Logger } from '@nestjs/common';
export interface Book {
id: number;
title: string;
}
@Injectable()
export class BooksService {
// The class name becomes the context printed in square brackets.
private readonly logger = new Logger(BooksService.name);
private readonly books: Book[] = [{ id: 1, title: 'Dune' }];
findAll(): Book[] {
this.logger.debug(() => `Listing ${this.books.length} books`); // lazy message
return this.books;
}
create(title: string): Book {
const book = { id: this.books.length + 1, title };
this.books.push(book);
// A plain object after the message is attached to the same record (v12).
this.logger.log('Book created', { bookId: book.id, title });
return book;
}
fail(): never {
const error = new Error('Search index unavailable');
this.logger.error('Search failed', error.stack);
throw error;
}
}
Calling create('Emma') and fail() with the default logger prints:
[Nest] 68199 - 10/02/2026, 5:47:07 PM DEBUG [BooksService] Listing 1 books
[Nest] 68199 - 10/02/2026, 5:47:07 PM LOG [BooksService] Book created { bookId: 2, title: 'Emma' }
[Nest] 68199 - 10/02/2026, 5:47:07 PM ERROR [BooksService] Search failed
Error: Search index unavailable
at BooksService.fail (...)
The error() method of a Logger instance takes (message, stack, context): the second argument is the stack trace, and the context normally comes from the instance. Calling error() directly on a ConsoleLogger with one extra string treats that string as the context, not the stack, so a stack passed that way ends up in the context field of the JSON output. Prefer Logger instances in application code.
debug() and the other methods also accept a function that returns the message. In the default text format the built-in ConsoleLogger calls it only when the level is enabled, so a disabled debug line does not pay for building a string (Listing above is such a line). A custom LoggerService does not get that treatment: it receives the function itself. The JsonLinesLogger below, for example, prints {"level":"debug","time":…} with no message for such a call, because JSON.stringify() drops functions, so call the function yourself in a custom logger if you want to support lazy messages.
Log levels
There are six levels, from most to least severe: fatal, error, warn, log, debug, verbose. Which of them are printed is set with the logger application option.
import { NestFactory } from '@nestjs/core';
import { filterLogLevels } from '@nestjs/common';
// Disable logging entirely
const silent = await NestFactory.create(AppModule, { logger: false });
// Only these levels (plus everything more severe, see below)
const quiet = await NestFactory.create(AppModule, { logger: ['error', 'warn'] });
// A threshold from an environment variable
const configured = await NestFactory.create(AppModule, {
logger: filterLogLevels(process.env.LOG_LEVEL ?? '>=log'), // ['log', 'warn', 'error', 'fatal']
});
// Change the levels later, once configuration has been read
configured.useLogger(['error', 'warn']);
Source: NestJS: Logger - Basic customization.
Levels cascade from the most severe level in the array: ['log'] also enables warn, error and fatal, and ['debug', 'error'] enables debug, error and fatal but not log or warn.
[Nest] 68199 - 10/02/2026, 5:47:07 PM DEBUG [Ctx] DEBUG
[Nest] 68199 - 10/02/2026, 5:47:07 PM ERROR [Ctx] ERROR
[Nest] 68199 - 10/02/2026, 5:47:07 PM FATAL [Ctx] FATAL
filterLogLevels() builds the array from a string, which suits an environment variable. It accepts >=warn (that level and everything more severe), >warn (strictly more severe), a comma-separated list such as log,error, or a single level; an empty or unrecognised string returns all levels and an unknown level after > or >= throws.
The 12.1.2 typings of @nestjs/common also export the constant LOG_LEVEL_ENV_VAR (NEST_LOG_LEVEL): a ConsoleLogger reads that environment variable for its default levels, in the same formats. The logger documentation page does not describe it, so treat it as an implementation detail and prefer the explicit filterLogLevels(process.env.LOG_LEVEL) call shown above. Setting NEST_LOG_LEVEL to >=warn makes a plain ConsoleLogger hide its log line and print its warn line.
ConsoleLogger options
Pass an options object to new ConsoleLogger(options) (or new ConsoleLogger('Context', options)) and pass the instance as the logger application option.
| Option | Effect | Default |
|---|---|---|
|
Enabled levels. |
all six levels |
|
Colourise text output. |
|
|
Print each entry as a JSON object. |
|
|
Print values on one line; a number means "unite this many inner levels on a line". |
|
|
Text in front of each line, |
|
|
Append the time since the previous log line ( |
|
|
The logger’s context. |
none |
|
Attach plain objects logged after the message to the same entry (v12). |
|
|
In JSON mode, spread the params into the root of the record instead of |
|
|
Property names or dotted paths to mask in params and object messages (in the 12.1.2 typings, see below). |
none |
|
Use |
|
|
Passed to Node’s |
see the docs table |
Source: NestJS: Logger - Basic customization (options table).
A development configuration that prints a prefix and the elapsed time since the previous line, and a production configuration that prints JSON, filters by an environment variable and masks credentials:
import { ConsoleLogger, filterLogLevels } from '@nestjs/common';
// Development: readable, colourised, with the time since the previous line.
export function developmentLogger(): ConsoleLogger {
return new ConsoleLogger({
prefix: 'Bookshelf',
timestamp: true,
logLevels: ['log', 'warn', 'error', 'fatal', 'debug', 'verbose'],
});
}
// Production: one JSON object per line on stdout/stderr, nothing else.
export function productionLogger(): ConsoleLogger {
return new ConsoleLogger({
json: true, // colors: false and compact: true follow from this
flattenParams: true, // spread params into the root of each record
logLevels: filterLogLevels(process.env.LOG_LEVEL ?? '>=log'),
redact: ['password', 'authorization'], // 12.1 typings; see the page text
});
}
Source: NestJS: Logger - JSON logging and Structured logging params.
developmentLogger().log('hello', 'BooksService') printed [Bookshelf] 68199 - 10/02/2026, 5:47:07 PM LOG [BooksService] hello +0ms; the +Nms suffix is the time since the previous line written by any ConsoleLogger in the process, not only by that instance. Colours are also switched off when NO_COLOR is set to any non-empty value.
The compact option decides how objects are laid out in text mode. With compact: false an object longer than the break length is printed on several lines, which reads well in a terminal and badly in a log aggregator, which is why json: true implies compact: true:
[Nest] 71386 - 10/02/2026, 5:51:29 PM LOG [X] Object(3) {
title: 'Dune',
author: {
name: 'Frank Herbert',
born: 1920
},
tags: [
'sf',
'classic'
]
}
JSON output and structured params
With json: true each entry is one JSON object per line: level, pid, timestamp (milliseconds since the Unix epoch), message, and, when present, context, stack and params. error and fatal entries go to stderr and the rest to stdout, exactly as in text mode. prefix and timestamp have no effect in JSON mode because the record carries its own timestamp. When json is true, colors defaults to false and compact to true; with those two values each entry is serialised with JSON.stringify() and is strict JSON. Setting colors: true or compact: false yourself switches formatting to util.inspect(), which is easier to read locally but no longer strict JSON.
Since NestJS 12, plain objects passed after the message are attached to the same entry instead of being logged as separate records. Several objects are merged. In text mode they are appended inline; in JSON mode they are nested under params, or spread into the record when flattenParams is enabled.
import { ConsoleLogger } from '@nestjs/common';
const nested = new ConsoleLogger('UserService', { json: true });
nested.log('User created', { userId: 1, email: 'ada@example.com' });
nested.log('Merged', { a: 1 }, { b: 2 });
const flat = new ConsoleLogger('UserService', { json: true, flattenParams: true });
flat.log('Flat', { userId: 1, level: 'x' });
{"level":"log","pid":68199,"timestamp":1790956027687,"message":"User created","context":"UserService","params":{"userId":1,"email":"ada@example.com"}}
{"level":"log","pid":68199,"timestamp":1790956027687,"message":"Merged","context":"UserService","params":{"a":1,"b":2}}
{"level":"log","pid":68199,"timestamp":1790956027687,"message":"Flat","context":"UserService","userId":1}
Three details are visible in that output. First, with flattenParams a param whose key the record already uses (level above, but also message) is dropped, so choose field names that do not collide. Second, only plain objects become params: arrays, strings, numbers, class instances and null are still logged as separate messages, a string passed last is still the context, and a plain object passed first is still the message. Third, structuredParams: false restores the pre-12 behaviour, where the object is printed as its own log record (Object(1) { a: 1 } in text mode).
The redact option masks sensitive properties before they are written. It takes key names (matched at any depth, case-insensitively) or dotted paths, applies to params and to non-string messages in both text and JSON mode, and does not mutate your objects. It is declared in the 12.1.2 typings of @nestjs/common but is not described on the logger documentation page, so check the typings of the version you run before depending on it. For example:
{"level":"log","pid":68199,"timestamp":1790956027688,"message":"login","context":"X","params":{"user":"ada","password":"[REDACTED]","headers":{"authorization":"[REDACTED]"}}}
String messages, error messages and stack traces are printed as they are, so never interpolate a secret into the message text. The first rule of logging credentials is not to pass them to the logger at all.
Bootstrap, useLogger and bufferLogs
NestFactory.create() starts logging before any of your modules exist ("Starting Nest application…", one line per module, one per mapped route). A logger that needs the dependency injection container, for instance because it reads ConfigService, cannot exist at that moment. The bufferLogs option solves the ordering problem: Nest holds all logs in memory until you install your logger with app.useLogger(), then replays them through it.
import { NestFactory } from '@nestjs/core';
import { AppLogger } from './app.logger.js';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(AppLogger)); // a provider imported by some module
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();
With bufferLogs: true the first line, Starting Nest application…, is printed by the custom logger (with its Bookshelf prefix). Without it, the same line and the module-initialisation lines are written by the default logger, and only the lines after useLogger() use yours:
[Nest] 68831 - 10/02/2026, 5:47:41 PM LOG [NestFactory] Starting Nest application...
[Nest] 68831 - 10/02/2026, 5:47:41 PM LOG [InstanceLoader] ConfigModule dependencies initialized
[Bookshelf] 68831 - 2026-10-02T15:47:41.487Z LOG [RoutesResolver] BooksController {/books}:
By default (autoFlushLogs is true) Nest flushes the buffer on its own: an HTTP application or microservice flushes when it starts listening, and a standalone application context flushes when you call useLogger(). If initialisation fails, Nest flushes immediately and prints the error with the original ConsoleLogger so you can see why startup failed. Set autoFlushLogs: false to flush yourself with app.flushLogs() or the static Logger.flush(); until then nothing reaches the custom logger.
The alternative to bufferLogs is logger: false, which discards everything until useLogger(). That also discards startup errors, so prefer buffering. app.useLogger() accepts a LoggerService, an array of levels or false, and the static Logger.overrideLogger() does the same for the process-wide logger that every Logger instance delegates to. For standalone applications (NestFactory.createApplicationContext()) see Platform Agnosticism and Standalone Apps.
A custom LoggerService
Any object with the LoggerService methods can be Nest’s logger. The global console object implements the interface, so { logger: console } is the shortest possible replacement. A real implementation owns its output format and receives, for each call, the message followed by optional parameters. By convention the last string argument is the context, error() receives (message, stack, context), and in v12 structured params arrive as plain objects between the message and the context. This logger writes one JSON line per call and understands all three conventions:
import type { LoggerService, LogLevel } from '@nestjs/common';
// A from-scratch LoggerService: one JSON line per call, no dependencies.
export class JsonLinesLogger implements LoggerService {
private levels = new Set<LogLevel>(['log', 'error', 'warn', 'fatal']);
constructor(private readonly write: (line: string) => void = (l) => process.stdout.write(l + '\n')) {}
log(message: unknown, ...optionalParams: unknown[]) {
this.emit('log', message, optionalParams);
}
fatal(message: unknown, ...optionalParams: unknown[]) {
this.emit('fatal', message, optionalParams);
}
error(message: unknown, ...optionalParams: unknown[]) {
this.emit('error', message, optionalParams);
}
warn(message: unknown, ...optionalParams: unknown[]) {
this.emit('warn', message, optionalParams);
}
debug(message: unknown, ...optionalParams: unknown[]) {
this.emit('debug', message, optionalParams);
}
verbose(message: unknown, ...optionalParams: unknown[]) {
this.emit('verbose', message, optionalParams);
}
// Called by app.useLogger(['warn', 'error']).
setLogLevels(levels: LogLevel[]) {
this.levels = new Set(levels);
}
private emit(level: LogLevel, message: unknown, args: unknown[]) {
if (!this.levels.has(level)) return;
// Nest passes the context as the last string argument, and v12 passes
// structured params as plain objects after the message.
const last = args[args.length - 1];
const context = typeof last === 'string' ? last : undefined;
const rest = context === undefined ? args : args.slice(0, -1);
const params = Object.assign({}, ...rest.filter(isPlainObject));
const stack = typeof rest[0] === 'string' ? rest[0] : undefined; // error(message, stack, context)
// JSON.stringify(new Error('x')) is "{}", so unpack errors explicitly.
const text = message instanceof Error ? message.message : message;
this.write(
JSON.stringify({
level,
message: text,
context,
stack: message instanceof Error ? message.stack : stack,
params: Object.keys(params).length ? params : undefined,
time: Date.now(),
}),
);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
}
const app = await NestFactory.create(BooksLoggingModule, {
logger: new JsonLinesLogger(),
});
await app.listen(3000);
app.useLogger(['error']); // forwarded to JsonLinesLogger.setLogLevels()
POST /books before the levels are restricted{"level":"log","message":"Book created","context":"BooksService","params":{"bookId":2,"title":"Emma"},"time":1790956329890}
{"level":"error","message":"Search failed","context":"BooksService","stack":"Error: Search index unavailable\n at BooksService.fail (...)","time":1790956329891}
Two pitfalls came out of running this. JSON.stringify(new Error('x')) is {}, so an Error passed as the message (Nest does this for unhandled exceptions) must be unpacked, as the emit() method does. And debug, verbose and setLogLevels are optional in the interface, but app.useLogger(['error']) only works if setLogLevels exists, because Nest forwards the array to the logger’s setLogLevels() method. The custom logger above does not use dependency injection, which makes it awkward to test and to configure; the next sections fix that.
Extending ConsoleLogger
Often you want the default behaviour with one change. Extend ConsoleLogger, override what you need and call super to delegate. The classic uses are adding an action to error() (for example forwarding to an error tracker) and changing the text timestamp by overriding the protected getTimestamp() method:
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// Singleton logger: extends the built-in one and gets ConfigService injected.
@Injectable()
export class AppLogger extends ConsoleLogger {
constructor(config: ConfigService) {
super({ json: config.get('LOG_FORMAT') === 'json', prefix: 'Bookshelf' });
}
// Override a method, then delegate to the parent.
error(message: any, ...rest: any[]) {
super.error(message, ...rest);
}
// Text mode only: ISO 8601 timestamps instead of the locale format.
protected getTimestamp(): string {
return new Date().toISOString();
}
}
This logger is an ordinary provider (@Injectable()), so it receives ConfigService through its constructor and the configuration decides between JSON and text. Its output in text mode uses ISO 8601 timestamps:
[Bookshelf] 68447 - 2026-10-02T15:47:24.854Z LOG [NestFactory] Starting Nest application...
The timestamp override applies to text output only; in JSON mode the timestamp field is always milliseconds since the epoch. Besides getTimestamp(), the typings expose protected formatMessage() and colorize() methods for deeper changes, and the public setContext(), resetContext() and isLevelEnabled(level); the last one lets you skip preparing data for a line that would be discarded.
Injecting a logger
There are three ways to get a logger into a class, and they differ in who owns the instance.
Instantiate a Logger in the class. private readonly logger = new Logger(BooksService.name) needs no registration, works in every class (including ones Nest does not construct) and automatically follows app.useLogger(). It is what the official documentation recommends for application logging, and what BooksService above does.
Provide your own logger class. Register it in a module and inject it. A provider that extends ConsoleLogger can have a custom method. With the default singleton scope there is one instance for the whole application, so calling setContext() in one class would overwrite the context everywhere. Give the class transient scope so that each consumer gets a private instance:
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
// Transient: every consumer gets its own instance, so setContext() is private.
@Injectable({ scope: Scope.TRANSIENT })
export class ScopedLogger extends ConsoleLogger {
audit(action: string, details: Record<string, unknown>) {
this.log(`AUDIT ${action}`, details);
}
}
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppLogger } from './app.logger.js';
import { ScopedLogger } from './scoped.logger.js';
@Module({
imports: [ConfigModule.forRoot({ ignoreEnvFile: true })],
providers: [AppLogger, ScopedLogger],
exports: [AppLogger, ScopedLogger],
})
export class LoggerModule {}
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ScopedLogger } from './scoped.logger.js';
import { BooksService } from './books.service.js';
@Controller('books')
export class BooksController {
// Transient scope: this controller has its own AppLogger instance,
// so setContext() does not leak into other classes.
constructor(
private readonly books: BooksService,
private readonly logger: ScopedLogger,
) {
this.logger.setContext(BooksController.name);
}
@Get()
findAll() {
this.logger.log('Listing books');
return this.books.findAll();
}
@Post()
create(@Body('title') title: string) {
const book = this.books.create(title);
this.logger.audit('book.created', { bookId: book.id });
return book;
}
}
POST /books with {"title": "Emma"} writes the controller’s line with the BooksController context, using the AUDIT method that exists only on ScopedLogger:
[Nest] 71722 - 10/02/2026, 5:54:10 PM LOG [BooksController] AUDIT book.created { bookId: 2 }
Source: NestJS: Logger - Injecting a custom logger and Injection scopes (see also Injection Scopes).
A transient provider cannot be fetched with app.get(). app.get(ScopedLogger) throws ScopedLogger is marked as a scoped provider. Request and transient-scoped providers can’t be used in combination with "get()" method. Please, use "resolve()" instead. Use await app.resolve(ScopedLogger), or keep the logger you hand to app.useLogger() as a singleton, as AppLogger is. The pattern is therefore two classes: a singleton that Nest uses for system logging (app.useLogger(app.get(AppLogger))) and, if you want extra methods or per-class contexts, a transient one that classes inject.
Use the global instance. Logger also has static methods (Logger.log('message', 'Context')) that call the installed logger without any instance. They are convenient in scripts and bootstrap code and are what Nest itself uses internally.
To assert on logs in a test, spy on the Logger prototype, or install a capturing logger with setLogger() on the testing module builder; the excerpt below shows both:
import { Logger } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { BooksService } from './books.service.js';
import { JsonLinesLogger } from './json-lines.logger.js';
it('logs the creation with structured params', async () => {
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
const moduleRef = await Test.createTestingModule({ providers: [BooksService] }).compile();
moduleRef.get(BooksService).create('Emma');
expect(logSpy).toHaveBeenCalledWith('Book created', { bookId: 2, title: 'Emma' });
logSpy.mockRestore();
});
it('captures logs with a custom logger', async () => {
const lines: string[] = [];
const moduleRef = await Test.createTestingModule({ providers: [BooksService] })
.setLogger(new JsonLinesLogger((line) => lines.push(line)))
.compile();
moduleRef.get(BooksService).create('Emma');
expect(lines.some((line) => line.includes('Book created'))).toBe(true);
});
Source: NestJS: Testing. See Testing for the rest of the testing toolbox.
Correlating logs with requests
A log line is only useful in production if you can find the other lines of the same request. That needs a correlation (request or trace) id on every line, including those written deep in services that never see the HTTP request. Passing the id as an argument through every call is noisy and easy to forget. Node.js solves it with AsyncLocalStorage: a store that is bound to an asynchronous call chain. Code started inside als.run(store, callback) sees that same store everywhere downstream, including after `await`s and timers, and concurrent requests each see their own.
The Nest documentation walks through the same technique for a request-scoped store and notes that Nest core has no built-in abstraction for it. Here it feeds a logger. The pieces are a storage, a middleware that opens it, and a logger that reads it:
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
export interface RequestStore {
requestId: string;
}
// One storage for the whole process; each request gets its own store.
export const requestContext = new AsyncLocalStorage<RequestStore>();
// Functional middleware: reuse the caller's id or create one, echo it back,
// and run the rest of the request inside the store.
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const incoming = req.headers['x-request-id'];
const requestId = typeof incoming === 'string' && incoming.length <= 64 ? incoming : randomUUID();
res.setHeader('x-request-id', requestId);
requestContext.run({ requestId }, () => next());
}
import { ConsoleLogger, LoggerService } from '@nestjs/common';
import { requestContext } from './request-context.js';
// Wraps ConsoleLogger and adds the current request id to every call.
export class CorrelatedLogger implements LoggerService {
constructor(
private readonly inner = new ConsoleLogger({ json: true, flattenParams: true }),
) {}
// A plain object right after the message is a structured param (v12).
private withId(params: unknown[]): unknown[] {
const requestId = requestContext.getStore()?.requestId;
return requestId ? [{ requestId }, ...params] : params;
}
log(message: unknown, ...params: unknown[]) {
this.inner.log(message, ...this.withId(params));
}
error(message: unknown, ...params: unknown[]) {
this.inner.error(message, ...this.withId(params));
}
warn(message: unknown, ...params: unknown[]) {
this.inner.warn(message, ...this.withId(params));
}
debug(message: unknown, ...params: unknown[]) {
this.inner.debug(message, ...this.withId(params));
}
verbose(message: unknown, ...params: unknown[]) {
this.inner.verbose(message, ...this.withId(params));
}
fatal(message: unknown, ...params: unknown[]) {
this.inner.fatal(message, ...this.withId(params));
}
}
import { Controller, Get, Injectable, Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { requestIdMiddleware } from './request-context.js';
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
// No request id is passed in: the logger finds it through AsyncLocalStorage.
async capture(orderId: string) {
await new Promise((resolve) => setTimeout(resolve, 5));
this.logger.log('Payment captured', { orderId });
}
}
@Controller('orders')
export class OrdersController {
private readonly logger = new Logger(OrdersController.name);
constructor(private readonly orders: OrdersService) {}
@Get(':id/pay')
async pay() {
this.logger.log('Payment requested');
await this.orders.capture('ord_1');
return { ok: true };
}
}
@Module({ controllers: [OrdersController], providers: [OrdersService] })
export class OrdersModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(requestIdMiddleware).forRoutes('{*splat}');
}
}
Source: NestJS: Async local storage - Custom implementation and NestJS: Middleware - Applying middleware.
Note that OrdersService receives no request id: it only calls this.logger.log('Payment captured', { orderId }). The application is created with NestFactory.create(OrdersModule, { logger: new CorrelatedLogger(), bufferLogs: true }). Two concurrent requests, one that sends X-Request-Id: req-a and one that does not, produce:
{"level":"log","pid":69536,"timestamp":1790956111868,"message":"Payment requested","context":"OrdersController","requestId":"req-a"}
{"level":"log","pid":69536,"timestamp":1790956111869,"message":"Payment requested","context":"OrdersController","requestId":"cc07cee5-9520-4f7e-a083-bf2aafac7d43"}
{"level":"log","pid":69536,"timestamp":1790956111874,"message":"Payment captured","context":"OrdersService","requestId":"req-a","orderId":"ord_1"}
{"level":"log","pid":69536,"timestamp":1790956111875,"message":"Payment captured","context":"OrdersService","requestId":"cc07cee5-9520-4f7e-a083-bf2aafac7d43","orderId":"ord_1"}
The lines of the two requests interleave, yet each carries its own id, also after the setTimeout inside capture(). The response header x-request-id returns the id to the caller. Because the middleware is the first thing a request reaches, the store is also visible in guards, pipes, interceptors, controllers and exception filters (see Middleware and Request Lifecycle). On the Fastify adapter the same module produces the same result for a request injected with X-Request-Id: fast-1; only the middleware’s parameter types would differ if you typed them as Fastify’s request and reply instead of Express’s. Do not trust an incoming id blindly: the middleware above accepts it only as a string of at most 64 characters, and for requests from untrusted callers you may prefer to ignore it and always generate one.
Use this technique responsibly: the store makes the code flow implicit. Keep it small (an id, a tenant, a user id) and avoid turning it into a bag of everything. It also covers only the entry points you wrap; background jobs, message handlers and cron tasks need their own als.run().
nestjs-cls
The nestjs-cls package (a third-party library, not maintained by the Nest core team) wraps the same mechanism in a ClsModule that mounts the middleware for you, can generate an id per request, has equivalents for non-HTTP transports and gives you an injectable, typed ClsService:
import { Controller, Get, Injectable, Logger, Module } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { ClsModule, ClsService } from 'nestjs-cls';
@Injectable()
export class ClsLogger {
constructor(private readonly cls: ClsService) {}
line(message: string) {
new Logger('Orders').log(message, { requestId: this.cls.getId() });
}
}
@Controller('cls-orders')
export class ClsOrdersController {
constructor(private readonly logger: ClsLogger) {}
@Get()
find() {
this.logger.line('Listing orders');
return { ok: true };
}
}
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true, // mounts the CLS middleware for all routes
generateId: true, // makes cls.getId() available
idGenerator: (req: any) => req.headers['x-request-id'] ?? randomUUID(),
},
}),
],
controllers: [ClsOrdersController],
providers: [ClsLogger],
})
export class ClsOrdersModule {}
A request with X-Request-Id: cls-1 wrote {"level":"log","pid":70877,"timestamp":1790956255578,"message":"Listing orders","context":"Orders","requestId":"cls-1"}.
If your application is instrumented with NestJS Observe (@nestjs/observe, a pre-1.0 first-party package), the SDK already keeps an AsyncLocalStorage store per request, job and message, and by default appends the request’s trace id to every line written by the built-in ConsoleLogger (a traceId field in JSON mode) without any code on your side. The official documentation notes that this augmentation applies to ConsoleLogger only: if you replace it with Pino or Winston, read the id yourself from TracerService. See Health Checks and Observability for the observability tools.
Pino with nestjs-pino
Pino is a fast JSON logger. The built-in ConsoleLogger with json: true already covers one-record-per-line output to stdout, and the Nest documentation recommends a dedicated library for the things the built-in logger leaves out: several in-process transports (files, syslog, HTTP), custom serialisers, and the lowest overhead on services that log at very high volume. Nest does not ship a Pino integration; the community package nestjs-pino is the usual choice. Version 5 supports NestJS 11.0.8+ and 12.0.2+, Pino 10, pino-http 11 and Node.js 22.12 or later.
npm install nestjs-pino pino pino-http
LoggerModule.forRoot() (imported once, in the root module) registers pino-http as middleware, so every request gets a request-bound child logger and one automatic "request completed" line, and it keeps the request context in an AsyncLocalStorage internally: every line written during a request carries the request data without any code of yours.
import { Controller, Get, Injectable, Logger, Module } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { InjectPinoLogger, LoggerModule, PinoLogger } from 'nestjs-pino';
import type { DestinationStream } from 'pino';
@Injectable()
export class PinoOrdersService {
private readonly nestLogger = new Logger(PinoOrdersService.name);
constructor(@InjectPinoLogger(PinoOrdersService.name) private readonly pino: PinoLogger) {}
capture(orderId: string) {
// Nest's Logger: delegated to pino once app.useLogger(app.get(Logger)) ran.
this.nestLogger.log('Payment captured');
// PinoLogger: pino's own API; merging object first, message second.
this.pino.info({ orderId }, 'Payment captured (pino API)');
}
}
@Controller('pino-orders')
export class PinoOrdersController {
constructor(private readonly orders: PinoOrdersService) {}
@Get()
find() {
this.orders.capture('ord_1');
return { ok: true };
}
}
export function pinoOrdersModule(stream?: DestinationStream) {
const pinoHttp = {
level: process.env.LOG_LEVEL ?? 'info',
// Reuse the caller's id or generate one; it appears as req.id in every line.
genReqId: (req: any) => req.headers['x-request-id'] ?? randomUUID(),
// Never log credentials.
redact: ['req.headers.authorization', 'req.headers.cookie'],
// Add fields to every line written during a request.
customProps: () => ({ service: 'bookshelf-api' }),
};
@Module({
imports: [LoggerModule.forRoot({ pinoHttp: stream ? [pinoHttp, stream] : pinoHttp })],
controllers: [PinoOrdersController],
providers: [PinoOrdersService],
})
class PinoOrdersModule {}
return PinoOrdersModule;
}
The example passes a stream as the second element of the pinoHttp tuple so that a test can read the lines; in production leave it out and Pino writes to stdout. The application replaces Nest’s logger with Pino’s, buffering the bootstrap logs first:
import { Logger } from 'nestjs-pino';
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
await app.listen(3000);
A request with X-Request-Id: p-1 and an Authorization header produced (shortened; the request object has more fields):
{"level":30,"time":1790956111917,"pid":69536,"context":"NestFactory","msg":"Starting Nest application..."}
{"level":30,"time":1790956111918,"pid":69536,"req":{"id":"p-1","method":"GET","url":"/pino-orders","headers":{"x-request-id":"p-1","authorization":"[Redacted]"}},"service":"bookshelf-api","context":"PinoOrdersService","msg":"Payment captured"}
{"level":30,"time":1790956111918,"pid":69536,"req":{"id":"p-1","method":"GET","url":"/pino-orders","headers":{"authorization":"[Redacted]"}},"service":"bookshelf-api","context":"PinoOrdersService","orderId":"ord_1","msg":"Payment captured (pino API)"}
Observe the points that matter:
-
The first line (bootstrap) comes from Nest and has no
req; lines written during a request includereq.id. The id is the onegenReqIdreturned. -
Calls through the standard
Loggerfrom@nestjs/commonare delegated to Pino onceapp.useLogger(app.get(Logger))has run, with Pino’s argument conventions: a merging object first, then a message (this.logger.log({ orderId }, 'Payment captured'));logmaps to Pino’sinfoandverbosetotrace. That is the idiomatic option because the code stays independent of Pino. -
PinoLogger(injectable,@InjectPinoLogger(context)) has Pino’s own API (info,warn,errorand so on) for people who prefer it. -
Pino’s levels are numbers (
30isinfo) and its keys aremsgandtime, unlikeConsoleLogger’s `messageandtimestamp. If you switch an existing application, check that your aggregator’s parsing and dashboards use the new names.nestjs-pinoshipsNativeLoggerandnativeLoggerOptions, a drop-in that produces theConsoleLoggerJSON field names on top of Pino, for exactly that migration. -
redactremoves credentials from the request object before it is written, andcustomPropsadds fields to every request line. -
Register
LoggerModuleonly throughforRoot()orforRootAsync(), once, in the root module. It is global; importing the bare class in a feature module registers the middleware a second time and every request is logged twice, silently. -
With Fastify you can also let the adapter’s own Pino logger do the work (
new FastifyAdapter({ logger: true })) and useLoggerModule.forRoot({ useExisting: true }); the library documents the trade-offs of that choice.
Pino and nestjs-pino are third-party packages: check their documentation for options that are not covered here.
Winston with nest-winston
Winston is the other widely used Node.js logger. Its strength is composition: a logger has formats (functions that transform each log record) and any number of transports (console, file, HTTP and so on), each with its own level and format. nest-winston connects it to Nest in three ways, summarised in the library’s own table: the WINSTON_MODULE_PROVIDER token injects the raw Winston logger; WINSTON_MODULE_NEST_PROVIDER injects a LoggerService that you can also pass to app.useLogger(); and WinstonModule.createLogger() builds a logger before the application exists, so bootstrap logs use it too (in that variant do not also use forRoot()).
npm install nest-winston winston
import { Controller, Get, Inject, Injectable, Logger, Module } from '@nestjs/common';
import type { LoggerService } from '@nestjs/common';
import { utilities as nestWinston, WINSTON_MODULE_NEST_PROVIDER, WINSTON_MODULE_PROVIDER, WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import { requestContext } from '../als/request-context.js';
// A winston format that copies the request id from AsyncLocalStorage.
const requestId = winston.format((info) => {
const id = requestContext.getStore()?.requestId;
if (id) info.requestId = id;
return info;
});
export function winstonOptions(extra: winston.transport[] = []): winston.LoggerOptions {
return {
level: process.env.LOG_LEVEL ?? 'info',
format: winston.format.combine(requestId(), winston.format.timestamp(), winston.format.errors({ stack: true })),
transports: [
// Human readable, Nest-style, for development.
new winston.transports.Console({
format: winston.format.combine(winston.format.ms(), nestWinston.format.nestLike('Bookshelf', { colors: false })),
}),
// One JSON object per line for a log shipper.
new winston.transports.File({ filename: 'combined.log', format: winston.format.json(), dirname: process.env.LOG_DIR ?? 'logs' }),
...extra,
],
};
}
@Injectable()
export class WinstonOrdersService {
// The Nest-compatible logger: log(message, context) / error(message, stack, context)
constructor(
@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService,
// The raw winston logger: its own API, with a metadata object.
@Inject(WINSTON_MODULE_PROVIDER) private readonly winston: winston.Logger,
) {}
capture() {
this.logger.log('Payment captured', WinstonOrdersService.name);
this.winston.info('Payment captured (winston API)', { orderId: 'ord_1' });
}
}
@Controller('winston-orders')
export class WinstonOrdersController {
constructor(private readonly orders: WinstonOrdersService) {}
@Get()
find() {
new Logger(WinstonOrdersController.name).log('Listing orders');
this.orders.capture();
return { ok: true };
}
}
export function winstonOrdersModule(extra: winston.transport[] = []) {
@Module({
imports: [WinstonModule.forRoot(winstonOptions(extra))],
controllers: [WinstonOrdersController],
providers: [WinstonOrdersService],
})
class WinstonOrdersModule {}
return WinstonOrdersModule;
}
The custom requestId format copies the id from the AsyncLocalStorage of the previous section onto every record, so Winston gets correlation with four lines of code. The application then installs the Nest-compatible logger and applies the request-id middleware:
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
await app.listen(3000);
The console transport prints Nest-style lines and the file transport one JSON object per line (here LOG_DIR pointed the file at a temporary directory):
[Bookshelf] 71172 10/2/2026, 5:51:15 PM LOG [NestFactory] Starting Nest application... +0ms
[Bookshelf] 71172 10/2/2026, 5:51:15 PM LOG [WinstonOrdersController] Listing orders +9ms
[Bookshelf] 71172 10/2/2026, 5:51:15 PM LOG [WinstonOrdersService] Payment captured +0ms
{"context":"WinstonOrdersService","level":"info","message":"Payment captured","requestId":"w-1","timestamp":"2026-10-02T15:48:31.926Z"}
Notes from running it:
-
The logger’s
log(),warn()anddebug()take(message, context)anderror()andfatal()take(message, stack, context): the Nest conventions again. Winston’s own API (logger.info(message, meta)) is the injectedWINSTON_MODULE_PROVIDERinstance. -
The
nestLikeformatter fromnest-winstonimitates Nest’s text layout for the console transport; its options arecolors,prettyPrint,processIdandappName. -
The v12 structured params are a
ConsoleLoggerfeature and do not carry over. The Nest-compatible logger follows the Nest argument conventions, sologger.log('hello', { a: 1 }, 'Ctx')produces"context":{"a":1}(the object landed in the context field) andlogger.error('boom', 'stacktrace', 'Ctx')produces"stack":["stacktrace"]. Use the raw Winston logger (WINSTON_MODULE_PROVIDER) for metadata:this.winston.info('Payment captured (winston API)', { orderId: 'ord_1' })prints{"level":"info","message":"Payment captured (winston API)","orderId":"ord_1","requestId":"w-1",…}with the request id added by the custom format. -
A file transport needs a writable filesystem and, in the long run, rotation (a separate
winston-daily-rotate-filestyle transport). In containers, prefer writing JSON to stdout and letting the platform ship it, as the previous sections do.
Choosing
-
Start with the built-in
ConsoleLoggerin JSON mode,flattenParamsif your aggregator indexes top-level fields,redactfor credentials, and anAsyncLocalStorage(ornestjs-cls) request id. That is one dependency-free stdout stream, which containers, Kubernetes and cloud log collectors ingest as it is. -
Add Pino when throughput, serialisers or in-process transports matter, or when you want the request-bound logging that
nestjs-pinoprovides out of the box. -
Add Winston when you need several transports with different formats and levels in one process, or the application already uses it.
-
In every case keep application code on
new Logger(Context)so that swapping the implementation is a change inmain.tsonly.
Where the book is outdated
The reference book (see the section bibliography), published in January 2025, covers structured logging with Winston (chapter 8) and Pino logging for performance (chapter 17). Those chapters describe an external library as the way to get structured, JSON logs. Since then ConsoleLogger gained JSON output (NestJS 11) and structured params, plain objects attached to the entry (NestJS 12), so a dependency is no longer needed for the common case, and the documentation now says so. The built-in filterLogLevels() helper and the redact option of the 12.1 typings are newer still. Pino and Winston remain valid choices for the reasons given above. Check the library versions too: nestjs-pino 5 and nest-winston 2 are the majors that support NestJS 12, and nestjs-pino 5 requires Node.js 22.12 or later.
What changed recently
-
NestJS 11:
ConsoleLoggercan print JSON (json: true), withcolorsandcompactdefaulting accordingly. -
NestJS 12: structured logging params (
structuredParams, on by default, andflattenParams) are part ofConsoleLogger; passingstructuredParams: falserestores the old behaviour. See the migration guide for the list of 12.0 changes. -
NestJS 12.1: the 12.1.2 typings of
ConsoleLoggerinclude theredactoption and theNEST_LOG_LEVELenvironment variable, neither of which is on the logger documentation page. Thenestjs-pinoREADME says itsNativeLoggermatchesConsoleLoggerbehaviour forfatal()argument parsing, anerrorfield for error objects and lazy messages from NestJS 12.1.1 on. -
The documentation now positions the built-in logger plus NestJS Observe’s trace id (
@nestjs/observe, pre-1.0) as the default production setup, and Pino or Winston as extras.