Для небольшого веб-приложения отслеживание ошибок JavaScript сводится к четырём вещам: ловить каждую неперехваченную ошибку и каждый необработанный reject промиса, группировать повторы, чтобы сотня падений читалась как один баг, превращать минифицированные стек-трейсы обратно в ваш код и сохранять активность консоли и сети за минуту до сбоя. Дашборды, правила алертов и трейсинг производительности подождут, пока следить за ними не станет чьей-то работой.
В этом посте — эти четыре вещи, браузерные детали за каждой из них и шум, который можно выбросить. Я только что добавил захват ошибок в BugDump, поэтому примеры из него, но механика одна и та же, какой бы инструмент вы ни выбрали.
Пользователи вам не скажут
Когда кнопка оформления заказа ничего не делает, большинство людей пробуют ещё раз, вздыхают и уходят. Единицы пишут — обычно через несколько дней и с описанием вроде «на телефоне всё сломано». А ошибка всё это время была в консоли, в которую никто не смотрел.
В одном предложении смысл отслеживания ошибок такой: оно превращает баги, о которых никто не сообщает, в список, который можно разобрать. Для небольшого приложения этот список важнее любого графика, нарисованного поверх него.
1. Ловим ошибки: два события
Браузеры сообщают о сбоях через два события, и нужны оба:
window.addEventListener('error', (event) => {
report(event.error ?? new Error(event.message));
});
window.addEventListener('unhandledrejection', (event) => {
report(event.reason);
});
error срабатывает на исключение, которое никто не перехватил: опечатку, чтение свойства у undefined, вызов несуществующей функции. unhandledrejection — на промис, который упал, когда его никто не слушал. В коде, где в основном async и await, большая часть ошибок попадает как раз во второе событие. fetch, который получил HTML-страницу с ошибкой и вызвал на ней response.json(), до window.onerror не доходит вообще.
Три вещи, которых эти события не дадут:
Ошибки, которые вы перехватили сами.
try/catch, который показывает вежливое сообщение, заодно прячет ошибку от обоих событий. Если знать о ней всё равно нужно, отправьте её из блокаcatch.Подробности ошибок с других origin. Скрипт с другого домена, загруженный без CORS, падает как
Script error.— без файла, строки и стека. Браузер прячет их намеренно. Загрузите скрипт сcrossorigin="anonymous"и отдавайте его с заголовкомAccess-Control-Allow-Origin— или смиритесь с тем, что внутрь него не заглянуть.Ошибки до загрузки трекера. Если скрипт трекинга загружается последним, падения во время старта пропадают. Загружайте его пораньше.
Слушатели — самое простое. Остальная часть поста — о том, что происходит после report().
2. Группировка: тысяча событий, один баг
Ошибка, которая бьёт по каждому посетителю страницы, приходит тысячи раз. Вам нужна одна строка со счётчиком, так что от группировки зависит, можно ли вообще пользоваться списком.
Обычный подход, который использую и я, строит отпечаток (fingerprint) из трёх частей:
тип ошибки, например
TypeError;сообщение без переменных частей:
Order 58213 not foundиOrder 61007 not found— одна и та же ошибка, поэтому числа, URL, email-адреса и ID заменяются плейсхолдерами;несколько верхних фреймов вашего собственного кода — по файлу и функции, а не по строке, чтобы несвязанная правка выше по файлу не разбивала группу.

