Con la configuración resuelta, Escena Viva ya arranca en cualquier entorno con los valores correctos y muere de inmediato si le falta un secreto. Pero sigue habiendo un problema mayor: cuando el Festival de Jazz de Primavera abra su venta anticipada y algo falle a las tres de la madrugada, ¿cómo te enteras? ¿Y cómo averiguas qué pasó exactamente en la compra de Lucia, entre las once mil peticiones que hubo ese minuto? Esta lección convierte a Escena Viva en una aplicación observable: que produce registros estructurados que una máquina puede consultar, métricas que alguien puede mirar en un panel, sondas de salud que el orquestador puede interrogar y alertas que suenan cuando merece la pena. En el módulo 10 aprendimos a producir métricas —el vigilante del bucle de eventos, el uso de memoria— pero se quedaban dentro del proceso. Hoy las mandamos a algún sitio.
Contenido
- Por qué
console.logdeja de servir - pino: registro estructurado en JSON
- El registrador hijo y la trazabilidad por petición
- Qué se registra y qué NUNCA se registra
- Errores: la traza completa solo del lado del servidor
- Los tres pilares de la observabilidad
- Métricas con
prom-client - Trazas distribuidas: cuándo compensan
- Sondas de salud: vivo frente a listo
- Alertas que sirven
- Retención, coste y adónde se envían los registros
- Por qué
console.log deja de servir
console.log deja de servirconsole.log es perfecto mientras desarrollas. En producción tiene cuatro defectos graves. No tiene niveles, así que no puedes bajar el ruido en producción y subirlo cuando investigas un incidente: o lo imprimes todo o nada. No tiene estructura: console.log('Compra de ' + correo) produce una frase, y para responder «¿cuántas compras falló Lucia?» hay que escribir una expresión regular frágil sobre gigabytes de texto. No tiene contexto: no sabes de qué petición, de qué usuario ni de qué trabajador viene esa línea, y con cuatro trabajadores en cluster las líneas de cuatro peticiones simultáneas se entrelazan. Y puede ser síncrono: cuando la salida estándar es un fichero o un TTY, process.stdout.write es bloqueante en Linux, así que cada console.log para el bucle de eventos — en una ruta caliente eso se mide en el p99 que tanto cuidamos en el módulo 10.
Un registro estructurado es, en cambio, una línea de JSON por evento:
{"level":30,"time":"2026-08-15T21:14:02.415Z","pid":4821,"idPeticion":"a3f1c2d4","ruta":"/api/compras","estado":201,"duracionMs":83,"evento":"compra.completada","idEvento":"evt-003","entradas":2,"totalCentimos":7000,"msg":"Compra completada"}La diferencia no es estética: con eso puedes preguntar «dame todas las compras de evt-003 que tardaron más de 500 ms y devolvieron 5xx, agrupadas por trabajador». Con una frase en prosa, no.
- pino: registro estructurado en JSON
pino es el registrador estándar de facto en Node por una razón concreta: es rápido. Serializa a JSON con un serializador propio y escribe de forma asíncrona a través de un transporte en un hilo aparte, así que registrar deja de competir con atender peticiones. Se instala con npm install pino y npm install --save-dev pino-pretty. pino-pretty va en devDependencies a propósito: formatear es un lujo de desarrollo; en producción escribimos JSON crudo y el entorno se encarga del resto.
// src/registro/logger.js
'use strict';
const pino = require('pino');
const { configuracion } = require('../config/index.js');
const { censurar } = require('./censura.js');
const opciones = {
level: configuracion.nivelRegistro,
// Campos fijos en TODAS las lineas.
base: { servicio: 'escena-viva', entorno: configuracion.entorno, pid: process.pid },
timestamp: pino.stdTimeFunctions.isoTime, // ISO: legible en cualquier visor.
serializers: {
err: pino.stdSerializers.err,
peticion: (req) => ({ metodo: req.method, ruta: req.path, ip: req.ip }),
// Reutiliza la censura recursiva del M8: nada sensible sale de aqui.
datos: (valor) => censurar(valor),
},
// Segunda capa de proteccion, por si alguien olvida el serializador.
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'contrasena',
'*.contrasena', 'token', '*.token'],
censor: '[CENSURADO]',
},
};
// pino-pretty SOLO en desarrollo: en produccion, JSON crudo a stdout.
const transporte =
configuracion.entorno === 'development'
? pino.transport({ target: 'pino-pretty', options: { colorize: true } })
: undefined;
module.exports = { logger: pino(opciones, transporte) };Tres decisiones merecen explicación. base añade campos fijos a todas las líneas: servicio es imprescindible cuando varios servicios escriben al mismo agregador, y pid distingue los trabajadores del cluster del módulo 10. serializers se aplican por nombre de campo, de modo que al escribir logger.info({ datos: cuerpo }) el objeto pasa por censurar antes de salir, reutilizando src/registro/censura.js del módulo 8 sin duplicar lógica. Y redact es la red de seguridad: aunque alguien olvide el serializador, esas rutas se sustituyen por [CENSURADO]. Dos capas, porque una sola falla.
| Nivel | Valor | Uso en Escena Viva | ¿En producción? |
|---|---|---|---|
trace |
10 | Cada consulta SQL, cada acierto de caché | No, solo depurando |
debug |
20 | Decisiones internas: qué política de aforo se aplicó | No por defecto |
info |
30 | Eventos de negocio: compra completada, entrada emitida | Sí |
warn |
40 | Algo raro pero recuperable: reintento de Redis, límite alcanzado | Sí |
error |
50 | Petición fallida, tarea de cola agotada tras reintentos | Sí |
fatal |
60 | El proceso no puede continuar: la BD no responde al arrancar | Sí |
La regla práctica: info responde «¿qué está haciendo el sistema?», error responde «¿qué se ha roto?», y el resto es para cuando ya sabes que hay un problema. NIVEL_REGISTRO de la lección anterior permite subir a debug en una instancia durante un incidente, sin desplegar.
Sustituir morgan y los console.error sueltos
El módulo 6 dejó morgan en src/middleware/registro-http.js. Morgan escribe texto de servidor web clásico, que ahora es la excepción en un flujo JSON, así que se sustituye por un middleware propio que cierra el ciclo de cada petición:
// src/middleware/registro-peticiones.js (reescrito sobre pino)
'use strict';
function crearRegistroPeticiones() {
return function registroPeticiones(req, res, siguiente) {
const inicio = process.hrtime.bigint();
// 'finish' se emite cuando la respuesta se ha enviado por completo.
res.on('finish', () => {
const duracionMs = Number(process.hrtime.bigint() - inicio) / 1e6;
const nivel = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info';
// El PATRON de ruta, no la URL concreta: si no, no puedes agrupar.
const ruta = req.route ? req.baseUrl + req.route.path : req.path;
req.log[nivel](
{ metodo: req.method, ruta, estado: res.statusCode, duracionMs },
'peticion completada'
);
});
siguiente();
};
}
module.exports = { crearRegistroPeticiones };Detalles que importan: la ruta se registra como patrón (/api/eventos/:idEvento), porque registrar la URL con el identificador dentro impide agrupar; el nivel depende del código de estado, para que un 500 aparezca al filtrar por error; y process.hrtime.bigint() da precisión de nanosegundos, a diferencia de Date.now(). En cuanto a los console.error sueltos por el código, se eliminan todos: un grep -rn "console\." src/ en la tubería de CI (lección 11-06) evita que vuelvan.
- El registrador hijo y la trazabilidad por petición
Aquí llega la pieza que llevamos prometiendo desde el módulo 6, cuando escribimos src/middleware/id-peticion.js para asignar a cada petición un crypto.randomUUID(). Hasta ahora servía de poco; ahora se convierte en el hilo que cose todos los registros de una petición, gracias a los registradores hijo de pino: logger.child({ campo: valor }) devuelve un registrador que añade esos campos a todas sus líneas sin coste de serialización repetida.
// src/middleware/id-peticion.js (ampliado)
'use strict';
const crypto = require('node:crypto');
function crearIdPeticion({ logger }) {
return function idPeticion(req, res, siguiente) {
// Respetamos el identificador del borde si el proxy ya puso uno.
const entrante = req.get('x-request-id');
req.idPeticion = entrante && entrante.length <= 64 ? entrante : crypto.randomUUID();
res.setHeader('X-Request-Id', req.idPeticion);
// Registrador hijo: TODO lo que se registre desde aqui lleva el identificador.
req.log = logger.child({ idPeticion: req.idPeticion });
siguiente();
};
}
module.exports = { crearIdPeticion };En src/app.js el orden importa: idPeticion va antes que cualquier middleware que quiera registrar algo, y después de trust proxy para que req.ip sea correcto. Los controladores y servicios reciben req.log y lo propagan hacia abajo; en el consumidor de la cola BullMQ del módulo 10 el idPeticion viaja como campo de la tarea, así que el trabajo asíncrono sigue correlacionado con la petición que lo originó. Seguir una compra fallida. Marc intenta comprar dos entradas para el Festival de Jazz y ve un error 500 con X-Request-Id: 9c4a.... Escribe al soporte con ese código y basta una consulta — grep '"idPeticion":"9c4a' registros.jsonl | jq -c '{time, level, evento, msg}':
{"time":"...02.101Z","level":30,"evento":"peticion.recibida","msg":"POST /api/compras"}
{"time":"...02.118Z","level":30,"evento":"aforo.comprobado","msg":"Disponible: 1189"}
{"time":"...02.140Z","level":30,"evento":"reserva.creada","msg":"Reserva provisional"}
{"time":"...02.402Z","level":50,"evento":"pago.fallido","msg":"Pasarela devolvio 502"}
{"time":"...02.410Z","level":30,"evento":"reserva.liberada","msg":"Reserva liberada"}
{"time":"...02.415Z","level":50,"evento":"peticion.completada","msg":"peticion completada"}Seis líneas y la historia completa: el aforo estaba bien, la reserva se creó, la pasarela de pago falló con un 502 y —muy importante— la reserva se liberó correctamente, así que esas dos butacas no quedaron bloqueadas. Sin el idPeticion esas seis líneas estarían mezcladas con las de otras cuatrocientas peticiones simultáneas. Ese es el valor de la trazabilidad, y es lo que justifica todo el trabajo de esta lección.
- Qué se registra y qué NUNCA se registra
| Capa | Qué registrar | Nivel |
|---|---|---|
| Middleware HTTP | Método, patrón de ruta, estado, duración | info / warn / error |
| Autenticación | Inicio de sesión correcto o fallido, identificador y rol | info / warn |
| Controlador | Evento de negocio y sus identificadores (evt-003, código de entrada) |
info |
| Dominio | Nada. El dominio es puro: devuelve resultados, no registra | — |
| Repositorio | Consultas lentas (sobre un umbral), errores de conexión | warn / error |
| Cola | Tarea iniciada, completada, fallida, número de intento | info / error |
| Arranque y apagado | Puerto, entorno, versión, señal recibida, cierre de conexiones | info |
Y la lista que no se negocia. Nunca se registran contraseñas, ni siquiera fallidas (revelan patrones y erratas de contraseñas reales); tokens JWT completos, tokens de refresco, cookies de sesión ni cabeceras Authorization; números de tarjeta, CVV o IBAN, ni completos ni «solo los cuatro últimos, que no pasa nada»; datos personales innecesarios como el correo completo, la dirección o el teléfono, en lugar del identificador del usuario; ni cuerpos de petición enteros «por si acaso», sino solo los campos que te importan. El módulo 8 ya nos dio src/registro/censura.js con censurar y CAMPOS_CENSURADOS, que recorre objetos recursivamente sustituyendo campos sensibles. Al conectarlo como serializador de pino, esa protección se aplica automáticamente a todo lo que pase por el campo datos, y la combinación de serializador más redact cubre tanto lo que anotas a propósito como lo que se te cuela. Recuerda además el marco legal: en la Unión Europea, un registro con datos personales es tratamiento de datos personales, con sus obligaciones de retención y de derecho al borrado. Registrar menos no es solo higiene técnica, es menos riesgo jurídico.
- Errores: la traza completa solo del lado del servidor
El manejador de errores del módulo 6 ya distingue errores operativos (previstos: aforo insuficiente, entrada no encontrada) de bugs (no previstos). Esa distinción gobierna también el registro:
// src/middleware/errores.js (fragmento del manejador final)
function manejadorErrores(error, req, res, siguiente) {
const estado = ESTADO_POR_CODIGO[error.codigo] ?? 500;
if (estado >= 500) {
req.log.error({ err: error, estado }, 'error no controlado'); // Traza completa.
} else {
req.log.warn({ codigo: error.codigo, estado }, error.message); // Sin traza: es ruido.
}
// Al cliente, NUNCA la traza. Solo el identificador para correlacionar.
const codigo = error.codigo ?? 'ERROR_INTERNO';
const mensaje = estado >= 500 ? 'Error interno del servidor' : error.message;
res.status(estado).json({ error: { codigo, mensaje, idPeticion: req.idPeticion } });
}Las trazas revelan rutas del sistema de ficheros, nombres de módulos internos y a veces valores. El cliente recibe solo el idPeticion; con él, soporte encuentra la traza completa en el agregador, y el usuario obtiene algo accionable sin que se filtre nada. Falta cerrar los dos casos que escapan a Express, en src/servidor.js:
for (const suceso of ['uncaughtException', 'unhandledRejection']) {
process.on(suceso, (motivo) => {
logger.fatal({ err: motivo, suceso }, 'fallo no manejado; terminando');
process.exitCode = 1;
cerrarOrdenadamente();
});
}Sí, se termina el proceso. Un uncaughtException lo deja en estado indefinido: registramos, apagamos ordenadamente (módulo 6) y dejamos que el supervisor —PM2 en 11-03, Docker en 11-04, la plataforma en 11-05— arranque una instancia sana.
- Los tres pilares de la observabilidad
| Pilar | Qué es | A qué pregunta responde | Coste |
|---|---|---|---|
| Registros | Eventos discretos con contexto | «¿Qué le pasó exactamente a esta petición?» | Alto: crece con el tráfico |
| Métricas | Valores numéricos agregados en el tiempo | «¿Cómo va el sistema en conjunto?» | Bajo: tamaño constante |
| Trazas | El recorrido de una petición por varios servicios | «¿Dónde se fue el tiempo?» | Medio: suele muestrearse |
La secuencia real de un incidente lo explica mejor que cualquier definición: una alerta salta porque una métrica (p99 de latencia) se ha disparado; el panel muestra que solo afecta a /api/compras; una traza revela que el 80 % del tiempo está en una consulta a PostgreSQL; y los registros de esa petición dan la consulta concreta y el evento (evt-003, cómo no) que la provoca. Métricas para detectar, trazas para localizar, registros para entender. Los tres, no uno.
- Métricas con
prom-client
prom-clientPrometheus es el estándar de facto: un servidor que consulta periódicamente un endpoint HTTP de tu aplicación y guarda series temporales; Grafana las dibuja, y prom-client es la librería que expone ese endpoint desde Node.
// src/observabilidad/metricas.js
'use strict';
const clienteProm = require('prom-client');
const { crearVigilanteDelBucle } = require('./bucle.js');
const registro = new clienteProm.Registry();
const etiquetasHttp = ['metodo', 'ruta', 'estado'];
// Metricas por defecto del proceso: CPU, heap, descriptores, recolector... gratis.
clienteProm.collectDefaultMetrics({ register: registro, prefix: 'escenaviva_' });
const peticionesTotales = new clienteProm.Counter({
name: 'escenaviva_peticiones_total', help: 'Peticiones HTTP atendidas',
labelNames: etiquetasHttp, registers: [registro],
});
const duracionPeticiones = new clienteProm.Histogram({
name: 'escenaviva_duracion_peticion_segundos', help: 'Duracion de las peticiones',
labelNames: etiquetasHttp, registers: [registro],
// Cubos elegidos con los datos de carga del M10, no al azar.
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
const entradasVendidas = new clienteProm.Counter({
name: 'escenaviva_entradas_vendidas_total', help: 'Entradas vendidas',
labelNames: ['idEvento', 'sala'], registers: [registro],
});
const tamanoCola = new clienteProm.Gauge({
name: 'escenaviva_cola_pendientes', help: 'Tareas pendientes en BullMQ',
labelNames: ['cola'], registers: [registro],
});
const retrasoBucle = new clienteProm.Gauge({
name: 'escenaviva_bucle_retraso_ms', help: 'Retraso del bucle de eventos',
registers: [registro],
});
// Reutilizamos el vigilante del M10: la metrica ya se producia, ahora se publica.
const vigilante = crearVigilanteDelBucle({
intervaloMs: 1000, alMedir: (retrasoMs) => retrasoBucle.set(retrasoMs),
});
module.exports = {
registro, peticionesTotales, duracionPeticiones, entradasVendidas, tamanoCola, vigilante,
};Usamos los tres tipos de métrica: el Counter solo sube y se reinicia al reiniciar el proceso (peticiones totales, entradas vendidas); el Gauge sube y baja (tareas en cola, retraso del bucle); y el Histogram reparte observaciones en cubos, de donde salen los percentiles (duración de peticiones). Sobre el histograma hay un detalle crucial: los percentiles no se pueden promediar, así que si cada trabajador calculase su propio p99, la media de esos p99 no sería el p99 del sistema. Por eso se exportan los cubos crudos y Prometheus calcula el percentil con histogram_quantile sobre todos ellos. Aviso crítico sobre las etiquetas: cada combinación de valores crea una serie temporal. Etiquetar por idEvento está bien (tres eventos); etiquetar por idUsuario, por URL completa o por idPeticion es una explosión de cardinalidad que tumba Prometheus. Es el error número uno con métricas.
// src/rutas/metricas.js — 404 en lugar de 401: ni confirmamos que existe.
enrutador.get('/metricas', async (req, res) => {
const esperado = configuracion.observabilidad.tokenMetricas;
if (!esperado || req.get('authorization') !== `Bearer ${esperado}`) return res.status(404).end();
res.set('Content-Type', registro.contentType);
res.end(await registro.metrics());
});Se protege porque /metricas revela topología interna, volumen de negocio y versiones, y se excluye del límite de peticiones porque Prometheus consulta cada 15 segundos. Con el cluster del módulo 10 hay un matiz: cada trabajador tiene su registro en memoria y Prometheus consulta un solo puerto, así que o se usa AggregatorRegistry de prom-client en el primario, o —más simple— se expone un puerto de métricas por trabajador y se dejan descubrir todos. Ninguna opción es mágica; lo importante es saber que el problema existe. Prometheus guarda las series y las consulta con PromQL; su configuración mínima es una lista de objetivos con su intervalo y sus credenciales. Grafana se conecta a Prometheus y dibuja. El panel mínimo de Escena Viva tiene cuatro gráficas: peticiones por segundo por estado, latencia p50/p95/p99, retraso del bucle de eventos y tareas pendientes en cola. Si solo puedes mirar una pantalla durante el estreno del Festival de Jazz, que sea esa.
- Trazas distribuidas: cuándo compensan
Una traza sigue una petición a través de varios servicios, encadenando spans (tramos con inicio, fin y padre). OpenTelemetry es el estándar abierto: instrumenta automáticamente Express, PostgreSQL, Redis y HTTP saliente, y exporta a Jaeger, Tempo o al servicio que uses. La instrumentación se carga antes que cualquier otro módulo (node --require ./src/observabilidad/trazas.js src/servidor.js) porque necesita envolverlos al cargarse. Ahora la honestidad: Escena Viva todavía no las necesita. Es una API, una base de datos relacional, una documental, Redis y un consumidor de cola. Con el idPeticion propagado a los registros y a las tareas de la cola tienes el 90 % del beneficio a coste casi cero. Las trazas compensan cuando hay muchos servicios, cuando cada petición cruza cinco o más saltos, o cuando el problema es «tarda mucho» y nadie sabe en qué salto. Cuando Escena Viva se parta en servicios de catálogo, ventas y notificaciones, se instrumenta; antes, es complejidad sin retorno.
- Sondas de salud: vivo frente a listo
Todo supervisor (PM2, Docker, la PaaS, Kubernetes) necesita preguntar a la aplicación cómo está. Y hay dos preguntas distintas que se confunden constantemente:
| Sonda | Pregunta | Si falla | Comprueba |
|---|---|---|---|
/salud/vivo |
¿El proceso está vivo y responde? | Se reinicia el proceso | Solo que el bucle de eventos responde |
/salud/listo |
¿Puede atender tráfico ahora mismo? | Se le retira tráfico, sin reiniciar | Dependencias: BD, Redis |
El error clásico —y es un clásico porque tumba sistemas enteros— es hacer que la sonda de vivacidad consulte la base de datos. Escenario: PostgreSQL se satura durante el estreno del Festival de Jazz, la sonda falla en las cuatro instancias, el supervisor las reinicia todas a la vez y, al arrancar, las cuatro abren sus pools de golpe contra una base de datos ya saturada. Falla otra vez: reinicio en cascada, y una incidencia de latencia se convierte en una caída total. La regla: vivacidad solo mira el proceso; disponibilidad mira las dependencias.
// src/rutas/salud.js
'use strict';
function crearRutasSalud({ comprobarPostgres, comprobarMongo, comprobarRedis, logger }) {
const enrutador = express.Router();
const arrancadoEn = Date.now();
// El apagado ordenado lo pone en false ANTES de empezar a cerrar.
const estado = { aceptandoTrafico: true };
// Tiempo limite: una sonda colgada es peor que una que falla.
const conLimite = (promesa, ms) =>
Promise.race([promesa, new Promise((_, r) => setTimeout(() => r(new Error('agotado')), ms))]);
// VIVACIDAD: si esto responde, el bucle de eventos funciona. Nada mas.
enrutador.get('/salud/vivo', (req, res) => {
res.json({ estado: 'vivo', activoSegundos: Math.floor((Date.now() - arrancadoEn) / 1000) });
});
// DISPONIBILIDAD: comprueba dependencias. allSettled informa de TODAS,
// no solo de la primera que falla.
enrutador.get('/salud/listo', async (req, res) => {
if (!estado.aceptandoTrafico) return res.status(503).json({ estado: 'apagando' });
const nombres = ['postgres', 'mongo', 'redis'];
const resultados = await Promise.allSettled([
conLimite(comprobarPostgres(), 1000),
conLimite(comprobarMongo(), 1000),
conLimite(comprobarRedis(), 500),
]);
const detalle = Object.fromEntries(
resultados.map((r, i) => [nombres[i], r.status === 'fulfilled'])
);
const listo = Object.values(detalle).every(Boolean);
if (!listo) logger.warn({ detalle }, 'sonda de disponibilidad fallida');
res.status(listo ? 200 : 503).json({ estado: listo ? 'listo' : 'no listo', detalle });
});
return { enrutador, estado };
}
module.exports = { crearRutasSalud };Tres decisiones deliberadas: las comprobaciones llevan tiempo límite, porque una sonda que se cuelga es peor que una que falla; Promise.allSettled informa de todas las dependencias; y estado.aceptandoTrafico se pone a false al recibir SIGTERM, antes de empezar a cerrar, para que el balanceador deje de mandar tráfico durante los segundos de drenaje. Ese detalle es lo que convierte el apagado ordenado del módulo 6 en un despliegue sin errores. Ambas rutas se excluyen del límite de peticiones y no exigen autenticación, pero tampoco exponen datos internos: nada de versiones de la base de datos ni cadenas de conexión.
- Alertas que sirven
Regla de oro de la ingeniería de fiabilidad: alerta sobre síntomas, no sobre causas. Un síntoma es algo que el usuario nota; una causa es una hipótesis sobre por qué. Si alertas sobre «CPU al 90 %», te despertarás noches en que la CPU estaba alta y todo iba perfecto, y no te despertarás la noche en que todo se cayó por otra razón.
| Alerta | Síntoma o causa | ¿Vale? |
|---|---|---|
p99 de /api/compras > 2 s durante 5 min |
Síntoma | Sí |
| Tasa de 5xx > 1 % durante 5 min | Síntoma | Sí |
| Cola de entradas > 500 tareas y creciendo 10 min | Síntoma | Sí |
| Ninguna entrada vendida en 30 min en horario de venta | Síntoma | Sí |
| CPU > 80 % o memoria > 70 % | Causa | No como alerta; sí como gráfica |
| Retraso del bucle > 200 ms durante 5 min | Frontera | Sí: correlaciona muy bien con el dolor real |
Dos matices marcan la diferencia entre un sistema de alertas útil y uno que se ignora. Toda alerta lleva duración: «más de 2 s» salta con un pico irrelevante, «más de 2 s sostenido 5 minutos» salta cuando hay un problema real. Y toda alerta que se dispara debe requerir una acción humana ahora; si no la requiere, no es una alerta sino una gráfica. Una alerta que nadie atiende es peor que ninguna, porque enseña al equipo a ignorar las notificaciones, y el día que salte una de verdad también se ignorará. Esto tiene nombre —fatiga de alertas— y ha causado más incidencias graves que cualquier bug. Cada alerta debería llevar un enlace a un procedimiento: qué mirar, qué comprobar, a quién escalar; si no sabes escribirlo, probablemente la alerta no sirve.
- Retención, coste y adónde se envían los registros
Los registros cuestan dinero: a nivel info, una API con 500 peticiones por segundo genera del orden de 100 GB al mes solo con la línea de fin de petición, y los servicios gestionados cobran por gigabyte ingerido y por día retenido.
| Tipo | Retención razonable | Por qué |
|---|---|---|
Registros de aplicación (info+) |
14-30 días | Cubre la investigación de un incidente reciente |
| Registros de error | 90 días | Para detectar patrones que se repiten |
| Auditoría (módulo 8) | 1-7 años | Requisito legal; van a BD, no al agregador |
| Métricas | 13 meses | Comparar el Festival de Jazz con el del año pasado |
Y llegamos al último punto, que cierra el círculo con la lección 11-01. Los doce factores, factor XI: trata los registros como flujos de eventos. La aplicación no escribe ficheros, no los rota y no sabe adónde van: escribe a stdout y ahí acaba su responsabilidad. Quien recoge esa salida depende del entorno —PM2 la redirige a ficheros (11-03), Docker la captura con su controlador de registro (11-04), la PaaS la envía a su agregador (11-05), Kubernetes la recoge con un agente—, y escribir ficheros desde la aplicación te obliga a resolver rotación, permisos y espacio en disco, además de perder los registros cuando un contenedor efímero muere. En código: pino(opciones) escribe a stdout y es lo correcto; pino(pino.destination('/var/log/escena-viva.log')) hace que la aplicación decida el destino, y es justo lo que no queremos. Una sola línea de diferencia, y una montaña de operaciones que te ahorras.
Errores Comunes y Consejos
- Dejar
pino-prettyactivo en producción. Es lento y produce texto que el agregador no puede indexar. - Registrar la URL completa como campo agrupable.
/api/eventos/evt-003en lugar de/api/eventos/:idEventoimpide agrupar y, en métricas, explota la cardinalidad. - Sonda de vivacidad que consulta la base de datos. Reinicio en cascada garantizado bajo carga.
- Devolver la traza al cliente. Filtra rutas y estructura interna. Devuelve solo el
idPeticion. - Registrar el cuerpo completo de las peticiones. Volumen enorme y riesgo de datos personales y secretos.
- Consejo: añade el
idPeticiona los mensajes de error que ve el usuario. Que soporte pueda pedir «dime el código que aparece en pantalla» convierte una investigación de una hora en una consulta de diez segundos. - Consejo: registra al arrancar un evento con la versión, el commit y el entorno. Saber qué versión estaba corriendo durante un incidente es la primera pregunta que te harás.
Ejercicios
Ejercicio 1 — Registro de consultas lentas
Añade a src/repositorios/ un envoltorio que mida la duración de cada consulta y registre en warn las que superen 200 ms, con el nombre del repositorio, el método y la duración, usando req.log cuando exista para conservar el idPeticion.
Ejercicio 2 — Métrica de negocio y prueba de las sondas
Instrumenta el caso de uso de compra para incrementar entradasVendidas con las etiquetas idEvento y sala, y escribe la consulta PromQL que responda: entradas vendidas por minuto en el Festival de Jazz de Primavera. Después, escribe una prueba de integración con supertest que verifique que /salud/vivo devuelve 200 aunque las comprobaciones de dependencias fallen, que /salud/listo devuelve 503 cuando comprobarRedis rechaza y que también devuelve 503 cuando estado.aceptandoTrafico es false.
Soluciones
Ejercicio 1. Un envoltorio genérico evita tocar cada método:
// src/repositorios/instrumentar.js
'use strict';
const { logger } = require('../registro/logger.js');
const UMBRAL_MS = 200;
function instrumentarRepositorio(nombre, repositorio) {
return new Proxy(repositorio, {
get(destino, metodo) {
const valor = destino[metodo];
if (typeof valor !== 'function') return valor;
return async function (...argumentos) {
const inicio = process.hrtime.bigint();
try {
return await valor.apply(destino, argumentos);
} finally {
const duracionMs = Number(process.hrtime.bigint() - inicio) / 1e6;
if (duracionMs > UMBRAL_MS) {
(this?.log ?? logger).warn(
{ repositorio: nombre, metodo: String(metodo), duracionMs }, 'consulta lenta');
}
}
};
},
});
}
module.exports = { instrumentarRepositorio };Ejercicio 2. En el caso de uso, tras confirmar la compra dentro de la transacción, entradasVendidas.inc({ idEvento: compra.idEvento, sala: compra.sala }, compra.entradas.length). La consulta usa rate sobre cinco minutos y multiplica por 60 para pasar de segundos a minutos: sum by (sala) (rate(escenaviva_entradas_vendidas_total{idEvento="evt-003"}[5m])) * 60. rate maneja correctamente los reinicios del contador cuando un trabajador se recicla, que es justo por lo que no se usa increase a pelo ni la diferencia cruda. Y para las sondas, inyectando comprobadores falsos en la factoría de la aplicación:
const request = require('supertest');
const { expect } = require('chai');
const { crearAplicacion } = require('../../src/app.js');
describe('sondas de salud', () => {
const ok = () => Promise.resolve(true);
const cae = () => Promise.reject(new Error('caida'));
const crear = (redis, resto = ok) =>
crearAplicacion({ comprobarPostgres: resto, comprobarMongo: resto, comprobarRedis: redis });
it('vivo responde 200 aunque las dependencias fallen', async () => {
await request(crear(cae, cae).app).get('/salud/vivo').expect(200);
});
it('listo responde 503 si Redis falla', async () => {
const respuesta = await request(crear(cae).app).get('/salud/listo').expect(503);
expect(respuesta.body.detalle).to.deep.include({ redis: false, postgres: true });
});
it('listo responde 503 durante el apagado', async () => {
const { app, estadoSalud } = crear(ok);
estadoSalud.aceptandoTrafico = false;
await request(app).get('/salud/listo').expect(503);
});
});Conclusión
Escena Viva ya no habla sola. Escribe registros estructurados en JSON con pino, cada línea etiquetada con el idPeticion que llevamos arrastrando desde el módulo 6 —de modo que una compra fallida se reconstruye entera con una consulta—, pasados por la censura del módulo 8 para que ni una contraseña ni un token acaben en el agregador. Publica métricas en /metricas protegido: peticiones, latencia con percentiles reales, entradas vendidas, tamaño de cola y el retraso del bucle de eventos que el módulo 10 nos enseñó a medir. Expone /salud/vivo y /salud/listo, distintas a propósito, listas para que un supervisor las interrogue. Y todo sale por stdout, porque decidir adónde van los registros no es asunto de la aplicación.
Precisamente eso —que alguien externo recoja la salida, vigile el proceso y lo levante si cae— es lo que todavía no tenemos. Ahora mismo Escena Viva se arranca a mano con npm start en una terminal, y si cierras la sesión SSH se muere con ella. En la próxima lección, Usando PM2 para la Gestión de Procesos, ponemos un supervisor delante: fichero de ecosistema con las dos aplicaciones del proyecto, modo cluster sin mantener nuestro propio src/cluster.js, recarga sin cortes apoyada en el apagado ordenado, protección contra bucles de reinicio y arranque automático al encender la máquina.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
