Son las 11:40 de un jueves. Un cliente escribe: «he intentado pagar el pedido y me ha dado un error». Nada más. Con lo que tienes hoy en src/app.js —ese console.log de la posición 5 que llevamos cinco lecciones prometiendo sustituir— la investigación consiste en buscar a mano entre miles de líneas de texto plano algo parecido a POST /v1/pedidos/ped_5001/pago → 500 (312 ms), sin saber qué cliente era, sin poder filtrar por estado, sin ver el error que lo causó y sin manera de relacionarlo con la consulta SQL que falló.

Peor todavía: la mayoría de las veces ni siquiera te enteras. Los problemas se descubren porque un cliente se queja, no porque el sistema avise. Y las decisiones que llevamos tomando desde 04-01 —los límites de 04-04, la caché de 04-06, la deuda de diseño de 04-01— dependen de datos que ahora mismo no estás recogiendo.

Esta lección cierra el módulo 4 con la capa que hace gobernable todo lo demás. Sustituiremos el console.log por pino con logs estructurados y correlación por trazaId, instrumentaremos la API con prom-client y expondremos /metricas, veremos las trazas distribuidas de un POST /v1/pedidos completo, distinguiremos liveness de readiness en /salud, y definiremos qué alertar y qué mirar el día del lanzamiento.

Advertencia. Los logs son uno de los lugares donde más fácilmente se filtran datos personales y credenciales, con consecuencias legales bajo el RGPD. La redacción de campos sensibles y la política de retención de esta lección son un punto de partida; cualquier despliegue real requiere revisión de compliance y de seguridad. Todos los datos son ficticios.

Contenido

  1. Monitorización frente a observabilidad
  2. Los tres pilares y qué pregunta responde cada uno
  3. Logs estructurados: por qué JSON y no texto
  4. pino y el logger de Tienda Aroma
  5. Qué registrar siempre y qué no registrar nunca
  6. Redacción automática de campos sensibles
  7. El middleware src/middleware/registro.js
  8. Correlación extremo a extremo con Aroma-Traza-Id
  9. Errores 5xx: qué se loguea frente a qué se devuelve
  10. Métricas: las cuatro señales de oro y el método RED
  11. Tipos de métrica y por qué el histograma
  12. Instrumentar con prom-client y exponer /metricas
  13. Cardinalidad: por qué ped_5001 revienta el sistema
  14. Trazas distribuidas: spans, contexto y traceparent
  15. Una traza de POST /v1/pedidos
  16. Comprobaciones de salud: liveness y readiness
  17. Alertas útiles, SLO y presupuesto de error
  18. El panel mínimo del día del lanzamiento
  19. El stack habitual
  20. Balance del módulo 4

  1. Monitorización frente a observabilidad

Monitorización Observabilidad
Pregunta ¿Está bien lo que ya sé mirar? ¿Qué está pasando, sea lo que sea?
Se basa en Umbrales sobre métricas conocidas Datos suficientes para responder preguntas nuevas
Fallos que detecta Los que anticipaste También los que no
Ejemplo «CPU > 80 %» «¿Por qué los pagos de clientes con más de 3 líneas tardan 2 s desde el martes?»

La monitorización es necesaria y no basta. Los sistemas modernos fallan de formas que nadie previó: una interacción entre el rate limiting y un cliente concreto, una consulta que se degrada solo con cierta distribución de datos, un webhook que se atasca únicamente cuando RápidoEnvíos va lento.

La definición operativa que sirve para trabajar: un sistema es observable si puedes responder preguntas nuevas sobre su comportamiento sin desplegar código nuevo. Si para entender un incidente tienes que añadir un console.log y esperar a que vuelva a pasar, tu sistema no es observable.

  1. Los tres pilares y qué pregunta responde cada uno

Pilar Responde a Granularidad Coste Ejemplo en Aroma
Logs ¿Qué pasó exactamente en este caso? Un evento Alto por volumen «El pago de ped_5001 falló: la pasarela devolvió timeout»
Métricas ¿Cómo va el sistema en conjunto? Agregada Muy bajo «El 2,3 % de los pagos falla; hace una hora era el 0,1 %»
Trazas ¿Dónde se fue el tiempo en esta petición? Una petición, entre servicios Medio (muestreo) «De los 312 ms, 280 los consumió el gRPC de inventario»

El flujo de trabajo real de un incidente los usa en este orden:

  1. Una métrica dispara la alerta: la tasa de error de POST /v1/pedidos/{id}/pago ha subido.
  2. Una traza de una petición fallida muestra dónde se rompe: la llamada a la pasarela.
  3. Un log con el trazaId de esa traza da el detalle: el mensaje exacto del error y el clienteId.

Por eso los tres tienen que estar correlacionados. Tres sistemas excelentes sin un identificador común valen mucho menos que tres sistemas mediocres que comparten el trazaId. Y ese identificador ya lo tenemos: Aroma-Traza-Id, desde 03-02.

  1. Logs estructurados: por qué JSON y no texto

Lo que emite hoy nuestro middleware:

POST /v1/pedidos/ped_5001/pago → 500 (312 ms)

Lo que debería emitir:

{"nivel":"error","hora":"2026-08-15T11:40:22.318Z","trazaId":"trz_9f3a2b7c","metodo":"POST","ruta":"/v1/pedidos/:id/pago","estado":500,"duracionMs":312,"clienteId":"cli_842","clienteOauth":"spa-tienda","codigoError":"error_interno","causa":"timeout de la pasarela"}
Texto plano JSON estructurado
Buscar grep y expresiones regulares frágiles Consulta por campo
Filtrar por estado Imposible sin parsear estado >= 500
Agregar Hay que escribir un parser Directo
Campos nuevos Rompen los parsers existentes Se ignoran si no interesan
Correlacionar A ojo Por trazaId
Legible por humanos En crudo no; con un formateador, sí

La única desventaja real —la legibilidad en desarrollo— se resuelve con un formateador, así que no hay motivo para no estructurar.

Dos reglas de fondo que evitan la mayoría de los problemas:

Los logs son eventos, no frases. "El usuario cli_842 ha creado el pedido ped_5001" obliga a extraer los identificadores con una expresión regular. {"evento":"pedido_creado","clienteId":"cli_842","pedidoId":"ped_5001"} es consultable.

La ruta se registra como plantilla, no como URI. "/v1/pedidos/:id/pago", nunca "/v1/pedidos/ped_5001/pago". Si registras la URI real, no puedes agrupar: cada pedido produce una ruta distinta y las estadísticas por endpoint son imposibles. El identificador va en su propio campo, donde sí sirve para filtrar.

  1. pino y el logger de Tienda Aroma

pino es el logger estándar de facto en Node: escribe JSON, es extremadamente rápido —serializa fuera del hilo crítico— y trae de serie la redacción de campos sensibles y los loggers hijo.

npm install pino pino-http
npm install --save-dev pino-pretty
// src/config/registrador.js  (fichero NUEVO)
import pino from 'pino';
import { entorno } from './entorno.js';

const esDesarrollo = entorno.NODE_ENV === 'desarrollo';

export const registrador = pino({
  // 1. Nivel mínimo: en producción, info; en desarrollo, debug.
  level: entorno.LOG_NIVEL ?? (esDesarrollo ? 'debug' : 'info'),

  // 2. Nombres de campo en español y coherentes con el resto del proyecto.
  messageKey: 'mensaje',
  errorKey: 'error',
  timestamp: pino.stdTimeFunctions.isoTime,   // ISO-8601 UTC, como el contrato

  formatters: {
    // Por defecto pino emite level:30 (numérico). Preferimos la etiqueta.
    level: (etiqueta) => ({ nivel: etiqueta }),
  },

  // 3. Contexto fijo en TODAS las líneas: identifica el proceso que las emitió.
  base: {
    servicio: 'api-tiendaaroma',
    version: entorno.VERSION_APP,
    entorno: entorno.NODE_ENV,
    instancia: entorno.NOMBRE_INSTANCIA ?? 'local',
  },

  // 4. Redacción automática: apartado 6.
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'req.headers["idempotency-key"]',
      'req.body.contrasena',
      'req.body.contrasenaActual',
      'req.body.token',
      'req.body.refreshToken',
      'res.headers["set-cookie"]',
      '*.contrasena',
      '*.hashContrasena',
      '*.hash_contrasena',
      '*.accessToken',
      '*.refreshToken',
      '*.numeroTarjeta',
      '*.cvv',
    ],
    censor: '[REDACTADO]',
  },

  // 5. En desarrollo, salida legible. En producción, JSON puro a stdout.
  transport: esDesarrollo
    ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
    : undefined,
});

