Ir para o conteúdo
·32 min de leitura

Observabilidade em Node.js: Logs, Métricas e Traces Sem Complexidade

Logging estruturado com Pino, métricas com Prometheus, tracing distribuído com OpenTelemetry. A stack de observabilidade que uso em apps Node.js de produção, do zero à visibilidade total.

Compartilhar:X / TwitterLinkedIn

Eu costumava pensar que observabilidade significava "adicionar alguns console.logs e verificá-los quando algo quebrar." Isso funcionou até não funcionar mais. O ponto de ruptura foi um incidente em produção onde nossa API estava retornando 200s mas os dados estavam desatualizados. Nenhum erro nos logs. Nenhuma exceção. Apenas respostas silenciosamente erradas porque um cache downstream ficou desatualizado e ninguém percebeu por quatro horas.

Foi quando aprendi a diferença entre monitoramento e observabilidade. Monitoramento diz que algo está errado. Observabilidade diz por que está errado. E a lacuna entre os dois é onde os incidentes de produção vivem.

Esta é a stack de observabilidade que adotei para serviços Node.js depois de experimentar a maioria das alternativas. Não é a configuração mais sofisticada do mundo, mas captura problemas antes que os usuários percebam, e quando algo escapa, consigo diagnosticar em minutos em vez de horas.

Os Três Pilares, e Por Que Você Precisa de Todos#

Todo mundo fala sobre os "três pilares da observabilidade" — logs, métricas e traces. O que ninguém diz é que cada pilar responde uma pergunta fundamentalmente diferente, e você precisa dos três porque nenhum pilar sozinho responde todas as perguntas.

Logs respondem: O que aconteceu?

Uma linha de log diz "às 14:23:07, o usuário 4821 solicitou /api/orders e recebeu um 500 porque a conexão do banco de dados expirou." É uma narrativa. Conta a história de um evento específico.

Métricas respondem: Quanto está acontecendo?

Uma métrica diz "nos últimos 5 minutos, o tempo de resposta p99 foi 2.3 segundos e a taxa de erro foi 4.7%." São dados agregados. Fala sobre a saúde do sistema como um todo, não sobre qualquer requisição individual.

Traces respondem: Para onde foi o tempo?

Um trace diz "esta requisição gastou 12ms no middleware Express, 3ms parseando o body, 847ms esperando o PostgreSQL, e 2ms serializando a resposta." É um waterfall. Diz exatamente onde está o gargalo, através de fronteiras de serviço.

Aqui está a implicação prática: quando seu pager toca às 3 da manhã, a sequência é quase sempre a mesma.

  1. Métricas dizem que algo está errado (pico na taxa de erros, aumento de latência)
  2. Logs dizem o que está acontecendo (mensagens de erro específicas, endpoints afetados)
  3. Traces dizem por quê (qual serviço downstream ou query de banco é o gargalo)

Se você só tem logs, saberá o que quebrou mas não quão ruim está. Se só tem métricas, saberá quão ruim mas não o que está causando. Se só tem traces, terá waterfalls lindos mas nenhuma maneira de saber quando olhar para eles.

Vamos construir cada um.

Logging Estruturado com Pino#

Por Que console.log Não É Suficiente#

Eu sei. Você tem usado console.log em produção e está "de boa." Deixe-me mostrar por que não está.

typescript
// O que você escreve
console.log("User login failed", email, error.message);
 
// O que acaba no seu arquivo de log
// User login failed john@example.com ECONNREFUSED
 
// Agora tente:
// 1. Buscar todas as falhas de login na última hora
// 2. Contar falhas por usuário
// 3. Filtrar apenas os erros ECONNREFUSED
// 4. Correlacionar isso com a requisição que disparou
// Boa sorte. É uma string não estruturada. Você está fazendo grep em texto.

Logging estruturado significa que cada entrada de log é um objeto JSON com campos consistentes. Em vez de uma string legível por humanos que é hostil a máquinas, você obtém um objeto legível por máquinas que também é legível por humanos (com as ferramentas certas).

typescript
// Como é o logging estruturado
{
  "level": 50,
  "time": 1709312587000,
  "msg": "User login failed",
  "email": "john@example.com",
  "error": "ECONNREFUSED",
  "requestId": "req-abc-123",
  "route": "POST /api/auth/login",
  "responseTime": 1247,
  "pid": 12345
}

Agora você pode consultar isso. level >= 50 AND msg = "User login failed" AND time > now() - 1h dá exatamente o que você precisa.

Pino vs Winston#

Usei ambos extensivamente. Aqui está a versão curta:

Winston é mais popular, mais flexível, tem mais transports, e é significativamente mais lento. Também encoraja padrões ruins — o sistema de "format" torna muito fácil criar logs não estruturados e formatados de forma bonita que ficam legais em desenvolvimento mas são imparsáveis em produção.

Pino é mais rápido (5-10x em benchmarks), opinativo sobre saída JSON, e segue a filosofia Unix: faça uma coisa bem (escrever JSON no stdout) e deixe outras ferramentas lidarem com o resto (transporte, formatação, agregação).

