pino: logging estruturado em Node.js

pino é o logger de mais alta performance para Node.js, até 10x mais rápido que winston, com output em JSON por padrão. Logs estruturados em JSON são indexáveis, filtráveis e fáceis de enviar para sistemas como Loki, Elasticsearch ou Datadog. Este guia cobre desde a configuração básica até contexto por request e integração com Grafana Loki.

Configurar pino no Express

Setup inicial com níveis e serializers customizados:

typescript
// npm install pino pino-http pino-pretty

// lib/logger.ts:
import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',

  // Em desenvolvimento: output legível (pino-pretty)
  // Em produção: JSON puro (mais performático)
  ...(process.env.NODE_ENV !== 'production' && {
    transport: {
      target: 'pino-pretty',
      options: {
        colorize: true,
        translateTime: 'SYS:standard',
        ignore: 'pid,hostname',
      },
    },
  }),

  // Serializers: controlar como objetos são logados
  serializers: {
    err: pino.stdSerializers.err,  // format padrão para erros
    req: (req) => ({
      method: req.method,
      url: req.url,
      // NUNCA logar: headers de auth, body com dados pessoais
    }),
    res: (res) => ({ statusCode: res.statusCode }),
  },

  // Campos base em todos os logs:
  base: {
    app: 'minha-api',
    env: process.env.NODE_ENV,
    version: process.env.npm_package_version,
  },
})

// Níveis disponíveis (crescente):
// trace → debug → info → warn → error → fatal
// Em produção: 'info' (debug/trace são muito verbosos)

Logging por request com contexto

Adicionar request_id a todos os logs de uma requisição:

typescript
// npm install pino-http

import pinoHttp from 'pino-http'
import { logger } from './lib/logger'
import { randomUUID } from 'crypto'

// Middleware pino-http — adiciona logger por request com request_id:
export const httpLogger = pinoHttp({
  logger,
  genReqId: () => randomUUID(),  // ID único por requisição

  // Customizar campos logados:
  customLogLevel: (req, res, err) => {
    if (err || res.statusCode >= 500) return 'error'
    if (res.statusCode >= 400) return 'warn'
    return 'info'
  },

  // Redactar informações sensíveis antes de logar:
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'req.body.senha',
      'req.body.password',
      'req.body.cartao',
    ],
    censor: '[REDACTED]',
  },

  // Não logar health checks (muito verbosos):
  autoLogging: {
    ignore: (req) => req.url === '/health' || req.url === '/metrics',
  },
})

app.use(httpLogger)

// Nos handlers: usar req.log (logger com contexto da requisição)
app.post('/api/pedidos', autenticar, async (req, res) => {
  req.log.info({ userId: req.usuario.userId }, 'Criando pedido')

  try {
    const pedido = await criarPedido(req.body)
    req.log.info({ pedidoId: pedido.id, valor: pedido.total }, 'Pedido criado')
    res.status(201).json(pedido)
  } catch (err) {
    req.log.error({ err }, 'Erro ao criar pedido')
    throw err
  }
})

Context propagation com AsyncLocalStorage

Propagar o request_id por toda a cadeia de chamadas (sem passar por parâmetro):

typescript
// lib/async-context.ts — propagar contexto sem drill-down:
import { AsyncLocalStorage } from 'async_hooks'
import pino from 'pino'
import { logger } from './logger'

interface RequestContext {
  requestId: string
  userId?: string
}

const storage = new AsyncLocalStorage<RequestContext>()

// Getter do logger com contexto do request atual:
export function getLogger(): pino.Logger {
  const ctx = storage.getStore()
  if (!ctx) return logger

  return logger.child({
    requestId: ctx.requestId,
    userId: ctx.userId,
  })
}

// Middleware para iniciar contexto por request:
export function contextMiddleware(req: Request, res: Response, next: NextFunction) {
  const ctx: RequestContext = {
    requestId: req.headers['x-request-id'] as string ?? randomUUID(),
    userId: undefined,
  }

  storage.run(ctx, () => {
    // Adicionar userId após autenticação:
    const originalJson = res.json.bind(res)
    res.json = (...args) => {
      const store = storage.getStore()
      if (store && req.usuario) store.userId = req.usuario.userId
      return originalJson(...args)
    }
    next()
  })
}

// Usar nos services (sem precisar receber o logger por parâmetro):
class PedidosService {
  async criar(dados: CriarPedidoDto) {
    const log = getLogger()  // automaticamente tem requestId e userId
    log.info({ dados }, 'Iniciando criação de pedido')
    // ...
  }
}

Enviar logs para Grafana Loki com Promtail

Coletar logs JSON do Node.js e indexar no Loki:

yaml
# Produção: gravar logs em arquivo e coletar com Promtail

# app: logar para stdout (Docker coleta automaticamente)
# Para arquivo: pino com pino-roll (rotação):
# npm install pino-roll
# node server.js | pino-roll /var/log/minha-api/app.log --frequency daily --size 100m