Cinco decisiones explicadas:

Los niveles de pino son trace (10), debug (20), info (30), warn (40), error (50) y fatal (60). El criterio de Tienda Aroma:

Nivel Cuándo Ejemplo
trace Nunca en producción Volcado de una consulta completa
debug Desarrollo y depuración puntual «Fallo de caché para cafe:caf_001»
info Eventos normales de negocio «Pedido creado», petición completada
warn Anomalía que no rompe nada Consulta lenta, 429 emitido, Redis caído
error Un fallo que afecta a la petición 500 con su stack
fatal El proceso no puede continuar No se puede abrir la base de datos al arrancar

Un error frecuente: registrar los 4xx como error. Un 404 o un 400 son comportamiento normal de la API, no fallos del sistema. Si los marcas como error, tus alertas se llenarán de ruido y dejarás de mirarlas. Solo los 5xx son error.

base añade servicio, version, entorno e instancia a cada línea. Sin eso, en un sistema con varias instancias no sabrás cuál emitió qué, ni podrás distinguir un problema de un despliegue concreto.

Salida a stdout. No a un fichero. En un despliegue moderno, el proceso escribe a la salida estándar y quien recoge, rota y envía los logs es la plataforma. Escribir a fichero desde la aplicación complica la rotación, los permisos y los contenedores.

  1. Qué registrar siempre y qué no registrar nunca

Siempre

Campo Por qué
trazaId Correlaciona los tres pilares y la respuesta al cliente
metodo Filtrar por tipo de operación
ruta (plantilla) Agrupar por endpoint
estado Filtrar errores
duracionMs Detectar lentitud
clienteId Reproducir el problema del usuario que se queja
clienteOauth Distinguir la SPA de CataBox (04-03)
ip Correlacionar abuso — es dato personal: ver abajo
tamanoRespuesta Detectar respuestas anómalas
codigoError Agrupar por tipo de fallo del catálogo

Nunca

No registrar Por qué
Contraseñas, en claro o hasheadas Evidente, y ocurre constantemente
Cabecera Authorization Un token en los logs es una sesión robada
Tokens de cualquier tipo Ídem
Números de tarjeta, CVV, IBAN PCI-DSS y sentido común
Correos, teléfonos, direcciones RGPD: minimización
El cuerpo completo de la petición Contiene todo lo anterior
Cookies Sesiones
Claves y secretos de configuración

Dos matices que no son obvios:

La IP es un dato personal bajo el RGPD. Registrarla suele estar justificado por seguridad (interés legítimo), pero exige una política de retención corta y documentada. Una opción prudente es guardar un hash con sal en lugar de la IP, que permite correlacionar sin identificar.

El clienteId también es un identificador personal, pero es seudónimo y necesario para operar. Se registra el identificador (cli_842), nunca el nombre ni el correo: si necesitas saber quién es, se consulta la base de datos con los controles de acceso correspondientes.

Retención. Los logs se conservan un tiempo limitado y documentado —30, 90, 180 días según el tipo— porque cada día de retención de más es riesgo y coste. Y hay que recordar que los logs entran en el ámbito del derecho de supresión del RGPD, otra razón para no meter en ellos datos identificativos.

  1. Redacción automática de campos sensibles

La redacción manual falla siempre, porque basta con que alguien añada un campo nuevo o registre un objeto entero:

// ❌ Un descuido y la contraseña acaba en el sistema de logs con retención de 90 días.
registrador.info({ cuerpo: req.body }, 'petición recibida');

Por eso redact de pino, ya configurado, funciona a nivel del logger: se aplique donde se aplique, esos caminos se censuran.

registrador.info({ req: { body: { email: '[email protected]', contrasena: 'Ficticia123' } } }, 'registro');
// → {"nivel":"info", ..., "req":{"body":{"email":"[email protected]","contrasena":"[REDACTADO]"}}}

Los comodines (*.contrasena) cubren cualquier profundidad, lo que atrapa objetos anidados que no habías previsto.

Dos refuerzos que conviene añadir:

Una prueba que lo verifique. La redacción es de esas cosas que se rompen en un refactor sin que nadie lo note:

// pruebas/unitarias/redaccion.prueba.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import pino from 'pino';
import { Writable } from 'node:stream';

describe('redacción de campos sensibles', () => {
  it('censura contraseñas y tokens a cualquier profundidad', () => {
    let salida = '';
    const destino = new Writable({
      write(trozo, _cod, cb) { salida += trozo.toString(); cb(); },
    });
    const log = pino(
      { redact: { paths: ['*.contrasena', '*.accessToken', 'req.headers.authorization'],
                  censor: '[REDACTADO]' } },
      destino
    );

    log.info({
      usuario: { email: '[email protected]', contrasena: 'Ficticia123' },
      sesion: { accessToken: 'eyJhbGciOi...' },
      req: { headers: { authorization: 'Bearer eyJhbGciOi...' } },
    });

    assert.ok(!salida.includes('Ficticia123'), 'la contraseña NO debe aparecer');
    assert.ok(!salida.includes('eyJhbGciOi'), 'ningún token debe aparecer');
    assert.equal((salida.match(/\[REDACTADO\]/g) ?? []).length, 3);
  });
});

Un escáner en la integración continua que busque patrones de secreto en los logs de las pruebas. Es la misma idea que el escáner de secretos del repositorio en 04-02.

  1. El middleware src/middleware/registro.js

Aquí sustituimos por fin el console.log de la posición 5.

// src/middleware/registro.js  (fichero NUEVO — sustituye al console.log de 03-02)
import pinoHttp from 'pino-http';
import { registrador } from '../config/registrador.js';

export const registrarPeticiones = pinoHttp({
  logger: registrador,

  // 1. El identificador de la petición ES nuestro trazaId, ya asignado en la
  //    posición 2 de la cadena. Así el log, la respuesta y la traza coinciden.
  genReqId: (req) => req.trazaId,

  // 2. Nombre del campo donde pino-http pone ese identificador.
  customProps: (req) => ({
    trazaId: req.trazaId,
    clienteId: req.usuario?.id ?? null,
    clienteOauth: req.usuario?.clienteOauth ?? null,
    // Plantilla, no URI: req.route existe cuando la ruta ya ha coincidido.
    ruta: req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path,
  }),

  // 3. Nivel según el resultado: los 4xx son comportamiento normal.
  customLogLevel: (req, res, err) => {
    if (err || res.statusCode >= 500) return 'error';
    if (res.statusCode === 429) return 'warn';       // interesa vigilarlos (04-04)
    if (res.statusCode >= 400) return 'info';        // NO error: es un 4xx normal
    return 'info';
  },

  customSuccessMessage: (req, res) => `${req.method} ${req.path} → ${res.statusCode}`,
  customErrorMessage: (req, res, err) => `${req.method} ${req.path} → ${res.statusCode}: ${err.message}`,

  // 4. Serializadores: se elige EXPLÍCITAMENTE qué se registra de req y res.
  //    Sin esto, pino-http registraría todas las cabeceras, incluida Authorization.
  serializers: {
    req: (req) => ({
      metodo: req.method,
      url: req.url,
      // Solo cabeceras inocuas, y en lista blanca.
      cabeceras: {
        'content-type': req.headers['content-type'],
        'content-length': req.headers['content-length'],
        'user-agent': req.headers['user-agent'],
        accept: req.headers.accept,
      },
      ip: req.ip,
    }),
    res: (res) => ({
      estado: res.statusCode,
      tamano: res.getHeader?.('content-length'),
    }),
  },

  // 5. /salud y /metricas se llaman constantemente: no ensucian los logs.
  autoLogging: {
    ignore: (req) => req.url === '/salud' || req.url === '/metricas',
  },
});

