Назад в блог
13 августа 2026 г.Sergei Solod15 мин чтения

Я алертил на всё подряд: как я превратил шум браузерных ошибок в нормальный продакшен-мониторинг

Мой фронтенд-репортёр одинаково тревожил меня из-за AdBlock, сбоев GTM, AbortError, бесполезного Script error и настоящих ошибок чанков Next.js. Я перестроил мониторинг вокруг принадлежности ошибки, влияния на пользователя, качества доказательств, корреляции и восстановления.

Мониторинг браузерных ошибокНаблюдаемость фронтендаОшибки JavaScriptПродакшен-мониторингNext.jsВеб-производительность

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

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

Не загрузился сторонний скрипт аналитики? Красный алерт. Заблокировалась реклама? Красный алерт. Краулер не смог получить Google Analytics? Красный алерт. Превью-видео вызвало play(), а затем было остановлено до завершения Promise? Красный алерт. Пришёл бесполезный Script error. без нормального источника и стека? Тоже красный алерт.

И в этом же потоке лежали события, которые действительно заслуживали внимания: мой собственный URL случайно превратился в https://example.comhttps://example.com/..., а у другого пользователя не загрузился файл Next.js из /_next/static/chunks/....

Сбор данных работал. Мониторинг — нет.

После этого я стал иначе смотреть на наблюдаемость фронтенда. Событие об ошибке — это свидетельство того, что что-то произошло. Это ещё не диагноз, не уровень критичности и не инцидент.

Первой проблемой оказалось само слово «ошибка»

Изначальная модель была почти такой:

браузер сообщил об ошибке
        ↓
отправить CLIENT ERROR
        ↓
разработчик должен срочно смотреть

Но внутри этой схемы смешано сразу несколько вопросов. Что именно сломалось? Это вообще мой код? Пострадала текущая страница? Это ожидаемая отмена операции? Браузер смог сообщить нормальный источник? Приложение восстановилось? Десять сообщений относятся к десяти инцидентам или к одному?

Пока ответов нет, событие не должно автоматически превращаться в алерт.

В одном из первых разборов передо мной было примерно восемнадцать сообщений. Большая часть оказалась сторонним шумом или нормальными событиями жизненного цикла. Два случая сильно выделялись: кривой URL моего приложения был однозначным багом, а не загруженный JavaScript-чанк мог реально сломать страницу. Но репортёр показывал их почти с той же срочностью, что и заблокированную рекламу.

Тогда я перестал считать задачи «собрать все браузерные ошибки» и «сделать продакшен-мониторинг» одной задачей. Сбор должен сохранять доказательства. Мониторинг должен сжимать эти доказательства до решений.

У браузера нет одного универсального канала ошибок

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

Событие error на window используется для синхронных ошибок скрипта и связано с ошибками загрузки ресурсов. Необработанный отклонённый Promise идёт по другому пути: браузер генерирует unhandledrejection. Элементы, которые загружают ресурсы, могут генерировать собственный error. А React или Next.js сверху добавляют сигналы своих error boundary.

Эти каналы говорят о разных вещах.

window.error
→ наружу могла выйти синхронная ошибка скрипта

unhandledrejection
→ в этот момент остался необработанным отклонённый Promise

ошибка элемента
→ ресурс не удалось загрузить или использовать

framework boundary
→ выполнение или рендер дошли до границы ошибки фреймворка

Ключевые слова здесь — «могла» и «в этот момент». Глобальный обработчик видит симптом на границе системы. Полную причинную цепочку он часто не знает.

Когда я это принял, идея немедленно превращать всё в один универсальный Error с одним уровнем критичности перестала казаться разумной.

Первый полезный фильтр — кому принадлежит ошибка

Самое полезное первое разделение — принадлежность.

Сбой /_next/static/chunks/app/... совсем не то же самое, что сбой рекламного SDK на чужом домене. Кривой URL, собранный моим кодом, не равен заблокированному запросу аналитики. Ошибка расширения браузера — ещё одна отдельная категория.

Я мысленно делю события минимум на четыре группы:

  • моё приложение: JavaScript, CSS, API, медиа и генерируемые URL;
  • фреймворк и рантайм: Next.js или React, если они находятся в цепочке выполнения приложения;
  • сторонние интеграции: аналитика, реклама, виджеты, внешние SDK;
  • окружение: расширения, краулеры, сеть, инструменты приватности и особенности конкретного браузера.

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

