Maîtriser AsyncLocalStorage pour le contexte en Node.js

Dans une application Node.js, une même requête peut traverser plusieurs fonctions asynchrones avant de produire une réponse. Lorsque le code doit conserver un identifiant de requête, un utilisateur authentifié ou une information de traçage, transmettre ces données manuellement devient vite répétitif. AsyncLocalStorage fournit un contexte local qui suit automatiquement l’exécution asynchrone.

Cette API du module natif node:async_hooks est particulièrement utile pour les journaux, la supervision, la sécurité et les transactions. Elle permet d’associer des métadonnées à une chaîne d’exécution sans ajouter un paramètre à chaque fonction, tout en respectant le modèle non bloquant de Node.js.

Comprendre le contexte asynchrone

AsyncLocalStorage fonctionne comme un espace de stockage propre à une exécution asynchrone. Une valeur créée au début d’une requête reste accessible dans les appels déclenchés ensuite, qu’il s’agisse d’un await, d’une promesse, d’un minuteur ou d’une opération réseau correctement prise en charge par Node.js.

Solution Portée des données Transmission Usage courant
Paramètres de fonction Explicite et locale Manuelle Logique métier simple
Variable globale Partagée par le processus Automatique mais risquée Configuration
Objet de requête Limitée au framework Via les fonctions web Données HTTP
AsyncLocalStorage Liée à l’exécution asynchrone Automatique Corrélation, logs, traçage

L’approche rappelle une forme de contexte implicite, mais elle ne transforme pas les données en variables globales. Chaque exécution possède son propre état. Pour raisonner sur les frontières d’exécution et la propagation d’un état, une lecture sur l’algorithme de Simplexe peut aussi illustrer l’importance de distinguer les étapes et les valeurs propres à chaque chemin de traitement.

Initialiser un contexte par requête

L’API expose une classe à instancier une seule fois, généralement dans un module dédié. La méthode run() reçoit un objet de contexte et une fonction callback. Toutes les opérations asynchrones créées pendant l’exécution de cette fonction pourront ensuite retrouver cet objet.

import { AsyncLocalStorage } from 'node:async_hooks';

export const requestContext = new AsyncLocalStorage();

export function withRequestContext(context, callback) {
  return requestContext.run(context, callback);
}

export function getRequestContext() {
  return requestContext.getStore();
}

Dans un serveur HTTP, le contexte doit être établi dès que possible, idéalement au début du traitement de la requête. Chaque appel à run() crée une portée isolée. Il faut donc éviter de placer un contexte mutable dans une variable de module, car plusieurs requêtes peuvent s’exécuter en parallèle.

import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { requestContext } from './context.js';

const server = http.createServer((req, res) => {
  const context = {
    requestId: req.headers['x-request-id'] ?? randomUUID(),
    startedAt: Date.now()
  };

  requestContext.run(context, async () => {
    await handleRequest(req, res);
  });
});

Lire les métadonnées dans le code métier

Une fonction profonde dans la pile peut appeler getStore() sans recevoir le contexte comme argument. Cette possibilité convient aux composants transversaux, comme un logger, un client de base de données ou un collecteur de métriques. La dépendance reste toutefois implicite : elle doit être documentée pour éviter de rendre le code difficile à tester.

export function log(level, message, extra = {}) {
  const context = requestContext.getStore();

  console.log(JSON.stringify({
    level,
    message,
    requestId: context?.requestId,
    ...extra,
    timestamp: new Date().toISOString()
  }));
}

Le résultat est particulièrement pratique lorsque plusieurs services internes écrivent pendant une même requête. Les lignes de journal peuvent être regroupées grâce à requestId, tandis que le code métier conserve des signatures plus lisibles. Si aucun contexte n’est disponible, l’opérateur ?. permet de fonctionner aussi dans un script lancé hors serveur.

Il est préférable de stocker de petites métadonnées stables : identifiant de corrélation, identifiant utilisateur, langue, tenant ou niveau de journalisation. Les objets volumineux, les réponses HTTP et les résultats de requêtes ne devraient pas être conservés inutilement dans ce contexte.

Intégrer l’API aux frameworks web

Avec Express, le middleware doit appeler run() avant de transmettre le contrôle à next(). La fonction suivante installe un identifiant pour chaque requête et conserve celui fourni par un proxy de confiance lorsque cette architecture est maîtrisée.

import { randomUUID } from 'node:crypto';
import { requestContext } from './context.js';

