Zum Inhalt springen

Request-Kontext in Node.js mit AsyncLocalStorage

Nutze AsyncLocalStorage, um Request-Kontext — Trace-IDs, Sitzungen, Mandantendaten — durch asynchrone Aufrufketten zu propagieren, statt ihn zu reichen.

4 Min. Lesezeit
Diagramm des asynchronen Aufruf-Stacks von Node.js, das zeigt, wie der Request-Kontext ohne explizite Parameterübergabe propagiert wird

Jeder nicht-triviale Node.js-Service stößt irgendwann an dieselbe Wand: Du brauchst eine Trace-ID, Tenant-ID oder den authentifizierten Benutzer sechs Ebenen tief im Call Stack, in einem Utility, das mit HTTP-Requests nichts zu tun haben sollte. Der instinktive Fix ist, jeder Funktion zwischen Request-Handler und diesem Utility einen ctx-Parameter zu verpassen. Drei Monate später ist der ctx-Typ auf fünfzehn Felder angewachsen, die Hälfte deiner Funktionssignaturen beginnt damit, und ein neuer Kontextwert bedeutet, ein Dutzend Dateien anzufassen.

AsyncLocalStorage — stabil seit Node 16, seit Node 12 hinter einem Flag verfügbar — löst das sauber. Es ist das Node.js-Äquivalent zu Thread-Local Storage: ein Wert, der an einen asynchronen Ausführungskontext gebunden ist und automatisch durch await, setTimeout, Promise.then und Event-Emitter-Callbacks fließt, ganz ohne explizite Parameterübergabe.

Das Problem mit dem Parameter-Durchreichen

Vor dem Blick auf die Lösung lohnt es sich, die Kosten des Status quo konkret zu machen.

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 });
}

Jede Funktion zwischen dem Handler und dem eigentlichen Konsumenten des Kontexts wird zum stumpfen Kurier. Schlimmer noch: Wer tenantId zu RequestCtx hinzufügt, muss jetzt processOrder, validateInventory, chargePayment und sendConfirmation anfassen — obwohl keine dieser Funktionen sich dafür interessiert.

Wie AsyncLocalStorage funktioniert

AsyncLocalStorage erzeugt einen Store, der automatisch von jeder asynchronen Operation geerbt wird, die innerhalb eines run()-Aufrufs gestartet wird. Die Laufzeitumgebung verfolgt, welcher Store zu welcher asynchronen Ausführungskette gehört, und hält sie voneinander isoliert.

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
}

Gleichzeitige Requests bekommen jeweils ihre eigene Store-Instanz. Es gibt keinen geteilten Zustand und kein Risiko, dass der Kontext eines Requests in einen anderen übergreift.

Ein typsicheres Kontext-Modul bauen

Ein dünner Wrapper um AsyncLocalStorage liefert dir eine typisierte, ergonomische API für die gesamte Codebasis.

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;
}

Das Werfen eines Fehlers in getContext(), wenn kein Store existiert, ist Absicht. Stille undefined-Rückgaben verstecken den Bug; ein expliziter Fehler bringt ihn während der Entwicklung sofort ans Licht.

In die Request-Pipeline einbinden

Der richtige Ort für den Aufruf von runWithContext ist deine Framework-Middleware — einmal pro Request, bevor irgendwelche asynchrone Arbeit beginnt.

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(),
  );
}

Registriere sie früh in deiner Middleware-Kette, vor allen Route-Handlern:

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

Jeder nachgelagerte Handler, Service und jedes Utility, das innerhalb dieses Requests aufgerufen wird, hat jetzt Zugriff auf den Kontext — ganz ohne Parameter.

Kontext in Utilities verwenden

Mit der Middleware an Ort und Stelle kann jedes Utility den Kontext direkt abrufen.

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);
  },
};

Die Datenzugriffsschicht profitiert genauso:

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 });
}

Die Funktionssignatur dokumentiert jetzt, was die Funktion tut — nicht, was sie für andere mitschleppen muss.

Die Falle der losgelösten Promises

AsyncLocalStorage propagiert den Kontext durch asynchrone Operationen, die innerhalb des run()-Scopes mit await erwartet werden. Ein Fire-and-Forget-Muster durchbricht das.

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;
}

Dieselbe Regel gilt für Hintergrundjobs, die über setTimeout geplant werden, oder für Consumer einer Message Queue: Sie starten einen frischen Ausführungskontext. Erfasse, was du brauchst, bevor du die Request-Grenze verlässt, oder stelle den Kontext explizit wieder her, wenn der Job läuft.

~

Für Hintergrundjobs ist es ein besseres Muster, die benötigten Kontextwerte als Teil des Job-Payloads zu speichern — { traceId, tenantId, ...jobData } — und runWithContext() am Anfang des Job-Handlers aufzurufen. Das macht den Kontext an der Consumer-Grenze explizit und übersteht Prozessneustarts.

Die wichtigsten Erkenntnisse

  1. AsyncLocalStorage eliminiert Kontextparameter — Trace-IDs, Tenant-IDs und Benutzersitzungen gehören in den Kontext-Store, nicht in Funktionssignaturen
  2. Eine Middleware, volle Abdeckung — ein einziger runWithContext()-Aufruf pro Request macht den Kontext im gesamten asynchronen Baum dieses Requests verfügbar
  3. Wirf einen Fehler, wenn der Store fehlt — stille undefined-Rückgaben von getStore() verstecken Bugs; ein expliziter Fehler bringt Fehlkonfiguration sofort ans Licht
  4. Losgelöste Promises brechen die Propagation — Fire-and-Forget-Operationen verlassen den ursprünglichen Ausführungskontext; erfasse, was du brauchst, bevor du sie abkoppelst, oder stelle den Kontext explizit wieder her
  5. Logger sind der größte Gewinn — ein kontextbewusster Logger, der traceId und tenantId automatisch in jede Zeile aufnimmt, erfordert null Änderungen an den Aufrufstellen
  6. Funktionssignaturen werden sauberer — Business-Logik-Funktionen hören auf, Kurier-Argumente mitzuschleppen, und spiegeln nur noch wider, worauf sie tatsächlich operieren
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX