Любая отладка начинается одинаково: вы открываете DevTools. В консоли ошибка, во вкладке Network упавший запрос, и через минуту вы знаете, что пошло не так.
Беда в том, что DevTools открыт на вашей машине, а баг происходит на машине пользователя.
Это руководство о том, как сократить это расстояние: инструментировать страницу так, чтобы, когда что-то ломается в браузере пользователя, вы получали те же три панели, которые открыли бы сами. Что патчить, в каком порядке и какие конкретные ловушки превращают наивную реализацию в источник новых багов.
Начните с порядка загрузки
Почти любое руководство по перехвату вывода консоли начинается с манкипатчинга. Начинать стоит не с него: сложно не то, как патчить, а то, когда.
Инструментация видит только то, что происходит после её загрузки. Всё, что залогировали раньше, потеряно. А самые нужные ошибки непропорционально часто случаются рано: не загрузился конфиг, сторонний скрипт упал при инициализации, разметка не совпала при гидратации. Пока бандл для баг-репортов скачается, распарсится и инициализируется, самая интересная часть хронологии уже в прошлом.
Справиться с этим можно двумя способами, и нужны оба.
Загружайте слой перехвата первым. Поставьте его перед бандлом приложения, синхронно, в <head>. И держите его маленьким — это довод в пользу заглушки, которая ставит хуки и больше ничего не делает, а тяжёлый интерфейс подгружается лениво потом.
Буферизуйте, даже когда данные ещё некуда девать. Хуки должны писать в кольцевой буфер в памяти с первой миллисекунды, задолго до того, как кто-то нажмёт «сообщить о баге». Когда форму отчёта наконец откроют, в буфере уже будет история. Именно поэтому можно собрать логи, записанные ещё до открытия виджета: перехватывает их вовсе не виджет.
Как пропатчить console и ничего не сломать
Механика простая:
const BUFFER = [];
const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
for (const level of LEVELS) {
const original = console[level].bind(console);
console[level] = (...args) => {
BUFFER.push({ level, ts: Date.now(), args: serialize(args) });
original(...args);
};
}
На практике здесь ломаются четыре вещи.
Ломается указание источника в DevTools. Теперь консоль показывает, что каждая строка пришла из вашей обёртки, а не из места вызова. Chrome и Firefox понимают комментарий //# sourceURL и правила blackboxing, но надёжнее снимать стек в момент вызова и хранить кадр вызывающего кода вместе с записью. Этот кадр вам всё равно понадобится в отчёте.
Рекурсия в самого себя. Если serialize бросает исключение, ваш обработчик ошибок пишет в лог, а логирование пропатчено, получается бесконечный цикл, который намертво вешает вкладку. Защититесь флагом повторного входа, и пусть хук целиком ничего не делает, если внутри что-то упало. Инструментация, которая роняет страницу, хуже, чем никакой.
Объекты не освобождаются. Если складывать в буфер сырые аргументы, он держит сильную ссылку на каждый объект, который когда-либо логировали: DOM-узлы, тела ответов, целые деревья компонентов. В долгоживущем одностраничном приложении это утечка памяти, которая растёт, пока открыта вкладка. Сериализуйте сразу в строку ограниченной длины и отпускайте ссылку.
Патчите не только вы. Скрипты аналитики, трекеры ошибок и dev-инструменты фреймворков тоже патчат console. Если вы сохранили оригинал при загрузке, а кто-то пропатчил после вас, его обёртка окажется поверх вашей, и вы всё равно увидите всё. Если пропатчили до вас, вы увидите вывод чужой обёртки. Оба варианта рабочие; не работает только расчёт на то, что вы одни. Никогда не возвращайте console[level] к оригиналу при отключении — так вы затрёте того, кто обернул его после вас. Восстанавливайте, только если ваша обёртка всё ещё на вершине цепочки.
Сериализация — бо́льшая часть работы
JSON.stringify падает на циклических ссылках, молча выбрасывает функции и undefined, бросает исключение на BigInt и превращает объекты Error в {} — а это как раз самый важный случай.
Сериализатор, пригодный для баг-репортов, должен справляться с циклическими ссылками (заменять их меткой), экземплярами Error (имя, сообщение и стек — явно), DOM-узлами (тег, id, классы, но не поддерево), Map и Set, а также ограничивать глубину и длину, чтобы один огромный объект не съел весь буфер. Длинные строки обрезайте с явной меткой, а не молча. Строка лога "[truncated at 2000 chars]" о чём-то говорит разработчику, а строка, которая просто обрывается, — нет.
Ошибки, которые вы не логировали
Большинство ошибок в продакшене ваш код никогда не передаёт в console.error. Их бросают, а обрабатывает кто-то другой. Этот пробел закрывают два глобальных хука:
window.addEventListener('error', (e) => {
record({ kind: 'uncaught', message: e.message, filename: e.filename,
line: e.lineno, col: e.colno, stack: e.error?.stack });
}, true);
window.addEventListener('unhandledrejection', (e) => {
record({ kind: 'unhandled-rejection', reason: serialize(e.reason) });
});
Для error используйте фазу перехвата (capture), чтобы видеть события, даже если кто-то ниже по цепочке остановит их распространение. И учтите, что событие error срабатывает и при неудачной загрузке ресурсов — битой картинке или скрипте, получившем 404, — и тогда его target — элемент, а не объект ошибки. Такие события стоит записывать отдельно: пропавший бандл объясняет очень многие отчёты в духе «страница просто пустая».
Перехват сети
Обернуть нужно два API, и ещё одно пригодится как страховка.
С fetch всё просто. Оберните его, запишите метод, URL, время начала, а когда промис завершится — статус и длительность. Записывайте и отклонённые промисы: сетевой сбой даёт отклонённый промис, а не ответ, и «запрос так и не завершился» по смыслу совсем другой отчёт, чем «запрос вернул 500».
XMLHttpRequest всё ещё важен. Многие зависимости используют его внутри, и если обернуть только fetch, вы будете получать отчёты, где упавшего запроса не видно. Пропатчите open, чтобы сохранить метод и URL на экземпляре, затем пропатчите send и слушайте loadend.
PerformanceObserver с типом записей resource — это страховка. Он видит каждый запрос браузера, включая картинки, стили, шрифты, sendBeacon и всё, что отправил скрипт, который взял ссылку на fetch до вашего патча. Он даёт тайминги и объём переданных данных, но не коды статуса и не тела, поэтому дополняет обёртки, а не заменяет их.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
record({ url: entry.name, type: entry.initiatorType,
duration: entry.duration, size: entry.transferSize });
}
}).observe({ type: 'resource', buffered: true });
Главное здесь — buffered: true: так вы получите записи, сделанные ещё до создания наблюдателя, и вернёте часть ранней хронологии, которая иначе пропала бы.
Тела запросов — самая опасная часть
Тела запросов и ответов — самое полезное, что можно приложить к баг-репорту, и то, что скорее всего превратит баг-репорт в инцидент безопасности. В телах лежат токены авторизации, персональные данные, ссылки для сброса пароля и платёжные данные.
Разумное значение по умолчанию — выключено, с явным включением для каждого проекта. В BugDump это настройка captureNetworkBodies, и по умолчанию она намеренно выключена, хотя с ней отчёты лучше. Если делаете такое сами, решите так же.
Когда тела включены, есть и механические ловушки. Тело Response — это поток, который можно прочитать один раз: перед чтением обязательно вызывайте clone(), иначе собственный await res.json() приложения упадёт на уже прочитанном теле. Ограничивайте объём чтения. И читайте асинхронно, чтобы не блокировать обработку ответа самим приложением.
Маскировать данные нужно в браузере
Самая надёжная защита приватности — когда чувствительные данные вообще не покидают машину. Значит, фильтровать нужно в момент перехвата, на клиенте, а не на сервере после загрузки.
Большинство задач закрывают два фильтра. Фильтр консоли отбрасывает записи по уровню или по шаблону — пригодится для библиотеки, которая на каждом рендере пишет токен сессии на уровне debug. Фильтр сети отбрасывает или переписывает записи по URL: так исключают эндпоинт авторизации, платёжного провайдера и маячки аналитики, которые иначе заняли бы 90% лога.
Bugdump.init({
apiKey: 'your-api-key',
networkFilter: (req) => !req.url.includes('/auth/'),
consoleFilter: (entry) => entry.level !== 'debug',
});
Здесь функции-предикаты лучше конфигурации. Любой белый список на регулярках кому-нибудь да не подойдёт, а функция подойдёт всем.
Размер буфера и когда остановиться
Баг-репорт — не пайплайн наблюдаемости. Вы записываете несколько минут одной сессии, а не весь парк машин.
Кольцевых буферов с жёстким пределом — порядка нескольких сотен записей консоли и нескольких сотен сетевых — почти всегда хватает: полезное окно — это секунды перед тем, как пользователь сдался. Храните самые свежие записи и отбрасывайте старые с начала. Запоминайте, сколько отбросили, чтобы отчёт мог сказать «показаны последние 200 из 4318 записей», а не молча делать вид, что лог был коротким.
Та же логика решает, чего не делать. Разбор source map, настройка уровней логирования по модулям, сэмплирование, политики хранения, агрегация — это задачи системы мониторинга ошибок. У баг-репорта есть автор, который тут же рассказывает, что пытался сделать. Этот контекст выполняет бо́льшую часть работы, которую в других местах делает агрегация.
Честная рекомендация
Всё описанное выше укладывается в несколько сотен строк. Разобраться в этом стоит, даже если вы не будете писать это сами: именно эти свойства нужно проверять в любом инструменте, которым пользуетесь. Ставится ли он раньше бандла приложения? Буферизует ли с момента загрузки? Переживает ли чужие патчи? Выключены ли тела по умолчанию? Можно ли замаскировать данные до отправки?
BugDump делает всё это одним тегом script, рендерит свой интерфейс внутри Shadow DOM, чтобы ничего не протекало ни внутрь, ни наружу, и прикладывает собранные логи консоли и сети к тому, что пользователь написал в форме отчёта. Бесплатный тариф — 500 отчётов на проект, без карты.
Но главное — слой перехвата, кто бы его ни написал. Всё остальное в инструменте — лишь способ доставить вам то, что он собрал.
Читайте также
Отслеживание ошибок JavaScript в небольшом веб-приложении: что нужно, а что подождёт — ловить сбои, группировать повторы, читать стек в своём коде и сохранять минуту перед падением.
Три четверти кода в моём SDK почти никто не запускал — как я измерил, что виджет грузит каждому посетителю 450 КБ, что исправил и в чём ошибся насчёт defer.
Воспроизведение сессии без слежки — запись сессии, ограниченная одним отчётом: пользователь может скрыть в ней лишнее, а вы — выключить её одной строкой.