Observabilidade
OpenTelemetry, Sentry, Prometheus metrics, structured logging — e os gotchas embutidos na ordem de bootstrap.
Última atualização 19 de jul. de 2026
O boilerplate entrega três surfaces de telemetria: traces (OTel), errors (Sentry), metrics (Prometheus), além de logs estruturados (pino). Cada uma é opt-in: deixe sua config vazia e o adapter no-opa sem quebrar o boot.
Logging — pino
apps/server/src/infrastructure/logger/. Pino com pretty output em dev, JSON em prod. Cada controller recebe um child logger via Awilix; requests HTTP têm um correlationId anexado por middleware, propagado para toda log line downstream daquela request.
logger.info({ userId, orgId }, 'created project');
logger.warn({ err }, 'Stripe webhook signature mismatch');Defina LOG_LEVEL=debug para ver queries do Prisma e dispatch de event-bus.
Tracing — OpenTelemetry
apps/server/src/otel-init.ts inicializa @opentelemetry/sdk-node com auto-instrumentations para Express, HTTP, Postgres, ioredis, BullMQ. Spans são exportados via OTLP para OTEL_EXPORTER_OTLP_ENDPOINT. Deixe o endpoint vazio e os traces ainda assim se constroem in-process — só não são enviados para lugar nenhum.
O gotcha da ordem de bootstrap
O OTel precisa ser importado antes de Express, Prisma, e Redis carregarem — caso contrário, as auto-instrumentations se anexam a funções que já foram resolvidas, e a maioria dos spans é silenciosamente descartada.
O primeiro import em apps/server/src/index.ts:
import './otel-init.js'; // PRECISA ser o primeiro.
import express from 'express';
// ...ESM hoista os imports acima de qualquer código no body, então você não pode consertar isso chamando startOtel() depois no arquivo. Mantenha ./otel-init.js como o primeiro import — não mova, não enfie entre outros.
OTEL_SERVICE_NAME default para mern-saas-server. Override por ambiente.
Errors — Sentry
O server usa @sentry/bun (não @sentry/node — o runtime é Bun). O client usa @sentry/nextjs com instrumentation-client.ts e instrumentation.ts.
SENTRY_DSN=https://[email protected]/...
SENTRY_TRACES_SAMPLE_RATE=0.1
APP_VERSION=Deixe SENTRY_DSN vazio para desabilitar reporte de erros completamente.
O gotcha do capture
logger.error(...) não auto-captura para o Sentry. Apenas escreve uma log line estruturada. Para erros que devem paginar alguém, use o helper:
import { captureError } from '@/infrastructure/observability/capture-error.js';
try {
await provider.charge(card);
} catch (err) {
captureError(logger, err, { userId, orgId, paymentId });
throw err;
}captureError(logger, err, ctx) loga com o pino logger fornecido e encaminha o erro para o SDK com o contexto como extra. Em test e dev (sem SENTRY_DSN) o encaminhamento ao Sentry é um no-op — ainda assim loga.
Só os erros inesperados chegam ao Sentry
O errorHandler terminal (apps/server/src/infrastructure/http/error-handler.js) classifica antes de capturar. Os erros de fluxo de controle esperados — ZodError (422) e os DomainError 4xx (NOT_FOUND 404, CONFLICT 409, VALIDATION_FAILED 422, UNAUTHORIZED 401, FORBIDDEN 403, RATE_LIMITED 429, QUOTA_EXCEEDED 402) — são respondidos com o seu status code e nunca enviados ao Sentry. Só as falhas inesperadas chegam ao funnel de captura: qualquer Error não classificado (→ 500) e InfrastructureError (→ 500), ambos passados por captureError com o correlation id e a rota do request como contexto.
Por isso existe um único middleware de erro terminal e nenhum handler de erro do Sentry à parte: capturar antes de classificar inundaria o canal com 404s e falhas de validação, treinando todos a ignorar os alertas.
Erros do cliente e falhas async
- JSON malformado (
entity.parse.failed) responde 400, um body grande demais (entity.too.large) 413, e umMulterError413 (LIMIT_FILE_SIZE) ou 400 — mantêm o status e não vão para o Sentry. - Express 4 ignora promises rejeitadas de middlewares/handlers
async.installAsyncErrorForwarding()(infrastructure/http/async-errors.ts) corrige oLayerdo Express uma vez no bootstrap, então uma rejeição chega aoerrorHandler(500 JSON + Sentry) em vez de derrubar o processo ou travar o request. - O worker standalone (
bun run worker) chamainitSentrycom o mesmo env da API; continua no-op semSENTRY_DSN. - Redis nunca é dependência dura de um request: os rate limiters falham abertos (o erro do store é logado e o request segue) e
RedisCacheStoredegrada para miss / no-op com um warning.
Metrics — Prometheus
apps/server/src/infrastructure/http/metrics.ts expõe um endpoint /metrics quando METRICS_ENABLED=true (default). Metrics default incluem:
- Contagem de requests HTTP + histograma de latência, labelado por route + method + status.
- Métricas de processo (CPU, memória, event loop lag).
- Counters do BullMQ por queue (waiting, active, completed, failed).
- Gauges do pool do Postgres via metrics extension do Prisma.
Faça scrape com Prometheus ou qualquer agente compatível (Grafana Agent, Datadog, etc.). O endpoint não tem auth — mantenha em uma rede privada.
Correlation IDs
Toda request recebe um x-correlation-id (de entrada se presente, gerado caso contrário). Ele é:
- Anexado a toda log line da request via o child logger.
- Retornado no header de response para que clients possam ecoar em bug reports.
- Definido como tag do Sentry e atributo de span do OTel.
Quando um usuário reporta um problema, peça o correlation id; você pode pivotar dali para logs, traces, e o evento do Sentry.
Dashboards sugeridos
As views de chart pré-definidas que você vai querer no Grafana / seu análogo:
- p50 / p95 / p99 de latência por route.
- Rate de 4xx / 5xx por route.
- BullMQ waiting + failed por queue.
- Contagem de subscriptions ativas (consultada do Postgres, não de uma metric — billing é a fonte de verdade).
- Auth attempts por minuto, segmentado por rate limiter de IP e email (sinal de brute force).
Health check
GET /health não tem auth, não toca o DB, e retorna { status: 'ok' }. Use como liveness probe. Para readiness (que depende de DB + Redis), use /ready.
GET /ready limita o ping ao Redis a 2 segundos, então um Redis travado reporta not-ready (503) em vez de travar a probe.