SaaS Starter

Observabilidade

OpenTelemetry, Sentry, Prometheus metrics, structured logging — e os gotchas embutidos na ordem de bootstrap.

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(err, { userId, orgId, paymentId });
  throw err;
}

captureError(err, ctx) adiciona o contexto como tags + extra do Sentry, e então encaminha o erro para o SDK. Em test e dev (sem SENTRY_DSN) é um no-op.

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.

Nesta página