Трассировка стека представляет собой список вызовов функций, которые выполнялись в момент, когда что-то выбросило исключение. Браузер записывает её изнутри наружу: сверху функция, в которой произошёл сбой, ниже та, что её вызвала, и так далее вниз до того, что запустило всё это.

Выглядит это как стена текста, и относятся к ней соответственно. Большинство читает только первую строку, ищет текст сообщения в интернете и никогда не заглядывает дальше, а жаль: первая строка говорит о том, что сломалось, а строки под ней объясняют, почему вообще там кто-то оказался.

Что означают эти строки на самом деле

Трассировка в браузере выглядит примерно так:

TypeError: Cannot read properties of undefined (reading 'total')
    at formatPrice (checkout.js:214:19)
    at renderSummary (checkout.js:188:7)
    at onCartLoaded (cart.js:97:5)
    at HTMLButtonElement.<anonymous> (cart.js:41:12)

В ней содержится четыре элемента, и каждый отвечает на свой вопрос.

Первая строка содержит саму ошибку: тип и текст сообщения. Она показывает, что пошло не так в момент сбоя, и оказывается наименее полезной строкой для поиска причины. В примере что-то внутри formatPrice попыталось прочитать .total у значения, которого там не было. Это случай с обращением к свойству undefined, и вина здесь почти никогда не лежит на самой formatPrice.

Каждая строка at обозначает фрейм: функцию, которая ожидала возврата той, что выше неё. formatPrice была вызвана из renderSummary, а та, в свою очередь, из onCartLoaded.

Числа обозначают файл, строку и столбец. checkout.js:214:19 означает строку 214, столбец 19, что важно, когда на одной строке кода стоит сразу несколько вызовов.

Нижний фрейм запускает всю цепочку, и обычно это самая информативная строка во всей трассировке. HTMLButtonElement.<anonymous> означает, что всё началось с обработчика клика. То есть что-то сделал человек, а не что-то произошло при загрузке страницы. Это уже готовый шаг для воспроизведения.

Читайте снизу вверх

Стоит выработать привычку читать трассировку в том порядке, в котором код действительно выполнялся, то есть снизу вверх.

Произошёл клик. Запустился onCartLoaded. Он вызвал renderSummary, та вызвала formatPrice, которая не нашла ничего там, где ожидала увидеть сумму корзины. При таком чтении вопрос перестаёт звучать как «что не так с formatPrice» и превращается в «что было на руках у onCartLoaded и откуда оно взялось?». Именно там почти всегда и находится исправление.

Слабо
Ошибка в formatPrice, строка 214. Добавлена проверка на undefined.
Лучше
Обработчик клика в cart.js:41 сработал раньше, чем пришёл ответ с данными корзины, поэтому renderSummary получила пустой объект. Порядок операций исправлен; formatPrice осталась без изменений.

Первый вариант убирает ошибку. Второй убирает баг. Это не одна и та же работа, и именно трассировка позволяет их различить.

Фреймы, которые вам не принадлежат

Большинство реальных трассировок длиннее примера и забиты фреймами из библиотек: React, jQuery, рантайм сборщика, полифилл. В основном это шум, и оба главных браузера умеют скрывать его за вас.

В Chrome DevTools за это отвечает Ignore list в Settings: он сворачивает фреймы из указанных вами скриптов и не даёт отладчику заходить внутрь них. По умолчанию туда уже входят /node_modules/ и /bower_components/, поэтому первым полезным шагом часто становится добавление туда собственных путей к сторонним библиотекам. У отладчика Firefox есть та же идея, только для каждого файла отдельно, и называется она Ignore source. В любом случае каждая последующая трассировка становится вдвое короче.

Остаётся ваш собственный код, а это именно то, что вы можете изменить.

Одно исключение стоит держать в уме: если все фреймы без исключения принадлежат библиотекам, вызов, скорее всего, произошёл внутри самой библиотеки, а не в вашем коде, и причина обычно в данных или опциях, которые вы ей передали.

