Saltar al contenido
·33 min de lectura

Observabilidad en Node.js: Logs, Métricas y Trazas sin Complejidad

Logging estructurado con Pino, métricas con Prometheus, trazas distribuidas con OpenTelemetry. El stack de observabilidad que uso para apps Node.js en producción, de cero a visibilidad total.

Compartir:X / TwitterLinkedIn

Solía pensar que la observabilidad significaba "añadir unos console.logs y revisarlos cuando algo se rompe". Eso funcionó hasta que dejó de funcionar. El punto de quiebre fue un incidente en producción donde nuestra API devolvía 200s pero los datos estaban obsoletos. Sin errores en los logs. Sin excepciones. Solo respuestas silenciosamente incorrectas porque una caché downstream se había vuelto obsoleta y nadie lo notó durante cuatro horas.

Fue entonces cuando aprendí la diferencia entre monitoreo y observabilidad. El monitoreo te dice que algo está mal. La observabilidad te dice por qué está mal. Y la brecha entre ambos es donde viven los incidentes de producción.

Este es el stack de observabilidad en el que me he decidido para servicios Node.js después de probar la mayoría de las alternativas. No es la configuración más sofisticada del mundo, pero detecta problemas antes de que los usuarios los noten, y cuando algo se escapa, puedo diagnosticarlo en minutos en lugar de horas.

Los tres pilares, y por qué necesitas todos#

Todo el mundo habla de los "tres pilares de la observabilidad" — logs, métricas y trazas. Lo que nadie te dice es que cada pilar responde a una pregunta fundamentalmente diferente, y necesitas los tres porque ningún pilar puede responder a todas las preguntas.

Logs responden: ¿Qué pasó?

Una línea de log dice "a las 14:23:07, el usuario 4821 solicitó /api/orders y recibió un 500 porque la conexión a la base de datos se agotó por tiempo". Es una narrativa. Te cuenta la historia de un evento específico.

Métricas responden: ¿Cuánto está pasando?

Una métrica dice "en los últimos 5 minutos, el tiempo de respuesta p99 fue de 2.3 segundos y la tasa de error fue del 4.7%". Son datos agregados. Te hablan de la salud del sistema en su conjunto, no de ninguna solicitud individual.

Trazas responden: ¿A dónde se fue el tiempo?

Una traza dice "esta solicitud pasó 12ms en el middleware de Express, 3ms parseando el body, 847ms esperando a PostgreSQL, y 2ms serializando la respuesta". Es una cascada. Te dice exactamente dónde está el cuello de botella, a través de los límites entre servicios.

Aquí está la implicación práctica: cuando tu buscapersonas suena a las 3 AM, la secuencia es casi siempre la misma.

  1. Métricas te dicen que algo está mal (pico en la tasa de error, aumento de latencia)
  2. Logs te dicen qué está pasando (mensajes de error específicos, endpoints afectados)
  3. Trazas te dicen por qué (qué servicio downstream o consulta de base de datos es el cuello de botella)

Si solo tienes logs, sabrás qué se rompió pero no qué tan grave es. Si solo tienes métricas, sabrás qué tan grave pero no qué lo está causando. Si solo tienes trazas, tendrás cascadas hermosas pero ninguna forma de saber cuándo mirarlas.

Construyamos cada uno.

Logging estructurado con Pino#

Por qué console.log no es suficiente#

Lo sé. Has estado usando console.log en producción y está "bien". Déjame mostrarte por qué no lo está.

typescript
// Lo que escribes
console.log("User login failed", email, error.message);
 
// Lo que termina en tu archivo de log
// User login failed john@example.com ECONNREFUSED
 
// Ahora intenta:
// 1. Buscar todos los fallos de login en la última hora
// 2. Contar fallos por usuario
// 3. Filtrar solo los errores ECONNREFUSED
// 4. Correlacionar esto con la solicitud que lo desencadenó
// Buena suerte. Es una cadena no estructurada. Estás haciendo grep en texto.

El logging estructurado significa que cada entrada de log es un objeto JSON con campos consistentes. En lugar de una cadena legible por humanos pero hostil para las máquinas, obtienes un objeto legible por máquinas que también es legible por humanos (con las herramientas adecuadas).

typescript
// Cómo se ve el logging estructurado
{
  "level": 50,
  "time": 1709312587000,
  "msg": "User login failed",
  "email": "john@example.com",
  "error": "ECONNREFUSED",
  "requestId": "req-abc-123",
  "route": "POST /api/auth/login",
  "responseTime": 1247,
  "pid": 12345
}

Ahora puedes consultar esto. level >= 50 AND msg = "User login failed" AND time > now() - 1h te da exactamente lo que necesitas.

Pino vs Winston#

He usado ambos extensamente. Aquí la versión corta:

Winston es más popular, más flexible, tiene más transports, y es significativamente más lento. También fomenta malos patrones — el sistema de "format" hace demasiado fácil crear logs no estructurados y pretty-printed que se ven bien en desarrollo pero son imposibles de parsear en producción.

Pino es más rápido (5-10x en benchmarks), tiene opiniones firmes sobre la salida JSON, y sigue la filosofía Unix: haz una cosa bien (escribe JSON a stdout) y deja que otras herramientas manejen el resto (transporte, formateo, agregación).

