Как читать стектрейс JavaScript
Читать stack trace JavaScript: находите первый полезный frame, понимайте async-пробелы, минификацию, source maps и Error.cause.
Стектрейс JavaScript читается в обратном порядке по времени: верхний фрейм — это вызов, который выбросил ошибку, а каждый фрейм ниже — вызов, который к нему привёл.
Обычно поступают так: пробегают глазами первую строку, вставляют её в поисковую строку и надеются на удачу. Это работает ровно до тех пор, пока верхний фрейм не принадлежит React, или JSON.parse, или бандлу, где каждая функция называется o.
В этой статье мы разберём один трейс от начала до конца, добавляя в каждом разделе новый приём чтения: какой фрейм стоит открывать на самом деле, о чём говорят async-фреймы, как выглядит минифицированный трейс и почему стек, стоящий за Error.cause, никогда не появляется в том стеке, который вы вывели.
Ключевые выводы
- Верхний фрейм — это место, где ошибка была выброшена, а не место, где была допущена ошибка в коде; расследование начинается с первого фрейма, указывающего на файл, который написали вы.
- Асинхронные фреймы, помеченные
async, восстанавливаются движком V8 по точкам, в которых каждыйawaitприостанавливался и возобновлялся, поэтомуawait,Promise.all()иPromise.any()сшиваются, а «голая» цепочка.then()оставляет разрыв. - По умолчанию V8 сохраняет только 10 фреймов; это число можно изменить через нестандартное свойство
Error.stackTraceLimit. new Error(message, { cause })сохраняет исходную ошибку, но движок не объединяет два стека:err.stackпоказывает только обёртку.
Как выглядит стектрейс JavaScript?
Стектрейс JavaScript — это имя ошибки и сообщение в первой строке, а затем по одному фрейму на каждый вызов, начиная с самого свежего. Вот трейс, к которому статья будет возвращаться снова и снова: файл корзины читается с диска, парсится и передаётся остальной части приложения.
SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
at JSON.parse (<anonymous>)
at parseCart (/app/src/cart.js:5:15)
at loadCart (/app/src/cart.js:10:20)
at main (/app/src/main.js:6:16)
at Object.<anonymous> (/app/src/main.js:12:1)
Прочитайте фреймы в этом порядке — и цепочка становится очевидной: JSON.parse выбросил ошибку, parseCart вызвал его, loadCart вызвал parseCart, и так далее вниз. Нижний фрейм — это место, где началась именно эта цепочка вызовов, и это не всегда точка входа программы: код, до которого добрались через обработчик клика, колбэк таймера или разрешившийся промис, получает новую цепочку, начинающуюся с колбэка.
Один фрейм даёт вам четыре вещи: имя функции, файл, строку и столбец. В минифицированном коде без source map хоть какую-то ценность имеет только столбец. Одна оговорка, о которой стоит знать заранее: Error.prototype.stack не стандартизирован. Его реализует каждый движок, каждый печатает чуть иную строку, а работа TC39 по фиксации формата не завершена. Примеры здесь в стиле V8, что покрывает Chrome, Edge и Node.
Верхний фрейм — обычно не ваш код
Верхний фрейм стектрейса обычно не относится к вашему коду. Это библиотека, фреймворк или встроенная функция, которая заметила некорректное значение, то есть он говорит вам, что сломалось, а не почему. В трейсе выше at JSON.parse (<anonymous>) — это встроенный парсер, сообщающий, что переданная ему строка не является валидным JSON. Чинить там нечего.
Нужный вам фрейм — первый, указывающий на файл, который написали вы. Это parseCart в /app/src/cart.js:5:15. Откройте эту строку — и вы найдёте вызов JSON.parse, из чего следует, что некорректная строка пришла аргументом. Значит, значение пришло из фрейма ниже: loadCart, строка 10, где файл был прочитан. Вот реальная отправная точка, и вопрос, на который она отвечает, — откуда взялось содержимое файла и почему его никто не провалидировал.
Такой порядок чтения обобщается. Пролистывайте вниз мимо путей в node_modules, фреймов <anonymous> и native, пока не дойдёте до собственного файла, а затем двигайтесь вниз по фреймам, которые поставляли значение.
Трейс фиксирует путь, которым программа пришла к сбою, но никогда — путь, которым шёл пользователь; именно поэтому два отчёта с идентичными фреймами могут обернуться пятиминутной правкой в одном случае и невоспроизводимым призраком в другом. Session replay закрывает эту вторую половину разрыва: вы читаете фреймы, чтобы понять, где сломалось, и смотрите запись сессии, чтобы понять, как приложение дошло до состояния, в котором это могло сломаться.
Асинхронные фреймы и граница await
Пометьте loadCart как async — и трейс охватит await, а восстановленные фреймы получат префикс async:
SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
at JSON.parse (<anonymous>)
at parseCart (/app/src/cart.js:5:15)
at async loadCart (/app/src/cart.js:10:20)
at async main (/app/src/main.js:6:16)
Эти фреймы с префиксом async захватываются не так, как синхронные. V8 восстанавливает их по точкам await, и делать это он может бесплатно, потому что await возобновляется ровно в том месте, где приостановился.
У такого восстановления есть пределы, и именно на этих пределах трейс начинает редеть. Сшивание доходит до точек await, Promise.all() и Promise.any() — и ни до чего больше, поэтому промис, возвращённый без await, или цепочка .then() оставляют дыру ровно там, где раньше был вызывающий контекст. Если фреймы резко обрываются на асинхронной границе, ищите пропущенный await уровнем выше. Никакого флага включать не нужно: --async-stack-traces включён по умолчанию начиная с V8 v7.3, поэтому асинхронные фреймы появляются в Chrome, во всех поддерживаемых релизах Node и в других актуальных рантаймах на V8.
Вторая причина пропажи фреймов — ограничение. V8 сохраняет 10 фреймов, а остальные отбрасывает, и Error.stackTraceLimit — это регулятор этого числа: новое значение применяется к ошибкам, созданным после того, как вы его установили, а всё, что не является числом или меньше нуля, оставит вас вообще без фреймов.
if (process.env.NODE_ENV !== 'production') {
Error.stackTraceLimit = Infinity;
}
Ограничьте это режимом разработки. Глубокие трейсы расходуют память, топят интересные фреймы в шуме и проталкивают пути к файлам и имена функций в логи, которые могут уехать куда-то ещё. Это свойство нестандартно: оно пришло из V8, а JavaScriptCore скопировал его ради совместимости, так что его установка нигде ничего не сломает, но значение по умолчанию и детали поведения зависят от движка.
Как читать минифицированный трейс?
На продакшен-бандле тот же сбой даёт примерно такие фреймы. Воспринимайте эту форму как иллюстрацию бандленного вывода, а не как точный формат какого-то конкретного инструмента:
SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
at JSON.parse (<anonymous>)
at o (/assets/index-4f1c8a2b.js:1:20874)
at async s (/assets/index-4f1c8a2b.js:1:21036)
Признаки безошибочны: однобуквенные имена функций, одно имя файла, строка 1 и пяти- или шестизначный номер столбца. Строка 1 и огромный столбец означают, что весь граф модулей уместился в одну строку, поэтому столбец — единственная координата, несущая информацию. parseCart и loadCart по-прежнему существуют по этому смещению столбца, но ничто в строке трейса не подскажет вам их имена.
Чтобы их восстановить, нужна source map, доступная инструментарию: сгенерированная на этапе сборки и загруженная туда, где трейс сможет с ней сопоставиться. Наше руководство о том, как работают source maps, разбирает формат и настройку сборки.
Error.cause не объединяет стеки
Обёртывание ошибки через new Error(message, { cause: originalError }) сохраняет исходный объект ошибки вместе с её типом и собственным стеком, но движок не объединяет два стека. err.stack показывает только обёртку, а исходная ошибка доступна исключительно через err.cause.stack.
export async function loadCart(path) {
const raw = await readFile(path, 'utf8');
try {
return parseCart(raw);
} catch (err) {
throw new Error(`Cart file ${path} is not valid JSON`, { cause: err });
}
}
Теперь вызывающий код получает сообщение с именем файла, и err.cause instanceof SyntaxError по-прежнему истинно. Чего он не получает — так это фрейма JSON.parse: стек обёртки начинается с throw внутри loadCart. Error.cause появился в ES2022 и работает в актуальных браузерах и во всех поддерживаемых релизах Node, начиная с Node 16.9.0. Он добавляется как собственное неперечисляемое свойство, поэтому не попадает в Object.keys(), for...in и наивный JSON.stringify() объекта ошибки.
Насколько глубоко консоль печатает цепочку, зависит от рантайма и от самой консоли, поэтому переносимый подход — обойти её самостоятельно:
function printChain(error) {
let current = error;
while (current instanceof Error) {
console.error(current.stack);
current = current.cause;
}
}
Это выведет фреймы обёртки, затем фреймы парсера — в том порядке, в котором ошибки выбрасывались.
Две привычки, уничтожающие след
Перехват ошибки и выбрасывание новой без передачи cause стирает исходный трейс из программы. Фреймы, которые сказали бы вам, где в код попало некорректное значение, больше нигде не существуют, и никакой поиск по логам их не вернёт:
catch (err) {
throw new Error('Could not load cart'); // JSON.parse frame is gone
}
Проглатывание ошибки в строку лога наносит тот же ущерб, только тише:
catch (err) {
console.log('cart load failed'); // message, no stack, no type
return [];
}
И то и другое отделяет от корректного варианта одно ключевое слово. Передавайте { cause: err } при повторном выбрасывании и логируйте сам err, а не фразу о нём.
В следующий раз, когда перед вами окажется трейс, не начинайте с первой строки. Прокрутите вниз до первого фрейма с вашим собственным именем файла, откройте эту строку и спросите, какое значение туда передали и кто именно. Если фреймы обрываются на границе async или на однобуквенной функции, перед вами разрыв сшивания или отсутствующая source map, а не вся история целиком.
Частые вопросы
Почему мой обработчик ошибок сообщает только 'Script error.' без стека?
Браузеры скрывают детали исключений, выброшенных скриптами с другого источника, поэтому window.onerror получает обобщённый текст 'Script error.' без полезного URL, номера строки и стека. Чтобы получить настоящее сообщение и фреймы, загружайте скрипт с атрибутом crossorigin, установленным в anonymous, и убедитесь, что сервер, на котором он размещён, возвращает заголовок Access-Control-Allow-Origin, покрывающий ваш источник. Большинство публичных CDN уже отправляют этот заголовок.
Работает ли Error.captureStackTrace за пределами Chrome и Node?
Это больше не исключительно V8. Error.captureStackTrace появился в V8 как часть его нестандартного API стектрейсов, и остальные движки с тех пор последовали примеру: JavaScriptCore выпустил его в Safari 17.2 (11 декабря 2023 года), а SpiderMonkey — в Firefox 138 (29 апреля 2025 года). Его вызов записывает строку стека в любой объект, который вы передадите. Поскольку он всё ещё не стандартизирован, защищайте вызов проверкой typeof Error.captureStackTrace, прежде чем использовать его в коде общей библиотеки.
Почему стектрейсы в Firefox выглядят иначе, чем в Chrome?
Error.prototype.stack находится за пределами любой спецификации, поэтому каждый движок печатает его так, как ему удобно, и содержимое различается. V8 записывает каждый фрейм в строке, начинающейся с 'at', тогда как Firefox использует форму functionName@file:line:column без такого префикса. Относитесь к строке стека как к выводу для человека, а не как к API для парсинга, и никогда не стройте группировку ошибок на одном лишь самописном регулярном выражении.
Можно ли захватить стектрейс, не выбрасывая ошибку?
Да. В большинстве актуальных движков стек заполняется в момент конструирования Error, а не в момент выбрасывания, поэтому const { stack } = new Error() тут же отдаёт вам стек вызовов — без throw и без catch. Верхним фреймом будет строка, создавшая ошибку, и обычное ограничение на количество фреймов по-прежнему действует: V8 сохраняет только 10 фреймов, если не увеличить Error.stackTraceLimit.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k