# promtail.yml — coletar logs de containers Docker:
server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  # Coletar logs de containers Docker:
  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
        filters:
          - name: status
            values: [running]
    relabel_configs:
      - source_labels: [__meta_docker_container_name]
        target_label: container
      - source_labels: [__meta_docker_compose_service]
        target_label: service
    pipeline_stages:
      # Parsear JSON (saída do pino):
      - json:
          expressions:
            level: level
            msg: msg
            time: time
            requestId: requestId
            userId: userId
      - labels:
          level:
          service:
      - timestamp:
          source: time
          format: Unix

# Queries LogQL no Grafana para buscar logs:
# Todos os erros da API:
# {service="minha-api"} | json | level="error"
# Logs de um request específico:
# {service="minha-api"} | json | requestId="abc-123"

Boas práticas de logging em produção

O que logar, como logar e o que nunca logar:

typescript
// ✅ O QUE LOGAR:

// Eventos de negócio importantes:
logger.info({ userId, pedidoId, valor }, 'Pedido criado com sucesso')
logger.info({ userId, plano }, 'Assinatura ativada')
logger.warn({ userId, tentativas }, 'Muitas tentativas de login falhadas')

// Erros e exceções (sempre com o erro completo):
logger.error({ err, userId, endpoint: req.url }, 'Erro inesperado no handler')

// Eventos de infraestrutura:
logger.info({ dbPool: pool.totalCount }, 'Conexão com PostgreSQL estabelecida')
logger.warn({ fila: 'emails', tamanho: queue.size }, 'Fila de emails crescendo')

// ❌ O QUE NUNCA LOGAR:
// Senhas, tokens, hashes de senha:
// logger.info({ senha: req.body.senha })  ← NUNCA

// Tokens JWT completos (podem ser usados para autenticação):
// logger.info({ token: req.headers.authorization })  ← NUNCA

// Dados pessoais sensíveis (CPF, cartão, endereço completo):
// logger.info({ cpf: usuario.cpf })  ← configurar redact ou mascarar

// Payloads enormes (podem causar out-of-memory):
// logger.debug({ body: req.body })  ← usar apenas campos específicos

// ✅ MASCARAR DADOS SENSÍVEIS:
function mascararCpf(cpf: string): string {
  return cpf.replace(/(d{3})d{3}d{3}(d{2})/, '$1.***.***-$2')
}

logger.info({
  cpf: mascararCpf(usuario.cpf),  // "123.***.***-09"
  email: usuario.email.replace(/(?<=.{3}).(?=[^@]*@)/, '*'),
}, 'Usuário autenticado')

Não quer configurar manualmente?

Implante o VPS para Node.js em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

pino usa um design assíncrono de I/O, serializa os objetos de log para JSON uma única vez e delega a escrita para um worker thread (pino-worker). winston serializa sincronamente em cada log.info(). Em APIs de alto throughput: a diferença é significativa, pino pode ser 5-10x mais rápido. Para desenvolvimento local (poucas requisições): a diferença é irrelevante. Para produção com centenas de req/s: pino reduz a latência adicionada pelo logger a microssegundos.

Ambos têm papéis distintos. request_id: identifica uma requisição HTTP específica, gerado pelo API gateway ou pelo pino-http. trace_id: identifica uma trace distribuída que pode abranger múltiplos serviços, gerado pelo OpenTelemetry ou Jaeger. Para monolito ou serviço único: request_id é suficiente. Para microserviços: use trace_id propagado via W3C Trace Context headers (traceparent), o trace_id é o mesmo em todos os serviços que participam de uma requisição distribuída.

"info" é o padrão correto para produção: registra eventos importantes sem spam. "debug" em produção: muito verboso, impacta performance, poluiu investigações, use apenas temporariamente para debug de incidentes específicos. "warn" para: configurações inadequadas, retentativas, limites próximos. "error" apenas para falhas reais que precisam de atenção. Nunca use "trace" em produção, registra todo detalhe interno e pode logar dados sensíveis inadvertidamente.

Sempre passe o erro como propriedade err (não como string): logger.error({ err }, "mensagem"). O serializer padrão do pino formata err com message, stack, name e code. Erros aninhados: logger.error({ err: { cause: originalError, err: wrapperError } }). Nunca: logger.error(err.message), perde o stack trace. O pino serializa automaticamente err instanceof Error corretamente quando você passa como objeto.

JSON é pesquisável e indexável: no Loki, você pode filtrar {service="api"} | json | level="error" | userId="123". Com texto livre: você usaria regex, mais lento e frágil. JSON garante schema consistente: cada log tem os mesmos campos em posições previsíveis. Ferramentas como Loki, Elasticsearch e Datadog processam JSON natively, sem precisar configurar parsers. A única desvantagem: logs JSON são menos legíveis no terminal, use pino-pretty em desenvolvimento.