Если отправлять всё в один срочный канал, этот канал перестаёт что-либо означать.

Два сбоя моего приложения показали, что такое полезный сигнал

С кривым URL всё было просто. Браузер пытался загрузить что-то вроде:

https://example.comhttps://example.com/resource

Здесь не нужно было придумывать теории про AdBlock, VPN или особенности браузера. Сам URL неверный. Где-то код фактически добавил origin к значению, которое уже было абсолютным адресом.

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

С ошибкой Next.js всё сложнее:

Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js

Ресурс тоже мой, и его отсутствие действительно способно сломать страницу. Но само событие не доказывает причину. Старый клиент мог попросить чанк предыдущего релиза. Запрос мог зависнуть. Мог сбоить reverse proxy или CDN. Могла пропасть сеть. Файла действительно могло не быть.

Правильная реакция поэтому не «я уже знаю причину», а «этот класс ошибок критичнее и требует больше контекста».

Для меня это стало важным правилом: критичность может быть высокой даже тогда, когда уверенность в причине низкая.

Script error. — это зацепка, а не stack trace

Периодически прилетала классика:

Error: Script error.
filename: unknown
line: 0
column: 0

Выглядит страшно. Полезной информации почти нет.

Детали ошибок cross-origin скриптов намеренно ограничиваются браузером. MDN пишет, что без корректных CORS-настроек обычный сторонний скрипт отдаёт в window.onerror минимум информации; поведение crossorigin у <script> напрямую влияет на доступность полных деталей.

Поэтому непрозрачный Script error. нельзя тихо переименовывать в «моё приложение упало». Источник может быть моим, сторонним, внедрённым расширением, либо браузер просто не имеет права раскрыть подробности.

Моя политика теперь такая: сохранять событие, связывать его со страницей, билдом, браузером и соседними событиями, но не будить человека из-за одного 0:0. Если одинаковый сигнал начинает группироваться вокруг одного релиза или маршрута, его приоритет меняется.

«Неизвестно» не означает «безопасно». Но и «критично» из этого не следует.

AbortError может быть настоящей ошибкой Promise и нормальным поведением продукта

Лучший пример разницы между «ошибка существует» и «есть инцидент» я получил на превью-видео.

Приходили необработанные отклонения вроде:

AbortError:
The play() request was interrupted by a call to pause()

HTMLMediaElement.play() возвращает Promise, и этот Promise может быть отклонён. Операции жизненного цикла медиа могут сознательно оборвать ожидающее воспроизведение; MDN отдельно документирует, что load() прерывает незавершённые play-Promise с AbortError.

В сетке превью это легко получить без единого пользовательского бага. Карточка появляется на экране, код вызывает play(), пользователь прокручивает страницу, карточка уходит из viewport, код останавливает или заменяет видео раньше, чем запуск воспроизведения успел закончиться.

Отклонение Promise настоящее. Пользовательского инцидента при этом может вообще не быть.

Нормальное решение находится рядом с вызовом: обработать Promise там, где начинается воспроизведение, и отличить ожидаемую отмену от настоящей ошибки проигрывания. Глобальный unhandledrejection полезен как страховка, но он не должен впервые узнавать смысл обычного жизненного цикла медиа.

Сторонним сбоям нужна отдельная модель здоровья

В первых логах было много аналитики и рекламных доменов. Часть приходила из браузеров с усиленной приватностью. Часть — от краулеров. Особенно бессмысленно выглядел алерт о том, что краулер не смог загрузить Google Analytics.

Это говорит, что один сетевой запрос не состоялся. Почти ничего не говорит о том, мог ли живой пользователь пользоваться сайтом.

Ошибка была не в том, что я сохранил событие. Ошибка была в том, что я положил его в один поток с не загруженным JavaScript-чанком моего приложения.

Теперь я разделяю два вопроса:

  • сломалось ли приложение для пользователя;
  • работает ли внешняя интеграция.

Заблокированная реклама может идти в метрику доставки рекламы. Сбой аналитики — в метрику покрытия аналитики. Ни то ни другое не должно называться «фронтенд упал», если нет доказательств, что от интеграции зависит основная функциональность.

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