Eu uso Pino. A diferença de performance importa quando você está logando milhares de requisições por segundo, e a abordagem opinativa significa que todo desenvolvedor no time produz logs consistentes.

Setup Básico do Pino#

typescript
// src/lib/logger.ts
import pino from "pino";
 
const isProduction = process.env.NODE_ENV === "production";
 
export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
  // Em produção, apenas JSON no stdout. PM2/container runtime cuida do resto.
  // Em desenvolvimento, use pino-pretty para saída legível.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Campos padrão em toda linha de log
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Serializar objetos Error corretamente
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Redatar campos sensíveis
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

A opção redact é crítica. Sem ela, você eventualmente vai logar uma senha ou chave de API. Não é uma questão de se, é quando. Algum desenvolvedor vai adicionar logger.info({ body: req.body }, "incoming request") e de repente você está logando números de cartão de crédito. Redação é sua rede de segurança.

Níveis de Log: Use-os Corretamente#

typescript
// FATAL (60) - O processo está prestes a crashar. Acorde alguém.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - Algo falhou que não deveria. Investigue em breve.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - Algo inesperado mas tratado. Fique de olho.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - Operações normais que vale registrar. O log do "o que aconteceu".
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - Informação detalhada para debugging. Nunca em produção.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - Extremamente detalhado. Só quando você está desesperado.
logger.trace({ headers: req.headers }, "Incoming request headers");

A regra: se você está debatendo entre INFO e DEBUG, é DEBUG. Se está debatendo entre WARN e ERROR, pergunte-se: "Eu gostaria de ser alertado sobre isso às 3 da manhã?" Se sim, ERROR. Se não, WARN.

Child Loggers e Contexto de Requisição#

É aqui que o Pino realmente brilha. Um child logger herda toda a configuração do pai mas adiciona campos extras de contexto.

typescript
// Todo log deste child logger incluirá userId e sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// Saída inclui userId e sessionId automaticamente
 
userLogger.info({ page: "/settings" }, "User navigated");
// Saída inclui userId, sessionId, E page

Para servidores HTTP, você quer um child logger por requisição para que toda linha de log no ciclo de vida daquela requisição inclua o ID da requisição:

typescript
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const startTime = performance.now();
 
  // Anexar um child logger à requisição
  req.log = logger.child({
    requestId,
    method: req.method,
    url: req.originalUrl,
    userAgent: req.headers["user-agent"],
    ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
        || req.socket.remoteAddress,
  });
 
  // Definir o header de request ID na resposta para correlação
  res.setHeader("x-request-id", requestId);
 
  req.log.info("Request received");
 
  res.on("finish", () => {
    const duration = Math.round(performance.now() - startTime);
    const logMethod = res.statusCode >= 500 ? "error"
                    : res.statusCode >= 400 ? "warn"
                    : "info";
 
    req.log[logMethod]({
      statusCode: res.statusCode,
      duration,
      contentLength: res.getHeader("content-length"),
    }, "Request completed");
  });
 
  next();
}

AsyncLocalStorage para Propagação Automática de Contexto#

A abordagem de child logger funciona, mas exige que você passe req.log por toda chamada de função. Isso fica tedioso. AsyncLocalStorage resolve isso — fornece um armazenamento de contexto que segue o fluxo de execução assíncrono sem passagem explícita.

typescript
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
 
interface RequestContext {
  requestId: string;
  logger: Logger;
  userId?: string;
  startTime: number;
}
 
export const asyncContext = new AsyncLocalStorage<RequestContext>();
 
// Obter o logger contextual de qualquer lugar na call stack
export function getLogger(): Logger {
  const store = asyncContext.getStore();
  return store?.logger || logger;
}
 
export function getRequestId(): string | undefined {
  return asyncContext.getStore()?.requestId;
}
typescript
// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function asyncContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const requestLogger = logger.child({ requestId });
 
  const context = {
    requestId,
    logger: requestLogger,
    startTime: performance.now(),
  };
 
  asyncContext.run(context, () => {
    res.setHeader("x-request-id", requestId);
    next();
  });
}

Agora qualquer função, em qualquer lugar na call stack, pode obter o logger escopado à requisição:

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // Automaticamente tem requestId anexado!
 
  log.info({ orderId }, "Processing order");
 
  const items = await fetchOrderItems(orderId);
  log.debug({ itemCount: items.length }, "Order items fetched");
 
  const total = calculateTotal(items);
  log.info({ orderId, total }, "Order processed successfully");
 
  return { orderId, total, items };
}
 
// Sem necessidade de passar logger como parâmetro. Simplesmente funciona.

Agregação de Logs: Para Onde os Logs Vão?#

Em desenvolvimento, logs vão para stdout e pino-pretty os torna legíveis. Em produção, é mais nuançado.

O Caminho PM2#

Se você está rodando em um VPS com PM2 (que cobri no meu post sobre setup de VPS), PM2 captura stdout automaticamente:

