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.