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:

TEXT
job processed
job processed
job processed
error
job processed

Boa 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 classe Logger que você provavelmente tem no projeto) não escala

  • Logs 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:

TS
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:

JSON
{"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:

BASH
pnpm add nestjs-pino pino pino-http
pnpm add -D pino-pretty

A configuração base

Cria um arquivo logger.config.ts:

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:

TS
@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:

TEXT
14:32:11.221 INFO  request completed { requestId: 'a1b2c3', method: 'POST', url: '/orders', statusCode: 201, responseTime: 47 }

Em prod, a mesma coisa vira:

JSON
{"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:

TS
// 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:

TS
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

action, userId, requestId viram filtros

Senha vaza se você logar o objeto inteiro

redact remove automaticamente

Cada log é uma ilha

child() propaga contexto

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.