El punto 4 es el más importante para la seguridad: por defecto, pino-http registra todas las cabeceras de la petición, incluida Authorization. La lista blanca de serializadores es lo que lo impide, y es defensa en profundidad junto con redact.

El logger hijo por petición

Un logger hijo es un logger que arrastra automáticamente un contexto fijo. pino-http lo crea en req.log, y a partir de ahí cualquier línea que escribas dentro de la petición lleva el trazaId sin que tengas que pasarlo:

// src/controladores/pedidos.js  (MODIFICADO)
export async function crear(req, res) {
  // req.log es el logger hijo: ya lleva trazaId, clienteId y ruta.
  req.log.info({ lineas: req.datosValidados.lineas.length }, 'creando pedido');

  const pedido = await servicios.pedidos.crear(req.datosValidados, req.usuario, req.log);

  req.log.info({ pedidoId: pedido.id, totalEuros: pedido.totalEuros }, 'pedido creado');
  res.status(201).location(`/v1/pedidos/${pedido.id}`).json(pedido);
}

Pasar req.log al servicio es la forma de que las capas internas registren con el mismo contexto sin conocer HTTP. La alternativa —AsyncLocalStorage de Node— evita el paso explícito y es lo que usan las soluciones más avanzadas; para un proyecto de este tamaño, pasar el logger es más simple y más explícito.

La posición en src/app.js

// src/app.js  (VERSIÓN FINAL DEL MÓDULO 4)
import express from 'express';
import cors from 'cors';
import compression from 'compression';
import { rutasV1 } from './rutas/index.js';
import { asignarTrazaId } from './middleware/traza.js';
import { cabecerasSeguridad } from './middleware/seguridad.js';
import { opcionesCors } from './config/cors.js';
import { registrarPeticiones } from './middleware/registro.js';        // ← NUEVO
import { limiteGlobal } from './middleware/limite-peticiones.js';
import { etagCondicional } from './middleware/cache.js';
import { metricasMiddleware, manejadorMetricas, protegerMetricas } from './observabilidad/metricas.js'; // ← NUEVO
import { manejadorSaludViva, manejadorSaludPreparada } from './rutas/salud.js';       // ← NUEVO
// opcionesCompresion se definió en 04-06, en este mismo fichero.
import { manejadorNoEncontrado } from './middleware/no-encontrado.js';
import { manejadorErrores } from './middleware/errores.js';

export const app = express();

app.disable('x-powered-by');                                      // 1
app.set('trust proxy', 1);
app.use(asignarTrazaId);                                          // 2
app.use(cabecerasSeguridad);                                      // 3  helmet (04-02)
app.use(cors(opcionesCors));                                      // 4  (04-05)
app.use(registrarPeticiones);                                     // 5  ← sustituye al console.log
app.use(metricasMiddleware);                                      // 6  ← NUEVO (04-07)
app.use(limiteGlobal);                                            // 7  (04-04)
app.use(compression(opcionesCompresion));                         // 8  (04-06)
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] }));  // 9
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salud', manejadorSaludViva);                            // 10 liveness
app.get('/salud/preparado', manejadorSaludPreparada);             // 11 readiness
app.get('/metricas', protegerMetricas, manejadorMetricas);        // 12 ← NUEVO, fuera de /v1
app.use(etagCondicional);                                         // 13 (04-06)
app.use('/v1', rutasV1);                                          // 14
app.use(manejadorNoEncontrado);                                   // 15
app.use(manejadorErrores);                                        // 16

Por qué el registro en la 5 y las métricas en la 6:

  • Después de CORS (4): si CORS rechaza algo, no hay nada que registrar como petición de la API.
  • Antes del rate limiting (7): si fuera después, los 429 no se registrarían ni se contarían, y quedarías ciego justo durante un ataque.
  • Antes del parser (9): para registrar también las peticiones con JSON mal formado, que producen un 400 y son señal de un cliente roto.
  • Las métricas justo después del registro, para medir todas las peticiones que se registran, sin desfase entre ambos.

  1. Correlación extremo a extremo con Aroma-Traza-Id

El trazaId que emitimos desde 03-02 cobra ahora todo su sentido. Su recorrido:

graph LR
  SPA[SPA: genera o recibe Aroma-Traza-Id] --> API[API: hereda o genera]
  API --> LOG[Logs: trazaId en cada linea]
  API --> INV[gRPC inventario: propaga el id]
  API --> DB[Consulta SQL: se registra con el id]
  API --> WH[Webhook a RapidoEnvios: Aroma-Traza-Id]
  API --> RES[Respuesta: cabecera + trazaId en 5xx]
  RES --> USR[Usuario ve trz_9f3a2b7c en el mensaje de error]
  USR --> SOP[Soporte busca ese id y ve TODO]
// src/middleware/traza.js  (MODIFICADO: se añade la interoperabilidad con W3C)
import crypto from 'node:crypto';

export function asignarTrazaId(req, res, next) {
  // 1. Se hereda la del cliente si viene y tiene formato válido.
  //    Validar el formato es importante: es entrada no confiable y acaba en los logs.
  const heredada = req.get('Aroma-Traza-Id');
  const valida = heredada && /^trz_[0-9a-f]{8,32}$/.test(heredada);

  req.trazaId = valida ? heredada : `trz_${crypto.randomBytes(4).toString('hex')}`;

  // 2. Si llega un traceparent de W3C (04-07 §14), se conserva el trace-id
  //    para poder correlacionar con sistemas que no hablan nuestro dialecto.
  const traceparent = req.get('traceparent');
  if (traceparent) {
    const partes = traceparent.split('-');
    if (partes.length === 4) req.traceIdW3C = partes[1];
  }

  // 3. Siempre se devuelve, para que el cliente pueda mostrarla.
  res.set('Aroma-Traza-Id', req.trazaId);
  next();
}

La validación del formato del punto 1 no es paranoia: sin ella, un atacante puede inyectar saltos de línea en la cabecera y falsificar líneas de log completas (log injection), o meter cargas útiles que exploten el visor de logs.

En la SPA, el ciclo se cierra mostrando el identificador al usuario:

// Cliente: mostrar la traza en los errores hace el soporte diez veces más rápido.
const respuesta = await fetch(url, opciones);
if (!respuesta.ok) {
  const traza = respuesta.headers.get('Aroma-Traza-Id');   // legible gracias a 04-05
  mostrarError(`Algo ha ido mal. Si contactas con soporte, indica la referencia ${traza}.`);
}

Y para que eso funcione, Aroma-Traza-Id tenía que estar en exposedHeaders de 04-05. Todas las piezas encajan.

  1. Errores 5xx: qué se loguea frente a qué se devuelve

En 03-07 prometimos que el trazaId sería el puente entre lo que ve el cliente y lo que ve el equipo. Aquí se materializa.

