DanLevy.net

Stack Traces Asíncronos: Por Qué `Error.stack` Te Miente

La cola de microtareas se comió mi tarea (y mi contexto de depuración).

Son las 2 de la mañana. La alarma de PagerDuty está sonando sin parar.

Abres los logs y ves esto:

Error: Cannot read properties of undefined (reading 'id')
at processTicksAndRejections (node:internal/process/task_queues:96:5)

Eso es todo. Sin nombre de función. Sin número de línea. Sin ruta de archivo. Solo “processTicksAndRejections”.

Bienvenido a JavaScript asíncrono, donde los stack traces son inventados y los números de línea no importan.


Por Qué Se Rompen los Stack Traces

En código síncrono, el Call Stack es una hermosa genealogía. A llamó a B, B llamó a C. Cuando C falla, puedes ver exactamente cómo llegaste ahí.

En código asíncrono (async/await), cada palabra clave await es un punto de suspensión.

Cuando haces await, tu función se arranca del stack. Se mete en un congelador criogénico llamado Microtask Queue. El stack ahora está vacío (o haciendo otra cosa).

Cuando la Promise se resuelve, tu función se descongela y se lanza de vuelta al stack. Pero el historial desapareció.

El motor no tiene idea de quién llamó a await hace 500 milisegundos. Solo sabe que tiene una tarea que ejecutar.


Los Intentos de V8 por Arreglarlo

Node.js intenta ayudar. Tenemos:

  1. Error.captureStackTrace(): Captura el stack en el momento de creación. Inútil si el error se lanza después.
  2. --async-stack-traces: Un flag que hace que Node.js mantenga un “shadow stack” de cadenas de promises.
    • El Costo: Hace tu app un 30% más lenta.
    • El Resultado: Ayuda, pero se vuelve ruidoso rápido.

La Solución Real: AsyncLocalStorage

Si quieres sobrevivir en producción, deja de mirar los stack traces. Mira la causalidad.

Necesitamos adjuntar contexto (User ID, Request ID) al “hilo” de ejecución, incluso mientras salta entre el Stack y la Microtask Queue.

Node.js tiene una herramienta builtin para esto: AsyncLocalStorage.

import { AsyncLocalStorage } from 'async_hooks';
const context = new AsyncLocalStorage();
// 1. Envolver la request
context.run({ requestId: '123' }, () => {
// 2. Llamar código asíncrono profundo
await processOrder();
});
// 3. En lo profundo de processOrder:
async function processOrder() {
await db.query();
// ¡Magia! Todavía podemos ver el requestId
const { requestId } = context.getStore();
console.log(`[${requestId}] Fallo al procesar la orden`);
}

No importa cuántos awaits ocurran en el medio. El contexto sobrevive.


Playbook para Producción

  1. Deja de confiar en err.stack. Es incompleto por diseño.
  2. Usa structured logging. Adjunta requestId a cada línea de log usando AsyncLocalStorage.
  3. Traza, no apiles. Usa OpenTelemetry. Visualiza la cadena causal entre servicios, que es lo que realmente te importa.

Tu código es asíncrono. Tu contexto de depuración no debería serlo.