Содержание
- Как отследить упавшие ассеты
- Перехват ошибок на DOM-элементах
- PerformanceResourceTiming API
- Обход всех ресурсов
- PerformanceObserver
- Подресурсы из CSS
- Timing-Allow-Origin
- Комбинируем оба подхода
- Какую информацию собирать
- Флаг «в этом хите есть упавший ассет»
- Конкретный упавший ассет
- Соотношение упавших запросов к общему числу
- Перегон логов в метрики на бэкенде
- Реализуем мониторинг
- Мониторинг есть — что дальше?
- Ручной рубильник на бэке
- Автоматический fallback на фронте
- Итого
Веб-страница помимо текста содержит много дополнительных ресурсов. Например, статику — картинки, стили, скрипты.
В большинстве случаев всё идёт по плану: статика загружается и страница становится полностью работоспособной.
Но иногда что-то может пойти не так.
Например, статика перестаёт грузиться совсем, и пользователь вместо страницы видит белый экран.
Или страница отрисовалась, выглядит нормально, но кнопки не работают, потому что скрипты не загрузились.
Причин незагруженной статики много: нестабильное соединение, различного рода блокировки, сбой на CDN или неудачно раскатившийся билд.
Хочется своевременно узнавать о подобных проблемах, чтобы вовремя принять меры. И желательно не через обращения пользователей в техподдержку.
Для этого нужен мониторинг со стороны фронтенда.
Как отследить упавшие ассеты
Есть два основных способа.
- Подписаться на событие
errorу DOM-элементов, которые подключают статику. - Использовать PerformanceResourceTiming API, чтобы получить данные о загруженных ресурсах на странице.
Давайте разберём каждый из подходов.
Перехват ошибок на DOM-элементах
Самый простой способ — повесить глобальный обработчик события error и смотреть, на каком элементе оно стрельнуло.
// можно отслеживать и другие элементы, например, video, audio,
// но для простоты в статье будем говорить только про
// скрипты, стили и картинки
//
// P.S. да, через link элемент можно грузить не только стили,
// но допустим, что в нашем случае там будут только стили
const elementsToWatch = new Set(['script', 'link', 'img']);
function handleError(event) {
const element = event.target;
if (!element || !element.tagName) {
return;
}
// отбираем только скрипты, стили, и картинки
if (!elementsToWatch.has(element.tagName.toLowerCase())) {
return;
}
// получаем URL ассета
const url = element.src || element.href || '';
if (!url) {
return;
}
console.log('Упавший ассет:', url);
}
window.addEventListener('error', handleError, { capture: true });
При ошибке загрузки ассета браузер генерирует событие error на элементе — <script>, <link> или <img>. Такое событие не всплывает по DOM, поэтому на window его можно поймать только в фазе capture — для этого и нужен { capture: true } в третьем аргументе.
URL берём из атрибутов src и href — какой из них присутствует, зависит от типа элемента.
Что нам даёт этот подход:
- Довольно простой способ подключения.
- Получаем URL упавшего ресурса и элемент, который его пытался загрузить.
Чего не хватает:
- Нет информации о причине падения — мы видим только, что файл не загрузился. А была это
404,500или сетевая ошибка — мы не знаем. Хотя это разные кейсы, на которые по-хорошему нужно по-разному реагировать. - Не фиксируются подресурсы, подключённые из CSS: шрифты, картинки в
background-imageи так далее. У них нет собственного DOM-элемента, поэтому событиеerrorв таких кейсах не стрельнет.
PerformanceResourceTiming API
В процессе жизни страницы браузер собирает информацию о каждом загруженном ресурсе: когда был запрос, сколько он исполнялся, какой статус ответа, какого размера был ответ. Прочитать эти данные можно через PerformanceResourceTiming.
Обход всех ресурсов
Самое простое, что можно сделать — получить список ресурсов и пробежаться по нему.
const typesToWatch = new Set(['script', 'link', 'img']);
const entries = performance.getEntriesByType('resource');
for (const entry of entries) {
// оставляем только скрипты, стили и картинки
if (!typesToWatch.has(entry.initiatorType)) {
continue;
}
// отбираем упавшие запросы
if (entry.responseStatus >= 400) {
const url = entry.name;
const httpCode = entry.responseStatus;
console.log('Упавший ассет:', url, httpCode);
}
}
В performance.getEntriesByType('resource') попадают все сетевые запросы со страницы — включая XHR и fetch. Чтобы отобрать только запросы на получение статики, делаем проверку на initiatorType — это аналог фильтра по тегу из DOM-подхода.
Ссылку на ресурс и HTTP-код ответа можно получить из полей entry: name и responseStatus.
Но опираться только на responseStatus недостаточно. Если ресурс упал из-за сетевой ошибки или по какой-то причине был заблокирован, responseStatus будет 0. Чтобы такие случаи тоже поймать, добавим ещё одно условие:
const typesToWatch = new Set(['script', 'link', 'img']);
const entries = performance.getEntriesByType('resource');
for (const entry of entries) {
if (!typesToWatch.has(entry.initiatorType)) {
continue;
}
const failedByStatus = entry.responseStatus >= 400;
const failedByNetwork =
entry.responseStatus === 0 &&
entry.transferSize === 0 &&
entry.decodedBodySize === 0;
if (failedByStatus || failedByNetwork) {
console.log('Упавший ассет:', entry.name);
}
}
Минус такого подхода в том, что он нединамический: мы делаем срез в определённый момент времени, когда обращаемся к performance.getEntriesByType('resource'). Если хочется ловить ошибки сразу, как только они случаются, нужен PerformanceObserver.
PerformanceObserver
PerformanceObserver позволяет подписаться на новые записи о ресурсах в реальном времени.
const typesToWatch = new Set(['script', 'link', 'img']);
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!typesToWatch.has(entry.initiatorType)) {
continue;
}
const failedByStatus = entry.responseStatus >= 400;
const failedByNetwork =
entry.responseStatus === 0 &&
entry.transferSize === 0 &&
entry.decodedBodySize === 0;
if (failedByStatus || failedByNetwork) {
console.log('Упавший ассет:', entry.name);
}
}
});
observer.observe({ type: 'resource', buffered: true });
Тут есть важный нюанс — параметр buffered: true. Без него observer начинает слушать ресурсы с момента создания. А значит всё, что грузилось до этого, пройдёт мимо. С buffered: true нам прилетят и те ресурсы, которые успели загрузиться до вызова observe.
На практике это критично: часть статики начинает грузиться очень рано, ещё до того, как мы успеваем выполнить свой код. Без buffered: true мы бы их просто не увидели.
Подресурсы из CSS
Performance API ловит так же и ресурсы, которые подключаются изнутри CSS — шрифты, фоновые изображения и так далее.
const entries = performance.getEntriesByType('resource');
for (const entry of entries) {
const failedByStatus = // ... код из примера выше
const failedByNetwork = // ... код из примера выше
const isFailed = failedByStatus || failedByNetwork;
// отбираем ресурсы, которые пытался подключить CSS
if (entry.initiatorType === 'css' && isFailed) {
console.log('Упавший CSS sub-resource:', entry.name);
}
}
DOM-обработчик такие ресурсы не видит — у них нет собственного <img> или <link> в разметке. Их инициировал CSS-парсер из правил вроде @font-face или background-image: url(...). Но благодаря тому, что запрос исполнялся, записи об этом попадают в Performance API, и поле initiatorType подсказывает, кто их подгрузил.
Timing-Allow-Origin
С Performance API есть один важный нюанс — заголовок Timing-Allow-Origin.
Когда мы запрашиваем кросс-доменный ресурс (например, страница хостится на example.com, а статика на cdn.example.com), браузер по умолчанию скрывает большую часть полей PerformanceResourceTiming. В результате responseStatus, transferSize, decodedBodySize будут равны 0 независимо от того, чем реально закончился запрос.
Чтобы поля стали доступны, сервер статики должен отдавать заголовок:
Timing-Allow-Origin: *
Или явно перечислить разрешённые origin-ы:
Timing-Allow-Origin: https://example.com
Без этого заголовка способ работает только в пределах одного origin — и сама страница, и ассеты для неё должны быть загружены с одного домена.
Что мы получаем через Performance API:
- Видим вообще все HTTP-запросы со страницы — не зависим от DOM.
- Можем понять причину падения:
404,500,0(сетевая ошибка или блокировка запроса). - Фиксируем незагруженные подресурсы из CSS.
Что важно учесть:
- Для кросс-доменных ресурсов нужен
Timing-Allow-Origin, иначе детали недоступны. - Нет ссылки на конкретный DOM-элемент. Мы знаем URL, но не знаем, какой именно элемент на странице ссылался на этот URL.
Комбинируем оба подхода
Оба способа неплохо дополняют друг друга.
С помощью DOM-обработчика можем получить и URL ассета, и элемент, который его пытался загрузить. Performance API предоставляет более подробную информацию — статус ответа, причину падения.
Их можно использовать вместе: ловим ошибку через DOM-обработчик, а статус докручиваем из Performance API по тому же URL.
function getReason(url) {
const entries = performance.getEntriesByName(url);
const entry = entries[0];
if (!entry) {
return 'unknown';
}
if (entry.responseStatus >= 400) {
return `http_${entry.responseStatus}`;
}
if (entry.responseStatus === 0) {
return 'network_or_blocked';
}
return 'unknown';
}
function handleError(event) {
const element = event.target;
if (!element || !element.tagName) {
return;
}
if (!elementsToWatch.has(element.tagName.toLowerCase())) {
return;
}
const url = element.src || element.href || '';
if (!url) {
return;
}
const reason = getReason(url);
console.log('Упавший ассет:', url, reason, element);
}
Плюс PerformanceObserver остаётся как страховка — ловит CSS sub-resources, которые DOM-обработчик в принципе не увидит.
Какую информацию собирать
Детектить незагруженную статику научились — теперь нужно понять, что именно слать в мониторинг.
Тут есть три уровня детализации, от самой грубой до самой точной. Они не взаимозаменяемы, их полезно собирать одновременно — каждый отвечает на свой вопрос.
Для простоты во всех примерах ниже будем считать, что есть функция send, которая просто отправляет данные на бэкенд. Внутри это может быть fetch, navigator.sendBeacon — что угодно, не важно.
function send(params) {
const url = `/log`;
fetch(url, {
method: 'POST',
body: JSON.stringify(params),
});
}
Флаг «в этом хите есть упавший ассет»
Самый простой сигнал: при первом обнаружении любого упавшего ассета один раз отправляем событие hitWithFailedAssets.
let hitWithFailedAssets = false;
function reportHitWithFailedAssets(url) {
if (!hitWithFailedAssets) {
hitWithFailedAssets = true;
send({ event: 'hitWithFailedAssets' });
}
}
Что это даёт. На бэке мы можем посчитать, у какой доли хитов были проблемы со статикой. Получается одна простая метрика «процент проблемных хитов», по которой удобно строить дашборд и навешивать алерт: пробил порог — что-то пошло не так.
Это не помогает понять что упало и насколько массово. Зато даёт сигнал о том, что есть проблемы.
Конкретный упавший ассет
Следующий уровень — детали по каждому упавшему ресурсу.
function reportFailedAsset(url, type, reason) {
send({
event: 'failedAsset',
url,
type,
reason,
});
}
Теперь в логи прилетает конкретный URL, тип ассета и причина падения. Это уже что-то, с чем можно работать руками: посмотреть, какие именно ресурсы валятся, открыть их в браузере, разобраться.
Но в продакшен-мониторинге быстро всплывает другая проблема. Допустим, на дашборде видно «1000 событий failedAsset за день». Это много или мало? Один пользователь со слабым 3G, у которого десятками падают картинки? Или это половина трафика, у которой не грузится главный JS-бандл?
Соотношение упавших запросов к общему числу
Чтобы понять масштаб, нужно знать не только сколько упало, но и сколько вообще запрашивалось.
В конце жизни страницы можно пройтись по всем ресурсам из Performance API, посчитать total и failed для каждого хоста — и залогировать.
function isAssetResource(entry) {
// реализацию опускаем,
// её рассматривали в примерах выше
}
function isFailedAsset(entry) {
// реализацию опускаем,
// её рассматривали в примерах выше
}
function sendHostStats() {
const statsByHost = {};
const entries = performance.getEntriesByType('resource');
for (const entry of entries) {
if (!isAssetResource(entry)) {
continue;
}
// собираем статистику по отдельному хосту
const host = new URL(entry.name).hostname;
const hostStats =
statsByHost[host] || { total: 0, failed: 0 };
// считаем общее число запросов
hostStats.total += 1;
// и упавших
if (isFailedAsset(entry)) {
hostStats.failed += 1;
}
statsByHost[host] = hostStats;
}
// объединяем статистику по разным хостам в один массив -
// чтобы отправить одним событием, а не множеством запросов
const stats = [];
for (const host in statsByHost) {
stats.push({
host,
total: statsByHost[host].total,
failed: statsByHost[host].failed,
});
}
send({
event: 'staticDomainStats',
stats: JSON.stringify(stats),
});
}
Функцию sendHostStats можно вызвать, когда пользователь уходит со страницы — например, на событие beforeunload или pagehide.
Тут важная деталь. Если send реализован через fetch, браузер может не дождаться завершения запроса и просто закрыть вкладку. Чтобы запрос с большей вероятностью дошел до бэкенда, нужно передать keepalive: true:
function send(params, options = {}) {
const url = `/log`;
fetch(url, {
method: 'POST',
body: JSON.stringify(params),
keepalive: options.keepalive,
});
}
function sendHostStats() {
// ... предыдущий код
send({
event: 'staticDomainStats',
stats: JSON.stringify(stats),
}, {
keepalive: true,
});
}
// отправляем стастику по хостам перед закрытием страницы
window.addEventListener('beforeunload', () => {
sendHostStats();
});
На бэкенде на основе этих данных можно построить перцентили: у какого процента хитов какая доля статики не грузится. И вот это уже даёт более точное понимание масштаба. У 0.1% пользователей упало больше 50% запросов к CDN — это похоже на одного человека в проблемной сети. У 5% пользователей упало больше 10% запросов — это уже массовый инцидент.
Перегон логов в метрики на бэкенде
Сырые события из браузера сами по себе ещё не мониторинг — это просто поток логов. Чтобы из них получился дашборд и алерты, их нужно превратить в метрики.
Тут вариантов несколько.
Можно прямо в обработчике на бэке инкрементировать счётчики и гистограммы Prometheus — для этого есть готовые клиентские либы под все основные языки.
А можно отдать обработку в специализированный пайплайн — например, Vector, который принимает события, обогащает, агрегирует и отдаёт на выходе нужный формат метрик, например, для того же самого Prometheus.
Выбор будет зависеть от принятых практик в вашем проекте.
Реализуем мониторинг
После того, как метрики сформированы, нужно их визуализировать.
Для этого можно использовать Grafana — в ней есть много готовых виджетов под различные графики, диаграммы и таблицы.
Например, на основе событий, описанных выше в статье, можно построить такой дашборд.