navigator.onLine — подсказка, а не доказательство доступности сервера

Я начал сохранять и признак того, считает ли браузер себя онлайн. Он полезный, пока не принимать его за истину.

У меня были события, которые дополнительная логика относила к сетевым сбоям, но в логе одновременно стояло:

Online: true

Противоречия здесь нет. MDN прямо предупреждает, что navigator.onLine основан на эвристиках браузера и ОС. Компьютер может видеть локальную сеть, но не иметь доступа к моему origin. VPN, firewall, DNS и частичные сетевые аварии делают картину ещё сложнее.

Поэтому online-state у меня только подсказка:

online === false
→ сильный сигнал, что сбой ресурса может быть внешним

online === true
→ НЕ доказывает, что мой origin или конкретный ресурс доступен

Небольшое различие, но оно не даёт мониторингу уверенно сформулировать неправильный диагноз.

Один корневой сбой способен породить несколько браузерных событий

После улучшения сбора проявилась следующая проблема: один инцидент мог прислать несколько сообщений.

JavaScript-чанк сначала способен дать элементный resource.error. Затем загрузчик модулей выбросит ChunkLoadError. После этого React или Next.js попадёт в error boundary. Логика восстановления запланирует reload. Если каждый слой шлёт отдельную тревогу, одно действие пользователя выглядит как несколько независимых поломок продакшена.

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

Решение — не тупой dedupe по строке сообщения. Нужна корреляция инцидента.

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

session
+ короткое временное окно
+ нормализованный класс ошибки
+ first-party ресурс
+ client build
+ route

После этого человеку можно показать самое информативное представление. Если уже есть framework boundary с моим stack trace и точным URL чанка, предыдущий общий resource error не обязан создавать второй срочный алерт.

Алертить нужно инциденты. Сохранять — события.

Контекст вокруг ошибки стал полезнее самой строки ошибки

Поздние версии телеметрии стали гораздо структурированнее. Помимо message, URL и User-Agent в событиях появились, например:

clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack

Для медиа я также собирал код media error, HTTP-статус, когда его удавалось получить отдельной проверкой, фактический Content-Type и классификацию сбоя доставки как HTTP или network.

Это полностью меняет отладку, потому что можно задавать вопросы, на которые одна строка исключения не отвечает:

  • ошибки начались только на одном билде?
  • получил ли браузер HTTP-ответ?
  • контролировал ли страницу Service Worker?
  • успела ли запуститься логика восстановления?
  • несколько событий указывают на один и тот же ресурс?
  • текущий маршрут реально перестал работать?

Resource Timing API браузера может дать длительность загрузки, объём передачи и, где это поддерживается и разрешено, HTTP-статус. У этих полей есть ограничения: cross-origin timing урезается, кешированные ресурсы способны иметь нулевой transferSize, а responseStatus доступен не везде. Поэтому null и 0 должны оставаться честными состояниями, а не превращаться в выдуманную определённость.

ChunkLoadError — это симптом, а не детектор 404

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

Браузер сообщил Next.js ChunkLoadError для чанка layout. Расширенная телеметрия одновременно содержала:

resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true

То есть в зафиксированном измерении ресурс занял примерно 170 секунд. Какая бы точная корневая причина ни была у этого события, данных уже достаточно, чтобы отбросить примитивное правило:

ChunkLoadError === сервер вернул 404

У других сбоев чанков не было доступного статуса вообще. Некоторые приходили как timeout. У некоторых была информация о передаче. Класс ошибки один, окружение — разное.

Для Next.js это особенно важно: файлы в /_next/static/ обычно имеют хеши содержимого и рассчитаны на immutable-кеширование. Актуальная документация Next.js по self-hosting прямо описывает долгоживущие cache headers для хешированных immutable assets. Поэтому в расследовании ChunkLoadError могут участвовать старые клиенты, перекос версий при деплое, сеть, reverse proxy, CDN, кеши, Service Worker или реально отсутствующие артефакты.

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

Медиа-ошибки научили тому же с другой стороны

Похожая ловушка обнаружилась в телеметрии видео. На уровне media element браузер сообщал:

MEDIA_ELEMENT_ERROR: Format error

На слух это почти готовый диагноз «кодек несовместим».

Но дополнительная проверка доставки для части таких событий показала:

HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http

Браузер просил видео, а получил HTTP-ошибку с HTML. Наружный симптом стал «format error», потому что media element не способен декодировать полученный HTML как видео. Полезный диагноз находится на уровне доставки.

Это тот же принцип: слой, который первым заметил проблему, не обязан быть слоем, где она возникла.

Поэтому я стараюсь не называть инцидент именем внешнего исключения. «Ошибка кодека», «авария сети», «баг кеша», «пропавший чанк» — уже выводы. Телеметрия сначала должна описывать наблюдения.

Я оцениваю браузерные сбои по пяти независимым признакам

Вместо огромного чёрного списка удобнее небольшая модель из нескольких измерений.

1. Принадлежность

Ресурс или код мой, относится к фреймворку, является сторонней интеграцией или приходит из окружения?

2. Влияние на пользователя

Сломался активный маршрут, рендер, авторизация, чат, checkout или другой основной сценарий? Или не загрузилась необязательная реклама, аналитика, preload или превью, а страница продолжила работать?

3. Качество доказательств

Есть stack моего кода, URL ресурса, HTTP-статус, build ID и component stack? Или только Script error. в 0:0?

4. Повторяемость и охват

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

5. Восстановление

Приложение само восстановилось? Запланирован reload чанка? Сработал media fallback? Пользователь остался заблокирован?

Тогда критичность становится функцией этих признаков, а не синонимом слова Error.

точно мой ресурс
+ сильное влияние на пользователя
+ хорошие доказательства
+ повторяется в разных сессиях
+ восстановления нет
= срочный инцидент

сторонний ресурс
+ необязательная функция
+ слабые данные
+ единичный случай
+ пользователь не пострадал
= метрика или неспешное расследование

Фильтрация должна быть консервативной

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

Если глушить любой AbortError, можно скрыть реальные обрывы API-запросов приложения. Если выбрасывать каждый Script error., можно потерять кластер браузерной проблемы, который становится важным только после агрегации. Если игнорировать всё стороннее, можно пропустить сломанный платёжный, auth- или consent-провайдер.

Поэтому у меня логически не два исхода «алерт/удалить», а три:

ALERT
→ сильный инцидент, требующий внимания

RETAIN / AGGREGATE
→ сохранить и считать; тревожить при кластере

METRIC / SAMPLE
→ ожидаемый или слабый шум; хранить тренд и примеры

Так система становится тише, но не слепее.

Классификатор — это прежде всего политика, а не хитрый код

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

function classifyClientEvent(event) {
  const owner = classifyOwner(event);

  if (isExpectedMediaCancellation(event)) {
    return { severity: "metric", reason: "expected-cancellation" };
  }

  if (owner === "first-party" && breaksActiveRoute(event)) {
    return { severity: "alert", reason: "first-party-user-impact" };
  }

  if (isActiveFirstPartyChunkFailure(event)) {
    return { severity: "alert", reason: "application-chunk" };
  }

  if (owner === "third-party") {
    return { severity: "aggregate", reason: "integration-health" };
  }

  if (isOpaqueScriptError(event)) {
    return { severity: "aggregate", reason: "insufficient-evidence" };
  }

  return { severity: "aggregate", reason: "needs-correlation" };
}

Вся сложность скрыта, например, внутри breaksActiveRoute(). Одного имени исключения недостаточно. Нужны контекст маршрута, принадлежность ресурса, данные error boundary и иногда знание конкретного продукта.

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

Fingerprint должен следовать за инцидентом, а не за текстом сообщения

Простое сравнение строк плохо подходит для дедупликации. После билда меняются minified offsets. Меняются хеши чанков. В URL бывают динамические идентификаторы. Формулировки браузеров отличаются.

Мне ближе нормализованный fingerprint из устойчивых признаков:

{
  errorClass,
  normalizedFirstPartyResource,
  routeFamily,
  clientBuild,
  sessionId,
  shortTimeBucket
}

Для глобального падения полезен верхний frame моего стека. Для сбоя чанка — нормализованный ресурс. Для медиа — класс доставки и маршрут могут быть важнее внешней строки браузера.