Yo uso Pino. La diferencia de rendimiento importa cuando estás registrando miles de solicitudes por segundo, y el enfoque con opiniones firmes significa que cada desarrollador del equipo produce logs consistentes.

Configuración básica de Pino#

typescript
// src/lib/logger.ts
import pino from "pino";
 
const isProduction = process.env.NODE_ENV === "production";
 
export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
  // En producción, solo JSON a stdout. PM2/runtime del contenedor maneja el resto.
  // En desarrollo, usa pino-pretty para salida legible por humanos.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Campos estándar en cada línea de log
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Serializar objetos Error correctamente
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Redactar campos sensibles
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

La opción redact es crítica. Sin ella, eventualmente registrarás una contraseña o clave de API. No es cuestión de si, sino de cuándo. Algún desarrollador añadirá logger.info({ body: req.body }, "incoming request") y de repente estarás registrando números de tarjetas de crédito. La redacción es tu red de seguridad.

Niveles de log: Úsalos correctamente#

typescript
// FATAL (60) - El proceso está a punto de crashear. Despierta a alguien.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - Algo falló que no debería haber fallado. Investiga pronto.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - Algo inesperado pero manejado. Mantenlo vigilado.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - Operaciones normales que vale la pena registrar. El log de "qué pasó".
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - Información detallada para depuración. Nunca en producción.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - Extremadamente detallado. Solo cuando estás desesperado.
logger.trace({ headers: req.headers }, "Incoming request headers");

La regla: si estás debatiendo entre INFO y DEBUG, es DEBUG. Si estás debatiendo entre WARN y ERROR, pregúntate: "¿Querría que me alerten sobre esto a las 3 AM?" Si sí, ERROR. Si no, WARN.

Child loggers y contexto de solicitud#

Aquí es donde Pino realmente brilla. Un child logger hereda toda la configuración del padre pero añade campos de contexto extra.

typescript
// Cada log de este child logger incluirá userId y sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// La salida incluye userId y sessionId automáticamente
 
userLogger.info({ page: "/settings" }, "User navigated");
// La salida incluye userId, sessionId, Y page

Para servidores HTTP, quieres un child logger por solicitud para que cada línea de log en el ciclo de vida de esa solicitud incluya el ID de solicitud:

typescript
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const startTime = performance.now();
 
  // Adjuntar un child logger a la solicitud
  req.log = logger.child({
    requestId,
    method: req.method,
    url: req.originalUrl,
    userAgent: req.headers["user-agent"],
    ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
        || req.socket.remoteAddress,
  });
 
  // Establecer el header de request ID en la respuesta para correlación
  res.setHeader("x-request-id", requestId);
 
  req.log.info("Request received");
 
  res.on("finish", () => {
    const duration = Math.round(performance.now() - startTime);
    const logMethod = res.statusCode >= 500 ? "error"
                    : res.statusCode >= 400 ? "warn"
                    : "info";
 
    req.log[logMethod]({
      statusCode: res.statusCode,
      duration,
      contentLength: res.getHeader("content-length"),
    }, "Request completed");
  });
 
  next();
}

AsyncLocalStorage para propagación automática de contexto#

El enfoque de child logger funciona, pero requiere que pases req.log a través de cada llamada de función. Eso se vuelve tedioso. AsyncLocalStorage resuelve esto — proporciona un almacén de contexto que sigue el flujo de ejecución asíncrono sin pasarlo explícitamente.

typescript
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
 
interface RequestContext {
  requestId: string;
  logger: Logger;
  userId?: string;
  startTime: number;
}
 
export const asyncContext = new AsyncLocalStorage<RequestContext>();
 
// Obtener el logger contextual desde cualquier lugar en la pila de llamadas
export function getLogger(): Logger {
  const store = asyncContext.getStore();
  return store?.logger || logger;
}
 
export function getRequestId(): string | undefined {
  return asyncContext.getStore()?.requestId;
}
typescript
// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function asyncContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const requestLogger = logger.child({ requestId });
 
  const context = {
    requestId,
    logger: requestLogger,
    startTime: performance.now(),
  };
 
  asyncContext.run(context, () => {
    res.setHeader("x-request-id", requestId);
    next();
  });
}

Ahora cualquier función, en cualquier lugar de la pila de llamadas, puede obtener el logger con alcance de solicitud:

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // ¡Automáticamente tiene requestId adjunto!
 
  log.info({ orderId }, "Processing order");
 
  const items = await fetchOrderItems(orderId);
  log.debug({ itemCount: items.length }, "Order items fetched");
 
  const total = calculateTotal(items);
  log.info({ orderId, total }, "Order processed successfully");
 
  return { orderId, total, items };
}
 
// No necesitas pasar el logger como parámetro. Simplemente funciona.

Agregación de logs: ¿A dónde van los logs?#

En desarrollo, los logs van a stdout y pino-pretty los hace legibles. En producción, es más matizado.

La ruta de PM2#

Si estás corriendo en un VPS con PM2 (que cubrí en mi post sobre configuración de VPS), PM2 captura stdout automáticamente:

bash
# Ver logs en tiempo real
pm2 logs api --lines 100
 
# Los logs se almacenan en ~/.pm2/logs/
# api-out.log  — stdout (tus logs JSON)
# api-error.log — stderr (excepciones no capturadas, stack traces)

La rotación de logs integrada de PM2 previene problemas de espacio en disco:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

Envío de logs a Loki o Elasticsearch#

Para cualquier cosa más allá de un solo servidor, necesitas agregación centralizada de logs. Las dos opciones principales:

Grafana Loki — El "Prometheus de los logs". Ligero, indexa solo etiquetas (no texto completo), funciona hermosamente con Grafana. Mi recomendación para la mayoría de los equipos.

Elasticsearch — Búsqueda de texto completo en logs. Más potente, consume más recursos, más overhead operacional. Usa esto si genuinamente necesitas búsqueda de texto completo en millones de líneas de log.

Para Loki, la configuración más simple usa Promtail para enviar logs:

yaml
# /etc/promtail/config.yml
server:
  http_listen_port: 9080
 
positions:
  filename: /tmp/positions.yaml
 
clients:
  - url: http://loki:3100/loki/api/v1/push
 
scrape_configs:
  - job_name: node-api
    static_configs:
      - targets:
          - localhost
        labels:
          job: node-api
          environment: production
          __path__: /home/deploy/.pm2/logs/api-out.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            msg: msg
            service: service
      - labels:
          level:
          service:
      - timestamp:
          source: time
          format: UnixMs

El formato NDJSON#

Pino produce Newline Delimited JSON (NDJSON) por defecto — un objeto JSON por línea, separado por \n. Esto es importante porque:

  1. Toda herramienta de agregación de logs lo entiende
  2. Es streamable (puedes procesar logs línea por línea sin hacer buffer del archivo completo)
  3. Las herramientas estándar de Unix funcionan con él: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Nunca configures Pino para producir JSON pretty-printed de múltiples líneas en producción. Romperás todas las herramientas del pipeline.

typescript
// MAL en producción — JSON de múltiples líneas rompe el procesamiento basado en líneas
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// BIEN en producción — NDJSON, un objeto por línea
{"level":30,"time":1709312587000,"msg":"Request completed"}

Métricas con Prometheus#

Los logs te dicen qué pasó. Las métricas te dicen cómo está rindiendo el sistema. La diferencia es como la diferencia entre leer cada transacción de tu estado de cuenta bancario versus mirar tu saldo.

Los cuatro tipos de métricas#

Prometheus tiene cuatro tipos de métricas. Entender cuál usar y cuándo te ahorrará los errores más comunes.

Counter — Un valor que solo sube. Conteo de solicitudes, conteo de errores, bytes procesados. Se reinicia a cero en cada restart.

typescript
// "¿Cuántas solicitudes hemos servido?"
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Un valor que puede subir o bajar. Conexiones actuales, tamaño de cola, temperatura, uso de heap.

typescript
// "¿Cuántas conexiones están activas ahora mismo?"
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Observa valores y los cuenta en buckets configurables. Duración de solicitudes, tamaño de respuesta. Así es como obtienes percentiles (p50, p95, p99).

typescript
// "¿Cuánto tardan las solicitudes?" con buckets en 10ms, 50ms, 100ms, etc.
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

Summary — Similar a Histogram pero calcula cuantiles del lado del cliente. Usa Histogram en su lugar a menos que tengas una razón específica para no hacerlo. Los Summaries no se pueden agregar entre instancias.

Configuración completa de prom-client#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Crear un registry personalizado para evitar contaminar el global
export const metricsRegistry = new Registry();
 
