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
- Monitorización frente a observabilidad
- Los tres pilares y qué pregunta responde cada uno
- Logs estructurados: por qué JSON y no texto
- pino y el logger de Tienda Aroma
- Qué registrar siempre y qué no registrar nunca
- Redacción automática de campos sensibles
- El middleware
src/middleware/registro.js - Correlación extremo a extremo con
Aroma-Traza-Id - Errores 5xx: qué se loguea frente a qué se devuelve
- Métricas: las cuatro señales de oro y el método RED
- Tipos de métrica y por qué el histograma
- Instrumentar con prom-client y exponer
/metricas - Cardinalidad: por qué
ped_5001revienta el sistema - Trazas distribuidas: spans, contexto y
traceparent - Una traza de
POST /v1/pedidos - Comprobaciones de salud: liveness y readiness
- Alertas útiles, SLO y presupuesto de error
- El panel mínimo del día del lanzamiento
- El stack habitual
- Balance del módulo 4
- 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.
- 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:
- Una métrica dispara la alerta: la tasa de error de
POST /v1/pedidos/{id}/pagoha subido. - Una traza de una petición fallida muestra dónde se rompe: la llamada a la pasarela.
- Un log con el
trazaIdde esa traza da el detalle: el mensaje exacto del error y elclienteId.
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.
- Logs estructurados: por qué JSON y no texto
Lo que emite hoy nuestro middleware:
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 | Sí | 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.
- 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.
// 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.
- 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.
- 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.
- El middleware
src/middleware/registro.js
src/middleware/registro.jsAquí 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); // 16Por 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
429no 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
400y 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.
- Correlación extremo a extremo con
Aroma-Traza-Id
Aroma-Traza-IdEl 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.
- 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) | Sí |
| 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.
- 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.
- 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"} 10000Los cubos sí 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.
- Instrumentar con prom-client y exponer
/metricas
/metricas// 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.
- Cardinalidad: por qué
ped_5001 revienta el sistema
ped_5001 revienta el sistemaLa 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 | Sí |
ruta (plantilla) |
~24 | Sí |
estado |
~8 | Sí |
tueste |
3 | Sí |
rol |
4 | Sí |
clienteOauth |
~10 | Sí |
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'.
- Trazas distribuidas: spans, contexto y
traceparent
traceparentCuando 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.
- Una traza de
POST /v1/pedidos
POST /v1/pedidossequenceDiagram 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
201tardarí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
}
});
}
- 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.
- 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.01Cada 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).
- 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.
- 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.
- 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:
console.logen lugar del logger. Sin estructura, sin nivel, sintrazaId, y no llega al sistema de logs con formato.- Registra el cuerpo completo. El cuerpo de un pago puede contener datos de tarjeta. Es un fallo grave de seguridad y de RGPD.
- Cardinalidad explosiva en la métrica.
rutacon el identificador real y una etiquetaclientecon elclienteId: una serie por cada pedido y por cada cliente. - Solo cuenta los éxitos. El
inc()está dentro deltrytras la operación, así que los errores no se cuentan y la tasa de error es siempre cero. - No mide la latencia como métrica, solo la imprime. No hay histograma ni percentiles.
- Devuelve
error.messageal cliente. Filtra el interior del sistema: rompe lo establecido en 03-07 y 04-02. - No usa el manejador de errores central. El formato del error no cumple el contrato (
codigo,mensaje,detalles,trazaId). - Falta
asincrono(): un rechazo no capturado dejaría la petición colgada. (En este caso eltry/catchlo tapa, pero el convenio del proyecto esasincrono.)
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/pedidoso 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/pedidoscon 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.
- Se corta en
- 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
trazaIdde 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?), porclienteId(¿un cliente concreto con datos raros?), por número de líneas del pedido. - Buscar
warnen 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
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
