Async JavaScript - 16 - Debugger l'asynchrone

Stack traces async, Chrome DevTools, console.trace, debugger dans du code async, et trouver l'origine des rejections non gerees.

Async JavaScript - Debugger l'asynchrone

Ce que tu vas apprendre

  • Pourquoi les stack traces asynchrones sont tronquees (et comment les récupérer)
  • L'option "Async" dans Chrome DevTools et ce qu'elle fait concrètement
  • Utiliser console.trace() pour cartographier les chemins d'exécution
  • Logger les opérations asynchrones de facon structuree
  • Placer des debugger dans du code async sans perdre le contexte
  • Trouver l'origine des rejections non gerees
  • Les flags Node.js --trace-warnings et --unhandled-rejections

Prerequisites


Le bug le plus frustrant de ma carriere impliquait une Promise rejetee. L'erreur dans la console disait Unhandled Promise rejection: Cannot read properties of undefined (reading 'id'). La stack trace pointait vers une ligne dans un fichier que je n'avais pas écrit, quelque part dans un .then() anonyme, trois niveaux de callbacks plus loin. Aucune indication sur QUI avait appele cette Promise, ni depuis OU. J'ai mis quatre heures a trouver. Aujourd'hui, je sais que ca aurait pu prendre cinq minutes avec les bons outils.

Le problème des stack traces asynchrones

En JavaScript synchrone, la stack trace est complète. Chaque fonction appelante apparaît dans la pile.

javascriptfunction c() { throw new Error("boom"); }
function b() { c(); }
function a() { b(); }
a();
// Error: boom
//     at c
//     at b
//     at a

En asynchrone, la stack est coupee a chaque franchissement de boundary asynchrone :

javascriptasync function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json(); // Erreur ici
  return data;
}

async function loadDashboard() {
  const user = await fetchUser(42);
  return user;
}

loadDashboard();
// Error: Unexpected token '<'
//     at fetchUser (app.js:3)
// Et c'est tout. Qui a appele fetchUser ? Disparu.

Pourquoi ? Parce que quand fetchUser reprend apres le await, la stack d'origine (celle qui contenait loadDashboard) a ete depilee. La microtask qui reprend fetchUser est une nouvelle entree dans la boucle d'événements, avec sa propre stack.

Chrome DevTools : l'option Async stack traces

Chrome capture et reconstitue les stack traces asynchrones automatiquement depuis la v73. Dans les DevTools (F12), ouvre l'onglet Sources, et dans le panneau Call Stack, tu verras les frames asynchrones separees par un label gris "async".

fetchUser          (app.js:3)
  -- async --
loadDashboard      (app.js:8)
  -- async --
(anonymous)        (app.js:12)

Pour activer ou vérifier cette option : DevTools > Settings (engrenage) > cocher "Capture async stack traces". C'est active par défaut, mais je l'ai deja vu désactivé sur des machines de collegues.

Le coût en mémoire est réel. Chrome limite la profondeur a 32 frames asynchrones par défaut. Si tu chaînes 40 awaits en cascade (ce qui serait un autre problème), les premières frames disparaissent.

console.trace() pour cartographier l'exécution

console.trace() affiche la stack trace a l'endroit ou tu l'appelles, sans lever d'erreur. C'est mon outil préféré pour comprendre "qui appelle quoi" dans du code asynchrone.

javascriptasync function saveOrder(order) {
  console.trace("saveOrder appele avec", order.id);
  // ...
}

La sortie dans la console :

saveOrder appele avec 42
    at saveOrder (orders.js:2)
    at processCheckout (checkout.js:15)
    at handleSubmit (form.js:8)
    at HTMLFormElement.onsubmit (index.html:12)

C'est non destructif (pas de throw), ca ne casse pas le flux, et ca te donne le chemin complet. Combine avec les async stack traces de Chrome, tu as une vision complète du parcours.

Un pattern que j'utilise souvent pour tracer les opérations async sans polluer la console :

javascriptconst DEBUG_ASYNC = true;

function traceAsync(label) {
  if (!DEBUG_ASYNC) return;
  const err = new Error();
  const stack = err.stack.split("\n").slice(1, 5).join("\n");
  console.groupCollapsed(`[ASYNC] ${label}`);
  console.log(stack);
  console.log("Timestamp:", performance.now().toFixed(2));
  console.groupEnd();
}

async function fetchData(url) {
  traceAsync(`fetchData(${url})`);
  const response = await fetch(url);
  traceAsync(`fetchData(${url}) - response recu`);
  return response.json();
}

console.groupCollapsed est genial pour ca : la trace est visible mais repliee, elle ne noie pas les autres logs.

Logger les opérations asynchrones

Pour le debugging en production (ou semi-production), j'utilise un pattern de logging structure :

javascriptlet asyncOpId = 0;

function asyncLog(operation, data = {}) {
  const id = ++asyncOpId;
  const start = performance.now();

  console.log(`[${id}] START ${operation}`, data);

  return {
    success(result) {
      const duration = (performance.now() - start).toFixed(1);
      console.log(`[${id}] OK ${operation} (${duration}ms)`, result);
    },
    fail(error) {
      const duration = (performance.now() - start).toFixed(1);
      console.error(`[${id}] FAIL ${operation} (${duration}ms)`, error);
    },
  };
}