// Recopilar métricas por defecto de Node.js:
// - process_cpu_seconds_total
// - process_resident_memory_bytes
// - nodejs_heap_size_total_bytes
// - nodejs_active_handles_total
// - nodejs_eventloop_lag_seconds
// - nodejs_gc_duration_seconds
collectDefaultMetrics({
  register: metricsRegistry,
  prefix: "nodeapp_",
  // Recopilar cada 10 segundos
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- Métricas HTTP ---
 
export const httpRequestsTotal = new Counter({
  name: "nodeapp_http_requests_total",
  help: "Total number of HTTP requests received",
  labelNames: ["method", "route", "status_code"] as const,
  registers: [metricsRegistry],
});
 
export const httpRequestDuration = new Histogram({
  name: "nodeapp_http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [metricsRegistry],
});
 
export const httpRequestSizeBytes = new Histogram({
  name: "nodeapp_http_request_size_bytes",
  help: "Size of HTTP request bodies in bytes",
  labelNames: ["method", "route"] as const,
  buckets: [100, 1000, 10000, 100000, 1000000],
  registers: [metricsRegistry],
});
 
// --- Métricas de negocio ---
 
export const ordersProcessed = new Counter({
  name: "nodeapp_orders_processed_total",
  help: "Total number of orders processed",
  labelNames: ["status"] as const, // "success", "failed", "refunded"
  registers: [metricsRegistry],
});
 
export const activeWebSocketConnections = new Gauge({
  name: "nodeapp_active_websocket_connections",
  help: "Number of active WebSocket connections",
  registers: [metricsRegistry],
});
 
export const externalApiDuration = new Histogram({
  name: "nodeapp_external_api_duration_seconds",
  help: "Duration of external API calls",
  labelNames: ["service", "endpoint", "status"] as const,
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
  registers: [metricsRegistry],
});
 
export const dbQueryDuration = new Histogram({
  name: "nodeapp_db_query_duration_seconds",
  help: "Duration of database queries",
  labelNames: ["operation", "table"] as const,
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [metricsRegistry],
});

El middleware de métricas#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normalizar rutas para evitar explosión de cardinalidad
// /api/users/123 → /api/users/:id
// Sin esto, Prometheus creará una nueva serie temporal para cada ID de usuario
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Reemplazar segmentos dinámicos comunes
  return route
    .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
    .replace(/\/\d+/g, "/:id")
    .replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // No rastrear métricas para el endpoint de métricas en sí
  if (req.path === "/metrics") {
    return next();
  }
 
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    const route = normalizeRoute(req);
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}

El endpoint /metrics#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Protección con autenticación básica — no expongas métricas públicamente
  const authHeader = req.headers.authorization;
  const expected = `Basic ${Buffer.from(
    `${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
  ).toString("base64")}`;
 
  if (!authHeader || authHeader !== expected) {
    res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
    return;
  }
 
  try {
    const metrics = await metricsRegistry.metrics();
    res.set("Content-Type", metricsRegistry.contentType);
    res.send(metrics);
  } catch (err) {
    res.status(500).send("Error collecting metrics");
  }
});
 
export default router;

Las métricas de negocio personalizadas son el verdadero poder#

Las métricas por defecto de Node.js (tamaño del heap, lag del event loop, duración del GC) son lo básico. Te hablan de la salud del runtime. Pero las métricas de negocio te hablan de la salud de la aplicación.

typescript
// En tu servicio de órdenes
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Medir el tiempo de la llamada al proveedor de pagos
    const paymentTimer = externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    });
 
    const charge = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
 
    paymentTimer({ status: "success" });
 
    ordersProcessed.inc({ status: "success" });
    return charge;
  } catch (err) {
    ordersProcessed.inc({ status: "failed" });
 
    externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    })({ status: "error" });
 
    throw err;
  }
}

Un pico en ordersProcessed{status="failed"} te dice algo que ninguna cantidad de métricas de CPU te dirá jamás.

Cardinalidad de etiquetas: El asesino silencioso#

Una palabra de precaución. Cada combinación única de valores de etiqueta crea una nueva serie temporal. Si añades una etiqueta userId a tu contador de solicitudes HTTP, y tienes 100,000 usuarios, acabas de crear más de 100,000 series temporales. Prometheus se detendrá por completo.

Reglas para las etiquetas:

  • Solo baja cardinalidad: método HTTP (7 valores), código de estado (5 categorías), ruta (decenas, no miles)
  • Nunca uses IDs de usuario, IDs de solicitud, direcciones IP o timestamps como valores de etiqueta
  • Si no estás seguro, no añadas la etiqueta. Siempre puedes añadirla después, pero eliminarla requiere cambiar dashboards y alertas

Dashboards de Grafana#

Prometheus almacena los datos. Grafana los visualiza. Aquí están los paneles que pongo en cada dashboard de servicio Node.js.

El dashboard esencial#

1. Tasa de solicitudes (solicitudes/segundo)

promql
rate(nodeapp_http_requests_total[5m])

Muestra el patrón de tráfico. Útil para detectar picos o caídas repentinas.

2. Tasa de error (%)

promql
100 * (
  sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
  /
  sum(rate(nodeapp_http_requests_total[5m]))
)

El número más importante. Si esto supera el 1%, algo está mal.

3. Latencia p50 / p95 / p99

promql
histogram_quantile(0.99,
  sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)

p50 te dice la experiencia típica. p99 te dice la peor experiencia. Si p99 es 10x el p50, tienes un problema de latencia de cola.

4. Lag del event loop

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Si esto supera los 100ms, tu event loop está bloqueado. Probablemente una operación síncrona en un camino asíncrono.

5. Uso de heap

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Vigila una tendencia ascendente constante — eso es una fuga de memoria. Los picos durante el GC son normales.

6. Handles activos

promql
nodeapp_nodejs_active_handles_total

Descriptores de archivo abiertos, sockets, timers. Un número en crecimiento continuo significa que estás fugando handles — probablemente no estás cerrando conexiones de base de datos o respuestas HTTP.

Dashboard de Grafana como código#

Puedes versionar tus dashboards usando la función de aprovisionamiento de Grafana:

yaml
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
  - name: "Node.js Services"
    orgId: 1
    folder: "Services"
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Exporta el JSON de tu dashboard desde Grafana, haz commit en tu repositorio, y tu dashboard sobrevive reinstalaciones de Grafana. Esto no es opcional para producción — es el mismo principio que la infraestructura como código.

Trazas distribuidas con OpenTelemetry#

Las trazas son el pilar que la mayoría de los equipos adoptan de último, y el que desearían haber adoptado primero. Cuando tienes múltiples servicios hablando entre sí (incluso si es solo "servidor API + base de datos + Redis + API externa"), las trazas te muestran la imagen completa del viaje de una solicitud.

¿Qué es una traza?#

Una traza es un árbol de spans. Cada span representa una unidad de trabajo — una solicitud HTTP, una consulta a la base de datos, una llamada a función. Los spans tienen un tiempo de inicio, tiempo de fin, estado y atributos. Están enlazados por un trace ID que se propaga a través de los límites de servicio.

Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│   ├── [Auth Service] validate-token (12ms)
│   ├── [Order Service] create-order (230ms)
│   │   ├── [PostgreSQL] INSERT INTO orders (15ms)
│   │   ├── [Redis] SET order:cache (2ms)
│   │   └── [Payment Service] charge (200ms)
│   │       ├── [Stripe API] POST /v1/charges (180ms)
│   │       └── [PostgreSQL] UPDATE orders SET status (8ms)
│   └── [Email Service] send-confirmation (async, 45ms)

Con un vistazo sabes: la solicitud de 250ms pasó 180ms esperando a Stripe. Ahí es donde optimizas.

Configuración de OpenTelemetry#

OpenTelemetry (OTel) es el estándar. Reemplazó el panorama fragmentado de clientes Jaeger, clientes Zipkin y SDKs específicos de vendedor con una API única y neutral respecto al vendedor.

typescript
// src/instrumentation.ts
// Este archivo DEBE cargarse antes que cualquier otro import.
// En Node.js, usa el flag --require o --import.
 
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
 
  // Enviar trazas a tu collector (Jaeger, Tempo, etc.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Opcionalmente enviar métricas a través de OTel también
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentación: crea spans automáticamente para
  // solicitudes HTTP, rutas de Express, consultas PostgreSQL, comandos Redis,
  // búsquedas DNS, y más
  instrumentations: [
    getNodeAutoInstrumentations({
      // Deshabilitar instrumentaciones ruidosas
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Configurar las específicas
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Apagado graceful
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK shut down successfully"),
    (err) => console.error("Error shutting down OTel SDK", err)
  );
});

Inicia tu app con:

bash
node --import ./src/instrumentation.ts ./src/server.ts

Eso es todo. Con cero cambios en el código de tu aplicación, ahora tienes trazas para cada solicitud HTTP, cada consulta a la base de datos, cada comando Redis.

Creación manual de spans#

La auto-instrumentación cubre las llamadas de infraestructura, pero a veces quieres trazar la lógica de negocio:

typescript
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
export async function processOrder(orderId: string): Promise<Order> {
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
 
      // Este span se convierte en el padre de cualquier
      // consulta DB o llamada HTTP auto-instrumentada dentro de estas funciones
      const order = await fetchOrder(orderId);
      span.setAttribute("order.total", order.total);
      span.setAttribute("order.item_count", order.items.length);
 
      const validationResult = await tracer.startActiveSpan(
        "validateOrder",
        async (validationSpan) => {
          const result = await validateInventory(order);
          validationSpan.setAttribute("validation.passed", result.valid);
          if (!result.valid) {
            validationSpan.setStatus({
              code: SpanStatusCode.ERROR,
              message: `Validation failed: ${result.reason}`,
            });
          }
          validationSpan.end();
          return result;
        }
      );
 
      if (!validationResult.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: "Order validation failed",
        });
        throw new Error(validationResult.reason);
      }
 
      const payment = await processPayment(order);
      span.setAttribute("payment.id", payment.id);
 
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end();
    }
  });
}

Propagación de contexto de traza#

La magia de las trazas distribuidas es que el trace ID sigue la solicitud entre servicios. Cuando el Servicio A llama al Servicio B, el contexto de traza se inyecta automáticamente en los headers HTTP (header traceparent según el estándar W3C Trace Context).

La auto-instrumentación maneja esto para llamadas HTTP salientes. Pero si estás usando una cola de mensajes, necesitas propagar manualmente:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Al publicar un mensaje
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Inyectar el contexto de traza actual en el carrier
  propagation.inject(context.active(), carrier);
 
  // Enviar tanto el payload como el contexto de traza
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Al consumir un mensaje
function consumeEvent(message: QueueMessage) {
  // Extraer el contexto de traza del mensaje
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Ejecutar el handler dentro del contexto extraído
  // Ahora cualquier span creado aquí será hijo de la traza original
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Dónde enviar las trazas#

Jaeger — La opción open-source clásica. Buena UI, fácil de ejecutar localmente con Docker. Almacenamiento a largo plazo limitado.

Grafana Tempo — Si ya estás usando Grafana y Loki, Tempo es la elección natural para trazas. Usa almacenamiento de objetos (S3/GCS) para retención a largo plazo rentable.

Grafana Cloud / Datadog / Honeycomb — Si no quieres administrar infraestructura. Más caro, menos overhead operacional.

Para desarrollo local, Jaeger en Docker es perfecto:

yaml
# docker-compose.otel.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # Jaeger UI
      - "4318:4318"     # OTLP HTTP receiver
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Endpoints de health check#

Los health checks son la forma más simple de observabilidad y lo primero que deberías implementar. Responden a una pregunta: "¿Es este servicio capaz de servir solicitudes ahora mismo?"

Tres tipos de health checks#

/health — Salud general. ¿Está el proceso en ejecución y respondiendo?

/ready — Disponibilidad. ¿Puede este servicio manejar tráfico? (¿Se ha conectado a la base de datos? ¿Ha cargado su configuración? ¿Ha calentado su caché?)

/live — Vitalidad. ¿Está el proceso vivo y sin deadlocks? (¿Puede responder a una solicitud simple dentro de un timeout?)

La distinción importa para Kubernetes, donde las sondas de vitalidad reinician contenedores atascados y las sondas de disponibilidad eliminan contenedores del balanceador de carga durante el inicio o fallos de dependencias.

typescript
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
 
const router = Router();
 
interface HealthCheckResult {
  status: "ok" | "degraded" | "error";
  checks: Record<
    string,
    {
      status: "ok" | "error";
      latency?: number;
      message?: string;
    }
  >;
  uptime: number;
  timestamp: string;
  version: string;
}
 
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await pool.query("SELECT 1");
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await redis.ping();
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
export function createHealthRoutes(pool: Pool, redis: Redis) {
  // Vitalidad — solo comprobar si el proceso puede responder
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Disponibilidad — comprobar todas las dependencias
  router.get("/ready", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const allOk = db.ok && cache.ok;
 
    res.status(allOk ? 200 : 503).json({
      status: allOk ? "ok" : "not_ready",
      checks: {
        database: db,
        redis: cache,
      },
    });
  });
 
  // Salud completa — estado detallado para dashboards y depuración
  router.get("/health", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const anyError = !db.ok || !cache.ok;
    const allError = !db.ok && !cache.ok;
 
    const result: HealthCheckResult = {
      status: allError ? "error" : anyError ? "degraded" : "ok",
      checks: {
        database: {
          status: db.ok ? "ok" : "error",
          latency: db.latency,
          ...(!db.ok && { message: "Connection failed" }),
        },
        redis: {
          status: cache.ok ? "ok" : "error",
          latency: cache.latency,
          ...(!cache.ok && { message: "Connection failed" }),
        },
      },
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      version: process.env.APP_VERSION || "unknown",
    };
 
    // Devolver 200 para ok/degraded (el servicio aún puede manejar algo de tráfico)
    // Devolver 503 para error (el servicio debería ser removido de la rotación)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Configuración de sondas de Kubernetes#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Reiniciar después de 3 fallos consecutivos (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Remover del LB después de 2 fallos (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Dar hasta 150s para el arranque

Un error común: hacer la sonda de vitalidad demasiado agresiva. Si tu sonda de vitalidad comprueba la base de datos, y la base de datos está temporalmente caída, Kubernetes reiniciará tu contenedor. Pero reiniciar no arreglará la base de datos. Ahora tienes un crash loop encima de una caída de base de datos. Mantén las sondas de vitalidad simples — solo deberían detectar procesos en deadlock o atascados.

Seguimiento de errores con Sentry#

Los logs capturan los errores que esperabas. Sentry captura los que no.

La diferencia es importante. Añades bloques try/catch alrededor de código que sabes que podría fallar. Pero los bugs que más importan están en el código que pensabas que era seguro. Rechazos de promesas no manejados, errores de tipo por respuestas inesperadas de API, acceso a null en cadenas opcionales que no eran lo suficientemente opcionales.

Configuración de Sentry para Node.js#

typescript
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
 
export function initSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || "development",
    release: process.env.APP_VERSION || "unknown",
 
    // Muestrear el 10% de las transacciones para monitoreo de rendimiento
    // (100% en desarrollo)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Perfilar el 100% de las transacciones muestreadas
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filtrar errores ruidosos
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // No enviar errores desde desarrollo
    enabled: process.env.NODE_ENV === "production",
 
    // Filtrar problemas conocidos que no son bugs
    ignoreErrors: [
      // Las desconexiones de clientes no son bugs
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bots enviando basura
      "SyntaxError: Unexpected token",
    ],
 
    // Eliminar PII antes de enviar
    beforeSend(event) {
      // Eliminar direcciones IP
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Eliminar parámetros de consulta sensibles
      if (event.request?.query_string) {
        const params = new URLSearchParams(event.request.query_string);
        params.delete("token");
        params.delete("api_key");
        event.request.query_string = params.toString();
      }
 
      return event;
    },
  });
}

Handler de errores de Express con Sentry#

typescript
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
 
// El request handler de Sentry debe ir primero
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// El tracing handler de Sentry
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Tu handler de errores personalizado va al final
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Añadir contexto personalizado al evento de Sentry
  Sentry.withScope((scope) => {
    scope.setTag("route", req.route?.path || req.path);
    scope.setTag("method", req.method);
 
    if (req.user) {
      scope.setUser({
        id: req.user.id,
        // No enviar email o nombre de usuario a Sentry
      });
    }
 
    // Añadir breadcrumbs para depuración
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Registrar el error con contexto completo
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Enviar una respuesta de error genérica
  // Nunca expongas detalles del error al cliente en producción
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source maps#

Sin source maps, Sentry te muestra stack traces minificados/transpilados. Inútil. Sube los source maps durante tu build:

bash
# En tu pipeline de CI/CD
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

O configúralo en tu bundler:

typescript
// vite.config.ts (o equivalente)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Requerido para Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

El costo de los rechazos de promesas no manejados#

Desde Node.js 15, los rechazos de promesas no manejados crashean el proceso por defecto. Esto es bueno — te obliga a manejar errores. Pero necesitas una red de seguridad:

typescript
// src/server.ts — cerca del inicio de tu punto de entrada
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Vaciar los eventos de Sentry antes de crashear
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});
 
process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception — crashing");
  Sentry.captureException(error);
 
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});

La parte importante: Sentry.flush() antes de process.exit(). Sin esto, el evento de error podría no llegar a Sentry antes de que el proceso muera.

Alertas: Las alertas que realmente importan#

Tener 200 métricas de Prometheus y cero alertas es solo monitoreo de vanidad. Tener 50 alertas que se disparan todos los días es fatiga de alertas — empezarás a ignorarlas, y entonces te perderás la que importa.

El objetivo es un número pequeño de alertas de alta señal que signifiquen "algo está genuinamente mal y un humano necesita mirarlo".

Configuración de AlertManager de Prometheus#

yaml
# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: $SLACK_WEBHOOK_URL
 
route:
  receiver: "slack-warnings"
  group_by: ["alertname", "service"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: "pagerduty-critical"
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: "slack-warnings"
 
receivers:
  - name: "pagerduty-critical"
    pagerduty_configs:
      - routing_key: $PAGERDUTY_ROUTING_KEY
        severity: critical
  - name: "slack-warnings"
    slack_configs:
      - channel: "#alerts"
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'

Las alertas que realmente me despiertan#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Tasa de error alta — algo está roto
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
            /
            sum(rate(nodeapp_http_requests_total[5m]))
          ) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate above 1% for 5 minutes"
          description: "{{ $value | humanizePercentage }} of requests are returning 5xx"
 
      # Respuestas lentas — los usuarios están sufriendo
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "p99 latency above 1 second for 5 minutes"
          description: "p99 latency is {{ $value | humanizeDuration }}"
 
      # Fuga de memoria — OOM pronto
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Heap usage above 80% for 10 minutes"
          description: "Heap usage is at {{ $value | humanizePercentage }}"
 
      # El proceso está caído
      - alert: ServiceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Node.js API is down"
 
  - name: node-api-warnings
    rules:
      # El event loop se está volviendo lento
      - alert: HighEventLoopLag
        expr: |
          nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Event loop lag above 100ms"
          description: "p99 event loop lag is {{ $value | humanizeDuration }}"
 
      # El tráfico cayó significativamente — posible problema de enrutamiento
      - alert: TrafficDrop
        expr: |
          sum(rate(nodeapp_http_requests_total[5m]))
          < (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Traffic dropped by more than 50% compared to 1 hour ago"
 
      # Las consultas a la base de datos se están volviendo lentas
      - alert: SlowDatabaseQueries
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "p99 database query time above 500ms"
          description: "Slow {{ $labels.operation }} queries: {{ $value | humanizeDuration }}"
 
      # La API externa está fallando
      - alert: ExternalAPIFailures
        expr: |
          (
            sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
            /
            sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
          ) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "External API {{ $labels.service }} failing >10%"

Observa la cláusula for en cada alerta. Sin ella, un pico momentáneo dispara una alerta. Un for de 5 minutos significa que la condición debe ser verdadera durante 5 minutos continuos. Esto elimina el ruido de fluctuaciones momentáneas.

Higiene de alertas#

Cada alerta debe pasar esta prueba:

  1. ¿Es accionable? Si nadie puede hacer nada al respecto, no alertes. Regístralo, ponlo en un dashboard, pero no despiertes a alguien.
  2. ¿Requiere intervención humana? Si se auto-recupera (como un breve corte de red), la cláusula for debería filtrarlo.
  3. ¿Se ha disparado en los últimos 30 días? Si no, podría estar mal configurada o el umbral es incorrecto. Revísala.
  4. Cuando se dispara, ¿le importa al equipo? Si el equipo regularmente la descarta, elimínala o ajusta el umbral.

Hago auditoría de mis alertas trimestralmente. Cada alerta obtiene uno de tres resultados: mantener, ajustar umbral o eliminar.

Poniéndolo todo junto: La aplicación Express#

Así es como todas las piezas encajan en una aplicación real:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Inicializar Sentry primero — antes de otros imports
initSentry();
 
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
  sentryRequestHandler,
  sentryTracingHandler,
  errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
 
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
 
// --- El orden de los middleware importa ---
 
// 1. Request handler de Sentry (debe ser primero)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Contexto asíncrono (crea contexto con alcance de solicitud)
app.use(asyncContextMiddleware);
 
// 3. Logging de solicitudes
app.use(requestLogger);
 
// 4. Recopilación de métricas
app.use(metricsMiddleware);
 
// 5. Parseo del body
app.use(express.json({ limit: "1mb" }));
 
// --- Rutas ---
 
// Health checks (sin autenticación requerida)
app.use(createHealthRoutes(pool, redis));
 
// Métricas (protegidas con auth básica)
app.use(metricsRouter);
 
// Rutas de la API
app.use("/api", apiRouter);
 
// --- Manejo de errores ---
 
// Handler de errores de Sentry (debe ir antes del handler personalizado)
app.use(Sentry.Handlers.errorHandler());
 
// Handler de errores personalizado (debe ser el último)
app.use(errorHandler);
 
// --- Inicio ---
 
const port = parseInt(process.env.PORT || "3000", 10);
 
app.listen(port, () => {
  logger.info(
    {
      port,
      nodeEnv: process.env.NODE_ENV,
      version: process.env.APP_VERSION,
    },
    "Server started"
  );
});
 
// Apagado graceful
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Dejar de aceptar nuevas conexiones
  // Procesar solicitudes en vuelo (Express hace esto automáticamente)
 
  // Cerrar el pool de base de datos
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Cerrar la conexión Redis
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error closing Redis connection");
  });
 
  // Vaciar Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown complete");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

El stack mínimo viable#

Todo lo anterior es el stack "completo". No necesitas todo desde el día uno. Así es como escalar tu observabilidad a medida que tu proyecto crece.

Etapa 1: Proyecto personal / Desarrollador en solitario#

Necesitas tres cosas:

  1. Logs de consola estructurados — Usa Pino, produce JSON a stdout. Incluso si solo los estás leyendo con pm2 logs, los logs JSON son buscables y parseables.

  2. Un endpoint /health — Toma 5 minutos implementarlo, te salva cuando estás depurando "¿está siquiera corriendo?"

  3. Tier gratuito de Sentry — Captura los errores que no anticipaste. El tier gratuito te da 5,000 eventos/mes, que es suficiente para un proyecto personal.

typescript
// Esta es la configuración mínima. Menos de 50 líneas. Sin excusas.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
 
const logger = pino({ level: "info" });
const app = express();
 
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
 
app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});
 
app.use("/api", apiRoutes);
 
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error({ err }, "Unhandled error");
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(3000, () => logger.info("Server started on port 3000"));

Etapa 2: Proyecto en crecimiento / Equipo pequeño#

Añade:

  1. Métricas de Prometheus + Grafana — Cuando "se siente lento" no es suficiente y necesitas datos. Comienza con tasa de solicitudes, tasa de error y latencia p99.

  2. Agregación de logs — Cuando hacer ssh al servidor y grep en archivos deja de escalar. Loki + Promtail si ya estás usando Grafana.

  3. Alertas básicas — Tasa de error > 1%, p99 > 1s, servicio caído. Tres alertas. Eso es todo.

Etapa 3: Servicio en producción / Múltiples servicios#

Añade:

  1. Trazas distribuidas con OpenTelemetry — Cuando "la API está lenta" se convierte en "¿cuál de los 5 servicios que llama es el lento?" La auto-instrumentación de OTel te da el 80% del valor con cero cambios de código.

  2. Dashboard como código — Versiona tus dashboards de Grafana. Te lo agradecerás cuando necesites recrearlos.

  3. Alertas estructuradas — AlertManager con enrutamiento, escalamiento y reglas de silencio adecuados.

  4. Métricas de negocio — Órdenes/segundo, tasa de conversión, profundidad de cola. Las métricas que le importan a tu equipo de producto.

Qué omitir#

  • Vendedores de APM con precios por host — A escala, el costo es absurdo. Open source (Prometheus + Grafana + Tempo + Loki) te da el 95% de la funcionalidad.
  • Niveles de log por debajo de INFO en producción — Generarás terabytes de logs DEBUG y pagarás por el almacenamiento. Usa DEBUG solo cuando estés investigando activamente problemas, luego apágalo.
  • Métricas personalizadas para todo — Comienza con el método RED (Rate, Errors, Duration) para cada servicio. Añade métricas personalizadas solo cuando tengas una pregunta específica que responder.
  • Muestreo de trazas complejo — Comienza con una tasa de muestreo simple (10% en producción). El muestreo adaptativo es una optimización prematura para la mayoría de los equipos.

Reflexiones finales#

La observabilidad no es un producto que compras ni una herramienta que instalas. Es una práctica. Es la diferencia entre operar tu servicio y esperar que tu servicio se opere solo.

El stack que he descrito aquí — Pino para logs, Prometheus para métricas, OpenTelemetry para trazas, Sentry para errores, Grafana para visualización, AlertManager para alertas — no es la configuración más simple posible. Pero cada pieza se gana su lugar respondiendo a una pregunta que las otras piezas no pueden.

Comienza con logs estructurados y un endpoint de health. Añade métricas cuando necesites saber "¿qué tan grave es?" Añade trazas cuando necesites saber "¿a dónde se va el tiempo?" Cada capa se construye sobre la anterior, y ninguna requiere que reescribas tu aplicación.

El mejor momento para añadir observabilidad fue antes de tu último incidente en producción. El segundo mejor momento es ahora.

Artículos relacionados