export function contextMiddleware(req, res, next) {
  const requestId = req.get('x-request-id') || randomUUID();

  requestContext.run({
    requestId,
    method: req.method,
    path: req.originalUrl
  }, next);
}

Dans Fastify, NestJS ou un framework maison, le principe reste identique : créer la portée au niveau du hook ou du middleware le plus extérieur. La zone d’intégration peut servir de repère pour organiser ce raccordement avec les autres composants d’une application web.

Il faut également vérifier le comportement des bibliothèques tierces qui créent des callbacks non standards. Les primitives natives et les promesses modernes conservent généralement le contexte, mais certains anciens modules, systèmes de files d’attente ou adaptateurs peuvent nécessiter une intégration spécifique.

Éviter les pièges de propagation

getStore() renvoie undefined lorsqu’il est appelé en dehors d’une portée active. Ce comportement est normal dans les tests unitaires, les scripts d’administration ou les tâches planifiées. Le code doit donc prévoir une valeur absente plutôt que supposer que le contexte existe toujours.

La méthode enterWith() permet d’entrer dans un contexte pour les événements suivants, mais elle est plus facile à mal utiliser. Dans un gestionnaire d’événements, elle peut faire fuiter un état vers des callbacks ultérieurs. Pour isoler clairement chaque requête, run() est généralement le choix le plus sûr.

Un autre piège consiste à modifier le même objet partout. Une fonction qui change store.user peut créer des effets inattendus pour les appels suivants. Lorsque le contexte doit évoluer, créer un nouvel objet ou limiter les mutations à une couche bien identifiée rend le comportement plus prévisible.

Renforcer les logs et l’observabilité

La corrélation des journaux est l’usage le plus visible. Un identifiant propagé dans le contexte peut être ajouté aux logs applicatifs, aux erreurs, aux spans OpenTelemetry et aux métriques. Cette cohérence accélère l’analyse d’un incident, surtout lorsqu’une requête traverse plusieurs appels internes.

Il faut cependant distinguer l’identifiant technique d’une donnée sensible. Un contexte ne doit pas contenir de mot de passe, de jeton d’accès ou de document confidentiel. Les identifiants utilisateur doivent être traités selon les règles de confidentialité de l’application, avec masquage dans les journaux lorsque cela est nécessaire.

Pour les traces distribuées, AsyncLocalStorage ne remplace pas un standard de propagation comme les en-têtes W3C Trace Context. Il peut compléter l’instrumentation locale en rendant accessibles le trace ID ou le span courant aux modules qui produisent des événements.

Tester et mesurer le coût

Les tests doivent couvrir deux aspects : la valeur correcte du contexte et son isolation entre plusieurs opérations concurrentes. Deux promesses lancées avec des identifiants différents doivent continuer à retrouver leur propre état après plusieurs await.

import test from 'node:test';
import assert from 'node:assert/strict';

test('isole deux contextes', async () => {
  const values = await Promise.all([
    requestContext.run({ requestId: 'a' }, async () => {
      await new Promise(resolve => setTimeout(resolve, 5));
      return requestContext.getStore().requestId;
    }),
    requestContext.run({ requestId: 'b' }, async () => {
      return requestContext.getStore().requestId;
    })
  ]);

  assert.deepEqual(values, ['a', 'b']);
});

L’impact sur les performances dépend du nombre d’opérations asynchrones et de la charge de l’application. Dans la plupart des serveurs modernes, le coût est acceptable pour des métadonnées de requête. Une mesure avec autocannon, des profils Node.js et des scénarios réalistes reste préférable à une décision fondée sur une intuition.

Adopter de bonnes pratiques

AsyncLocalStorage est puissant lorsqu’il reste cantonné à des préoccupations transversales. Il ne doit pas devenir un conteneur universel qui dissimule toutes les dépendances de l’application. Les données métier essentielles devraient continuer à circuler explicitement entre les fonctions concernées.

Quelques règles simples facilitent son adoption :

Une séparation entre contexte de requête, contexte de trace et contexte d’authentification peut être utile dans les systèmes complexes. Elle rend les responsabilités plus claires et évite qu’un composant accède à des informations dont il n’a pas besoin.

Pour une application Node.js, cette API offre donc un compromis efficace entre la transmission manuelle de paramètres et les variables globales dangereuses. Commencez par l’ajouter aux logs et au suivi des requêtes, mesurez son comportement en production, puis étendez son usage aux transactions et à l’observabilité avec des limites clairement définies.