// Utilisation
async function chargerProfil(userId) {
  const log = asyncLog("chargerProfil", { userId });
  try {
    const user = await fetchUser(userId);
    const prefs = await fetchPreferences(userId);
    log.success({ user: user.name, prefsCount: Object.keys(prefs).length });
    return { user, prefs };
  } catch (err) {
    log.fail(err);
    throw err;
  }
}

La sortie :

[1] START chargerProfil { userId: 42 }
[2] START chargerProfil { userId: 99 }
[1] OK chargerProfil (234.5ms) { user: "Alice", prefsCount: 7 }
[2] FAIL chargerProfil (1502.3ms) Error: 404 Not Found

Les IDs permettent de suivre les opérations qui se chevauchent. Les durees revelent les goulots d'etranglement.

debugger dans du code async

Le mot-clé debugger fonctionne dans du code async exactement comme dans du code synchrone. Le navigateur met l'exécution en pause quand il atteint cette ligne.

javascriptasync function processerCommande(commande) {
  const stock = await verifierStock(commande.items);
  debugger; // Pause ici : inspecte stock dans le scope
  const prix = calculerTotal(stock, commande.reduction);
  const paiement = await debiterCarte(commande.carte, prix);
  debugger; // Pause ici : verifie le resultat du paiement
  return confirmer(commande.id, paiement.transactionId);
}

Quand tu es en pause sur un debugger apres un await, la variable locale (stock, prix) est accessible dans le scope. Le panneau "Scope" des DevTools affiche les variables locales de la fonction async, meme si techniquement la stack a ete reconstruite.

Un piège classique : mettre un debugger dans un .then(). Ca marche, mais le contexte affiche est celui du callback, pas celui de la chaîne de Promises.

javascript// Prefere ca :
const data = await fetchData();
debugger;
traiter(data);

// Plutot que ca :
fetchData().then(data => {
  debugger; // Fonctionne, mais le scope est moins clair
  traiter(data);
});

Trouver la source des rejections non gerees

Les rejections non gerees sont le cauchemar du debugger async. Voici comment les traquer.

Dans le navigateur

javascript// Ecouter toutes les rejections non gerees
window.addEventListener("unhandledrejection", event => {
  console.error("Promise rejetee non geree :", event.reason);
  console.error("Promise :", event.promise);

  // Capturer la stack si c'est une Error
  if (event.reason instanceof Error) {
    console.error("Stack :", event.reason.stack);
  }
});

Dans Node.js

javascriptprocess.on("unhandledRejection", (reason, promise) => {
  console.error("Rejection non geree :", reason);
  console.error("Promise :", promise);
});

Node.js : flags utiles

bash# Afficher les warnings avec la stack trace complete
node --trace-warnings app.mjs

# Faire planter le process sur une rejection non geree (recommande en production)
node --unhandled-rejections=throw app.mjs

# Les deux ensemble
node --trace-warnings --unhandled-rejections=throw app.mjs

--trace-warnings est precieux. Sans ce flag, Node.js affiche un warning générique. Avec, il affiche la stack trace complète qui mene a la Promise rejetee.

Techniques avancees

Nommer tes Promises

Les Promises anonymes sont difficiles a tracer. Un pattern simple :

javascriptfunction namedPromise(name, executor) {
  const p = new Promise(executor);
  p._debugName = name;
  return p;
}

// En debug, tu peux inspecter promise._debugName
const userPromise = namedPromise("fetchUser:42", (resolve, reject) => {
  fetch("/api/users/42").then(r => r.json()).then(resolve).catch(reject);
});

Performance timeline

L'API Performance permet de mesurer les opérations async dans le Performance tab des DevTools :

javascriptasync function operationTracee(nom, fn) {
  performance.mark(`${nom}-start`);
  try {
    const result = await fn();
    performance.mark(`${nom}-end`);
    performance.measure(nom, `${nom}-start`, `${nom}-end`);
    return result;
  } catch (err) {
    performance.mark(`${nom}-error`);
    performance.measure(`${nom}-failed`, `${nom}-start`, `${nom}-error`);
    throw err;
  }
}

// Visible dans DevTools > Performance > Timings
await operationTracee("charger-dashboard", () => fetchDashboard());

Résumé

Les stack traces asynchrones sont naturellement tronquees a chaque boundary async, mais Chrome DevTools les reconstitue automatiquement (option "Async stack traces"). console.trace() permet de cartographier les chemins d'exécution sans casser le flux. Un logger structure avec IDs et durees aide a suivre les opérations concurrentes. Le mot-clé debugger fonctionne dans du code async et donne acces au scope local reconstruit. Pour les rejections non gerees, l'événement unhandledrejection (navigateur) et --trace-warnings (Node.js) sont tes meilleurs allies. La clef du debug async, c'est l'instrumentation preventive : n'attends pas d'avoir un bug pour ajouter du logging.

Retrouve d'autres articles techniques sur paltemps.fr.


Navigation : Precedent : 15 - Top-level await | Suivant : 17 - Performance


Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.