Асинхронные стек-трейсы: почему `Error.stack` врёт вам
Очередь микрозадач съела мою домашку (и контекст отладки).
Два часа ночи. Воет PagerDuty.
Вы открываете логи и видите это:
Error: Cannot read properties of undefined (reading 'id') at processTicksAndRejections (node:internal/process/task_queues:96:5)И всё. Ни имени функции. Ни номера строки. Ни пути к файлу. Только «processTicksAndRejections».
Добро пожаловать в асинхронный JavaScript, где стек-трейсы выдуманы, а номера строк не имеют значения.
Почему стек-трейсы ломаются
В синхронном коде стек вызовов — прекрасная генеалогия. A вызвал B, B вызвал C. Когда C падает, видно, как именно вы туда попали.
В асинхронном коде (async/await) каждое ключевое слово await — точка подвеса.
Когда вы делаете await, функция срывается со стека. Её помещают в криогенную камеру под названием «очередь микрозадач». Стек теперь пуст (или занят чем-то другим).
Когда промис разрешается, функцию размораживают и бросают обратно на стек. Но история уже утеряна.
Движение понятия не имеет, кто вызывал await 500 миллисекунд назад. Он просто знает, что есть задача для выполнения.
Попытки V8 это исправить
Node.js пытается помочь. У нас есть:
Error.captureStackTrace(): Фиксирует стек в момент создания. Бесполезно, если ошибка выбрасывается позже.--async-stack-traces: Флаг, заставляющий Node.js вести «теневой стек» цепочек промисов.- Цена: приложение замедляется на 30 %.
- Результат: помогает, но быстро становится шумным.
Настоящее решение: AsyncLocalStorage
Если хотите выжить в продакшене, перестаньте смотреть на стек-трейсы. Смотрите на причинно-следственные связи.
Нужно привязать контекст (User ID, Request ID) к «потоку» выполнения, даже когда он прыгает между стеком и очередью микрозадач.
В Node.js для этого есть встроенный инструмент: AsyncLocalStorage.
import { AsyncLocalStorage } from 'async_hooks';
const context = new AsyncLocalStorage();
// 1. Оборачиваем запросcontext.run({ requestId: '123' }, () => { // 2. Вызываем глубокий асинхронный код await processOrder();});
// 3. Глубоко внутри processOrder:async function processOrder() { await db.query();
// Магия! Мы всё ещё видим requestId const { requestId } = context.getStore(); console.log(`[${requestId}] Failed to process order`);}Неважно, сколько await произошло между ними. Контекст выживает.
Продакшен-плейбук
- Перестаньте доверять
err.stack. Он неполон по своей природе. - Используйте структурированное логирование. Привязывайте
requestIdк каждой строке лога черезAsyncLocalStorage. - Трейсите, а не стектрейсите. Используйте OpenTelemetry. Он визуализирует причинную цепочку между сервисами — а это именно то, что вам нужно.
Ваш код асинхронен. Контекст отладки — не должен быть.