// src/middleware/errores.js  (MODIFICADO — fragmento)
export function manejadorErrores(err, req, res, next) {
  const esErrorApi = err instanceof ErrorApi;
  const estado = esErrorApi ? err.estado : 500;

  if (estado >= 500) {
    // AL EQUIPO: absolutamente todo.
    req.log.error(
      {
        err,                                  // pino serializa mensaje, tipo y stack completo
        codigoError: esErrorApi ? err.codigo : 'error_interno',
        ruta: req.route?.path ?? req.path,
        metodo: req.method,
        clienteId: req.usuario?.id ?? null,
        // El cuerpo NO se registra: puede contener datos personales o secretos.
      },
      'error no controlado'
    );
  } else if (estado === 429) {
    req.log.warn({ codigoError: err.codigo, clave: req.rateLimit?.key }, 'límite alcanzado');
  } else {
    // 4xx: nivel info. Son comportamiento normal de la API.
    req.log.info({ codigoError: err.codigo, estado }, 'petición rechazada');
  }

  // AL CLIENTE: lo mínimo, y el trazaId solo en 5xx (contrato de 03-07).
  const cuerpo = {
    error: {
      codigo: esErrorApi ? err.codigo : 'error_interno',
      mensaje: esErrorApi ? err.mensaje : 'Se ha producido un error inesperado.',
      detalles: esErrorApi ? (err.detalles ?? []) : [],
    },
  };
  if (estado >= 500) cuerpo.error.trazaId = req.trazaId;

  res.set('Cache-Control', 'no-store');       // los errores no se cachean (04-06)
  res.status(estado).json(cuerpo);
}

La asimetría, en una tabla:

Respuesta al cliente Log del equipo
Código error_interno El real, con su tipo de excepción
Mensaje Genérico El interno completo
Stack Nunca Completo
SQL Nunca Sí, la plantilla
trazaId Sí (solo 5xx)
Cuerpo de la petición Tampoco: datos personales

Fíjate en la última fila: ni siquiera al equipo se le registra el cuerpo. La tentación de «guardar todo por si acaso» es exactamente cómo acaban las contraseñas en un sistema de logs con retención de un año.

  1. Métricas: las cuatro señales de oro y el método RED

Las cuatro señales de oro (del libro de SRE de Google):

Señal Qué mide En Tienda Aroma
Latencia Cuánto tarda Histograma por ruta y estado
Tráfico Cuánta demanda hay Peticiones por segundo
Errores Qué proporción falla Tasa de 5xx
Saturación Cuán lleno está Conexiones a la BD, memoria, cola de eventos

El método RED es la versión simplificada para servicios de petición-respuesta, y es la que se aplica a una API REST:

  • Rate: peticiones por segundo.
  • Errors: peticiones fallidas por segundo.
  • Duration: distribución de la latencia.

Con esas tres, por ruta y por código de estado, cubres el 90 % de lo que necesitas de una API. La saturación se añade como métricas de recursos: memoria del proceso, event loop, pool de conexiones.

Un matiz que evita un error común sobre la latencia: hay que medirla separando los éxitos de los errores. Un 401 responde en 2 ms, así que una avalancha de 401 mejora tu latencia media mientras el servicio está roto. Separar por estado lo evita.

  1. Tipos de métrica y por qué el histograma

Tipo Qué es Ejemplo Operaciones
Contador Solo sube; se reinicia al reiniciar el proceso Peticiones totales Tasa por segundo
Gauge Sube y baja Conexiones activas Valor actual, máximo
Histograma Distribución en cubos Latencia Percentiles, media
Summary Percentiles calculados en el cliente Latencia No agregable entre instancias

Por qué el histograma y no la media. Como vimos en 04-06, la media miente. Pero además, si cada instancia enviara su media, esas medias no se pueden combinar correctamente: la media de las medias no es la media global salvo que todas tengan el mismo número de muestras.

Un histograma resuelve las dos cosas. Se definen cubos y se cuenta cuántas observaciones caen en cada uno:

latencia_bucket{le="0.005"}  1200    ← 1200 peticiones bajo 5 ms
latencia_bucket{le="0.01"}   3400
latencia_bucket{le="0.05"}   8900
latencia_bucket{le="0.1"}    9500
latencia_bucket{le="0.5"}    9950
latencia_bucket{le="+Inf"}  10000

Los cubos son sumables entre instancias, y el percentil se calcula después interpolando. En PromQL:

# p99 de la latencia por ruta, en una ventana de 5 minutos.
histogram_quantile(0.99, sum(rate(aroma_http_duracion_segundos_bucket[5m])) by (le, ruta))

La precisión depende de los cubos: si el p99 real es 180 ms y tus cubos saltan de 100 ms a 500 ms, obtendrás una interpolación pobre. Los cubos se eligen a partir de tu presupuesto de latencia (04-06), poniendo fronteras alrededor de los valores que te importan.

  1. Instrumentar con prom-client y exponer /metricas

npm install prom-client
// src/observabilidad/metricas.js  (fichero NUEVO)
import client from 'prom-client';
import { entorno } from '../config/entorno.js';

export const registro = new client.Registry();

// Métricas del proceso: CPU, memoria, event loop, descriptores. Gratis y muy útiles.
client.collectDefaultMetrics({ register: registro, prefix: 'aroma_' });

// --- 1. Tráfico y errores: un contador por ruta, método y estado ---
const peticiones = new client.Counter({
  name: 'aroma_http_peticiones_total',
  help: 'Peticiones HTTP atendidas',
  labelNames: ['metodo', 'ruta', 'estado'],
  registers: [registro],
});

// --- 2. Latencia: histograma con cubos alineados al presupuesto de 04-06 ---
const duracion = new client.Histogram({
  name: 'aroma_http_duracion_segundos',
  help: 'Duración de las peticiones HTTP',
  labelNames: ['metodo', 'ruta', 'estado'],
  buckets: [0.005, 0.015, 0.03, 0.05, 0.08, 0.15, 0.3, 0.5, 1, 2, 5],
  registers: [registro],
});

// --- 3. Métricas de negocio: las que de verdad dicen si la tienda funciona ---
export const pedidosCreados = new client.Counter({
  name: 'aroma_pedidos_creados_total',
  help: 'Pedidos creados',
  labelNames: ['origenCliente'],          // spa | movil | catabox — cardinalidad baja
  registers: [registro],
});

export const stockAgotado = new client.Counter({
  name: 'aroma_stock_agotado_total',
  help: 'Intentos de compra rechazados por falta de stock',
  labelNames: ['tueste'],                 // 3 valores posibles: seguro
  registers: [registro],
});

export const limitesEmitidos = new client.Counter({
  name: 'aroma_limite_peticiones_total',
  help: 'Respuestas 429 emitidas',
  labelNames: ['nivel'],                  // anonimo | cliente | socio | panel
  registers: [registro],
});

export const cacheAciertos = new client.Counter({
  name: 'aroma_cache_total',
  help: 'Accesos a la caché de aplicación',
  labelNames: ['resultado'],              // acierto | fallo
  registers: [registro],
});

/**
 * Middleware de instrumentación. Posición 6 de src/app.js.
 */
export function metricasMiddleware(req, res, next) {
  const fin = duracion.startTimer();

  res.on('finish', () => {
    // CLAVE: la plantilla de ruta, NUNCA la URI con identificadores. Ver §13.
    // Si ninguna ruta coincidió (404), se agrupa bajo 'desconocida' para no
    // generar una etiqueta por cada URL inexistente que alguien pruebe.
    const ruta = req.route?.path ? `${req.baseUrl}${req.route.path}` : 'desconocida';
    const etiquetas = { metodo: req.method, ruta, estado: String(res.statusCode) };

    peticiones.inc(etiquetas);
    fin(etiquetas);
  });

  next();
}

/** Endpoint /metricas: formato de texto de Prometheus. */
export async function manejadorMetricas(req, res) {
  res.set('Content-Type', registro.contentType);
  res.set('Cache-Control', 'no-store');
  res.end(await registro.metrics());
}

/**
 * Protección de /metricas: expone rutas internas, versiones y volumen de negocio.
 * NUNCA debe ser público.
 */
export function protegerMetricas(req, res, next) {
  const token = (req.get('Authorization') ?? '').replace('Bearer ', '');
  if (token !== entorno.METRICAS_TOKEN) return res.status(404).end();   // 404, no 401
  return next();
}

