Correlacionar registros entre llamadas asíncronas con AsyncLocalStorage
Aprenda cómo Node.js AsyncLocalStorage sigue el contexto por solicitud, como requestId, a través de los límites de await, sin tener que pasarlo manualmente por cada función.
Fuentes: The Node.js Feature That Replaces Passing `requestId` Through Every Single Function
Cada desarrollador de backend se encuentra eventualmente con este escenario exacto: se crea un requestId cuando llega una solicitud, y en algún lugar a seis niveles de profundidad, dentro de una función auxiliar de base de datos o de un sistema de registro compartido, se necesita ese mismo identificador para poder vincular una entrada de registro con la solicitud que la generó. La solución obvia es pasar el ID como argumento a través de cada función de la cadena. Funciona, pero resulta difícil de mantener, ya que ahora cada función intermedia lleva un parámetro del que no tiene uso alguno más allá de pasarlo a la siguiente llamada.
Las soluciones ingenuas y por qué ambas fallan
Es tentador recurrir a una variable simple a nivel de módulo, pero ese instinto es incorrecto, y los problemas solo se harán evidentes cuando se esté en entorno de producción:
let currentRequestId = null;
app.use((req, res, next) => {
currentRequestId = req.headers["x-request-id"];
next();
});
function logSomething(message) {
console.log(`[${currentRequestId}] ${message}`); // works... until it doesn't
}
Este enfoque parece razonable si se imagina que solo se procesa una solicitud a la vez, pero falla cuando se tiene en cuenta cómo maneja Node realmente la concurrencia. Node atiende muchas solicitudes “simultáneamente” intercalando su ejecución alrededor de los puntos await en lugar de ejecutarlas estrictamente una tras otra. La solicitud A escribe en currentRequestId, luego llega a un await y cede el control al bucle de eventos, el cual puede comenzar inmediatamente a procesar la solicitud B y sobrescribir esa misma variable con el ID de B. Para cuando el código de la solicitud A reanuda su ejecución, currentRequestId ya apunta a B, y a partir de ese momento cada declaración de registro en la solicitud A atribuye silenciosamente, pero con certeza, el ID incorrecto.
AsyncLocalStorage: Contexto que sigue la ejecución, no la pila de llamadas
AsyncLocalStorage, incluido como parte del módulo integrado async_hooks de Node, está diseñado precisamente para resolver este problema. Mantiene un valor distinto para cada cadena lógica de ejecución asíncrona, y lo hace correctamente incluso cuando las solicitudes se entrelazan a través de llamadas await, cadenas de promesas y callbacks.
const { AsyncLocalStorage } = require("async_hooks");
const requestContext = new AsyncLocalStorage();
app.use((req, res, next) => {
const store = { requestId: req.headers["x-request-id"] || crypto.randomUUID() };
requestContext.run(store, next); // everything inside next() runs "inside" this context
});
function logSomething(message) {
const store = requestContext.getStore();
console.log(`[${store?.requestId}] ${message}`); // correct, regardless of interleaving
}
Llamar a requestContext.run(store, next) no se limita a asignar un valor; en realidad abre un contexto que permite el flujo automático de todo lo que se invoca desde dentro de esa llamada, ya sea de forma síncrona o asíncrona. Esto incluye código que continúa después de un await, que se ejecuta dentro de un manejador .then() o que se dispara posteriormente como callback de alguna operación de E/S. Incluso en el interior de una herramienta para consultas a bases de datos definida en un archivo completamente separado, llamar a requestContext.getStore() devuelve el contexto perteneciente a la solicitud correcta, y nunca uno tomado prestado de la solicitud que se ejecutó anteriormente. Esto se debe a que el contexto está vinculado a la cadena específica de ejecución asíncrona que realiza la llamada, y no a una variable compartida en la que cada solicitud escribe sus datos.
Por qué esto funciona realmente más allá de los límites de await
Esta es la característica que diferencia a AsyncLocalStorage de una variable ordinaria, y está directamente relacionada con el funcionamiento interno de async/await. Cada vez que una función async se suspende en un await y luego vuelve a continuar, Node registra con qué contexto asíncrono está asociada esa reanudación y lo restaura automáticamente antes de que se ejecute la continuación:
app.get("/orders/:id", async (req, res) => {
requestContext.run({ requestId: req.headers["x-request-id"] }, async () => {
logSomething("fetching order"); // context is present
const order = await db.getOrder(req.params.id); // pauses, other requests may run here
logSomething("order fetched"); // context is STILL correctly present after resuming
res.json(order);
});
});
Entre las dos llamadas a logSomething, el bucle de eventos tuvo total libertad para ejecutar el código de otras solicitudes, pero ninguna de esas intercalaciones afectó el contexto que veía esa particular continuación una vez se reanudaba. Esta es exactamente la garantía que la variable compartida tradicional no podía ofrecer: una corrección que se mantiene incluso bajo el tipo de ejecución intercalada que promueve activamente el modelo de concurrencia de Node.
El verdadero beneficio: registro estructurado sin parámetros de hilos por todas partes
Este es el punto en el que la complejidad adicional de AsyncLocalStorage rinde frutos en una aplicación real: se puede crear un registrador que adjunte automáticamente el contexto de la solicitud a cada llamada, sin que el código de llamada necesite pasar ningún parámetro para ello:
function createLogger() {
return {
info(message, extra = {}) {
const store = requestContext.getStore();
logger.info({ requestId: store?.requestId, ...extra }, message);
},
};
}
// deep inside some completely unrelated file, five layers into the call stack
const log = createLogger();
log.info("Payment processed", { amount: 4999 }); // requestId is present, automatically
Nada en la firma de función processPayment tuvo que cambiar para que esto funcione. Nunca acepta un argumento requestId ni lo transmite a ninguna de las funciones a las que llama, sin embargo, cada línea de registro que emite está correctamente etiquetada con la solicitud responsable. Ese es el verdadero beneficio aquí: los datos relacionados con una solicitud quedan disponibles en cualquier punto de la cadena de llamadas sin saturar cada firma de función entre el inicio de la solicitud y el lugar donde realmente se utilizan esos datos.
El límite que confunde a las personas
El contexto que se establece dentro de .run() solo afecta al código que realmente forma parte de esa misma cadena de ejecución: todo lo que se invoca desde dentro del callback, ya sea directa o indirectamente, incluyendo a través de cadenas de promesas pendientes. No se extiende hacia atrás al código que se ejecutó antes de que se llamara a .run(), ni hacia adelante al trabajo que realmente se separa de esa cadena:
app.use((req, res, next) => {
requestContext.run({ requestId: req.headers["x-request-id"] }, () => {
setTimeout(() => {
logSomething("delayed log"); // still correct — setTimeout inside the context stays tracked
}, 1000);
next();
});
});
Llamar a setTimeout desde dentro del contexto sigue funcionando como se esperaba, ya que el mecanismo de propagación async_hooks de Node abarca los temporizadores, las promesas y casi todas las primitivas asíncronas estándar que se pueden encontrar. Donde realmente surgen problemas es con tareas de tipo “fire-and-forget” diseñadas intencionalmente para sobrevivir más tiempo que la solicitud que las inició. Una tarea enviada a un proceso trabajador separado e independiente es un buen ejemplo: no existe una cadena de ejecución que la vincule al contexto del cual proviene, porque en realidad no es una continuación de la misma secuencia asíncrona de llamadas. Se trata de una secuencia distinta y no relacionada.
La nota honesta sobre el rendimiento
AsyncLocalStorage no es gratuito. Capturar y restaurar el contexto en cada transición asíncrona requiere un esfuerzo real, y las versiones antiguas de Node gestionaban esto con mucha menos eficiencia que el entorno de ejecución actual. En las versiones actuales de Node, la carga adicional para una tarea típica de aplicación web es lo suficientemente pequeña como para no ser un problema en la gran mayoría de los proyectos, y las ventajas en términos de corrección y código más limpio casi siempre justifican ese pequeño costo. Si operas a una escala extrema donde cada microsegundo por solicitud cuenta, merece la pena realizar pruebas de rendimiento en tu caso específico. Pero para casi todos los servicios backend, considerarlo como un valor por defecto seguro es razonable. No se trata de algún truco de optimización especializado al que recurrir ocasionalmente; es una herramienta con un costo pequeño y conocido que se compensa por sí mismo en casi todas las circunstancias normales.
La idea real detrás de esto
AsyncLocalStorage no debe considerarse como una variable global que, por casualidad, se comporta de manera adecuada. Está estructuralmente vinculado al contexto de una cadena específica de ejecución asíncrona: sobrevive a cada await, a cada callback y a cada resolución de promesa dentro de esa cadena, al tiempo que permanece completamente invisible para cualquier otra cadena que se ejecute al mismo tiempo. Una vez que comprendes ese modelo, su comportamiento deja de parecer un truco y comienza a verse como la herramienta exactamente adecuada para una tarea específica: hacer que los datos del ámbito de la solicitud sean accesibles desde cualquier lugar dentro de una cadena de llamadas, sin obligar a cada función de esa cadena a saber o preocuparse por el hecho de que los datos se estén pasando a través de ella.
Lecturas relacionadas
- Elegir entre Promise.all, Promise.race y esperas secuenciales — Aprenda cuándo Promise.all() acelera las APIs de Node.js, por qué falla rápidamente ante cualquier rechazo, y un marco de toma de decisiones para elegir el patrón asíncrono adecuado.
- Arreglando errores en el manejo de errores con Async/Await en código producción de Node.js — Conozca cinco errores comunes al manejar errores con async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.