No post anterior a gente colocou filas com BullMQ rodando bonitinho no NestJS. Job entra, job sai, retry funciona. Em desenvolvimento, claro.
Agora imagina o seguinte cenário: 03h17 da manhã, seu celular vibra. Um cliente avisa que o e-mail de confirmação dele não chegou. Você abre o terminal, dá um docker logs api --tail 500 e o que aparece é isso aqui:
job processed
job processed
job processed
error
job processedBoa sorte descobrindo qual job era esse error no meio de outros 12 mil processados na última hora. Esse é o ponto em que a maioria dos projetos descobre, no piorzão, que rodar código em produção é diferente de entender o que ele tá fazendo em produção.
Esse post é sobre fechar esse buraco. Não com APM de R$ 2 mil/mês, não com 14 dashboards bonitos que ninguém olha. Só o tripé que importa: logs estruturados, métricas e tracing, com a stack mais enxuta que ainda te dá visibilidade real.
A gente vai passar por:
Por que
console.log(e a classeLoggerque você provavelmente tem no projeto) não escalaLogs estruturados com Pino no NestJS, mantendo a API que você já gosta
Métricas com Prometheus: os 4 sinais que importam e os 40 que não importam
Tracing distribuído com OpenTelemetry, e quando ele realmente vale o esforço
Health checks que não mentem
O que usar se você tá sozinho (free tier honesto) vs self-hosted
O que ignorar pra não virar engenheiro de dashboard
Bora.
1. Por que console.log não escala (e a sua classe Logger também não)
Vamos combinar uma coisa de cara: console.log em desenvolvimento é ótimo. Rápido, direto, mostra o que você precisa. O problema é levar essa mesma postura pra produção.
Em algum momento todo projeto chega num desses dois estágios.
Estágio 1. console.log cru espalhado pelo código. Funciona até o primeiro deploy.
Estágio 2. Uma classe Logger caprichada. Geralmente parece com isso:
export class Logger {
public constructor(public action: string) {}
private log(message: unknown, type: 'info' | 'error' | 'warn' | 'success' = 'info') {
if (isDevelopment()) {
console.log(
`%c${this.action.toUpperCase()} [${type.toUpperCase()}]`,
/* css de estilo do nível */,
'\n',
message,
);
}
}
public info(...message: unknown[]) { this.log(message, 'info'); }
public error(...message: unknown[]) { this.log(message, 'error'); }
public warn(...message: unknown[]) { this.log(message, 'warn'); }
public success(...message: unknown[]) { this.log(message, 'success'); }
}Honestamente? Isso já é melhor que console.log espalhado. Tem encapsulamento, tem o action pra dar contexto, tem níveis. Em dev, o console fica organizado e gostoso de ler.
Mas em produção, essa classe tem três furos que só aparecem quando o caldo entorna.
Furo 1: isDevelopment() esconde tudo em produção
Esse é o mais comum e o mais cruel. A intuição é "log em produção polui". A realidade é o oposto: em produção o log é mais importante, não menos. Em dev você tem o debugger, o hot reload, o console.log ad-hoc. Em prod, log é tudo que você tem.
O isDevelopment não deveria controlar se loga, e sim como loga:
Dev: formato bonito, colorido, humano.
Prod: JSON puro, uma linha por evento, pronto pra ser coletado.
Furo 2: saída em texto solto, não em JSON
Quando o seu app sobe pra produção, alguém vai querer buscar nos logs. "Mostra tudo que aconteceu com o userId=42 nos últimos 30 minutos." Com texto livre, você tá refém de grep e regex. Com JSON estruturado, você filtra por campo:
{"level":"error","time":"2026-05-19T14:32:11.221Z","action":"order.create","userId":42,"orderId":"ord_1a2b","msg":"payment gateway timeout"}Esse formato é o que ferramentas como Better Stack, Axiom, Loki, Datadog ou CloudWatch entendem nativamente. Sem isso, você tá pagando por uma ferramenta cara só pra fazer Ctrl+F.
Furo 3: zero correlação entre logs do mesmo request
Imagine 200 requisições por segundo entrando na sua API. Cada uma dispara uns 10 logs pelo caminho (controller, service, repository, fila, etc). Sem identificador único, você abre o log e vê 2000 linhas por segundo embaralhadas. Boa sorte montando a história de uma única requisição.
Isso se resolve com um requestId (ou traceId) gerado no início do request e propagado pra todo log emitido durante aquele ciclo. Vamos chegar lá daqui a pouco.
O resumo brutal
A sua classe Logger é um bom começo de API. O motor dela é que precisa mudar. Em vez de reescrever do zero, a gente faz uma cirurgia: mantém a interface POO que você já gosta e troca o console.log por um logger sério por baixo.
E o "sério" aqui tem nome: Pino.
2. Logs estruturados com Pino no NestJS
Por que Pino e não Winston
Comparação honesta, sem ideologia:
Critério | Pino | Winston |
|---|---|---|
Performance | ~5x mais rápido em benchmarks reais | Mais lento, faz mais coisa |
Formato padrão | JSON | Texto, JSON opcional |
Configuração | Mínima, opinionado | Flexível, mais código |
Ecossistema | Plugins focados (HTTP, NestJS, Fastify) | Mais transports prontos |
Pra 95% dos projetos Node/NestJS, Pino é a escolha certa. Winston só ganha em casos específicos onde você precisa de muitos transports customizados saindo da própria aplicação (e mesmo aí, a recomendação moderna é deixar o app cuspir JSON em stdout e outro processo cuidar do envio).
Instalação:
pnpm add nestjs-pino pino pino-http
pnpm add -D pino-prettyA configuração base
Cria um arquivo logger.config.ts:
import { LoggerModule } from 'nestjs-pino';
const isDev = process.env.NODE_ENV !== 'production';
export const loggerConfig = LoggerModule.forRoot({
pinoHttp: {
level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
transport: isDev
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss.l',
ignore: 'pid,hostname,req,res',
messageFormat: '{action} {msg}',
},
}
: undefined, // em prod: JSON puro pro stdout
redact: {
// não vaza credencial em log, nunca
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
remove: true,
},
customProps: (req) => ({
requestId: req.id,
}),
},
});Importa isso no AppModule:
@Module({
imports: [loggerConfig /* ... */],
})
export class AppModule {}Pronto. Cada request HTTP já tá ganhando log estruturado automaticamente, com requestId único, método, status e duração. Em dev você vê assim:
14:32:11.221 INFO request completed { requestId: 'a1b2c3', method: 'POST', url: '/orders', statusCode: 201, responseTime: 47 }Em prod, a mesma coisa vira:
{"level":30,"time":1747662731221,"requestId":"a1b2c3","method":"POST","url":"/orders","statusCode":201,"responseTime":47,"msg":"request completed"}Refatorando a sua classe Logger por cima do Pino
Aqui é onde a mágica acontece sem dor. Você não precisa abandonar a API POO que já usa em todo lugar. Só troca o motor:
// logger.ts
import pino, { Logger as PinoLogger } from 'pino';
type LogContext = Record<string, unknown>;
const isDev = process.env.NODE_ENV !== 'production';
const baseLogger = pino({
level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
transport: isDev
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } }
: undefined,
redact: {
paths: ['*.password', '*.token', '*.authorization'],
remove: true,
},
});
export class Logger {
private readonly pino: PinoLogger;
constructor(public action: string, context: LogContext = {}) {
this.pino = baseLogger.child({ action, ...context });
}
info(message: string, context?: LogContext) {
this.pino.info(context ?? {}, message);
}
warn(message: string, context?: LogContext) {
this.pino.warn(context ?? {}, message);
}
error(message: string, context?: LogContext | Error) {
if (context instanceof Error) {
this.pino.error({ err: context }, message);
return;
}
this.pino.error(context ?? {}, message);
}
success(message: string, context?: LogContext) {
// 'success' não é nível padrão; mapeia pra info com flag
this.pino.info({ ...context, success: true }, message);
}
/** Cria um logger filho herdando contexto (ex: por request) */
child(context: LogContext): Logger {
const next = Object.create(this) as Logger;
Object.assign(next, this, { pino: this.pino.child(context) });
return next;
}
}O uso continua o mesmo que você já tem hoje:
const log = new Logger('order.create');
log.info('starting order', { userId: 42, items: 3 });
log.error('payment failed', new Error('gateway timeout'));A diferença é que agora cada chamada vira JSON estruturado em produção, com timestamp ISO, nível numérico, redaction automática de campos sensíveis e, em breve, requestId propagado.
O que mudou na prática
Antes | Depois |
|---|---|
Texto colorido só em dev | JSON em prod, bonito em dev |
Some em produção | Aparece em produção, formato máquina-legível |
Sem campos pesquisáveis |
|
Senha vaza se você logar o objeto inteiro |
|
Cada log é uma ilha |
|
Bonus: correlação por request com AsyncLocalStorage
O nestjs-pino já injeta requestId em todo log emitido através do Logger do Nest. Mas e quando você instancia a sua classe Logger num service, longe do request? Como ela sabe o requestId?
A resposta é AsyncLocalStorage. O Node permite carregar contexto através de toda a cadeia assíncrona sem ter que passar parâmetro a parâmetro. Vou deixar a implementação completa pra próxima parte do post (envolve um interceptor mais um store global), mas o princípio é: no início do request, você cria um store com { requestId }, e o Logger lê esse store no constructor.
Resultado final: você instancia new Logger('payment.refund') lá no fundo de um service, e o log já sai marcado com o requestId do request que originou aquela cadeia. Sem mágica negra, sem passar requestId em 14 argumentos.