В разработке давно привыкли смотреть на потребление процессора и памяти, на ошибки и бизнес-метрики, но метрики скорости фронтенда почему-то до сих пор игнорируют даже в крупных компаниях. 

При этом даже незначительные задержки напрямую влияют на конверсию. Это подтверждается в исследовании Google и Deloitte «Milliseconds Make Millions». Пара секунд — и часть пользователей уже не ждёт загрузку.

Синтетика вроде Lighthouse полезна, но она показывает только идеальные условия. Тяжёлые RUM‑платформы предоставляют максимум подробностей, но для большинства команд это избыточно. Хочется простого и рабочего инструмента, который:

  • Показывает реальные Web Vitals с продовых браузеров

  • Поднимается за вечер

  • Почти не требует поддержки

  • Помогает ловить деградации сразу после релиза

В этой статье я расскажу, как решить эту задачу, сделав свой RUM поверх Prometheus.


Синтетического анализа не хватает

Lighthouse — отличный инструмент. Я бы даже сказал, что сейчас без него выпускать проект стыдно. Однако Lighthouse отвечает на вопрос: «Как эта страница может работать в контролируемых, близких к идеальным условиях?». Реальные пользователи — это другая история:

  • Старые смартфоны с минимальным свободным местом

  • Слабый Wi-Fi, нестабильная мобильная сеть

  • 100 открытых вкладок

  • География и провайдеры с разной маршрутизацией

  • Блокировки отдельных ресурсов, корпоративные прокси, VPN

  • Авторизация, персонализированный контент, A/Б-тесты

Синтетика не покажет, что:

  • LCP проседает только у части пользователей в конкретном регионе

  • CLS растёт после незаметного рефакторинга вёрстки

  • INP улетает в космос на мобильных после добавления тяжёлого компонента

  • Новый CDN замедлил загрузку страницы во многих регионах

Чтобы всё это увидеть, нужны данные с настоящих браузеров, то есть RUM (Real User Monitoring).


Полноценные RUM-платформы часто избыточны

Dynatrace, New Relic, Datadog и подобные продукты умеют очень многое. В частности, они:

  • Собирают много ненужных вам событий

  • Строят трассировку и даже умеют в автоинструментирование

  • Хранят гигабайты сырых данных

  • Умеют искать подробности конкретных запросов

  • Составляют сложные отчёты

Но для продуктовой команды обычно хватает базовых функций:

  • Видеть 75 перцентиль LCP и INP в проде

  • Смотреть поведение метрик — тренды по неделям и месяцам

  • Сравнивать мобильные и десктопные метрики

  • Понимать, стало ли хуже сразу после релиза

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


Идея: RUM на Prometheus

Prometheus изначально заточен под агрегированные метрики: ему нужны не сырые события, а только обновления bucket'ов. Это делает систему лёгкой и предсказуемой при большом трафике. Надо только сразу где-то собирать статистику вместо отправки каждого события в базу.

Опишу идею пошагово:

  1. Cобираем в браузере метрики Web Vitals (LCP, INP, CLS и др.)

  2. Периодически отправляем их на бэкенд пачками через sendBeacon

  3. На сервере валидируем данные и обновляем гистограммы (Histogram) из prom-client

  4. Prometheus периодически забирает эти гистограммы

  5. В Grafana строим дашборды по 75 перцентилю

Важно, что мы отдаём ему уже готовую агрегацию без миллионов событий. Это обеспечивает:

  • Снижение нагрузки на бэкенд

  • Низкую кардинальность

  • Экономию места (не требуется большое хранилище)

  • Быстрое получение данных даже за годовой период

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


Архитектура

Схема примерно такая:

Никаких очередей сообщений и отдельных хранилищ событий. Один небольшой сервис на Node.js, плюс Prometheus и Grafana, которые и так во многих компаниях уже развёрнуты.


Сбор Web Vitals на фронтенде

Сначала подключаем библиотеку:

npm install web-vitals

Затем пишем небольшой агент. В моём случае он умеет:

  • Собирать LCP, INP и CLS

  • Группировать URL-ы по нескольким типам страниц

  • Определять тип устройства (mobile, desktop)

  • Складывать всё в очередь и отправлять пачкой при уходе со страницы

Пример:

import { onCLS, onINP, onLCP } from 'web-vitals';

let queue = [];

// Группировка роутов по типам страниц
const pathMap = [
  ['Search', /\/search/],
  ['Card', /\/card\/\d+/],
  ['Order', /\/order\/\d+/],
];

function getPath() {
  const entry = pathMap.find(([_, regex]) => regex.test(location.pathname));
  return entry ? entry[0] : 'Other';
}

function getDevice() {
  if (matchMedia('(pointer: coarse)').matches) return 'mobile';
  if (matchMedia('(pointer: fine)').matches) return 'desktop';
  return 'other';
}

function push(metric) {
  queue.push({
    name: metric.name,
    value: metric.value,
    path: getPath(),
    device: getDevice(),
    ts: Date.now(),
  });
}

onCLS(push);
onINP(push);
onLCP(push);

window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden' && queue.length) {
    const body = JSON.stringify(queue);

    navigator.sendBeacon(
      '/rum',
      new Blob([body], { type: 'text/plain; charset=UTF-8' })
    );

    queue = [];
  }
});

Но есть несколько важных моментов.

Зачем очередь

Web Vitals приходят не одновременно: LCP может сработать достаточно поздно, INP зависит от пользователя и его действий, а CLS может обновляться по мере изменений. Отправка каждого замера отдельным запросом приводит к избыточному трафику и нагрузке на бэкенд.

Очередь позволяет:

  • Накопить несколько метрик в памяти

  • Отправить одним запросом в тот момент, когда пользователь уходит со страницы

На практике это получается устойчиво и почти незаметно для клиента.

Почему sendBeacon

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

  • Не блокирует переход на другую страницу

  • Умеет отправлять данные в момент выгрузки документа

  • Работает асинхронно, без явных промисов и ожиданий

Можно было бы сделать обычный fetch, но браузер вполне способен его оборвать при закрытии вкладки.

Почему Blob и text/plain

Можно было бы не заморачиваться и просто отправить строку navigator.sendBeacon('/rum', JSON.stringify(queue));, но я предпочитаю указывать явно.

  • text/plain — один из CORS‑safelisted типов. Если отправлять application/json, то браузер может добавить preflight‑запрос и отправка может быть проигнорирована или заблокирована

  • Blob здесь нужен, чтобы явно указать Content-Type

Зачем pathMap

Если в метку метрики положить полный путь (/card/123456/card/654321 и т. д.), то кардинальность моментально улетит, что не нравится админам и Prometheus. Поэтому мы заранее группируем страницы по нескольким типам:

  • Поисковая выдача

  • Карточка

  • Страница оформления

  • Всё остальное

В итоге в label path прилетают не тысячи разных URL-ов, а пять—десять категорий. Это удобно и с точки зрения анализа: смотреть на «страницу карточки» в целом проще, чем отдельно на каждый конкретный экземпляр карточки.


Агрегация на бэкенде

Бэкенд можно написать на чём угодно. Я приведу пример на Fastify, потому что он лёгкий, быстрый и простой.

Установим зависимости:

npm install prom-client fastify

Напишем минимальный сервис:

import Fastify from 'fastify';
import { Histogram, collectDefaultMetrics, register } from 'prom-client';

const app = Fastify();

// Базовые метрики самого сервиса, пусть тоже будут
collectDefaultMetrics();

// Гистограмма для LCP
const lcp = new Histogram({
  name: 'rum_lcp_seconds',
  help: 'Largest Contentful Paint (seconds)',
  buckets: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 5],
  labelNames: ['env', 'path', 'device'],
});

// Парсер для text/plain, в котором лежит JSON
app.addContentTypeParser(
  'text/plain',
  { parseAs: 'string' },
  (request, body, done) => {
    try {
      const parsed = JSON.parse(body);
      done(null, parsed);
    } catch (err) {
      done(err as Error);
    }
  }
);

app.post('/rum', async (req, reply) => {
  const data = req.body;

  for (const metric of data) {
    if (metric.name === 'LCP') {
      // Здесь можно добавить валидацию, срезать явные выбросы и т.д.
      lcp.observe(
        {
          env: 'prod',
          path: metric.path,
          device: metric.device,
        },
        metric.value
      );
    }
  }

  reply.code(204).send();
});

app.get('/metrics', async (_, reply) => {
  reply.type('text/plain').send(await register.metrics());
});

app.listen({ port: 3000 });

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


Настройка Prometheus

Конфигурация тривиальна. Добавляем в prometheus.yml:

scrape_configs:
  - job_name: 'rum'
    static_configs:
      - targets: ['rum-backend:3000']

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

Графики в Grafana

Затем с помощью PromQL вы можете настроить любые графики с этим данными. Простейший пример: p75 для LCP за последние 5 минут.

histogram_quantile(
  0.75,
  sum(rate(rum_lcp_seconds_bucket[5m])) by (le)
)

Точно так же можно вывести график с группировкой по типам устройств:

histogram_quantile(
  0.75,
  sum(rate(rum_lcp_seconds_bucket[5m])) by (le, device)
)

На дашборде обычно делают несколько графиков:

Здесь можно проследить:

  • Как менялась производительность за последние релизы

  • Где именно деградировала скорость: на карточках, в поиске или где-то ещё

  • Отличается ли мобильный трафик от десктопного


Достоинства решения

  • Данные реальные, а не из лаборатории

  • Простая инфраструктура: небольшой сервис, Prometheus, Grafana

  • Предсказуемая нагрузка: количество bucket-метрик почти не зависит от количества пользователей

  • Дешёвое хранение: сохраняется не каждое событие, а только распределение значений по bucket'ам

  • Длинная история: можно без проблем смотреть p75 за год и больше

  • Минимальная поддержка: сервис выполняет одну задачу и делает это довольно надёжно


Ограничения

У минималистичного решения обязательно имеются границы применимости. Важно понимать их заранее. Здесь нет и не будет:

  • Разборов до уровня конкретного пользователя и его сессии

  • Возможности «придумать новый фильтр» и применить его к старым данным, если заранее не было соответствующей метки

  • Анализа сложных сценариев вроде последовательности действий пользователя

  • Точного пересчёта перцентилей по сырым данным, поскольку мы работаем с аппроксимацией через гистограммы

Если вам нужна полноценная аналитика событий, session replay, продвинутая трассировка и сложные разрезы по десяткам параметров, то придётся смотреть в сторону более тяжёлых систем.

Но если задача звучит так:

«Я хочу видеть p75 LCP/INP на проде, отслеживать деградации после релизов и иметь дешёвое и предсказуемое хранение»

то такой lite RUM на Prometheus закрывает её идеально.


Вместо заключения

Вместо того, чтобы ждать, когда кто-то внедрит «правильную платформу», можно за один вечер собрать маленький сервис, который:

  • Показывает реальные Web Vitals в проде

  • Помогает быстро поймать регрессии после релизов

  • Использует уже знакомый стек Prometheus + Grafana

  • Не требует сложной поддержки и масштабирования

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

Комментарии (0)