🪝 Hooks y Middleware
Qué son
Los hooks son puntos de extensión que se disparan en el ciclo de vida de cada
log, antes o después de que el mensaje se despache a los transports.
Permiten reaccionar, mutar o descartar entradas sin tener que subclassar el
Logger ni envolver cada llamada a info()/warn()/…
Hay dos mecanismos complementarios:
- Eventos (
on/once/off) — callbacks puntuales atados a fases concretas del pipeline (beforeLog,afterLog,onError). - Middleware (
use) — pipeline estilo Koa(entry, next) => ...que corre entrebeforeLogyafterLog, ideal para cross-cutting concerns (correlación, redacción de PII, sampling).
Cuándo usar
| Caso | Mecanismo |
|---|---|
| Redactar PII antes de emitir | on('beforeLog', ...) o use() |
| Métricas / telemetría post-log | on('afterLog', ...) |
| Capturar errores de transports o de otros hooks | on('onError', ...) |
| Atributos globales (correlationId, requestId) | use() middleware |
| Filtrado/sampling condicional | use() sin llamar next() |
| Setup one-shot (warm-up, captura del primer log) | once('afterLog', ...) |
Eventos
beforeLog — AWAITED, mutaciones se reflejan en el log
Se dispara antes de despachar el entry a transports. El manager awaita
cada callback en orden de prioridad y mergea cualquier Partial<HookLogEntry>
retornado al entry que verán los siguientes hooks y el log final.
import logger from "@mks2508/better-logger";
// La mutación SÍ cambia el mensaje que llega a los transports
logger.on("beforeLog", (entry) => {
entry.message = entry.message.replace(/password=\S+/g, "password=***");
return entry; // o return { message: "..." } — partial también funciona
});
afterLog — fire-and-forget
Se dispara después de que el log se escribió. Mutar el entry aquí no cambia el output ya emitido; usar solo para side-effects: incrementar contadores, flushar buffers externos, audit trail.
logger.on("afterLog", (entry) => {
metrics.increment(`logs.${entry.level}`);
});
onError — errores de transports o hooks
Se dispara cuando un transport lanza, un hook de beforeLog/afterLog falla,
o un batch agota reintentos. El callback recibe entry.error (la excepción) y
entry.extra (payload estructurado: dropped, status, retryCount, …).
logger.on("onError", (entry) => {
telemetry.capture(entry.error, {
level: entry.level,
extra: entry.extra,
});
});
API
Todos los métodos viven directamente en la instancia de Logger. No hace falta
importar nada extra para registrar hooks desde el logger default.
import logger from "@mks2508/better-logger";
const off = logger.on("beforeLog", async (entry) => {
return { correlationId: getCorrelationId() };
}, 80);
off(); // desregistro explícito vía unsubscribe handle
logger.off("beforeLog", cb); // alternativa por referencia de callback
| Método | Firma | Devuelve |
|---|---|---|
on |
(event, callback, priority=50) |
función unsubscribe() |
once |
(event, callback, priority=50) |
función unsubscribe() (auto-remove tras 1er disparo exitoso) |
off |
(event, callback) |
boolean (true si se eliminó) |
use |
(middleware, priority=50) |
función unsubscribe() |
on / once / use devuelven un unsubscribe handle (patrón Disposable).
Llamarlo retira el registro sin necesidad de pasar el callback original. off
es la vía alternativa por referencia, útil cuando se integra con APIs que
piden un removeListener(cb).
Referencia completa de la interfaz: docs/api/hooks-module/classes/HookManager.md.
Ejemplo: redacción de PII con beforeLog
import logger from "@mks2508/better-logger";
logger.on("beforeLog", (entry) => {
entry.message = entry.message.replace(/password=\S+/g, "password=***");
if (entry.context?.token) {
return { context: { ...entry.context, token: "***" } };
}
return entry;
});
logger.info("login password=s3cr3t"); // → "login password=***"
El callback puede devolver un Partial<HookLogEntry>; el manager lo mergea
sobre el entry actual antes de invocar al siguiente hook, así la cadena se
comporta como una pipeline de transformaciones.
Ejemplo: métricas con afterLog
import logger from "@mks2508/better-logger";
logger.on("afterLog", (entry) => {
metrics.increment(`logs.${entry.level}`);
if (entry.level === "error" || entry.level === "critical") {
alerts.notify(entry.message);
}
});
Middleware estilo Koa con use
Los middlewares corren entre beforeLog y afterLog, en cascada vía
next(). Si un middleware NO llama a next(), corta la cadena y el log se
descarta (short-circuit).
import logger from "@mks2508/better-logger";
// Inyecta correlationId en cada entry
logger.use((entry, next) => {
entry.correlationId = getCorrelationId();
next();
});
// Short-circuit: descarta logs debug en producción
logger.use((entry, next) => {
if (entry.level === "debug" && process.env.NODE_ENV === "production") return;
next();
}, 100); // prioridad alta para que corra primero
Prioridad
Tanto hooks como middlewares se ordenan por priority descendente:
mayor número = se ejecuta primero. Empates respetan el orden de registro.
Default 50.
// Redacción (90) corre antes que tracing (50)
logger.on("beforeLog", redactPII, 90);
logger.on("beforeLog", addTraceAttrs, 50);
Robustez: guard de reentrancia onError
Si un hook registrado para onError lanza, el manager no re-emite otro
onError (rompería el ciclo); se loguea a console.error y se traga. Además,
un guard de profundidad (MAX_ONERROR_DEPTH = 5) corta cualquier burst
inesperado: si onError se re-entra más de 5 veces, el entry se dropea al
console en lugar de loopear para siempre.
Consecuencia práctica: un transport defectuoso que lanza en cada log no puede colgar el proceso vía recursion de hooks.
Subpath ./hooks (uso avanzado)
Para escenarios que necesitan una instancia propia del manager (tests aislados, múltiples loggers con pipelines distintos, instrumentación custom), el subpath expone la implementación y los helpers:
import {
HookManager,
getDefaultHookManager,
createHookBridge,
} from "@mks2508/better-logger/hooks";
// Manager dedicado, no compartido con el logger default
const myHooks = new HookManager();
myHooks.on("beforeLog", (entry) => {
/* ... */
});
// O el bridge equivalente al que usa el Logger internamente
const bridge = createHookBridge();
bridge.use((entry, next) => { next(); });
// Singleton compartido por todo el proceso (lazy)
getDefaultHookManager().on("onError", async (entry) => {
telemetry.capture(entry.error);
});
getDefaultHookManager() es perezoso: instancia en la primera llamada y
devuelve siempre la misma. Para aislamiento, instancia tu propio
new HookManager().
API detallada de la clase: HookManager.