Voltar ao blog Tutoriais

Observabilidade em Node.js: logs estruturados, health checks e métricas em produção

Como configurar logs JSON, health checks HTTP e métricas Prometheus em aplicações Node.js para saber exatamente o que acontece em produção.

9 min de leitura

Por Guara Cloud Editorial

Testado com Node.js 20 / Docker / pino 9.x / prom-client 15.x

Você já abriu os logs de produção tentando entender por que a API respondeu 500 e encontrou um console.log("erro aqui") sem timestamp, sem contexto e sem stack trace? Isso acontece em times de todos os tamanhos. Observabilidade não é sobre dashboards bonitos. É sobre conseguir responder “o que aconteceu?” sem precisar reproduzir o bug localmente.

Este tutorial cobre os três pilares que fazem diferença real no dia a dia: logs estruturados com pino, health checks honestos e métricas Prometheus para alertas. Nada de APM pago. Só o que uma aplicação Node.js comum precisa para você dormir tranquilo.

Resposta rápida

Para ter observabilidade em uma aplicação Node.js em produção, adicione três coisas: logs em formato JSON com pino (buscáveis na Guara Cloud), um endpoint /health que verifica banco e dependências (não só responde 200), e métricas Prometheus com prom-client para acompanhar latência, throughput e erros. A Guara Cloud coleta os logs de stdout automaticamente e pode raspar métricas via scraping endpoint.

Principais pontos

  • Troque console.log por pino. Logs JSON permitem filtrar por request ID, status code e duração direto na plataforma.
  • Seu health check precisa testar as dependências reais. Um /health que só retorna {"status":"ok"} não detecta banco fora do ar.
  • Métricas de latência em histograma são mais úteis que contadores simples. O percentil p99 mostra onde os usuários realmente sofrem.
  • Adicione requestId em cada log de requisição. Sem correlação entre logs, debugar um erro específico vira trabalho de arqueologia.

Quando se aplica

Qualquer API Node.js em produção com usuários reais. Não importa se é Express, Fastify, NestJS ou Hono. Se a aplicação recebe tráfego e você precisa responder incidentes, esses três componentes (logs, health checks, métricas) são o mínimo necessário.

Quando não se aplica

Se o projeto é um script batch que roda e termina, a parte de métricas e health checks perde sentido. Para microsserviços com mais de 10 serviços e milhares de requisições por segundo, você provavelmente vai querer distributed tracing com OpenTelemetry, o que é um passo além do que este tutorial cobre.

Antes de começar

  • Node.js 20 instalado localmente
  • Uma API Node.js existente (Express, Fastify ou similar)
  • Docker funcionando
  • Conta na Guara Cloud para deploy

1. Logs estruturados com pino

console.log funciona em desenvolvimento. Em produção, ele gera texto sem formato que ninguém consegue filtrar. O pino resolve isso com JSON estruturado e performance alta (é um dos loggers mais rápidos do ecossistema Node).

Instale o pino e o middleware de HTTP:

npm install pino pino-http

Configure o logger em src/logger.js:

import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: { service: 'minha-api' },
  timestamp: pino.stdTimeFunctions.isoTime,
});

Agora integre com o Express em src/app.js:

import express from 'express';
import pinoHttp from 'pino-http';
import { logger } from './logger.js';
import { randomUUID } from 'crypto';

const app = express();