/metricas va fuera de /v1, igual que /salud: no forma parte del contrato de negocio ni se versiona con él. Y debe estar protegido: revela tus rutas internas, tus versiones, tu volumen de pedidos y tus tasas de error, información valiosa tanto para un competidor como para un atacante. Se responde 404 en lugar de 401 para no confirmar siquiera que existe. En un despliegue real, además, se expone solo en la red interna o en un puerto distinto no publicado.

Ejemplo de instrumentación de negocio:

// src/servicios/pedidos.js  (MODIFICADO — fragmento)
import { pedidosCreados, stockAgotado } from '../observabilidad/metricas.js';

export async function crear(datos, solicitante, log) {
  for (const linea of datos.lineas) {
    const cafe = await repositorios.cafes.porId(linea.cafeId);
    if (cafe.stock < linea.cantidad) {
      stockAgotado.inc({ tueste: cafe.tueste });
      log.warn({ cafeId: cafe.id, solicitado: linea.cantidad, disponible: cafe.stock },
               'stock insuficiente');
      throw errores.conflicto('stock_insuficiente', `No hay stock suficiente de ${cafe.nombre}.`);
    }
  }
  const pedido = await repositorios.pedidos.crear(datos);
  pedidosCreados.inc({ origenCliente: solicitante.clienteOauth ?? 'spa' });
  return pedido;
}

Las métricas de negocio son las más valiosas y las que casi nadie pone. «Los pedidos por minuto han caído a cero» detecta incidentes que ninguna métrica técnica ve: la API responde 200, la CPU está bien, y sin embargo un cambio en la SPA ha roto el botón de comprar.

  1. Cardinalidad: por qué ped_5001 revienta el sistema

La cardinalidad de una métrica es el número de combinaciones distintas de etiquetas. Cada combinación es una serie temporal independiente, con su propia memoria y su propio coste.

// ✅ Cardinalidad acotada y predecible.
// 6 métodos × 24 rutas × ~8 estados = ~1.150 series. Perfectamente manejable.
peticiones.inc({ metodo: 'GET', ruta: '/v1/pedidos/:id', estado: '200' });

// ❌ CATASTRÓFICO: una serie temporal por CADA pedido, para siempre.
// Con 100.000 pedidos: 100.000 series. El sistema de métricas se cae.
peticiones.inc({ metodo: 'GET', ruta: '/v1/pedidos/ped_5001', estado: '200' });

Se llama explosión de cardinalidad y es la forma número uno de tumbar un sistema de métricas —a veces junto con el resto del clúster.

Etiqueta Valores posibles ¿Segura?
metodo 6
ruta (plantilla) ~24
estado ~8
tueste 3
rol 4
clienteOauth ~10
clienteId Millones No
pedidoId Ilimitados No
ip Ilimitados No
trazaId Uno por petición Nunca
userAgent Miles No

La regla mnemotécnica:

Los identificadores van en los logs y en las trazas. En las métricas van solo categorías con un número pequeño y conocido de valores.

Cuando necesites investigar un caso concreto, el camino correcto es: la métrica te dice que hay un problema y dónde; el log y la traza te dicen cuál. Nunca al revés.

Cuidado también con el 404 de rutas inexistentes: si registraras req.path sin coincidencia de ruta, cualquiera podría hacer explotar tu cardinalidad pidiendo URLs aleatorias. Por eso el middleware del apartado 12 agrupa bajo 'desconocida'.

  1. Trazas distribuidas: spans, contexto y traceparent

Cuando una petición atraviesa varios servicios, los logs de cada uno cuentan un trozo de la historia. Una traza los cose.

Concepto Qué es
Traza El recorrido completo de una petición por todo el sistema
Span Una unidad de trabajo dentro de la traza (una consulta, una llamada)
Span padre El span que originó a otro: da la estructura de árbol
Contexto de traza Lo que se propaga entre servicios para unir los spans
Muestreo Guardar solo un porcentaje: trazar todo es carísimo

El estándar de propagación es W3C Trace Context, con la cabecera traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ↑   ↑                                ↑                ↑
          versión  trace-id (16 bytes)      span-id (8 bytes)   flags
  • El trace-id es el mismo en toda la traza: es lo que une los servicios.
  • El span-id identifica la operación actual; el siguiente servicio lo usará como padre.
  • Los flags indican, entre otras cosas, si esta traza está muestreada.

OpenTelemetry es el estándar de instrumentación (API, SDK y protocolo) que se ha impuesto en el sector; su principal ventaja es que desacopla la instrumentación del backend: instrumentas una vez y puedes enviar a Jaeger, Tempo, Datadog o lo que uses después.

// src/observabilidad/trazas.js  (fichero NUEVO)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { entorno } from '../config/entorno.js';

/**
 * Debe inicializarse ANTES de importar express y demás librerías: la
 * instrumentación automática las parchea al cargarse. Por eso src/servidor.js
 * hace `import './observabilidad/trazas.js'` en su primera línea.
 */
