Traces de pile asynchrones : pourquoi `Error.stack` vous ment
La file de microtâches a mangé mon contexte de débogage.
Il est 2 heures du matin. L’alarme PagerDuty hurle.
Vous ouvrez les logs et voyez ceci :
Error: Cannot read properties of undefined (reading 'id') at processTicksAndRejections (node:internal/process/task_queues:96:5)C’est tout. Pas de nom de fonction. Pas de numéro de ligne. Pas de chemin de fichier. Juste « processTicksAndRejections ».
Bienvenue dans le JavaScript asynchrone, où les traces de pile sont inventées et les numéros de ligne ne comptent pas.
Pourquoi les traces de pile se brisent
Dans le code synchrone, la pile d’appels est une belle généalogie. A a appelé B, B a appelé C. Quand C plante, on voit exactement comment on en est arrivé là.
Dans le code asynchrone (async/await), chaque mot-clé await est un point de suspension.
Quand vous utilisez await, votre fonction est arrachée de la pile. Elle est placée dans un congélateur cryogénique appelé la file de microtâches (Microtask Queue). La pile est maintenant vide (ou fait autre chose).
Quand la Promise se résout, votre fonction est décongelée et renvoyée sur la pile. Mais l’historique a disparu.
Le moteur n’a aucune idée de qui a appelé await il y a 500 millisecondes. Il sait juste qu’il a une tâche à exécuter.
Les tentatives de V8 pour corriger le problème
Node.js essaie d’aider. Nous avons :
Error.captureStackTrace(): Capture la pile à la création. Inutile si l’erreur est lancée plus tard.--async-stack-traces: Un drapeau qui force Node.js à garder une « pile fantôme » des chaînes de promises.- Le coût : Il rend votre application 30 % plus lente.
- Le résultat : Ça aide, mais ça devient bruyant rapidement.
La vraie solution : AsyncLocalStorage
Si vous voulez survivre en production, arrêtez de regarder les traces de pile. Regardez la causalité.
Nous devons attacher un contexte (ID utilisateur, ID de requête) au « fil » d’exécution, même quand il saute entre la pile et la file de microtâches.
Node.js possède un outil intégré pour ça : AsyncLocalStorage.
import { AsyncLocalStorage } from 'async_hooks';
const context = new AsyncLocalStorage();
// 1. Envelopper la requêtecontext.run({ requestId: '123' }, () => { // 2. Appeler du code asynchrone profond await processOrder();});
// 3. Au fin fond de processOrder :async function processOrder() { await db.query();
// Magie ! On peut toujours voir le requestId const { requestId } = context.getStore(); console.log(`[${requestId}] Échec du traitement de la commande`);}Peu importe le nombre d’await qui se produisent entre les deux. Le contexte survit.
Playbook de production
- Arrêtez de faire confiance à
err.stack. Il est incomplet par conception. - Utilisez des logs structurés. Attachez un
requestIdà chaque ligne de log avecAsyncLocalStorage. - Tracez, n’empilez pas. Utilisez OpenTelemetry. Il visualise la chaîne causale entre les services, ce qui est ce qui compte vraiment.
Votre code est asynchrone. Votre contexte de débogage ne devrait pas l’être.