app.use(pinoHttp({
  logger,
  genReqId: () => randomUUID(),
  customProps: () => ({ env: process.env.NODE_ENV }),
  customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`,
  customErrorMessage: (req, res, err) => `${req.method} ${req.url} ${res.statusCode} ${err.message}`,
}));

// Todos os logs agora incluem o requestId automaticamente
app.get('/pedidos', async (req, res) => {
  req.log.info({ filtros: req.query }, 'Buscando pedidos');
  // ...
});

Cada linha de log agora tem: timestamp ISO, nível, requestId, método, URL, status code e duração. Nos logs da Guara Cloud, você busca por exemplo requestId:"abc-123" e vê toda a jornada daquela requisição.

2. Health checks que testam algo de verdade

A maioria dos health checks que eu vejo em produção faz isso:

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

Isso diz que o processo está rodando. Não diz se o banco está acessível, se a API externa está respondendo, ou se a memória não estourou. Um health check honesto verifica as dependências:

import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.get('/health', async (req, res) => {
  const checks = {};
  let healthy = true;

  // Verifica banco de dados
  try {
    const start = Date.now();
    await pool.query('SELECT 1');
    checks.database = { status: 'up', latency: Date.now() - start };
  } catch (err) {
    checks.database = { status: 'down', error: err.message };
    healthy = false;
  }

  // Verifica serviço externo (se aplicável)
  try {
    const resp = await fetch(process.env.PAYMENT_API_URL + '/ping', {
      signal: AbortSignal.timeout(3000),
    });
    checks.paymentApi = { status: resp.ok ? 'up' : 'degraded' };
  } catch {
    checks.paymentApi = { status: 'down' };
    healthy = false;
  }

  checks.uptime = process.uptime();
  checks.memory = process.memoryUsage.rss();

  res.status(healthy ? 200 : 503).json(checks);
});

A Guara Cloud usa esse endpoint para decidir se o container está saudável. Se retorna 503, a plataforma reinicia o serviço e manda tráfego só quando ele volta. Isso é muito melhor que ter usuários recebendo erro e você descobrindo horas depois.

Um detalhe importante: o health check roda a cada 30 segundos por padrão. Queries pesadas nele vão sobrecarregar o banco. Mantenha as verificações leves (SELECT 1, não SELECT com JOIN).

Variáveis de ambiente para observabilidade

Nome Valor
LOG_LEVEL info (use debug só em staging)
NODE_ENV production
DATABASE_URL postgres://user:pass@host/db
METRICS_ENABLED true

3. Métricas Prometheus com prom-client

Métricas servem para duas coisas: entender padrões de uso (pico às 14h, latência piora depois do deploy) e configurar alertas automáticos. O prom-client é a biblioteca padrão para expor métricas no formato Prometheus.

npm install prom-client

Configure em src/metrics.js:

import client from 'prom-client';

// Coleta métricas padrão do Node (CPU, memória, event loop lag, GC)
client.collectDefaultMetrics({ prefix: 'nodejs_' });

export const httpRequestsTotal = new client.Counter({
  name: 'http_requests_total',
  help: 'Total de requisições HTTP por método, rota e status',
  labelNames: ['method', 'route', 'status'],
});

export const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duração de requisições HTTP em segundos',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

export const dbQueryDuration = new client.Histogram({
  name: 'db_query_duration_seconds',
  help: 'Duração de queries no banco de dados',
  labelNames: ['operation', 'table'],
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
});

Registre as métricas em cada requisição (middleware Express):

import { httpRequestsTotal, httpRequestDuration } from './metrics.js';

app.use((req, res, next) => {
  const end = httpRequestDuration.startTimer({
    method: req.method,
    route: req.route?.path || req.path,
  });

  res.on('finish', () => {
    const labels = {
      method: req.method,
      route: req.route?.path || req.path,
      status: res.statusCode,
    };
    httpRequestsTotal.inc(labels);
    end(labels);
  });

  next();
});

Exponha no endpoint /metrics:

import client from 'prom-client';

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', client.register.contentType);
  res.end(await client.register.metrics());
});

Com isso, você tem métricas de:

  • http_request_duration_seconds_bucket: percentis de latência por rota
  • http_requests_total: throughput por status (200, 404, 500)
  • nodejs_eventloop_lag_seconds: se o event loop está bloqueado
  • nodejs_heap_size_used_bytes: uso de memória

Um alerta útil baseado nessas métricas: “disparar se o p99 de latência passar de 2 segundos por mais de 5 minutos.” Isso pega degradação antes dos usuários reclamarem.

4. Proteja os endpoints internos

Metrics e health checks expõem informação sobre o sistema. Em ambientes com dados sensíveis, o /metrics pode revelar nomes de tabelas, padrões de rotas e volume de tráfego. Restrinja o acesso:

// Só responde se o header secreto está presente
app.use(['/metrics', '/health'], (req, res, next) => {
  const token = req.headers['x-internal-token'];
  if (token !== process.env.INTERNAL_TOKEN && req.ip !== '127.0.0.1') {
    return res.status(403).end();
  }
  next();
});

Na Guara Cloud, o scraping de métricas vem da rede interna da plataforma. O INTERNAL_TOKEN garante que só a infra autorizada acessa esses endpoints.

5. Dockerfile e deploy

O Dockerfile não precisa de nada especial para observabilidade. O importante é garantir que os logs vão para stdout (não para arquivo) e que os endpoints estão acessíveis:

FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY src/ ./src/

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "src/server.js"]

Publicando na Guara Cloud

  1. Push do Dockerfile e código para o repositório Git
  2. Crie um novo serviço na Guara Cloud apontando para o repositório
  3. Configure as variáveis de ambiente (LOG_LEVEL, DATABASE_URL, INTERNAL_TOKEN)
  4. Inicie o deploy e confira nos logs se os primeiros registros aparecem em JSON
  5. Acesse /health pela URL pública para confirmar que está retornando o status das dependências
Deploy via CLI
guara deploy --name minha-api --env LOG_LEVEL=info --env METRICS_ENABLED=true

Depois do deploy, os logs estruturados aparecem automaticamente no painel da Guara Cloud. Você pode filtrar por level:error, requestId, rota ou duração.

O que fazer com tudo isso

Ter logs, health checks e métricas funcionando é o início. O próximo passo é criar alertas. Alguns exemplos que eu uso e recomendo:

Taxa de erro alta: se mais de 5% das requisições retornarem 5xx nos últimos 5 minutos, notificar. Usa rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]).

Latência subindo: se o p99 passar de 1s por mais de 10 minutos. Usa histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[10m])).

Memory leak: se nodejs_heap_size_used_bytes cresce consistentemente por 2 horas sem liberar.

Na Guara Cloud, os logs já são coletados automaticamente. Você define filtros e alertas direto pela interface, sem precisar configurar um ELK separado.

Problemas comuns

Problema Os logs aparecem como texto puro, não JSON
Solução Verifique se está usando pino corretamente. Se usar console.log misturado com pino, os console.log não são formatados. Substitua todos os console por logger.
Problema O health check retorna 503 mesmo com tudo funcionando
Solução O timeout do health check (3s) pode ser curto se o banco está longe. Aumente para 5s ou use um endpoint de liveness que só checa o processo, não as dependências.
Problema O /metrics retorna erro 500
Solução Geralmente é um label duplicado no registro de métricas. Confira se cada combinação de nomes de métrica e labels é única.
Problema Alta cardinalidade nas métricas (muitos labels diferentes)
Solução Nunca use valores de usuário como label (userId, email). Use apenas valores finitos: method, route, status. Rotas com parâmetro (/:id) devem ser normalizadas.
Problema Logs muito verbosos e caros de armazenar
Solução Use LOG_LEVEL=info em produção e configure sampling para rotas de alto volume (health checks, endpoints de status não precisam ser logados).

Preciso de OpenTelemetry para observabilidade em Node.js?

Não necessariamente. OpenTelemetry é útil para distributed tracing, quando uma requisição passa por vários serviços. Se você tem um monolíto ou poucos serviços, pino + prom-client resolve 90% dos casos com muito menos complexidade.

Qual a diferença entre liveness e readiness probe?

Liveness verifica se o processo está vivo (responde rápido, sem dependências). Readiness verifica se está pronto para receber tráfego (banco conectado, cache carregado). Use /health/readiness para readiness e /health/liveness para liveness.

Devo logar o body da requisição?

Em produção, não. Logs com body aumentam volume de armazenamento e podem conter dados sensíveis (PII, tokens). Log só método, URL, status e duração. Se precisar do body para debug, use staging com LOG_LEVEL=debug.

Quanto de overhead o pino adiciona?

Praticamente zero. Pino serializa JSON em um buffer separado da thread principal e é considerado um dos loggers mais rápidos para Node.js. O impacto em latência é menor que 1ms por requisição.

Monitore suas aplicações na Guara Cloud

Logs estruturados coletados automaticamente, health checks gerenciados e métricas acessíveis. Tudo em infraestrutura brasileira com cobrança em Real.

Começar grátis