Тогда последовательность:

resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled

становится одним инцидентом с четырьмя наблюдениями, а не четырьмя авариями.

Именно такой объект уже имеет смысл отправлять человеку.

Срочный канал должен решать очень узкую задачу

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

  • ошибка моего runtime-кода с полезным stack trace, которая ломает активный маршрут;
  • React/Next.js error boundary, реально мешающий взаимодействию;
  • сбой активного JavaScript или stylesheet моего приложения;
  • повторяющиеся ChunkLoadError в разных сессиях или билдах;
  • сбой ключевого API или данных, после которого пользователь заблокирован;
  • нарушение явного инварианта моего кода вроде кривого генерируемого URL.

Сохранял бы, но не тревожил мгновенно:

  • один непрозрачный Script error.;
  • единичный сбой моего ресурса с успешным восстановлением;
  • media error с ещё неизвестным корневым слоем;
  • аномалии конкретного браузера, которым нужна группировка.

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

  • известные сбои рекламных и аналитических ресурсов;
  • ожидаемые media AbortError;
  • сбои сторонних ресурсов только у краулеров;
  • события с сильным признаком offline-состояния;
  • необязательные speculative resources, не влияющие на открытый маршрут.

Главное не конкретный список. Главное правило: срочный алерт должен означать полезное для действия влияние на пользователя, а не просто количество жалоб браузера.

Вместо общего «числа ошибок» я бы измерял инциденты

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

Мне гораздо полезнее видеть:

  • инциденты моего приложения на 1000 сессий;
  • количество затронутых сессий по build ID;
  • инциденты error boundary по маршрутам;
  • сбои чанков по ресурсу и релизу;
  • процент сбоев сторонней интеграции по провайдеру;
  • объём ожидаемых отмен, чтобы внезапный рост всё равно был заметен;
  • долю успешного автоматического восстановления;
  • число уникальных инцидентов отдельно от числа сырых событий.

Так регрессии видны без притворства, будто AdBlock и падение рендера одинаково важны.

И порог алерта становится осмысленнее. «Произошло одно событие» редко хороший продакшен-порог. «Один и тот же сбой моего приложения уже затронул несколько независимых сессий на новом билде и восстановление не срабатывает» — намного ближе к тому, что нужно человеку.

Даже хороший клиентский мониторинг не доказывает корневую причину

У телеметрии браузера есть жёсткие границы.

Отсутствие HTTP-статуса может означать, что ответ не был доступен API, браузер не поддерживает нужное поле, сработало cross-origin ограничение, запрос отменился или просто есть другой пробел наблюдаемости. Активный Service Worker не доказывает, что именно он отдал устаревший ресурс. ChunkLoadError после деплоя не доказывает перекос версий. online: true не доказывает доступность origin.

Клиентский мониторинг сужает набор гипотез. Для доказательства всё ещё могут понадобиться серверные и Nginx-логи, манифесты релиза, состояние кеша и воспроизведение.

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

Хорошая телеметрия — это не обязательно больше данных. Это данные, которые лучше различают классы отказов.

Моё правило теперь: собирать события, расследовать инциденты, алертить влияние

Изначально я хотел, чтобы браузерный репортёр отвечал на простой вопрос: «Что-нибудь сломалось?»

В продакшене этот вопрос бессмысленен. Где-нибудь постоянно что-нибудь не срабатывает: краулер не получает аналитику, инструмент приватности режет рекламу, Promise медиа ожидаемо отменяется, пользователь теряет сеть, сторонний SDK ведёт себя странно.

Полезные вопросы гораздо уже:

Это наше?
Пользователь потерял функцию?
Насколько сильны доказательства?
Ошибка повторяется?
Приложение восстановилось?
Это несколько событий или один инцидент?

Когда я начал строить мониторинг вокруг этих вопросов, поток красных сообщений перестал быть пожарным шлангом и стал инженерным инструментом.

Главное изменение было не в одном фильтре. Оно было в модели мышления.

Браузерная ошибка — это наблюдение. Инцидент — связанное объяснение влияния на пользователя. Алерт — решение о том, что человеку пора действовать.

Я больше не хочу, чтобы эти три вещи означали одно и то же.