bash
# Ver logs em tempo real
pm2 logs api --lines 100
 
# Logs ficam em ~/.pm2/logs/
# api-out.log  — stdout (seus logs JSON)
# api-error.log — stderr (exceções não tratadas, stack traces)

A rotação de logs nativa do PM2 previne problemas de espaço em disco:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

Enviando Logs para Loki ou Elasticsearch#

Para qualquer coisa além de um único servidor, você precisa de agregação centralizada de logs. As duas opções principais:

Grafana Loki — O "Prometheus para logs." Leve, indexa apenas labels (não texto completo), funciona maravilhosamente com Grafana. Minha recomendação para a maioria dos times.

Elasticsearch — Busca full-text em logs. Mais poderoso, mais consumo de recursos, mais overhead operacional. Use se você genuinamente precisa de busca full-text através de milhões de linhas de log.

Para Loki, o setup mais simples usa Promtail para enviar logs:

yaml
# /etc/promtail/config.yml
server:
  http_listen_port: 9080
 
positions:
  filename: /tmp/positions.yaml
 
clients:
  - url: http://loki:3100/loki/api/v1/push
 
scrape_configs:
  - job_name: node-api
    static_configs:
      - targets:
          - localhost
        labels:
          job: node-api
          environment: production
          __path__: /home/deploy/.pm2/logs/api-out.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            msg: msg
            service: service
      - labels:
          level:
          service:
      - timestamp:
          source: time
          format: UnixMs

O Formato NDJSON#

Pino produz Newline Delimited JSON (NDJSON) por padrão — um objeto JSON por linha, separado por \n. Isso é importante porque:

  1. Toda ferramenta de agregação de logs entende
  2. É streamável (você pode processar logs linha por linha sem buffering do arquivo inteiro)
  3. Ferramentas Unix padrão funcionam: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Nunca configure Pino para produzir JSON formatado em múltiplas linhas em produção. Você vai quebrar toda ferramenta no pipeline.

typescript
// ERRADO em produção — JSON multi-linha quebra processamento baseado em linhas
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// CERTO em produção — NDJSON, um objeto por linha
{"level":30,"time":1709312587000,"msg":"Request completed"}

Métricas com Prometheus#

Logs dizem o que aconteceu. Métricas dizem como o sistema está performando. A diferença é como a diferença entre ler cada transação no seu extrato bancário versus olhar o saldo da conta.

Os Quatro Tipos de Métrica#

Prometheus tem quatro tipos de métrica. Entender qual usar quando vai te poupar dos erros mais comuns.

Counter — Um valor que só sobe. Contagem de requisições, contagem de erros, bytes processados. Reseta para zero no restart.

typescript
// "Quantas requisições servimos?"
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Um valor que pode subir ou descer. Conexões atuais, tamanho da fila, temperatura, uso de heap.

typescript
// "Quantas conexões estão ativas agora?"
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Observa valores e os conta em buckets configuráveis. Duração da requisição, tamanho da resposta. É assim que você obtém percentis (p50, p95, p99).

typescript
// "Quanto tempo as requisições levam?" com buckets em 10ms, 50ms, 100ms, etc.
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

Summary — Similar ao Histogram mas calcula quantis no lado do cliente. Use Histogram em vez disso a menos que tenha uma razão específica para não usar. Summaries não podem ser agregados entre instâncias.

Setup Completo do prom-client#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Criar um registry customizado para não poluir o global
export const metricsRegistry = new Registry();
 