Почему трассировка иногда указывает на бессмыслицу

Есть три вещи, которые регулярно делают трассировку нечитаемой, и все три связаны не с самим багом, а со способом доставки кода.

Минификация. В продакшене весь ваш код превращается в одну гигантскую строку с однобуквенными именами, поэтому трассировка выглядит как at n (app.min.js:1:84213). Номер строки настоящий, но бесполезный. Решение: source maps. Сборка выпускает файл .map, браузер его применяет, и DevTools показывает исходные имена файлов и строк. Прежде чем решить, что трассировка безнадёжна, проверьте, генерируются ли source maps и доступны ли они браузеру.

Асинхронные границы. Трассировка обрывается там, где обрывается стек вызовов, а setTimeout, коллбэк промиса или обработчик события запускают новый. Фрейм, который поставил задачу в очередь, по умолчанию в трассировку не попадает. В Chrome DevTools по умолчанию включены Async stack traces, которые сшивают оба стека вместе, поэтому в DevTools трассировка одной и той же ошибки выглядит полнее, чем в лог-файле.

Межсайтовые скрипты. Если скрипт отдаётся с другого домена без разрешения раскрывать свои ошибки, браузер вообще отказывается что-либо о нём сообщать: сообщением становится голая строка Script error. без файла, строки и трассировки. Исправление лежит на стороне сервера: Access-Control-Allow-Origin на скрипте плюс crossorigin="anonymous" на теге. Это стоит знать, потому что симптом выглядит как загадочная ошибка, а на деле это просто правило доступа.

Трассировка, которую вы не собрали

Всё написанное выше предполагает, что трассировка у вас уже есть. Неудобный случай при этом самый важный: ошибка произошла на чужом компьютере, в браузере, которого у вас нет, а до вас дошло только одно предложение.

Просить пользователя прислать трассировку стека бессмысленно. Она живёт на панели, которую он никогда не открывал, пропадает при закрытии вкладки, а консоль не переживает переход на другую страницу, если только кто-то заранее не поставил галочку Preserve log. Поэтому практический выбор стоит между наличием трекера ошибок или инструмента для отчётов, который захватывает консоль в момент сбоя, и его отсутствием.

Без этого в отчёте будет написано «страница оформления заказа стала пустой», а четыре строки, которые дали бы ответ за минуту, браузер выбросил через несколько секунд после этого.

Session Replay

Бесплатное расширение для Chrome. Один клик на странице, которая ведёт себя неправильно, и оно захватывает скриншот, консоль и сетевой журнал, а затем выдаёт вам ссылку, которую можно вставить в тикет.

Установить расширение

Как получить трассировку намеренно

Есть две вещи, которые стоит знать, когда трассировка нужна вам самим, а не когда вы её просто ждёте.

console.trace() выводит текущий стек вызовов, ничего при этом не выбрасывая, и это самый быстрый способ ответить на вопрос «кто вызывает эту функцию?» в незнакомой кодовой базе.

new Error().stack даёт то же самое в виде строки, и именно это трекеры ошибок отправляют к себе на сервер. Так же можно прикрепить трассировку к перехваченному исключению, которое иначе бы просто исчезло: если поймать исключение и залогировать только e.message, весь стек выбрасывается, и именно эта привычка отвечает за огромное количество нечитаемых продакшен-логов.

Что из этого следует

Верхняя строка называет симптом. Нижняя строка называет то, что всё запустило. Середина представляет собой путь между ними, и исправление обычно находится где-то на этом пути, а не на одном из его концов.

Читайте снизу вверх, скрывайте чужие фреймы и проверяйте source maps прежде чем винить трассировку. А если нужная вам трассировка находится в чужой сессии браузера, договариваться о её получении нужно до бага, а не после: к моменту, когда придёт отчёт, она уже пропадёт.