Imagine: the application crashes at two in the morning. You open the logs and see thousands of lines like Order confirmed: {"id":"abc","items":[...],"userId":...}. Finding the moment when things went wrong is a task of several hours.
Now a different picture: every log line is JSON with fields orderId, trace_id, userId. You filter by orderId in Loki in a second and reconstruct the entire chain of events.
The difference is in structured logs. In NestJS this is done through nestjs-pino.
Why console.log doesn't work in production
console.log writes unstructured text. This record has no fields — it can't be filtered by userId or orderId. It doesn't know which HTTP request it belongs to. There's no severity level, no trace identifier.
Log collection systems (Loki, ELK, Datadog) can parse JSON. If a log is a JSON object, every field becomes a filter. If it's free text — you need a regex that breaks at the slightest change of format.
nestjs-pino is the integration of pino with NestJS. Pino is one of the fastest loggers for Node.js: it buffers output and serializes objects only when needed.
Setup: JSON in production, colored output locally
The first step is to wire up nestjs-pino in the root module:
// app.module.ts
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'crypto';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization', '*.password', '*.email', '*.phone'],
genReqId: (req) =>
(req.headers['x-request-id'] as string) ?? randomUUID(),
autoLogging: true,
},
}),
],
})
export class AppModule {}
What happens here:
- In
development—pino-prettyformats the output with colors and human-readable time. Locally this is more convenient. - In
production— no formatting. Pino writes JSON on a single line per event. Loki and ELK parse it without any additional rules. redact— a list of paths that pino automatically replaces with[Redacted]before writing. This is a safeguard in case personal data somewhere in the code ends up in an object passed to the logger.genReqId— takesX-Request-Idfrom the header or generates a new UUID. This identifier will be present in every record related to the request.autoLogging: true— pino itself writes an access log for every HTTP request: method, path, status, response time.
A DI logger instead of new Logger()
In NestJS the logger is obtained through Dependency Injection — the same as any other service:
// order.service.ts
import { Injectable } from '@nestjs/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@Injectable()
export class OrderService {
constructor(
@InjectPinoLogger(OrderService.name)
private readonly logger: PinoLogger,
private readonly orderRepository: OrderRepository,
) {}
async confirm(orderId: string): Promise<Order> {
this.logger.info({ orderId }, 'confirming order');
const order = await this.orderRepository.findById(orderId);
order.confirm();
return this.orderRepository.save(order);
}
}
@InjectPinoLogger(OrderService.name) passes the class name to the logger — it appears in every record as the context field. If the class is renamed during a refactor, the field updates automatically.
Creating a logger via new Logger() or using console is bad practice: such a logger doesn't know about the current request and won't add requestId and trace_id to the records.
Structured fields instead of strings
The most common mistake is passing data through a template string:
// Bad: JSON.stringify runs always, even if the level is disabled
this.logger.info(`Order created: ${JSON.stringify(order)}`);
// Good: pino serializes the object only when the level is active
this.logger.info({ orderId: order.id, customerId: order.customerId }, 'order created');
When data is passed as an object as the first argument, pino adds each field to the top level of the JSON record. This lets you filter logs by orderId directly, without a regex.
When the level is disabled (for example, debug in production), pino doesn't serialize the object at all — this saves CPU on hot paths.
Levels: when to use what
Choosing a level isn't a formality. It determines what ends up in alerts:
| Level | When to use |
|---|---|
error | An unhandled error, a failed transaction, an unavailable external service. Always pass { err } — pino automatically includes the stack trace. |
warn | A degradation the system recovered from: the Circuit Breaker tripped, a retry, a fallback path was used. |
info | A significant business event: order confirmed, payment accepted, job started. |
debug | Details for debugging. Disabled in production, enabled for a specific module during an investigation. |
trace | Very verbose output. Never in production. |
// Examples of correct level usage
this.logger.error({ err, orderId }, 'payment charge failed');
this.logger.warn({ orderId, attempt }, 'payment provider retry');
this.logger.info({ orderId, amount: order.amount }, 'order confirmed');
this.logger.debug({ orderState: order }, 'aggregate state after confirm');
An important point: autoLogging: true already writes an access log for every HTTP request. Adding info in every controller method is extra noise. The info level inside a handler is needed only for critical operations like a payment.
requestId and trace_id in every record
The most valuable thing about nestjs-pino is that the logger knows about the current HTTP request. Under the hood it uses AsyncLocalStorage: a store that lives within a single request and is accessible from any function in its call chain.
Every record automatically gets:
req.id— the request identifier fromX-Request-Idor a generated UUID.trace_idandspan_id— added automatically when@opentelemetry/instrumentation-pinois installed.userId— added after JWT verification:
// auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(@InjectPinoLogger() private readonly logger: PinoLogger) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const payload = this.verifyToken(request.headers.authorization);
this.logger.assign({ userId: payload.sub });
return true;
}
}
logger.assign({ userId }) adds the field to the current AsyncLocalStorage context. All subsequent logs within this request — from a service, a repository, an external client — automatically get userId.
As a result, a record in Loki looks like this:
{
"time": "2026-05-25T22:30:00.123Z",
"level": "info",
"context": "OrderService",
"msg": "order confirmed",
"req": { "id": "0193a8f3-7c21-7e3f-9b4a-..." },
"trace_id": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
"userId": "user-42",
"orderId": "order-789",
"amount": 4990
}
By trace_id you can reconstruct the full call chain through tracing. By orderId — find all events related to a specific order.
Where to log: the service boundaries
Logs are needed at the boundaries — where the service communicates with the outside world:
Incoming HTTP requests — autoLogging already writes an access log. You only need to add info for critical commands:
@Post(':id/confirm')
async confirmOrder(@Param('id') orderId: string): Promise<void> {
this.logger.info({ orderId }, 'confirm order request received');
await this.confirmOrderUseCase.execute({ orderId });
}
Outgoing HTTP requests — log the call and the result:
async charge(orderId: string, amount: number): Promise<ChargeResult> {
this.logger.info({ orderId, amount }, 'calling payment provider charge');
try {
const result = await this.http.post('/charge', { orderId, amount });
return result.data;
} catch (err) {
this.logger.error({ err, orderId }, 'payment provider charge failed');
throw err;
}
}
Background jobs — log the start and end with the number of processed records:
async process(job: Job): Promise<void> {
this.logger.info({ jobId: job.id }, 'outbox relay started');
const count = await this.relay.publish();
this.logger.info({ jobId: job.id, count }, 'outbox relay completed');
}
In the middle of business logic, logs are needed only when an important decision is made or a degradation occurred. A record like "Entering method" or "Loaded N rows" is noise that gets in the way of finding what you need.
Personal data in logs
Personal data in logs is a serious problem: it ends up in log storage systems that may have broad access, it's hard to delete and it violates data protection requirements.
The rule is simple: email, phone, full name, address, tokens, passwords — never in logs in plain form.
// Bad — email and phone in the logs
this.logger.info(
{ email: user.email, phone: user.phone },
'customer registered',
);
// Good — only the internal identifier
this.logger.info({ customerId: user.id }, 'customer registered');
// Good — mask it if the context is needed for an investigation
this.logger.info(
{ customerId: user.id, emailMask: maskEmail(user.email) },
'email verification sent',
);
// maskEmail('john@example.com') → 'j***@example.com'
redact in the configuration is the second line of defense: if data does end up in an object, pino replaces it with [Redacted]. But relying on redact alone is unreliable — it's better not to pass personal data to the logger at all.
When logging payment operations — only identifiers, no card data:
// Bad — the payload may contain card data
this.logger.info({ chargeRequest }, 'charge request');
// Good — only orderId and amount
this.logger.info({ orderId: chargeRequest.orderId, amount: chargeRequest.amount }, 'charge request');
For errors, always pass the error object through { err }, not the message string:
// Bad — the stack trace is lost
this.logger.error(`Failed to charge: ${err.message}`);
// Good — pino expands { err } into type, message, stack
this.logger.error({ err, orderId }, 'payment charge failed');
In short
nestjs-pinois the standard for structured logging in NestJS: JSON in production,pino-prettylocally.console.logandnew Logger()don't provide request context — use@InjectPinoLogger.- Pass data as an object as the first argument, not through a template string — it's both faster and more convenient for filtering.
autoLogging: truewrites the access log automatically — don't duplicateinfoin every controller method.- AsyncLocalStorage in
nestjs-pinoprovidesrequestIdandtrace_idin every record without passing them explicitly. logger.assign({ userId })in a guard addsuserIdto all subsequent logs of the request.- Personal data — never in logs;
redactas an additional line of defense, not the main one. - Errors through
{ err }, noterr.message— otherwise the stack trace is lost.
What to read next
- Metrics — prom-client, RED/USE and standard labels
- Tracing — OTel NodeSDK and auto-instrumentations
- Health checks — terminus, liveness/readiness