// Coletar métricas padrão do Node.js:
// - process_cpu_seconds_total
// - process_resident_memory_bytes
// - nodejs_heap_size_total_bytes
// - nodejs_active_handles_total
// - nodejs_eventloop_lag_seconds
// - nodejs_gc_duration_seconds
collectDefaultMetrics({
  register: metricsRegistry,
  prefix: "nodeapp_",
  // Coletar a cada 10 segundos
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- Métricas HTTP ---
 
export const httpRequestsTotal = new Counter({
  name: "nodeapp_http_requests_total",
  help: "Total number of HTTP requests received",
  labelNames: ["method", "route", "status_code"] as const,
  registers: [metricsRegistry],
});
 
export const httpRequestDuration = new Histogram({
  name: "nodeapp_http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [metricsRegistry],
});
 
export const httpRequestSizeBytes = new Histogram({
  name: "nodeapp_http_request_size_bytes",
  help: "Size of HTTP request bodies in bytes",
  labelNames: ["method", "route"] as const,
  buckets: [100, 1000, 10000, 100000, 1000000],
  registers: [metricsRegistry],
});
 
// --- Métricas de Negócio ---
 
export const ordersProcessed = new Counter({
  name: "nodeapp_orders_processed_total",
  help: "Total number of orders processed",
  labelNames: ["status"] as const, // "success", "failed", "refunded"
  registers: [metricsRegistry],
});
 
export const activeWebSocketConnections = new Gauge({
  name: "nodeapp_active_websocket_connections",
  help: "Number of active WebSocket connections",
  registers: [metricsRegistry],
});
 
export const externalApiDuration = new Histogram({
  name: "nodeapp_external_api_duration_seconds",
  help: "Duration of external API calls",
  labelNames: ["service", "endpoint", "status"] as const,
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
  registers: [metricsRegistry],
});
 
export const dbQueryDuration = new Histogram({
  name: "nodeapp_db_query_duration_seconds",
  help: "Duration of database queries",
  labelNames: ["operation", "table"] as const,
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [metricsRegistry],
});

O Middleware de Métricas#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normalizar rotas para evitar explosão de cardinalidade
// /api/users/123 → /api/users/:id
// Sem isso, Prometheus criará uma nova série temporal para cada ID de usuário
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Substituir segmentos dinâmicos comuns
  return route
    .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
    .replace(/\/\d+/g, "/:id")
    .replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Não rastrear métricas para o próprio endpoint de métricas
  if (req.path === "/metrics") {
    return next();
  }
 
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    const route = normalizeRoute(req);
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}

O Endpoint /metrics#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Proteção com auth básico — não exponha métricas publicamente
  const authHeader = req.headers.authorization;
  const expected = `Basic ${Buffer.from(
    `${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
  ).toString("base64")}`;
 
  if (!authHeader || authHeader !== expected) {
    res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
    return;
  }
 
  try {
    const metrics = await metricsRegistry.metrics();
    res.set("Content-Type", metricsRegistry.contentType);
    res.send(metrics);
  } catch (err) {
    res.status(500).send("Error collecting metrics");
  }
});
 
export default router;

Métricas de Negócio Customizadas São o Verdadeiro Poder#

As métricas padrão do Node.js (tamanho do heap, lag do event loop, duração do GC) são o básico. Elas falam sobre a saúde do runtime. Mas métricas de negócio falam sobre a saúde da aplicação.

typescript
// No seu serviço de pedidos
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Medir a chamada ao provedor de pagamento
    const paymentTimer = externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    });
 
    const charge = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
 
    paymentTimer({ status: "success" });
 
    ordersProcessed.inc({ status: "success" });
    return charge;
  } catch (err) {
    ordersProcessed.inc({ status: "failed" });
 
    externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    })({ status: "error" });
 
    throw err;
  }
}

Um pico em ordersProcessed{status="failed"} diz algo que nenhuma quantidade de métricas de CPU jamais dirá.

Cardinalidade de Labels: O Assassino Silencioso#

Uma palavra de cautela. Cada combinação única de valores de label cria uma nova série temporal. Se você adicionar um label userId ao seu contador de requisições HTTP, e tem 100.000 usuários, acabou de criar 100.000+ séries temporais. Prometheus vai travar.

Regras para labels:

  • Apenas baixa cardinalidade: método HTTP (7 valores), status code (5 categorias), rota (dezenas, não milhares)
  • Nunca use IDs de usuário, IDs de requisição, endereços IP ou timestamps como valores de label
  • Se não tem certeza, não adicione o label. Você sempre pode adicionar depois, mas remover exige mudar dashboards e alertas

Dashboards Grafana#

Prometheus armazena os dados. Grafana os visualiza. Aqui estão os painéis que coloco em todo dashboard de serviço Node.js.

O Dashboard Essencial#

1. Taxa de Requisições (requisições/segundo)

promql
rate(nodeapp_http_requests_total[5m])

Mostra o padrão de tráfego. Útil para identificar picos ou quedas repentinas.

2. Taxa de Erros (%)

promql
100 * (
  sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
  /
  sum(rate(nodeapp_http_requests_total[5m]))
)

O número mais importante. Se passar de 1%, algo está errado.

3. Latência p50 / p95 / p99

promql
histogram_quantile(0.99,
  sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)

p50 diz a experiência típica. p99 diz a pior experiência. Se p99 é 10x o p50, você tem um problema de latência de cauda.

4. Lag do Event Loop

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Se passar de 100ms, seu event loop está bloqueado. Provavelmente uma operação síncrona em um caminho assíncrono.

5. Uso de Heap

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Observe uma tendência constante de subida — isso é um memory leak. Picos durante GC são normais.

6. Handles Ativos

promql
nodeapp_nodejs_active_handles_total

File descriptors abertos, sockets, timers. Um número continuamente crescente significa que você está vazando handles — provavelmente não está fechando conexões de banco ou respostas HTTP.

Dashboard Grafana como Código#

Você pode versionar seus dashboards usando o recurso de provisionamento do Grafana:

yaml
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
  - name: "Node.js Services"
    orgId: 1
    folder: "Services"
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Exporte o JSON do seu dashboard do Grafana, commite no seu repo, e seu dashboard sobrevive a reinstalações do Grafana. Isso não é opcional para produção — é o mesmo princípio de infraestrutura como código.

Tracing Distribuído com OpenTelemetry#

Tracing é o pilar que a maioria dos times adota por último, e aquele que gostariam de ter adotado primeiro. Quando você tem múltiplos serviços conversando entre si (mesmo que seja apenas "servidor API + banco + Redis + API externa"), tracing mostra o quadro completo da jornada de uma requisição.

O Que É um Trace?#

Um trace é uma árvore de spans. Cada span representa uma unidade de trabalho — uma requisição HTTP, uma query de banco, uma chamada de função. Spans têm tempo de início, tempo de fim, status e atributos. Eles são vinculados por um trace ID que é propagado através de fronteiras de serviço.

Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│   ├── [Auth Service] validate-token (12ms)
│   ├── [Order Service] create-order (230ms)
│   │   ├── [PostgreSQL] INSERT INTO orders (15ms)
│   │   ├── [Redis] SET order:cache (2ms)
│   │   └── [Payment Service] charge (200ms)
│   │       ├── [Stripe API] POST /v1/charges (180ms)
│   │       └── [PostgreSQL] UPDATE orders SET status (8ms)
│   └── [Email Service] send-confirmation (async, 45ms)

Um olhar diz: a requisição de 250ms gastou 180ms esperando Stripe. É aí que você otimiza.

Setup do OpenTelemetry#

OpenTelemetry (OTel) é o padrão. Substituiu a paisagem fragmentada de clientes Jaeger, clientes Zipkin, e SDKs específicos de vendors com uma única API neutra de vendor.

typescript
// src/instrumentation.ts
// Este arquivo DEVE ser carregado antes de qualquer outro import.
// No Node.js, use a flag --require ou --import.
 
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
 
  // Enviar traces para seu coletor (Jaeger, Tempo, etc.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Opcionalmente enviar métricas pelo OTel também
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentação: cria spans automaticamente para
  // requisições HTTP, rotas Express, queries PostgreSQL, comandos Redis,
  // lookups DNS, e mais
  instrumentations: [
    getNodeAutoInstrumentations({
      // Desabilitar instrumentações barulhentas
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Configurar específicas
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Shutdown gracioso
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK shut down successfully"),
    (err) => console.error("Error shutting down OTel SDK", err)
  );
});

Inicie seu app com:

bash
node --import ./src/instrumentation.ts ./src/server.ts

É isso. Com zero mudanças no código da sua aplicação, agora você tem traces para toda requisição HTTP, toda query de banco, todo comando Redis.

Criação Manual de Spans#

Auto-instrumentação cobre chamadas de infraestrutura, mas às vezes você quer rastrear lógica de negócio:

typescript
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
export async function processOrder(orderId: string): Promise<Order> {
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
 
      // Este span se torna o pai de quaisquer queries de banco
      // ou chamadas HTTP auto-instrumentadas dentro destas funções
      const order = await fetchOrder(orderId);
      span.setAttribute("order.total", order.total);
      span.setAttribute("order.item_count", order.items.length);
 
      const validationResult = await tracer.startActiveSpan(
        "validateOrder",
        async (validationSpan) => {
          const result = await validateInventory(order);
          validationSpan.setAttribute("validation.passed", result.valid);
          if (!result.valid) {
            validationSpan.setStatus({
              code: SpanStatusCode.ERROR,
              message: `Validation failed: ${result.reason}`,
            });
          }
          validationSpan.end();
          return result;
        }
      );
 
      if (!validationResult.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: "Order validation failed",
        });
        throw new Error(validationResult.reason);
      }
 
      const payment = await processPayment(order);
      span.setAttribute("payment.id", payment.id);
 
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end();
    }
  });
}

Propagação de Contexto de Trace#

A mágica do tracing distribuído é que o trace ID segue a requisição entre serviços. Quando o Serviço A chama o Serviço B, o contexto de trace é automaticamente injetado nos headers HTTP (header traceparent conforme padrão W3C Trace Context).

A auto-instrumentação lida com isso para chamadas HTTP de saída. Mas se você está usando uma fila de mensagens, precisa propagar manualmente:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Ao publicar uma mensagem
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Injetar contexto de trace atual no carrier
  propagation.inject(context.active(), carrier);
 
  // Enviar tanto o payload quanto o contexto de trace
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Ao consumir uma mensagem
function consumeEvent(message: QueueMessage) {
  // Extrair o contexto de trace da mensagem
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Executar o handler dentro do contexto extraído
  // Agora quaisquer spans criados aqui serão filhos do trace original
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Para Onde Enviar Traces#

Jaeger — A opção open-source clássica. Boa interface, fácil de rodar localmente com Docker. Armazenamento de longo prazo limitado.

Grafana Tempo — Se você já usa Grafana e Loki, Tempo é a escolha natural para traces. Usa object storage (S3/GCS) para retenção de longo prazo economicamente viável.

Grafana Cloud / Datadog / Honeycomb — Se você não quer administrar infraestrutura. Mais caro, menos overhead operacional.

Para desenvolvimento local, Jaeger em Docker é perfeito:

yaml
# docker-compose.otel.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # Jaeger UI
      - "4318:4318"     # OTLP HTTP receiver
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Endpoints de Health Check#

Health checks são a forma mais simples de observabilidade e a primeira coisa que você deve implementar. Eles respondem uma pergunta: "Este serviço é capaz de servir requisições agora?"

Três Tipos de Health Check#

/health — Saúde geral. O processo está rodando e respondendo?

/ready — Prontidão. Este serviço pode lidar com tráfego? (Conectou ao banco? Carregou sua configuração? Aqueceu o cache?)

/live — Vivacidade. O processo está vivo e não está em deadlock? (Consegue responder a uma requisição simples dentro de um timeout?)

A distinção importa para Kubernetes, onde probes de liveness reiniciam containers travados e probes de readiness removem containers do load balancer durante startup ou falhas de dependência.

typescript
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
 
const router = Router();
 
interface HealthCheckResult {
  status: "ok" | "degraded" | "error";
  checks: Record<
    string,
    {
      status: "ok" | "error";
      latency?: number;
      message?: string;
    }
  >;
  uptime: number;
  timestamp: string;
  version: string;
}
 
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await pool.query("SELECT 1");
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await redis.ping();
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
export function createHealthRoutes(pool: Pool, redis: Redis) {
  // Liveness — apenas verifica se o processo pode responder
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Readiness — verifica todas as dependências
  router.get("/ready", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const allOk = db.ok && cache.ok;
 
    res.status(allOk ? 200 : 503).json({
      status: allOk ? "ok" : "not_ready",
      checks: {
        database: db,
        redis: cache,
      },
    });
  });
 
  // Health completo — status detalhado para dashboards e debugging
  router.get("/health", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const anyError = !db.ok || !cache.ok;
    const allError = !db.ok && !cache.ok;
 
    const result: HealthCheckResult = {
      status: allError ? "error" : anyError ? "degraded" : "ok",
      checks: {
        database: {
          status: db.ok ? "ok" : "error",
          latency: db.latency,
          ...(!db.ok && { message: "Connection failed" }),
        },
        redis: {
          status: cache.ok ? "ok" : "error",
          latency: cache.latency,
          ...(!cache.ok && { message: "Connection failed" }),
        },
      },
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      version: process.env.APP_VERSION || "unknown",
    };
 
    // Retornar 200 para ok/degraded (serviço ainda pode lidar com algum tráfego)
    // Retornar 503 para error (serviço deve ser removido da rotação)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Configuração de Probes Kubernetes#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Reiniciar após 3 falhas consecutivas (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Remover do LB após 2 falhas (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Dar até 150s para startup

Um erro comum: tornar a probe de liveness muito agressiva. Se sua probe de liveness verifica o banco, e o banco está temporariamente fora, Kubernetes reiniciará seu container. Mas reiniciar não vai consertar o banco. Agora você tem um crash loop em cima de uma falha de banco. Mantenha probes de liveness simples — elas devem apenas detectar processos em deadlock ou travados.

Rastreamento de Erros com Sentry#

Logs capturam erros que você esperava. Sentry captura os que você não esperava.

A diferença é importante. Você adiciona blocos try/catch em código que sabe que pode falhar. Mas os bugs que mais importam estão em código que você achava que era seguro. Rejeições de promise não tratadas, erros de tipo de respostas de API inesperadas, acesso a null em optional chains que não eram opcionais o suficiente.

Setup do Sentry para Node.js#

typescript
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
 
export function initSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || "development",
    release: process.env.APP_VERSION || "unknown",
 
    // Amostrar 10% das transações para monitoramento de performance
    // (100% em desenvolvimento)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Perfilar 100% das transações amostradas
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filtrar erros barulhentos
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Não enviar erros do desenvolvimento
    enabled: process.env.NODE_ENV === "production",
 
    // Filtrar problemas conhecidos que não são bugs
    ignoreErrors: [
      // Desconexões de cliente não são bugs
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bots enviando lixo
      "SyntaxError: Unexpected token",
    ],
 
    // Remover PII antes de enviar
    beforeSend(event) {
      // Remover endereços IP
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Remover query params sensíveis
      if (event.request?.query_string) {
        const params = new URLSearchParams(event.request.query_string);
        params.delete("token");
        params.delete("api_key");
        event.request.query_string = params.toString();
      }
 
      return event;
    },
  });
}

Error Handler Express com Sentry#

typescript
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
 
// O request handler do Sentry deve vir primeiro
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// O tracing handler do Sentry
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Seu error handler customizado vem por último
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Adicionar contexto customizado ao evento Sentry
  Sentry.withScope((scope) => {
    scope.setTag("route", req.route?.path || req.path);
    scope.setTag("method", req.method);
 
    if (req.user) {
      scope.setUser({
        id: req.user.id,
        // Não enviar email ou username para o Sentry
      });
    }
 
    // Adicionar breadcrumbs para debugging
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Logar o erro com contexto completo
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Enviar uma resposta de erro genérica
  // Nunca exponha detalhes do erro ao cliente em produção
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source Maps#

Sem source maps, Sentry mostra stack traces minificados/transpilados. Inútil. Faça upload de source maps durante seu build:

bash
# No seu pipeline CI/CD
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

Ou configure no seu bundler:

typescript
// vite.config.ts (ou equivalente)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Obrigatório para Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

O Custo de Rejeições de Promise Não Tratadas#

Desde o Node.js 15, rejeições de promise não tratadas crasham o processo por padrão. Isso é bom — força você a tratar erros. Mas você precisa de uma rede de segurança:

typescript
// src/server.ts — próximo ao topo do seu entry point
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Flush dos eventos Sentry antes de crashar
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});
 
process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception — crashing");
  Sentry.captureException(error);
 
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});

A parte importante: Sentry.flush() antes de process.exit(). Sem isso, o evento de erro pode não chegar ao Sentry antes do processo morrer.

Alertas: Os Alertas Que Realmente Importam#

Ter 200 métricas Prometheus e zero alertas é apenas monitoramento de vaidade. Ter 50 alertas que disparam todo dia é fadiga de alertas — você vai começar a ignorá-los, e então vai perder o que importa.

O objetivo é um pequeno número de alertas de alto sinal que significam "algo está genuinamente errado e um humano precisa olhar."

Configuração do AlertManager do Prometheus#

yaml
# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: $SLACK_WEBHOOK_URL
 
route:
  receiver: "slack-warnings"
  group_by: ["alertname", "service"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: "pagerduty-critical"
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: "slack-warnings"
 
receivers:
  - name: "pagerduty-critical"
    pagerduty_configs:
      - routing_key: $PAGERDUTY_ROUTING_KEY
        severity: critical
  - name: "slack-warnings"
    slack_configs:
      - channel: "#alerts"
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'

Os Alertas Que Realmente Me Acordam#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Alta taxa de erros — algo está quebrado
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
            /
            sum(rate(nodeapp_http_requests_total[5m]))
          ) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate above 1% for 5 minutes"
          description: "{{ $value | humanizePercentage }} of requests are returning 5xx"
 
      # Respostas lentas — usuários estão sofrendo
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "p99 latency above 1 second for 5 minutes"
          description: "p99 latency is {{ $value | humanizeDuration }}"
 
      # Memory leak — vai dar OOM em breve
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Heap usage above 80% for 10 minutes"
          description: "Heap usage is at {{ $value | humanizePercentage }}"
 
      # Processo está down
      - alert: ServiceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Node.js API is down"
 
  - name: node-api-warnings
    rules:
      # Event loop está ficando lento
      - alert: HighEventLoopLag
        expr: |
          nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Event loop lag above 100ms"
          description: "p99 event loop lag is {{ $value | humanizeDuration }}"
 
      # Tráfego caiu significativamente — possível problema de roteamento
      - alert: TrafficDrop
        expr: |
          sum(rate(nodeapp_http_requests_total[5m]))
          < (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Traffic dropped by more than 50% compared to 1 hour ago"
 
      # Queries de banco ficando lentas
      - alert: SlowDatabaseQueries
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "p99 database query time above 500ms"
          description: "Slow {{ $labels.operation }} queries: {{ $value | humanizeDuration }}"
 
      # API externa está falhando
      - alert: ExternalAPIFailures
        expr: |
          (
            sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
            /
            sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
          ) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "External API {{ $labels.service }} failing >10%"

Note a cláusula for em todo alerta. Sem ela, um único pico dispara um alerta. Um for de 5 minutos significa que a condição deve ser verdadeira por 5 minutos contínuos. Isso elimina ruído de blips momentâneos.

Higiene de Alertas#

Todo alerta deve passar neste teste:

  1. É acionável? Se ninguém pode fazer nada sobre isso, não alerte. Logue, coloque no dashboard, mas não acorde alguém.
  2. Requer intervenção humana? Se se auto-resolve (como um breve blip de rede), a cláusula for deveria filtrá-lo.
  3. Disparou nos últimos 30 dias? Se não, pode estar mal configurado ou o limiar está errado. Revise.
  4. Quando dispara, as pessoas se importam? Se o time regularmente ignora, remova ou ajuste o limiar.

Eu audito meus alertas trimestralmente. Cada alerta recebe um de três resultados: manter, ajustar limiar, ou deletar.

Juntando Tudo: A Aplicação Express#

Veja como todas as peças se encaixam em uma aplicação real:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Inicializar Sentry primeiro — antes de outros imports
initSentry();
 
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
  sentryRequestHandler,
  sentryTracingHandler,
  errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
 
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
 
// --- A Ordem dos Middleware Importa ---
 
// 1. Request handler do Sentry (deve ser primeiro)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Contexto async (cria contexto escopado por requisição)
app.use(asyncContextMiddleware);
 
// 3. Logging de requisição
app.use(requestLogger);
 
// 4. Coleta de métricas
app.use(metricsMiddleware);
 
// 5. Parsing de body
app.use(express.json({ limit: "1mb" }));
 
// --- Rotas ---
 
// Health checks (sem auth necessário)
app.use(createHealthRoutes(pool, redis));
 
// Métricas (protegido com auth básico)
app.use(metricsRouter);
 
// Rotas da API
app.use("/api", apiRouter);
 
// --- Tratamento de Erros ---
 
// Error handler do Sentry (deve ser antes do error handler customizado)
app.use(Sentry.Handlers.errorHandler());
 
// Error handler customizado (deve ser o último)
app.use(errorHandler);
 
// --- Iniciar ---
 
const port = parseInt(process.env.PORT || "3000", 10);
 
app.listen(port, () => {
  logger.info(
    {
      port,
      nodeEnv: process.env.NODE_ENV,
      version: process.env.APP_VERSION,
    },
    "Server started"
  );
});
 
// Shutdown gracioso
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Parar de aceitar novas conexões
  // Processar requisições em andamento (Express faz isso automaticamente)
 
  // Fechar pool do banco
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Fechar conexão Redis
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error closing Redis connection");
  });
 
  // Flush do Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown complete");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

A Stack Mínima Viável#

Tudo acima é a stack "completa." Você não precisa de tudo no primeiro dia. Veja como escalar sua observabilidade conforme seu projeto cresce.

Estágio 1: Projeto Pessoal / Desenvolvedor Solo#

Você precisa de três coisas:

  1. Logs de console estruturados — Use Pino, saída JSON no stdout. Mesmo que você esteja apenas lendo com pm2 logs, logs JSON são pesquisáveis e parsáveis.

  2. Um endpoint /health — Leva 5 minutos para implementar, te salva quando está debugando "ele está ao menos rodando?"

  3. Sentry tier gratuito — Captura os erros que você não antecipou. O tier gratuito dá 5.000 eventos/mês, que é suficiente para um projeto pessoal.

typescript
// Este é o setup mínimo. Menos de 50 linhas. Sem desculpas.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
 
const logger = pino({ level: "info" });
const app = express();
 
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
 
app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});
 
app.use("/api", apiRoutes);
 
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error({ err }, "Unhandled error");
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(3000, () => logger.info("Server started on port 3000"));

Estágio 2: Projeto Crescendo / Time Pequeno#

Adicione:

  1. Métricas Prometheus + Grafana — Quando "parece lento" não é suficiente e você precisa de dados. Comece com taxa de requisições, taxa de erros e latência p99.

  2. Agregação de logs — Quando fazer ssh no servidor e grep em arquivos para de escalar. Loki + Promtail se já estiver usando Grafana.

  3. Alertas básicos — Taxa de erros > 1%, p99 > 1s, serviço down. Três alertas. Só isso.

Estágio 3: Serviço de Produção / Múltiplos Serviços#

Adicione:

  1. Tracing distribuído com OpenTelemetry — Quando "a API está lenta" vira "qual dos 5 serviços que ela chama está lento?" Auto-instrumentação do OTel te dá 80% do valor com zero mudanças de código.

  2. Dashboard como código — Versione seus dashboards Grafana. Você vai se agradecer quando precisar recriá-los.

  3. Alertas estruturados — AlertManager com roteamento adequado, escalação e regras de silêncio.

  4. Métricas de negócio — Pedidos/segundo, taxa de conversão, profundidade de fila. As métricas que seu time de produto se importa.

O Que Pular#

  • Vendors de APM com preço por host — Em escala, o custo é absurdo. Open source (Prometheus + Grafana + Tempo + Loki) dá 95% da funcionalidade.
  • Níveis de log abaixo de INFO em produção — Você vai gerar terabytes de logs DEBUG e pagar pelo armazenamento. Use DEBUG apenas quando investigando problemas ativamente, depois desligue.
  • Métricas customizadas para tudo — Comece com o método RED (Rate, Errors, Duration) para cada serviço. Adicione métricas customizadas apenas quando tiver uma pergunta específica para responder.
  • Amostragem de traces complexa — Comece com uma taxa de amostragem simples (10% em produção). Amostragem adaptativa é uma otimização prematura para a maioria dos times.

Considerações Finais#

Observabilidade não é um produto que você compra ou uma ferramenta que instala. É uma prática. É a diferença entre operar seu serviço e torcer para que seu serviço opere sozinho.

A stack que descrevi aqui — Pino para logs, Prometheus para métricas, OpenTelemetry para traces, Sentry para erros, Grafana para visualização, AlertManager para alertas — não é o setup mais simples possível. Mas cada peça justifica seu lugar respondendo uma pergunta que as outras peças não conseguem.

Comece com logs estruturados e um endpoint de health. Adicione métricas quando precisar saber "quão ruim está." Adicione traces quando precisar saber "para onde está indo o tempo." Cada camada se constrói sobre a anterior, e nenhuma delas exige que você reescreva sua aplicação.

O melhor momento para adicionar observabilidade foi antes do seu último incidente de produção. O segundo melhor momento é agora.

Posts Relacionados