Saltar al contenido

Contexto de petición en Node.js con AsyncLocalStorage

Usa AsyncLocalStorage para propagar contexto de petición — IDs de traza, sesiones, datos del tenant — por pilas asíncronas sin pasarlo en cada función.

5 min de lectura
Diagrama de la pila de llamadas asíncronas de Node.js que muestra el contexto de petición propagándose sin paso explícito de parámetros

Todo servicio de Node.js no trivial acaba chocando con el mismo muro: necesitas un trace ID, un tenant ID o el usuario autenticado disponible seis capas abajo en tu pila de llamadas, dentro de una utilidad que no tiene por qué saber nada de peticiones HTTP. La solución instintiva es añadir un parámetro ctx a cada función entre el manejador de la petición y esa utilidad. Tres meses después, el tipo ctx ha crecido hasta quince campos, la mitad de tus firmas de función empiezan con él, y añadir un nuevo valor de contexto implica tocar una docena de archivos.

AsyncLocalStorage — estable desde Node 16, disponible desde Node 12 tras un flag — resuelve esto de forma limpia. Es el equivalente en Node.js del almacenamiento local de hilo: un valor vinculado a un contexto de ejecución asíncrono que fluye automáticamente a través de await, setTimeout, Promise.then y los callbacks de event emitters sin ningún paso explícito de parámetros.

El problema de encadenar parámetros

Antes de ver la solución, conviene ser explícitos sobre el coste del statu quo.

tstypescript
// ❌ Context bleeds into every layer's signature
async function handleCheckout(req: Request, res: Response) {
  const ctx: RequestCtx = { traceId: req.headers["x-trace-id"] as string, userId: req.user.id };
  const result = await processOrder(ctx, req.body.orderId);
  res.json(result);
}
 
async function processOrder(ctx: RequestCtx, orderId: string) {
  await validateInventory(ctx, orderId);   // ctx passed down
  await chargePayment(ctx, orderId);       // ctx passed down
  await sendConfirmation(ctx, orderId);    // ctx passed down
}
 
async function validateInventory(ctx: RequestCtx, orderId: string) {
  // ctx finally consumed — just for one log line
  logger.info("stock check", { traceId: ctx.traceId, userId: ctx.userId });
}

Cada función entre el manejador y el consumidor real del contexto se convierte en un mensajero tonto. Peor aún, añadir tenantId a RequestCtx ahora requiere tocar processOrder, validateInventory, chargePayment y sendConfirmation — a ninguna de las cuales le importa.

Cómo funciona AsyncLocalStorage

AsyncLocalStorage crea un almacén que es heredado automáticamente por cualquier operación asíncrona lanzada dentro de una llamada a run(). El runtime rastrea qué almacén pertenece a qué cadena de ejecución asíncrona y los mantiene aislados entre sí.

tstypescript
import { AsyncLocalStorage } from "node:async_hooks";
 
const store = new AsyncLocalStorage<{ traceId: string }>();
 
store.run({ traceId: "abc-123" }, async () => {
  await someDeepUtility(); // store is accessible here
});
 
async function someDeepUtility() {
  const ctx = store.getStore();
  console.log(ctx?.traceId); // "abc-123" — no parameter needed
}

Las peticiones concurrentes obtienen cada una su propia instancia del almacén. No hay estado compartido ni riesgo de que el contexto de una petición se filtre a otra.

Construyendo un módulo de contexto con tipado seguro

Un wrapper fino alrededor de AsyncLocalStorage te da una API tipada y ergonómica en todo tu código.

tstypescript
import { AsyncLocalStorage } from "node:async_hooks";
 
export interface RequestContext {
  traceId: string;
  requestId: string;
  userId?: string;
  tenantId?: string;
}
 
const storage = new AsyncLocalStorage<RequestContext>();
 
export function runWithContext<T>(ctx: RequestContext, fn: () => T): T {
  return storage.run(ctx, fn);
}
 
export function getContext(): RequestContext {
  const ctx = storage.getStore();
  if (!ctx) {
    throw new Error(
      "getContext() called outside a request context. " +
      "Ensure runWithContext() wraps the call chain."
    );
  }
  return ctx;
}
 
export function getContextOrNull(): RequestContext | null {
  return storage.getStore() ?? null;
}

Lanzar un error en getContext() cuando no existe almacén es intencionado. Devolver undefined en silencio oculta el bug; un error explícito lo saca a la luz inmediatamente durante el desarrollo.

Integrándolo en tu pipeline de peticiones

El lugar correcto para llamar a runWithContext es el middleware de tu framework — una vez por petición, antes de que empiece cualquier trabajo asíncrono.

tstypescript
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { runWithContext } from "./context.js";
 