export const sdk = new NodeSDK({
  serviceName: 'api-tiendaaroma',
  traceExporter: new OTLPTraceExporter({ url: entorno.OTLP_ENDPOINT }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // El sondeo de salud generaría miles de trazas sin valor.
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) =>
          req.url === '/salud' || req.url === '/salud/preparado' || req.url === '/metricas',
      },
      // El sistema de ficheros produce muchísimo ruido y casi ningún valor.
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

if (entorno.OTLP_ENDPOINT) sdk.start();

Las instrumentaciones automáticas cubren HTTP entrante y saliente, Express, gRPC y los clientes de base de datos habituales, así que la mayor parte de la traza aparece sin escribir código. Los spans manuales se añaden solo donde hay lógica de negocio que quieres ver.

Muestreo. Trazar el 100 % del tráfico es caro en red, almacenamiento y CPU. Lo habitual es un muestreo bajo (1–10 %) con dos excepciones: siempre se conservan las trazas de peticiones con error y las lentas, que son justo las que interesan.

  1. Una traza de POST /v1/pedidos

sequenceDiagram
  participant SPA as SPA
  participant API as API Aroma
  participant DB as SQLite
  participant INV as Servicio inventario (gRPC)
  participant RE as RapidoEnvios (webhook)

  SPA->>API: POST /v1/pedidos (traceparent)
  Note over API: span: validacion Zod (3 ms)
  API->>INV: ReservarStock (gRPC, propaga traceparent)
  INV->>API: reservado (48 ms)
  Note over API: span: transaccion
  API->>DB: INSERT pedido (6 ms)
  API->>DB: INSERT lineas (4 ms)
  API->>DB: UPDATE stock (3 ms)
  API->>RE: POST webhook pedido.creado (asincrono, 120 ms)
  API->>SPA: 201 Created (total 68 ms)

Vista como árbol de spans, con la duración de cada uno:

POST /v1/pedidos ......................... 68 ms  [trace: 4bf92f35...]
├── middleware.autenticar ................  2 ms
├── middleware.validar ...................  3 ms
├── grpc.inventario.ReservarStock ........ 48 ms  ← el 70 % del tiempo
│   └── inventario.consultaAlmacen ....... 41 ms
├── db.transaccion ....................... 13 ms
│   ├── db.insert.pedidos ................  6 ms
│   ├── db.insert.lineas_pedido ..........  4 ms
│   └── db.update.cafes.stock ............  3 ms
└── webhook.pedido_creado (asíncrono) .... 120 ms (fuera del camino crítico)

Lo que se ve de un vistazo y que ninguna otra herramienta te habría dado:

  • El gRPC de inventario consume el 70 % del tiempo. Optimizar la consulta SQL, que suma 13 ms, sería trabajar en el sitio equivocado.
  • El webhook es asíncrono y no bloquea la respuesta. Si estuviera dentro del camino crítico, el 201 tardaría 188 ms.
  • La transacción está bien acotada: tres escrituras seguidas, sin llamadas de red dentro, que es exactamente lo que se buscaba en 03-05. Una llamada gRPC dentro de la transacción mantendría el bloqueo de la base de datos durante 48 ms.

Un span manual, cuando la instrumentación automática no llega:

// src/servicios/pedidos.js (fragmento)
import { trace } from '@opentelemetry/api';

const trazador = trace.getTracer('api-tiendaaroma');

export async function calcularTotal(lineas) {
  return trazador.startActiveSpan('pedidos.calcularTotal', async (span) => {
    try {
      span.setAttribute('lineas.cantidad', lineas.length);   // atributo de baja cardinalidad
      const total = /* ... cálculo en céntimos ... */ 4190;
      span.setAttribute('total.centimos', total);
      return total;
    } catch (error) {
      span.recordException(error);
      span.setStatus({ code: 2 });      // ERROR
      throw error;
    } finally {
      span.end();                        // SIEMPRE, o el span queda abierto
    }
  });
}

  1. Comprobaciones de salud: liveness y readiness

Una única /salud mezcla dos preguntas distintas, y confundirlas provoca incidentes en el despliegue.

Liveness (/salud) Readiness (/salud/preparado)
Pregunta ¿Está vivo el proceso? ¿Puede atender peticiones ahora?
Si falla Se reinicia el proceso Se saca del balanceo, sin reiniciar
Comprueba Nada externo Base de datos, migraciones, Redis
Coste Mínimo Puede consultar dependencias
Frecuencia Cada 10 s Cada 5 s
// src/rutas/salud.js  (fichero NUEVO)
import { db } from '../config/base-datos.js';
import { redis } from '../config/redis.js';
import { entorno } from '../config/entorno.js';

/**
 * LIVENESS: solo dice que el proceso responde.
 * NO comprueba dependencias: si la base de datos cae, reiniciar la API no la
 * arregla, y reiniciar en bucle todas las instancias empeora el incidente.
 */
export function manejadorSaludViva(req, res) {
  res.set('Cache-Control', 'no-store');
  res.status(200).json({ estado: 'ok', version: entorno.VERSION_APP });
}

/**
 * READINESS: ¿puede esta instancia atender peticiones?
 */
export async function manejadorSaludPreparada(req, res) {
  res.set('Cache-Control', 'no-store');
  const comprobaciones = {};
  let preparado = true;

  // 1. Base de datos: imprescindible. Consulta trivial, sin tocar datos.
  try {
    db.prepare('SELECT 1').get();
    comprobaciones.baseDatos = 'ok';
  } catch (error) {
    comprobaciones.baseDatos = 'error';
    preparado = false;
    req.log.error({ err: error }, 'readiness: base de datos no disponible');
  }

  // 2. Migraciones al día: arrancar con el esquema viejo produce errores raros.
  try {
    const { version } = db.prepare('SELECT MAX(version) AS version FROM migraciones').get();
    comprobaciones.migraciones = version >= entorno.MIGRACION_MINIMA ? 'ok' : 'desactualizadas';
    if (comprobaciones.migraciones !== 'ok') preparado = false;
  } catch {
    comprobaciones.migraciones = 'error';
    preparado = false;
  }

  // 3. Redis: DEGRADADO, no fatal. Sin caché ni rate limiting compartido la API
  //    funciona peor, pero funciona. Sacarla del balanceo sería contraproducente.
  try {
    await redis.ping();
    comprobaciones.redis = 'ok';
  } catch {
    comprobaciones.redis = 'degradado';
  }

  res.status(preparado ? 200 : 503).json({
    estado: preparado ? 'preparado' : 'no_preparado',
    comprobaciones,
  });
}

Tres decisiones que evitan incidentes clásicos:

Liveness no comprueba la base de datos. Si lo hiciera y la base de datos cayera, el orquestador reiniciaría todas las instancias en bucle, añadiendo una tormenta de arranques a un incidente que ya existía.

Redis es «degradado», no fatal. Es coherente con las decisiones de 04-04 (fail-open) y 04-06 (la caché nunca es dependencia dura).

Readiness devuelve 503, no 500. Es «no ahora», no «estoy roto», y encaja con el servicio_no_disponible del catálogo. Durante el apagado ordenado de 03-07, lo correcto es empezar devolviendo 503 en readiness y seguir atendiendo las peticiones en curso: así el balanceador deja de mandar tráfico nuevo antes de que el proceso muera, y no se pierde ninguna petición. Ese detalle es el que hace posible un despliegue sin errores visibles, y volveremos sobre él en 05-05.

  1. Alertas útiles, SLO y presupuesto de error

La regla: alerta sobre síntomas que afectan a los usuarios, no sobre causas que quizá no afecten a nadie.

❌ Alerta sobre causas ✅ Alerta sobre síntomas
«CPU > 80 %» «El p99 de /v1/cafes supera 500 ms»
«Memoria > 90 %» «La tasa de 5xx supera el 1 %»
«Redis caído» «La latencia se ha duplicado»
«Disco al 85 %» «Los pedidos por minuto han caído a cero»

Una CPU al 90 % con la latencia dentro del presupuesto no es un problema: es un servidor bien aprovechado. Despertar a alguien por eso es la forma más rápida de que las alertas dejen de mirarse.

SLI, SLO y presupuesto de error

  • SLI (indicador): lo que mides. «Proporción de peticiones con estado < 500».
  • SLO (objetivo): el umbral que te comprometes a cumplir. «99,9 % en 30 días».
  • Presupuesto de error: lo que te permites fallar. Con un 99,9 %, es el 0,1 % del mes: unos 43 minutos.

Los de Tienda Aroma:

SLI SLO Presupuesto mensual
Disponibilidad (< 500) 99,9 % 43 min
Latencia GET /v1/cafes p99 < 150 ms 99 % de las peticiones
Latencia POST /v1/pedidos p99 < 400 ms 99 %
Éxito de webhooks a RápidoEnvíos 99,5 % en 24 h

El presupuesto de error convierte una discusión de opiniones en una regla: si queda presupuesto, se despliegan funcionalidades nuevas; si se ha agotado, se dedica el tiempo a fiabilidad. Ya no hay que negociar «¿arreglamos esto o sacamos aquello?»: lo decide el dato.

Y la alerta correcta no es sobre el valor instantáneo, sino sobre la velocidad de consumo del presupuesto (burn rate): «a este ritmo, el presupuesto del mes se agota en dos horas». Eso distingue un pico irrelevante de un problema real.

# Tasa de error en 5 minutos, sobre el total de peticiones.
sum(rate(aroma_http_peticiones_total{estado=~"5.."}[5m]))
  / sum(rate(aroma_http_peticiones_total[5m])) > 0.01

Cada alerta debe cumplir cuatro condiciones, y si falla alguna hay que borrarla: es accionable (hay algo concreto que hacer), es urgente (no puede esperar a mañana), está documentada (un runbook con los primeros pasos) y no es ruidosa (si salta a diario, o es un problema real que hay que arreglar o es una alerta que sobra).

  1. El panel mínimo del día del lanzamiento

Seis gráficos. Ni uno más, porque un panel con cuarenta gráficos no lo mira nadie:

Gráfico Qué muestra Qué buscas
1. Peticiones por segundo, por estado Tráfico y errores juntos Que suba el tráfico y no los 5xx
2. Latencia p50 / p95 / p99 Tres líneas, mismo gráfico Que el p99 no se dispare
3. Tasa de error por ruta Qué endpoint falla Un endpoint concreto rompiéndose
4. Pedidos creados por minuto La métrica de negocio Que no caiga a cero
5. 429 emitidos por nivel Efecto del rate limiting Que no estés bloqueando a usuarios legítimos
6. Saturación: memoria, event loop, pool BD Recursos Fugas y agotamiento

Los gráficos 4 y 5 son los que distinguen un panel útil de uno decorativo. El 4 detecta el fallo que ninguna métrica técnica ve: todo responde 200 y sin embargo nadie compra. El 5 es la comprobación directa de que el trabajo de 04-04 no se ha vuelto en tu contra.

El día del lanzamiento, además: ten a mano la consulta de logs filtrada por estado >= 500 ordenada por hora, y el enlace a las trazas de las peticiones más lentas. Es lo que convierte «algo va mal» en «el gRPC de inventario está tardando 2 s» en menos de un minuto.

  1. El stack habitual

Pilar Herramientas Nota
Métricas Prometheus + Grafana El estándar de facto; Prometheus recolecta de /metricas
Logs Loki + Grafana, o ELK (Elasticsearch, Logstash, Kibana) Loki es más barato: indexa etiquetas, no el texto
Trazas Jaeger, Grafana Tempo Ambos hablan OTLP
Todo en uno Grafana Cloud, Datadog, New Relic, Honeycomb De pago, sin operación propia
Instrumentación OpenTelemetry Estándar; te desacopla del backend

Dos consejos al elegir, sin entrar en instalaciones:

Empieza por lo gestionado. Operar Prometheus, Loki y Jaeger es un trabajo a tiempo parcial. Para un proyecto como Tienda Aroma, un servicio gestionado cuesta menos que el tiempo de mantenerlo.

Instrumenta con OpenTelemetry pase lo que pase. Es lo único que te permite cambiar de proveedor sin volver a tocar el código.

  1. Balance del módulo 4

Al empezar el módulo tenías una API correcta. Esto es lo que se ha endurecido:

Lección Qué añadió Ficheros
04-01 Criterio de diseño, antipatrones, lista de revisión, Spectral .spectral.yaml, docs/decisiones/
04-02 OWASP Top 10, helmet, secretos, RGPD, subidas, SSRF src/middleware/seguridad.js, src/servicios/{descargas,imagenes}.js
04-03 OAuth 2.0, OIDC, JWKS, ámbitos src/config/oauth.js, src/middleware/autenticacion-oauth.js
04-04 Rate limiting, 429, Redis, backoff src/middleware/limite-peticiones.js, src/config/redis.js
04-05 CORS con lista blanca, Expose-Headers src/config/cors.js
04-06 Caché HTTP, ETag, 304, If-Match/412, compresión src/middleware/cache.js, src/servicios/cache*.js
04-07 Logs, métricas, trazas, salud, SLO src/config/registrador.js, src/middleware/registro.js, src/observabilidad/*

La cadena de src/app.js ha pasado de 8 pasos a 16, y cada uno responde a un problema concreto que ahora sabes nombrar. El catálogo de errores creció con precondicion_requerida, y el de ámbitos apareció con OAuth. Y, sobre todo, la API ha dejado de ser una caja negra: cuando algo falle, lo sabrás antes que tus clientes y podrás averiguar qué fue.

Errores Comunes y Consejos

Registrar la URI completa en lugar de la plantilla. Impide agrupar en los logs y hace explotar la cardinalidad en las métricas.

Registrar el cuerpo de la petición «para depurar». Es la vía más rápida para meter contraseñas y datos personales en un sistema con retención larga.

Marcar los 4xx como error. Genera ruido, y el ruido hace que las alertas dejen de mirarse.

Usar identificadores como etiquetas de métrica. clienteId o pedidoId revientan el sistema de métricas.

Poner la comprobación de la base de datos en liveness. Convierte una caída de la base de datos en un bucle de reinicios de toda la flota.

Dejar /metricas público. Expone rutas internas, versiones y volumen de negocio.

Alertar sobre CPU y memoria. Son causas, no síntomas. Alerta sobre latencia, errores y métricas de negocio.

Mirar la media en lugar de los percentiles. Ya lo vimos en 04-06 y sigue siendo el error de medición más frecuente.

Registrar el logger sin serializadores. pino-http incluye por defecto todas las cabeceras, incluida Authorization.

Consejo: pon el trazaId en los mensajes de error de la SPA. Convierte «no me funciona» en una investigación de dos minutos.

Consejo: instrumenta el negocio, no solo la técnica. «Pedidos por minuto» detecta incidentes que ninguna métrica de infraestructura ve.

Consejo: revisa tus alertas cada trimestre. Borra las que nunca han sido accionables. Un sistema de alertas con ruido es peor que no tener ninguno.

Consejo: antes de un incidente, escribe el runbook. A las tres de la mañana nadie improvisa bien.

Ejercicios

Ejercicio 1: revisar una instrumentación

Este código se ha propuesto para instrumentar el endpoint de pago. Encuentra al menos cinco problemas y corrígelos.

router.post('/:id/pago', autenticar, async (req, res) => {
  console.log(`Pago de ${req.params.id}, cuerpo: ${JSON.stringify(req.body)}`);
  const inicio = Date.now();
  try {
    const resultado = await servicios.pedidos.pagar(req.params.id, req.body);
    peticiones.inc({ ruta: `/v1/pedidos/${req.params.id}/pago`, cliente: req.usuario.id });
    console.log(`OK en ${Date.now() - inicio} ms`);
    res.json(resultado);
  } catch (error) {
    console.error(`ERROR: ${error.stack}`);
    res.status(500).json({ error: error.message });
  }
});

Ejercicio 2: diseñar las métricas de una funcionalidad

Tienda Aroma añade la suscripción mensual de café. Diseña las métricas para observarla: nombre, tipo, etiquetas (con su cardinalidad estimada) y qué pregunta responde cada una. Incluye al menos una métrica de negocio y una alerta útil expresada en palabras.

Ejercicio 3: investigar un incidente

A las 10:15 salta la alerta «tasa de 5xx > 1 % en POST /v1/pedidos». Describe, paso a paso, cómo usarías los tres pilares para llegar a la causa, indicando qué buscas en cada uno y qué conclusión sacarías de cada resultado posible.

Soluciones

Solución 1

Problemas:

  1. console.log en lugar del logger. Sin estructura, sin nivel, sin trazaId, y no llega al sistema de logs con formato.
  2. Registra el cuerpo completo. El cuerpo de un pago puede contener datos de tarjeta. Es un fallo grave de seguridad y de RGPD.
  3. Cardinalidad explosiva en la métrica. ruta con el identificador real y una etiqueta cliente con el clienteId: una serie por cada pedido y por cada cliente.
  4. Solo cuenta los éxitos. El inc() está dentro del try tras la operación, así que los errores no se cuentan y la tasa de error es siempre cero.
  5. No mide la latencia como métrica, solo la imprime. No hay histograma ni percentiles.
  6. Devuelve error.message al cliente. Filtra el interior del sistema: rompe lo establecido en 03-07 y 04-02.
  7. No usa el manejador de errores central. El formato del error no cumple el contrato (codigo, mensaje, detalles, trazaId).
  8. Falta asincrono(): un rechazo no capturado dejaría la petición colgada. (En este caso el try/catch lo tapa, pero el convenio del proyecto es asincrono.)

Corrección:

router.post(
  '/:id/pago',
  autenticar,
  exigirRol('cliente', 'empleado', 'administrador'),
  limiteEscritura,
  exigirClaveIdempotencia,
  validar(esquemaPago, 'body'),
  asincrono(async (req, res) => {
    // El logger hijo ya lleva trazaId, clienteId y ruta plantilla.
    // Se registran METADATOS, nunca el cuerpo.
    req.log.info({ pedidoId: req.params.id, metodoPago: req.datosValidados.metodo }, 'iniciando pago');

    const resultado = await servicios.pedidos.pagar(req.params.id, req.datosValidados, req.usuario);

    // Métrica de negocio con etiquetas de cardinalidad baja.
    pagosCompletados.inc({ metodo: req.datosValidados.metodo });
    req.log.info({ pedidoId: req.params.id }, 'pago completado');

    res.json(resultado);
    // Sin try/catch: los errores suben al manejador central, que ya registra el
    // 5xx con stack completo, devuelve error_interno + trazaId y cuenta la métrica.
    // La latencia la mide metricasMiddleware para TODAS las rutas por igual.
  })
);

Solución 2

Métrica Tipo Etiquetas (cardinalidad) Pregunta que responde
aroma_suscripciones_activas Gauge periodicidad (2), tueste (3) = 6 ¿Cuántas suscripciones hay vivas ahora?
aroma_suscripciones_creadas_total Contador origenCliente (~4) ¿Cuántas altas por día? Tendencia
aroma_suscripciones_canceladas_total Contador motivo (~5) ¿Cuánta fuga hay y por qué?
aroma_suscripciones_cobros_total Contador resultado (ok/fallo/reintento) = 3 ¿Cuántos cobros fallan?
aroma_suscripciones_envios_generados_total Contador resultado (2) ¿Se generan los envíos del ciclo mensual?
aroma_suscripciones_cobro_duracion_segundos Histograma resultado (3) ¿Cuánto tarda la pasarela?

Métrica de negocio principal: aroma_suscripciones_activas. Es la que refleja el valor real de la funcionalidad; si cae, hay un problema aunque toda la técnica esté verde.

Etiquetas descartadas por cardinalidad: clienteId (millones), suscripcionId (ilimitados), cafeId (crece con el catálogo, y con miles de referencias sería problemático).

Alerta útil: «la proporción de cobros de suscripción con resultado fallo supera el 5 % en una ventana de 30 minutos». Cumple las cuatro condiciones: es accionable (revisar la pasarela y los métodos de pago caducados), urgente (cada hora son ingresos perdidos y clientes molestos), documentable en un runbook, y no ruidosa, porque un porcentaje pequeño de fallos es normal y el umbral está por encima.

Alerta complementaria: «el proceso mensual de generación de envíos no ha registrado ningún incremento en aroma_suscripciones_envios_generados_total durante su ventana de ejecución». Detecta el fallo silencioso más peligroso de un proceso programado: que sencillamente no se ejecute.

Solución 3

Paso 1 — Métricas: acotar el problema (2 minutos).

  • Gráfico de tasa de error por ruta: ¿es solo POST /v1/pedidos o también otros? Si son todos, apunta a algo transversal (base de datos, despliegue). Si es solo ese, a su lógica o a una dependencia suya.
  • ¿Cuándo empezó exactamente? Correlacionar con el historial de despliegues. Un salto brusco a una hora exacta suele ser un despliegue o un cambio de configuración; una subida progresiva apunta a agotamiento de recursos o crecimiento de datos.
  • Latencia: si el p99 subió antes que los errores, probablemente son timeouts. Si los errores aparecieron sin cambio de latencia, es un fallo lógico (una excepción).
  • aroma_pedidos_creados_total: ¿ha caído? Confirma el impacto real en el negocio y da urgencia.
  • Saturación: memoria, event loop, pool de conexiones. Descarta o confirma agotamiento de recursos.

Paso 2 — Trazas: localizar dónde se rompe (3 minutos).

  • Filtrar trazas de POST /v1/pedidos con error en los últimos 15 minutos.
  • Mirar el árbol de spans: ¿en qué span termina la traza? Resultados posibles y su lectura:
    • Se corta en grpc.inventario.ReservarStock → el servicio de inventario falla o va lento.
    • Se corta en db.transaccion → problema de base de datos: bloqueo, disco, migración a medias.
    • Termina rápido sin llegar a las dependencias → excepción en la validación o en la lógica.
    • Todos los spans son normales pero el total es enorme → contención o pausas del recolector de basura.
  • Comparar con una traza correcta de antes del incidente: la diferencia salta a la vista.

Paso 3 — Logs: obtener el detalle (2 minutos).

  • Tomar el trazaId de una traza fallida y buscar todas sus líneas.
  • Leer la línea de nivel error: mensaje, tipo de excepción y stack completo.
  • Comprobar el patrón: ¿fallan todos los pedidos o solo algunos? Filtrar por clienteOauth (¿solo la SPA? ¿solo CataBox?), por clienteId (¿un cliente concreto con datos raros?), por número de líneas del pedido.
  • Buscar warn en los minutos previos: consultas lentas, Redis caído, 429. A menudo la causa raíz aparece ahí antes que el error.

Conclusión y acción. Con los tres pasos tienes: qué falla (la métrica), dónde (la traza) y por qué (el log). Si la causa es un despliegue reciente, se revierte antes de seguir investigando —restaurar el servicio primero, entender después—. Si es una dependencia externa, se activa el circuit breaker de 04-04 para degradar en lugar de fallar. Y el incidente se cierra con dos cosas: una prueba de regresión que lo reproduzca (03-08) y, si la alerta llegó tarde, una alerta nueva o un ajuste del umbral.

Conclusión

La observabilidad es la capa que convierte un sistema que funciona en un sistema que se puede operar. Has sustituido por fin el console.log de la posición 5 por pino con logs estructurados en JSON, con un logger hijo por petición que arrastra el trazaId que llevamos emitiendo desde 03-02, la ruta registrada como plantilla y no como URI, niveles bien asignados —los 4xx no son errores—, serializadores en lista blanca y redacción automática de contraseñas, tokens y datos personales, con su prueba y con la política de retención que exige el RGPD. Has visto cómo se correlaciona Aroma-Traza-Id de extremo a extremo, desde la SPA hasta el gRPC de inventario y los webhooks a RápidoEnvíos, y cómo se cierra el círculo que abrió 03-07: al equipo, el stack completo; al cliente, error_interno y una referencia con la que soporte encuentra todo en diez segundos. Has instrumentado la API con prom-client siguiendo las cuatro señales de oro y el método RED, con histogramas cuyos cubos salen del presupuesto de latencia de 04-06, métricas de negocio —pedidos creados, stock agotado, 429 emitidos, aciertos de caché— y /metricas protegido y fuera de /v1, sabiendo por qué etiquetar con ped_5001 revienta el sistema. Conoces las trazas distribuidas, traceparent de W3C y OpenTelemetry, y sabes leer un árbol de spans de POST /v1/pedidos para descubrir que el 70 % del tiempo estaba donde no mirabas. Y has separado liveness de readiness, definido SLO con presupuesto de error, y elegido qué alertar —síntomas, nunca CPU— y qué mirar el día del lanzamiento.

Con esto se cierra el módulo 4. La API de Tienda Aroma ya no es solo correcta: tiene criterio de diseño con antipatrones identificados y linting del contrato; conoce sus amenazas y las defiende con helmet, gestión de secretos y controles de RGPD; delega el acceso a terceros con OAuth 2.0 y OpenID Connect sin ver una sola contraseña; se protege del abuso con límites por nivel, 429 y Retry-After; deja entrar a la SPA y al panel sin abrir la puerta a nadie más; responde 304 en lugar de repetirse y cierra la concurrencia optimista con If-Match y el 412; y cuenta lo que le pasa en logs, métricas y trazas correlacionadas. Es una API lista para producción.

Lo que falta ya no es la API: son las herramientas que la rodean y el trabajo de equipo que la sostiene. En el módulo 5, Herramientas y Frameworks, dejaremos de escribir código de servidor para trabajar sobre él: Postman para explorar, probar y compartir colecciones ejecutables (05-01); Swagger y OpenAPI para convertir ese openapi.yaml que arrastramos desde 02-08 en documentación viva, generación de clientes y validación (05-02); un recorrido comparado por los frameworks populares —Fastify, NestJS, Django REST, Spring Boot, ASP.NET Core— para saber qué se gana y qué se pierde al elegir cada uno (05-03); contratos, mocks y pruebas automatizadas para que consumidor y proveedor no se rompan mutuamente (05-04); integración continua y despliegue, donde Spectral, npm audit, las pruebas y el readiness que acabamos de escribir se convierten en una tubería que despliega sin cortes (05-05); y los API gateways y portales de desarrollador, donde varios de los mecanismos de este módulo —rate limiting, autenticación, caché, métricas— reaparecen resueltos una capa por encima (05-06).

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados