Мониторинг упавшей статики

Содержание

Веб-страница помимо текста содержит много дополнительных ресурсов. Например, статику — картинки, стили, скрипты.

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

Но иногда что-то может пойти не так.

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

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

Причин незагруженной статики много: нестабильное соединение, различного рода блокировки, сбой на 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 по хостам в конце жизни страницы.

На бэке логи агрегируются в метрики и на основе них строятся дашборд и алерты.

Когда алерт сработал, есть два пути: переключить хост статики руками или попробовать на фронте подгрузить ассет с резервного домена. Полезно иметь оба варианта в своем арсенале.


Надеюсь, данный материал поможет вам минимизировать негативный опыт пользователей и позволит сохранить доход компании в период нестабильности.