Здесь мы выводим:
- статистику по числу хитов к страницам сайта, в которых не загрузился хотя бы один ассет;
- статистику по числу упавших запросов за статикой от общего числа;
- перцентили: в каком проценте хитов какой процент статики не загрузился.
Также выводим данные по доменам статики, браузерам и стране пользователя.
Все эти данные вместе помогают понять мастштаб проблемы.
После того, как построили дашборд, важно не забыть настроить алерты — чтобы максимально быстро реагировать на возникающие проблемы.
Мониторинг есть — что дальше?
Хорошо, проблему мы теперь видим почти сразу. Что дальше?
Тут зависит от того, насколько быстро нужно реагировать.
Ручной рубильник на бэке
Самый простой вариант — рубильник на стороне бэкенда. На бэке есть конфиг, в котором прописан хост статики. При алерте дежурный дергает рубильник и переключает хост на запасной — на страницы статика начинает подключается с рабочего CDN.
Просто, надёжно, но требует человека в момент инцидента.
Автоматический fallback на фронте
Более автоматический подход — фронт пытается перезагрузить упавший ассет с резервного хоста.
Идея такая: в обработчике error для упавшего ассета смотрим, есть ли для этого хоста fallback-домен. Если есть — создаём новый элемент с тем же набором атрибутов, но с URL на запасном домене, и подменяем им упавший.
const fallbackHosts = [
{
main: 'cdn.example.com',
fallback: 'cdn-fallback.example.com',
},
];
function findFallbackHost(host) {
for (const item of fallbackHosts) {
if (host === item.main) {
return item.fallback;
}
}
return null;
}
function tryFallback(element, url, type) {
const originalUrl = new URL(url, location.href);
const originalHost = originalUrl.hostname;
const fallbackHost = findFallbackHost(originalHost);
if (!fallbackHost) {
return;
}
const fallbackUrl = new URL(url, location.href);
fallbackUrl.host = fallbackHost;
// создаём копию элемента
//
// P.S. для упрощения, представим,
// что грузим фолбэки только для скриптов и стилей
const newElement = type === 'script'
? document.createElement('script')
: document.createElement('link');
// копируем все атрибуты из оригинального элемента
for (const attr of element.attributes) {
newElement.setAttribute(attr.name, attr.value);
}
// меняем только ссылку — на фолбэк
const urlAttr = type === 'script' ? 'src' : 'href';
newElement.setAttribute(urlAttr, fallbackUrl.toString());
// и помечаем, что это фолбэк
newElement.dataset.fallback = 'true';
// если фолбэк успешно загрузится, сохраним это событие
newElement.addEventListener('load', () => {
send({
event: 'assetFallback',
status: 'success',
originalUrl: originalUrl.toString(),
fallbackUrl: fallbackUrl.toString(),
});
}, { once: true });
// фэйл тоже сохраним
newElement.addEventListener('error', () => {
send({
event: 'assetFallback',
status: 'fail',
originalUrl: originalUrl.toString(),
fallbackUrl: fallbackUrl.toString(),
});
}, { once: true });
// меняем в DOM'е оригинальный элемент на фолбэк
element.insertAdjacentElement('afterend', newElement);
element.remove();
}
Заодно шлём отдельное событие assetFallback со статусом загрузки — это позволяет потом отдельно мониторить, насколько fallback-хосты справляются с нагрузкой.
И обязательно — в handleError не обрабатывать fallback-элементы, иначе можно зациклиться, если запасной хост тоже упадёт:
function handleError(event) {
const element = event.target;
// незагруженные фолбэки элементы снова загружать не нужно
if (element.dataset.fallback === 'true') {
return;
}
// ...остальная логика
}
Такой подход позволяет легче перенести проблему с незагружаемой статикой. Появляется больше вероятности, что пользователи не отвалятся и бизнес не будет терять доход.
Но и этот подход не серебрянная пуля: если проблема не в доступе к конкретному хосту, а в некорректно раскатившимся билде или битых файлах, то тут фолбэки могут не помочь и придется решать проблему иначе.
Итого
Мониторинг упавшей статики позволит реагировать на инциденты быстро и своевременно, опережая обращения пользователей в техподдержку.
Задетектить упавшую статику можно двумя способами:
- через событие
errorу DOM-элементов - через
PerformanceResourceTiming API.
Первый даёт сам факт ошибки и элемент, второй — статус и причину. Вместе они покрывают почти все случаи, включая подресурсы из CSS.
Слать в мониторинг полезно сразу три уровня детализации:
- флаг «в этом хите есть упавший ассет»
- подробности по каждому конкретному ассету
- сводную статистику
total/failedпо хостам в конце жизни страницы.
На бэке логи агрегируются в метрики и на основе них строятся дашборд и алерты.
Когда алерт сработал, есть два пути: переключить хост статики руками или попробовать на фронте подгрузить ассет с резервного домена. Полезно иметь оба варианта в своем арсенале.
Надеюсь, данный материал поможет вам минимизировать негативный опыт пользователей и позволит сохранить доход компании в период нестабильности.