Это работает хорошо, но не идеально. Браузеры описывают один и тот же сбой разными словами: Chrome пишет Cannot read properties of undefined (reading 'total'), Safari — undefined is not an object (evaluating 'cart.summary.total'). И стеки они строят по-разному: Firefox добавляет фрейм для места, где зарегистрирован колбэк. Поэтому время от времени один баг приходит как две ошибки, по одной на браузер. Инструмент должен давать привязать обе к одной задаче и идти дальше, а не делать вид, что такого не бывает.
3. Шум: что выбросить в первую очередь
Некоторые ошибки не ваши, а некоторые — вовсе не ошибки. Отбрасывайте их до того, как они попадут в список, иначе список перестанет стоить того, чтобы его читать.
Что вы видите | Почему это происходит | Что делать |
|---|---|---|
| скрипт с другого origin, браузер скрыл подробности | отбросить или добавить |
фрейм в | расширение браузера внедрило код в вашу страницу | отбросить |
| безобидный цикл лейаута, о котором предупреждает браузер | отбросить |
| блокировщик рекламы удалил скрипт аналитики, который ваш код всё ещё вызывает | игнорировать или обернуть вызов в проверку |
| запрос, который отменил ваш же код | игнорировать |
Ещё два правила держат объём в разумных пределах и ничего не теряют:
Отправляйте каждую отдельную ошибку один раз за сессию. Падение внутри цикла рендера может срабатывать сотни раз в секунду в одной вкладке. Первое событие уже говорит всё.
Ограничьте частоту на страницу. Десяти в минуту для одной вкладки хватает с запасом. Всё, что сверх этого, — цикл, а не новая информация.
Сэмплирование, то есть отправка только части ошибок, помогает, когда трафик настолько большой, что и 10 % от него находят каждый баг. В небольшом приложении оно в основном прячет ту редкую ошибку, которую вам нужнее всего было увидеть. Держите 100 %, пока месячный счётчик не скажет иначе.
4. Читаемые стек-трейсы без публикации исходников
Продакшен-код минифицирован, поэтому сырой стек-трейс выглядит так:
TypeError: Cannot read properties of undefined (reading 'subtotal')
at T (https://acme-store.com/assets/app-L5L4OHQM.js:2:1095)
at x (https://acme-store.com/assets/app-L5L4OHQM.js:2:2081)
Source map превращает это обратно в renderCartSummary в src/cart/cart-summary.ts, строка 6. Загвоздка в том, где лежат карты. Если отдавать их рядом с JavaScript, ими пользуется DevTools, но и ваши исходники видит любой, кто откроет вкладку Network.
Лучше использовать скрытые (hidden) source maps. Сборщик пишет файлы .map, но не ссылается на них из JavaScript, CI загружает их в трекер ошибок, и до вашего сервера они не доходят.
Дальше трекеру нужно найти правильную карту для каждого фрейма. Сопоставление по URL файла и имени релиза — старый способ, и он легко ломается: CDN меняет URL, имя релиза не совпадает, две сборки получают одно имя. Надёжнее debug ID — на него перешёл Sentry, и я использую его же. Шаг после сборки вычисляет ID из содержимого каждого файла и вписывает его и в файл, и в его карту. Во время выполнения файл регистрирует свой ID, ошибка несёт его с собой, и карта находится по одному ID.
На практике это три команды:
vite build --sourcemap hidden
npx @bugdump/cli sourcemaps inject dist
npx @bugdump/cli sourcemaps upload dist --release "$GIT_SHA" --delete-after

5. Минута до ошибки
Стек-трейс говорит, где код сдался. Почему — часто не говорит.
Вот пример из демо-магазина. Ошибка — SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON, брошенная внутри маленького хелпера getJson(). Стек указывает на JSON.parse: это верно, но ничем не помогает. Объясняет всё сетевой лог того же события: GET /api/recommendations вернул страницу 502 от балансировщика нагрузки, и код попытался разобрать HTML как JSON.

Поэтому событие ошибки должно нести последнюю минуту логов консоли, сетевых запросов и кликов — те же улики, что есть в хорошем баг-репорте. Без них половина ваших ошибок заканчивается на «не воспроизводится».
Вторая половина контекста — ваши пользователи. В том же демо-магазине задачу AS-1 завели по жалобам покупателей: «Checkout button does nothing on Safari», то есть кнопка оформления заказа в Safari ничего не делает. Сама по себе это классическая жалоба, с которой ничего не сделать. Теперь на той же задаче висит пойманная ошибка, замеченная четыре раза и только в Safari и Mobile Safari: window.requestIdleCallback is not a function. В Safari нет requestIdleCallback, поэтому обработчик клика падает ещё до того, как заказ отправлен. То, что видели покупатели, и строка, которая это объясняет, — на одной карточке.

Именно это упускает отдельный дашборд ошибок. Баг, который описывает пользователь, и ошибка, которую бросает код, часто оказываются одним и тем же багом, и держать их стоит вместе.
Нужен ли полноценный инструмент мониторинга?
Не всегда. Специализированные продукты для мониторинга ошибок умеют гораздо больше, чем ловить ошибки в браузере, и если вам нужно то, что они добавляют, берите такой. Для небольшого приложения честное сравнение выглядит так:
Захват ошибок в баг-трекере | Отдельный инструмент мониторинга | |
|---|---|---|
Что ловит | ошибки JavaScript и отклонённые промисы в браузере | ошибки браузера, сервера и мобильных приложений, плюс производительность |
Куда попадают ошибки | на ту же доску, что и баг-репорты пользователей | в отдельные «входящие» со ссылками на ваш трекер |
Настройка | виджет, который у вас уже стоит | второй SDK, второй проект и свой набор алертов |
Алерты | уведомление о новых и вернувшихся ошибках | правила, пороги и маршрутизация на дежурных |
Кому подходит | небольшим веб-приложениям, нескольким разработчикам | командам с дежурствами, несколькими сервисами и SLO |
Если ваше приложение — это один фронтенд и API, а баг-репорты читают и ошибки исправляют одни и те же два-три человека, одну доску проще настроить и проще проверять. Когда появляется бэкенд, который надо мониторить, график дежурств или бюджет производительности, отдельный инструмент себя окупает.
Чек-лист для небольшого приложения
Слушайте и
error, иunhandledrejection, а трекер загружайте пораньше.Отправляйте ошибки, которые перехватываете сами, если они вам всё ещё важны.
Задавайте
releaseв каждой сборке, чтобы отличать новый баг от старой вкладки.Собирайте со скрытыми source maps и загружайте их из CI. Никогда не деплойте карты.
Отбрасывайте ошибки расширений,
Script error.и безобидные предупреждения браузера ещё до отправки.Отправляйте каждую ошибку один раз за сессию и не включайте сэмплирование, пока этого не потребует объём.
Превращайте ошибки в задачи рядом с баг-репортами, а не во вторые «входящие», которые никто не открывает.
Частые вопросы
Замедляет ли отслеживание ошибок сайт? Не настолько, чтобы это можно было измерить. Два слушателя ничего не делают, пока что-нибудь не сломается, а нормальный трекер отправляет каждую отдельную ошибку один раз за сессию, в фоне.
Что значит «Script error.»? Ошибка случилась в скрипте с другого origin, и браузер скрыл подробности. Добавьте crossorigin="anonymous" в тег этого скрипта и настройте его сервер на отправку Access-Control-Allow-Origin — или отбрасывайте такие ошибки: без подробностей с ними ничего не сделать.
Безопасно ли загружать source maps? Загружать их в трекер ошибок — нормально. Исходники раскрывает публичная раздача карт. Соберите скрытые карты, загрузите их и удалите перед деплоем.
Сколько ошибок отправит небольшое приложение? Это гораздо сильнее зависит от того, сколько у вас разных багов, чем от трафика. Если каждая ошибка отправляется один раз за сессию, страница с одним багом отправляет одно событие на каждый затронутый визит, а не на каждое падение.
Как попробовать в BugDump
Захват ошибок входит в виджет BugDump на всех тарифах и включён по умолчанию. На бесплатном тарифе — 1000 событий ошибок в месяц на проект. Начните со статьи Автоматический захват ошибок JavaScript, затем прочитайте Читаемые стек-трейсы: загрузка source maps и Работа с пойманными ошибками.
Читайте также
Воспроизведение сессии для отладки: что пользователь сделал перед багом — как работает воспроизведение сессии и почему вместе с логами консоли и сети оно превращает расплывчатый отчёт в баг, который можно найти.
Как собрать логи консоли и сетевые запросы из браузера пользователя — как перехватить консоль, ошибки, fetch и XHR и не сломать при этом страницу, за которой следите.
Лёгкий баг-трекер для небольшой команды: когда Jira — это слишком — почему тяжёлые трекеры не приживаются в маленьких командах и что нужно от лёгкого.