export function contextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const traceId =
    (req.headers["x-trace-id"] as string | undefined) ?? randomUUID();
  const requestId = randomUUID();
 
  // Stamp the trace ID onto the response so clients can correlate logs
  res.setHeader("x-trace-id", traceId);
 
  runWithContext(
    {
      traceId,
      requestId,
      userId: req.user?.id,
      tenantId: req.user?.tenantId,
    },
    () => next(),
  );
}

Regístralo al principio de tu cadena de middlewares, antes de cualquier manejador de ruta:

tstypescript
app.use(contextMiddleware);
app.use("/api", router);

Cada manejador, servicio y utilidad llamados dentro de esa petición tiene ahora acceso al contexto con cero parámetros.

Usando el contexto en utilidades

Con el middleware en su sitio, cualquier utilidad puede obtener el contexto directamente.

tstypescript
// ✅ Logger auto-attaches trace context — no ctx parameter needed
import { getContextOrNull } from "./context.js";
import pino from "pino";
 
const baseLogger = pino({ level: "info" });
 
export const logger = {
  info: (msg: string, data?: Record<string, unknown>) => {
    const ctx = getContextOrNull();
    baseLogger.info({ ...data, ...ctx }, msg);
  },
  error: (msg: string, error: unknown, data?: Record<string, unknown>) => {
    const ctx = getContextOrNull();
    baseLogger.error({ ...data, ...ctx, err: error }, msg);
  },
};

La capa de acceso a datos se beneficia igualmente:

tstypescript
// ❌ Previously required ctx threading just for audit logging
export async function updateUserEmail(
  ctx: RequestContext,
  userId: string,
  email: string,
): Promise<void> { /* ... */ }
 
// ✅ Signature reflects only what the function actually operates on
export async function updateUserEmail(userId: string, email: string): Promise<void> {
  const { traceId, tenantId } = getContext();
  await db.query(
    "UPDATE users SET email = $1 WHERE id = $2 AND tenant_id = $3",
    [email, userId, tenantId],
  );
  await auditLog.record({ event: "email_updated", userId, traceId });
}

La firma de la función ahora documenta lo que la función hace, no lo que tiene que cargar para otros.

La trampa de las promesas desacopladas

AsyncLocalStorage propaga el contexto a través de las operaciones asíncronas a las que se hace await dentro del ámbito de run(). Un patrón fire-and-forget rompe esto.

tstypescript
// ❌ Detached promise loses context — getContext() will throw inside sendWelcomeEmail
export async function registerUser(email: string): Promise<User> {
  const user = await createUser(email);
  sendWelcomeEmail(user); // NOT awaited — detached from the request context
  return user;
}
 
// ✅ Capture context before detaching, or use a queue
export async function registerUser(email: string): Promise<User> {
  const user = await createUser(email);
  const ctx = getContext(); // capture while still in scope
 
  setImmediate(() => {
    // Re-enter the context for the detached work
    runWithContext(ctx, () => sendWelcomeEmail(user));
  });
 
  return user;
}

La misma regla se aplica a los trabajos en segundo plano programados con setTimeout o a los consumidores de colas de mensajes: empiezan un contexto de ejecución nuevo. Captura lo que necesites antes de salir del límite de la petición, o restablece el contexto explícitamente cuando se ejecute el trabajo.

~

Para los trabajos en segundo plano, un patrón mejor es guardar los valores de contexto que necesitas como parte del payload del trabajo — { traceId, tenantId, ...jobData } — y llamar a runWithContext() al principio del manejador del trabajo. Esto hace el contexto explícito en el límite del consumidor y sobrevive a reinicios del proceso.

Puntos clave

  1. AsyncLocalStorage elimina los parámetros de contexto — los trace IDs, tenant IDs y sesiones de usuario pertenecen al almacén de contexto, no a las firmas de función
  2. Un middleware, cobertura total — llamar a runWithContext() una vez por petición hace el contexto disponible en todo el árbol asíncrono de esa petición
  3. Lanza un error cuando falte el almacén — los undefined silenciosos de getStore() ocultan bugs; un error explícito saca a la luz la mala configuración inmediatamente
  4. Las promesas desacopladas rompen la propagación — las operaciones asíncronas fire-and-forget abandonan el contexto de ejecución original; captura lo que necesites antes de desacoplarlas o restablece el contexto explícitamente
  5. Los loggers son la mayor victoria — un logger consciente del contexto que incluye automáticamente traceId y tenantId en cada línea no requiere ningún cambio en los puntos de llamada
  6. Las firmas de función se vuelven más limpias — las funciones de lógica de negocio dejan de cargar argumentos de mensajería y empiezan a reflejar solo aquello sobre lo que realmente operan
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX