Logging with a Custom Logger
- nestjs
- nodejs
- typescript
- logging
- structured-logging
- observability
- backend-development
- software-architecture
In Rate Limiting and Throttling we protected the API from abuse and traffic spikes. That kind of defense only pays off if you can see what it's doing: which routes are getting throttled, for which tenants, and how often. That visibility comes from logging, and NestJS's default Logger gets you further than most people expect before it runs out of road.
This is part of the NestJS Production Guide, a series about the decisions that separate a NestJS app that works on a laptop from one a team can operate. Logging sits early in that list because almost every other production concern, health checks, rate limiting, error tracking, eventually needs to write something down, and it should all write it down the same way.
A custom logger is not glamorous work. It is also one of the cheapest investments you can make relative to how much it saves you during an incident at 2 a.m., when the difference between a five-minute fix and a two-hour one is whether the log line you need actually exists.
What NestJS gives you out of the box
NestJS ships with a built-in Logger class, and it's genuinely usable for a while. It writes to stdout, color-codes by level in development, and every framework-internal message (module initialization, route mapping, lifecycle events) already goes through it. If you call new Logger('UsersService') and log a line, you get a readable, timestamped message with the context name attached.
The problem shows up once the app leaves your laptop. The default logger writes plain text, not structured data, so anything downstream (CloudWatch, Datadog, an ELK stack) has to parse a string to pull out a request ID or a status code. It also has no concept of log levels being controlled by environment, no built-in redaction for sensitive fields, and no easy way to attach the same correlation ID to every log line produced while handling one request. None of that is a defect in the default logger. It does exactly what a development logger should do. Production logging is a different job.
The fix keeps NestJS's own logging system in place. Implement the LoggerService interface NestJS already defines, and plug your own implementation in.
Implementing the LoggerService interface
LoggerService is a small interface: log, error, warn, debug, and verbose. Anything that implements those five methods can be handed to NestFactory.create() as the app-wide logger. Here's a version that wraps a structured backend and adds the fields a real production log needs.
// src/common/logger/app-logger.service.ts
import { Injectable, LoggerService, Scope } from '@nestjs/common';
import pino from 'pino';
const REDACT_PATHS = ['password', 'token', 'authorization', 'refreshToken'];
@Injectable({ scope: Scope.TRANSIENT })
export class AppLogger implements LoggerService {
private context?: string;
private readonly instance = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: REDACT_PATHS,
base: { service: 'api' },
timestamp: pino.stdTimeFunctions.isoTime,
});
setContext(context: string) {
this.context = context;
}
log(message: unknown, context?: string) {
this.instance.info({ context: context ?? this.context }, this.stringify(message));
}
error(message: unknown, trace?: string, context?: string) {
this.instance.error(
{ context: context ?? this.context, trace },
this.stringify(message),
);
}
warn(message: unknown, context?: string) {
this.instance.warn({ context: context ?? this.context }, this.stringify(message));
}
debug(message: unknown, context?: string) {
this.instance.debug({ context: context ?? this.context }, this.stringify(message));
}
verbose(message: unknown, context?: string) {
this.instance.trace({ context: context ?? this.context }, this.stringify(message));
}
private stringify(message: unknown): string {
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
The Scope.TRANSIENT is worth calling out. It gives every injecting class its own instance of AppLogger, which is what lets setContext behave the way you'd expect: call it once in a service's constructor and every log line from that service carries its own context, without one shared instance's context bleeding into another's.
Wiring it in happens at bootstrap:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AppLogger } from './common/logger/app-logger.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
app.useLogger(app.get(AppLogger));
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
bufferLogs: true matters here. Without it, anything NestJS logs before useLogger runs (module initialization, route mapping) goes to the default logger instead of yours, and you get a split log stream on every boot. Buffering holds those early messages and replays them through your logger once it's attached.
Adding request-scoped context
A logger that tags every line with a service name is useful. One that also tags every line from a single HTTP request with the same correlation ID is what actually makes an incident tractable, because you can pull every log line touched by one request regardless of which service or module produced it.
The cleanest way to get that in NestJS is nestjs-cls, a library built around AsyncLocalStorage that lets you stash a value once per request and read it anywhere downstream without threading it through every function signature.
// src/common/interceptors/request-context.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { randomUUID } from 'crypto';
@Injectable()
export class RequestContextInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const requestId = req.headers['x-request-id'] ?? randomUUID();
this.cls.set('requestId', requestId);
return next.handle();
}
}
Then AppLogger reads that value on every call instead of relying only on what's passed in explicitly:
log(message: unknown, context?: string) {
this.instance.info(
{ context: context ?? this.context, requestId: this.cls.get('requestId') },
this.stringify(message),
);
}
Once that's wired up, every log line written while handling a given request carries the same requestId, which turns "find every log from this failed request" into a single filter instead of a manual reconstruction of the timeline.
Tradeoffs against just using a logging module directly
There's a legitimate alternative to writing your own LoggerService: pull in nestjs-pino or a similar package and let it wrap the framework's logging hooks for you, configuring redaction and pretty-printing through its options object rather than your own class. For a lot of teams that's the better starting point, since it's less code to maintain and the maintainers have already worked through edge cases like buffered logs and HTTP request/response serialization.
The reason to write a custom LoggerService instead is control. Once your logging needs grow past what a library's config surface exposes, whether that's routing certain log levels to a different sink, attaching organization-level context in a multi-tenant system, or swapping the underlying library (pino for winston, or vice versa) without touching call sites across the codebase, owning the class means that change happens in one file. The interface NestJS injects everywhere stays the same; only the implementation behind it moves.
The honest tradeoff is that a custom logger is one more piece of infrastructure code you own, test, and keep in sync with whatever library you're wrapping. For a small app with modest logging needs, reaching for nestjs-pino directly and configuring it well is often the right call, at least until you hit a wall its options can't get you past.
Production pitfalls
Logging at the wrong level. debug and verbose calls that make sense while developing locally turn into noise and cost once they run in production at high volume. Gate them behind LOG_LEVEL, and default that environment variable to info in production configs, not debug.
Forgetting redaction. It's easy to log an entire request body during development for convenience and forget that body sometimes contains a password or an API token. Redaction has to be part of the logger's baseline configuration, not something added after a security review catches it.
Synchronous logging on the hot path. Console-based logging or a naive file writer can block the event loop under load. Libraries like pino are built to minimize that cost, but a custom wrapper that adds expensive synchronous work per call (deep object cloning, unnecessary JSON.stringify calls on already-structured data) reintroduces the same problem.
Losing context across async boundaries. Passing a request ID through function arguments works until someone forgets to pass it three calls deep. AsyncLocalStorage-based solutions like nestjs-cls solve this at the cost of a small amount of indirection; it's worth that cost the first time it saves you from a broken trace during an incident.
Key takeaways
- NestJS's default `Logger` is fine for development but lacks structured output, redaction, and environment-driven levels, which production logging needs.
- Implementing `LoggerService` lets you plug a structured backend like pino behind the same interface NestJS uses internally, so framework logs and application logs go through one pipeline.
- Use `bufferLogs: true` in `NestFactory.create()` so early bootstrap logs aren't lost before your custom logger attaches.
- `AsyncLocalStorage`-based context (via `nestjs-cls` or similar) is what makes request-scoped correlation IDs practical without threading them through every function signature.
- A library like `nestjs-pino` is a reasonable default for smaller apps; write a custom `LoggerService` once you need control the library's config surface doesn't give you.
- Redaction and log-level discipline belong in the initial setup, not a follow-up fix after an incident or security review flags them.
Frequently asked questions
Do I need a custom logger if I'm already using nestjs-pino?
Not necessarily. `nestjs-pino` implements the `LoggerService` contract for you and exposes most of what a production app needs through configuration. Reach for a custom `LoggerService` when you need behavior its options don't cover, like routing specific log levels to a different destination or attaching context that's specific to your domain.
What's the difference between LoggerService and just injecting a pino instance directly?
Injecting pino directly works, but it bypasses the framework's own logging hooks, so NestJS's internal lifecycle logs (module initialization, route mapping) keep going through the default `Logger` while your application logs go elsewhere. Implementing `LoggerService` and calling `app.useLogger()` merges both into one stream.
Should log level be configurable per environment?
Yes. Set it through an environment variable like `LOG_LEVEL`, default to `info` in production and staging, and allow `debug` locally or in a controlled debugging session. Avoid shipping `debug` as the production default; the volume and cost aren't worth it for logs nobody reads.
How do I avoid logging sensitive data like passwords or tokens?
Configure redaction at the logging library level (pino's `redact` option, for example) rather than relying on every call site to remember not to log a sensitive field. Treat it as part of the logger's baseline setup, the same way you'd treat validation on an incoming DTO.
Is AsyncLocalStorage overhead a real concern?
There's a small per-request overhead, and it's generally worth it for the visibility it buys during incident response. If you're logging at very high throughput and profiling shows it as a bottleneck, that's a signal to measure specifically rather than avoid the pattern preemptively.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.