SaaS Starter

Observabilidad

OpenTelemetry, Sentry, Prometheus metrics, structured logging — y los gotchas integrados al orden de bootstrap.

El boilerplate trae tres surfaces de telemetría: traces (OTel), errors (Sentry), metrics (Prometheus), más logs estructurados (pino). Cada una es opt-in: dejá su config vacía y el adapter no-opea sin romper el boot.

Logging — pino

apps/server/src/infrastructure/logger/. Pino con pretty output en dev, JSON en prod. Cada controller obtiene un child logger vía Awilix; las requests HTTP tienen un correlationId attacheado por middleware, propagado a cada log line downstream para esa request.

logger.info({ userId, orgId }, 'created project');
logger.warn({ err }, 'Stripe webhook signature mismatch');

Seteá LOG_LEVEL=debug para ver queries de Prisma y dispatch de event-bus.

Tracing — OpenTelemetry

apps/server/src/otel-init.ts inicializa @opentelemetry/sdk-node con auto-instrumentations para Express, HTTP, Postgres, ioredis, BullMQ. Los spans se exportan vía OTLP a OTEL_EXPORTER_OTLP_ENDPOINT. Dejá el endpoint sin definir y los traces igual se construyen in-process — sólo no se envían a ningún lado.

El gotcha del orden de bootstrap

OTel debe ser importado antes de que carguen Express, Prisma, y Redis — si no, las auto-instrumentations se attachean a funciones que ya fueron resueltas, y la mayoría de los spans se descartan silenciosamente.

El primer import en apps/server/src/index.ts:

import './otel-init.js';   // DEBE ser el primero.
import express from 'express';
// ...

ESM hoistea los imports por encima de cualquier código en el body, así que no podés arreglar esto llamando startOtel() más tarde en el archivo. Mantené ./otel-init.js como el primer import — no lo muevas, no lo encajones entre otros.

OTEL_SERVICE_NAME default a mern-saas-server. Override por entorno.

Errors — Sentry

El server usa @sentry/bun (no @sentry/node — el runtime es Bun). El client usa @sentry/nextjs con instrumentation-client.ts y instrumentation.ts.

SENTRY_DSN=https://[email protected]/...
SENTRY_TRACES_SAMPLE_RATE=0.1
APP_VERSION=

Dejá SENTRY_DSN vacío para deshabilitar el reporte de errores por completo.

El gotcha del capture

logger.error(...) no auto-captura a Sentry. Sólo escribe una log line estructurada. Para errores que deban paginar a alguien, usá el 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) agrega el contexto como tags + extra de Sentry, y luego forwardea el error al SDK. En test y dev (sin SENTRY_DSN) es un no-op.

Metrics — Prometheus

apps/server/src/infrastructure/http/metrics.ts expone un endpoint /metrics cuando METRICS_ENABLED=true (default). Las metrics default incluyen:

  • Conteo de requests HTTP + histograma de latencia, labelado por route + method + status.
  • Métricas de proceso (CPU, memoria, event loop lag).
  • Counters de BullMQ por queue (waiting, active, completed, failed).
  • Gauges del pool de Postgres vía la metrics extension de Prisma.

Scrapealo con Prometheus o cualquier agente compatible (Grafana Agent, Datadog, etc.). El endpoint no tiene auth — mantenelo en una red privada.

Correlation IDs

Toda request obtiene un x-correlation-id (entrante si está, generado si no). Es:

  • Attacheado a cada log line para la request vía el child logger.
  • Devuelto en el header de response para que los clients puedan reflejarlo en bug reports.
  • Seteado como tag de Sentry y atributo de span de OTel.

Cuando un user reporta un problema, pedile el correlation id; podés pivotar desde ahí a logs, traces, y el evento de Sentry.

Dashboards sugeridos

Las vistas de chart pre-definidas que vas a querer en Grafana / tu análogo:

  • p50 / p95 / p99 de latencia por route.
  • Rate de 4xx / 5xx por route.
  • BullMQ waiting + failed por queue.
  • Conteo de subscriptions activas (consultado desde Postgres, no desde una metric — billing es la fuente de verdad).
  • Auth attempts por minuto, segmentado por rate limiter de IP y email (señal de brute force).

Health check

GET /health no tiene auth, no toca la DB, y devuelve { status: 'ok' }. Usalo como liveness probe. Para readiness (que depende de DB + Redis), usá /ready.

En esta página