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

При этом даже незначительные задержки напрямую влияют на конверсию. Это подтверждается в исследовании 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'ов. Это делает систему лёгкой и предсказуемой при большом трафике. Надо только сразу где-то собирать статистику вместо отправки каждого события в базу.
Опишу идею пошагово:
Cобираем в браузере метрики Web Vitals (LCP, INP, CLS и др.)
Периодически отправляем их на бэкенд пачками через
sendBeaconНа сервере валидируем данные и обновляем гистограммы (
Histogram) изprom-clientPrometheus периодически забирает эти гистограммы
В 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
Не требует сложной поддержки и масштабирования
С точки зрения усилий и результата баланс получается очень приятным: минимум кода и настроек, плюс очень ощутимая польза для команды.