grafana-faro-wrapper
v1.4.0
Published
Grafana Faro wrapper for web pages and React apps: one-call setup with OTLP transport, URL and label sanitization, custom metrics and step-based SLO metrics
Maintainers
Readme
grafana-faro-wrapper
Обёртка над Grafana Faro — SDK, который собирает с фронтенда ошибки, Web Vitals и события и отправляет их в Grafana. Пакет подключает Faro к обычной HTML-странице или React-приложению одним вызовом и добавляет то, чего в Faro нет:
- Отправка по OTLP — логи, ошибки и измерения уходят в OTLP-эндпоинт; тела измерений и ошибок — в logfmt,
их разбирает
| logfmtв LogQL. - Санитизация — из URL страниц, ресурсов и стектрейсов убираются query, hash и типовые идентификаторы в пути (UUID, hex, длинные числа), чтобы в Grafana не утекали токены из query и не росла кардинальность.
sendMetric— пользовательская метрика с единицей, типом, метками и результатом в одном вызове.trackSlo— SLO-метрика «страница дошла до состояния»: шаги с дедлайнами, пауза при скрытой вкладке, отправка в едином формате.- Проверки вёрстки — готовые предикаты для шагов: элементы отрисованы, их достаточно, картинки и фоны загрузились.
Работает с Faro 2.0.2+. Все бандлы собраны под ES2019: Chrome 73+, Firefox 69+, Safari 12.1+. Web Vitals
из getWebInstrumentations() считаются только в браузерах, которые отдают эти метрики и умеют
Array.prototype.at и findLast (Chrome 97+, Firefox 104+, Safari 15.4+): в более старых
getWebInstrumentations() их не включает, остальная инструментация работает.
Установка
npm install grafana-faro-wrapper @grafana/faro-web-sdk @grafana/faro-transport-otlp-httpДля инструментации React Router нужен ещё @grafana/faro-react той же версии, что и @grafana/faro-web-sdk;
@grafana/faro-react 2.12+ требует react-router 7.12+.
Без сборщика подключите один файл — dist/index.umd.full.js (его отдают unpkg и jsdelivr по умолчанию)
содержит Faro внутри и выставляет глобал GrafanaFaroWrapper. Отдельно Faro на такую страницу не подключайте:
getWebInstrumentations берётся из того же глобала.
<script src="https://unpkg.com/grafana-faro-wrapper@1/dist/index.umd.full.js"></script>
<script>
const faroService = new GrafanaFaroWrapper.FaroService();
faroService.init({
faroUrl: 'https://otlp.example.com/v1/logs',
faroKey: 'your-key',
app: { name: 'my-site' },
instrumentations: GrafanaFaroWrapper.getWebInstrumentations(),
});
</script>Файлы бандлов доступны и из установленного пакета — grafana-faro-wrapper/dist/index.umd.full.js, например
для копирования сборкой бекенда. Второй бандл, dist/index.umd.js, не содержит Faro и ждёт его IIFE-бандлы,
подключённые раньше него, — они выставляют глобалы GrafanaFaroWebSdk и GrafanaFaroTransportOtlpHttp:
<script src="https://unpkg.com/@grafana/faro-web-sdk@2/dist/bundle/faro-web-sdk.iife.js"></script>
<script src="https://unpkg.com/@grafana/faro-transport-otlp-http@2/dist/bundle/faro-transport-otlp-http.iife.js"></script>
<script src="https://unpkg.com/grafana-faro-wrapper@1/dist/index.umd.js"></script>Быстрый старт
import { FaroService, getWebInstrumentations } from 'grafana-faro-wrapper';
export const faroService = new FaroService();
faroService.init({
faroUrl: 'https://otlp.example.com/v1/logs',
faroKey: 'your-key',
app: { name: 'my-app', version: '1.0.0', environment: 'production' },
instrumentations: getWebInstrumentations(),
});faroUrl— OTLP-эндпоинт для логов,faroKey— API-ключ. Трейсы не отправляются.- Остальные поля — обычный
BrowserConfigFaro:app,user,sessionTracking,batchingи т. д. Полейurl,apiKeyиpausedв типе нет: первые два заменяет транспорт обёртки, вместоpaused—enabled. Своиtransportsдобавляются к транспорту обёртки. enabled: falseинициализирует Faro на паузе — удобно для dev-окружения; послеdestroy()иinit()с тем же флагом Faro тоже остаётся на паузе.init()возвращает инстанс Faro и, как сам Faro, регистрирует его глобаломwindow.faro; отключается опциейpreventGlobalExposure: true.
[!IMPORTANT] По умолчанию
instrumentations— пустой список: Faro не собирает ни ошибки, ни Web Vitals, ни сессии, пока вы не передадитеgetWebInstrumentations(), как в примере выше.
Faro регистрируется один раз на страницу, поэтому на странице — один FaroService: второй получит ошибку.
destroy() ставит Faro на паузу, а следующий init() снимает паузу с того же инстанса: применяется только
новый beforeSend, а об изменении faroUrl, faroKey или app выводится предупреждение. Биконы, которые
Faro не успел отправить из батча до destroy(), уходят после следующего init() через санитайзеры и
beforeSend того init(), при котором они созданы; без повторного init() они не отправляются.
React Router
import { createReactRouterV7Options, ReactIntegration } from '@grafana/faro-react';
import { FaroService, getWebInstrumentations } from 'grafana-faro-wrapper';
import {
createRoutesFromChildren,
matchRoutes,
Routes,
useLocation,
useNavigationType,
} from 'react-router';
const faroService = new FaroService();
faroService.init({
faroUrl: 'https://otlp.example.com/v1/logs',
faroKey: 'your-key',
app: { name: 'my-app' },
instrumentations: getWebInstrumentations(),
routerAdapter: new ReactIntegration({
router: createReactRouterV7Options({
createRoutesFromChildren,
matchRoutes,
Routes,
useLocation,
useNavigationType,
}),
}),
});Затем замените Routes на FaroRoutes из @grafana/faro-react. Хелперы для React Router 4–6 и data-router
описаны в документации faro-react.
Пользовательские метрики
faroService.sendMetric({
name: 'checkout',
value: 1,
unit: 'EVENTS',
type: 'counter',
labels: { payment: 'card' },
result: 'success',
});| Поле | Тип | Описание |
| ------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------- |
| name | string | Имя метрики |
| value | number | Значение; не конечное число (NaN, ±Infinity) даёт 0 |
| unit | MetricUnit | BYTES, MILLISECONDS, SECONDS, REQUESTS, ERRORS, OPERATIONS, EVENTS, UNITLESS |
| type | MetricType | counter, gauge, histogram |
| result | 'success' \| 'fail' | Итог, попадает и в тело лога, необязательно |
| labels | Record<string, string \| number \| boolean> | Плоские метки, типы значений сохраняются, необязательно |
| buckets | number[] | Границы бакетов гистограммы, необязательно |
| description | string | Описание, необязательно |
| timestamp | number | Время события в мс, по умолчанию — момент вызова |
Каждый вызов — отдельное измерение Faro с type: 'custom' и values: { [name]: value }; одинаковые метрики
подряд не схлопываются. Пустое name, unit или type вне списка не останавливают отправку, но один раз
дают предупреждение в консоли. Остальные поля уходят в контекст измерения под ключами measurement.* (экспорт
MEASUREMENT_KEYS) и попадают в Grafana атрибутом faro.measurement.context. Тело лога — logfmt:
faro_signal=measurement type=custom name=checkout value=1 result=success, его разбирает | logfmt в LogQL.
Метки становятся измерениями в Grafana: кладите в них только значения с небольшим числом вариантов.
Идентификаторы пользователя, сессии, заказа и URL уже есть в meta бикона, а как метки они взорвут
кардинальность.
SLO-метрика из нескольких шагов
trackSlo измеряет, за сколько страница дошла до состояния, описанного шагами, и отправляет гистограмму
длительности в миллисекундах. Каждый шаг — предикат, который опрашивается каждые 100 мс, пока не вернёт
true или промис с true; из JavaScript подойдёт и любое истинное значение, например найденный элемент.
Все шаги прошли — result: 'success'; какой-то не успел к дедлайну — result: 'fail', и в метках видно,
какой именно.
const pageReady = faroService.trackSlo({
name: 'page_ready',
failTime: 10_000,
buckets: [100, 500, 1000, 2000, 5000, 10_000, 10_100],
steps: {
data: async () => (await fetchData()).ok,
render: () => isRendered(),
images: { check: () => asyncCheckImagesIsDisplayed('img.hero'), failTime: 3000 },
},
});
pageReady.dispose(); // при уходе со страницы — остановить без отправки| Поле | Описание |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| name | Имя метрики |
| failTime | Дедлайн всего прогона в мс; по его истечении метрика отправляется с fail |
| steps | Шаги: функция или { check, failTime }. Дедлайн шага не может быть больше дедлайна прогона |
| buckets | Границы бакетов гистограммы, необязательно |
| labels | Дополнительные метки: объект или функция, которая вернёт их в момент отправки, необязательно |
| startWhen | Селектор или предикат: часы не идут, пока по селектору нет элемента или предикат false, необязательно |
| pauseWhenHidden | Пауза, пока вкладка скрыта или фокус ушёл из окна; по умолчанию true |
| log | Писать в консоль ход прогона и первую ошибку каждой проверки |
- Исключение или отклонённый промис в проверке — «ещё не готов», повтор на следующем тике; асинхронная проверка не запускается заново, пока идёт предыдущая.
- Шаг, не успевший к дедлайну, получает
falseокончательно, но прогон ждёт остальные — до своегоfailTime. - Хелперы картинок проваливают шаг сразу, не дожидаясь дедлайна, если у
<img>нет ниsrc, ниsrcsetи она не внутри<picture>: такая вёрстка сама не исправится. Это работает, когда хелпер вызван прямо в проверке шага, например() => asyncCheckImagesIsDisplayed('img.hero'); если проверка сначала ждётawait, шаг опрашивается как обычно. - Время на паузе не входит ни в длительность, ни в дедлайны, поэтому пока вкладка скрыта, метрика не уходит: асинхронная проверка, завершившаяся на паузе, засчитывается, а прогон заканчивается после возврата. Уход фокуса во вложенный iframe паузой не считается; окно без фокуса на старте — тоже, пауза начинается с ухода фокуса и длится, пока фокус не вернётся, даже если вкладку за это время скрыли и показали.
- Уходит одно измерение:
unit: 'MILLISECONDS',type: 'histogram',result, а вlabels— метки изlabels, результат каждого шага иstatus(то же, чтоresult). Имена шагов становятся ключами меток, шаг с именемstatusзапрещён типом, а метки не перезаписывают ниstatus, ни результаты шагов — о таком совпадении предупреждает консоль. - Невалидный селектор в
startWhenне бросает исключение: прогон ждёт, в консоли — одно предупреждение. Без шагов прогон не запускается и ничего не отправляет. startWhenпроверяется, как и шаги, каждые 100 мс — селектор срабатывает и на вставку элемента, и на смену его класса или атрибута. Пока условие не выполнено, прогон ждёт без ограничения по времени.- Трекер сообщает
state(waiting,running,paused,done,disposed) и умеетdispose(). Без него незавершённый прогон опрашивает шаги доfailTime, а ждущийstartWhen— бессрочно, поэтому при уходе со страницы вызывайтеdispose().
В React прогон живёт в эффекте, а смена состояния страницы — это смена зависимости эффекта:
useEffect(() => {
if (!isOpen) return;
const slo = faroService.trackSlo({ name: `payments:${view}`, failTime: 40_000, steps });
return () => slo.dispose();
}, [isOpen, view]);Проверки вёрстки
Хелперы проверяют DOM, возвращают boolean или Promise<boolean> и не бросают исключений — их можно
передавать в шаги напрямую. Невалидный селектор даёт false (у getAmount — 0) и одно предупреждение в
консоли.
import {
asyncCheckBackgroundImagesIsDisplayed,
asyncCheckImagesIsDisplayed,
checkGTEAmount,
checkRender,
checkRenderInnerValue,
} from 'grafana-faro-wrapper';
faroService.trackSlo({
name: 'payments:payments-all',
failTime: 4000,
steps: {
tariff: () => checkRenderInnerValue('[data-slo="tariff-name"]'),
methods: () => checkGTEAmount('[data-slo="payment-list"] li', 12),
'method-images': () => asyncCheckImagesIsDisplayed('[data-slo="payment-list"] img'),
'reseller-logos': () => asyncCheckBackgroundImagesIsDisplayed('[data-slo="reseller-link"]'),
controls: () => checkRender(['[data-slo="code-input"]', '[data-slo="code-button"]']),
},
});| Хелпер | Проходит, когда |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| checkRender(selectors) | каждый селектор (строка или массив) есть в DOM |
| checkRenderInnerValue(selectors) | по каждому селектору есть видимый элемент с текстом (см. ниже) |
| checkEnabledButtonState(selector) | есть видимая кнопка, которую можно нажать (см. ниже) |
| getAmount(selector) | возвращает число элементов |
| checkAmount(selector, n), checkGTEAmount(selector, n) | элементов ровно n / не меньше n |
| checkImagesIsDisplayed(selector) | синхронно: все картинки уже загружены (битый SVG не отличает, см. ниже) |
| asyncCheckImagesIsDisplayed(selector, timeoutMs?) | все картинки загрузились за timeoutMs |
| asyncCheckBackgroundImagesIsDisplayed(selector, timeoutMs?) | у каждого элемента загрузилась своя картинка: <img> внутри или background-image |
| extractBackgroundUrl(element) | URL из background-image, у image-set() — под плотность экрана, или null |
| loadImage(src, timeoutMs?) | картинка по адресу загрузилась за timeoutMs |
timeoutMsпо умолчанию —DEFAULT_IMAGE_TIMEOUT_MS, 10 секунд;Infinity— ждать без ограничения, пока проверку не прервёт дедлайн шага. Нет элементов по селектору —false.- Селектор может указывать на
<img>или на обёртку: тогда проверяются картинки внутри, обёртка без них (например, только с inline<svg>) —false.<img>безsrcиsrcset— тожеfalse, а вtrackSloшаг с ней проваливается сразу. Картинки внутри Shadow DOM хелперы не видят. - Асинхронные проверки верят браузеру: картинка засчитывается, если загрузилась без ошибки, в том числе SVG без
собственных размеров. Синхронная проверка видит только то, что уже загружено, и не отличает битый SVG (404,
HTML-страница вместо картинки, неверный MIME) от загруженного — для SVG берите асинхронную.
«IsDisplayed» значит «загружено», а не «видно на экране». Картинку с
loading="lazy"за пределами экрана браузер не грузит, и проверка вернётfalseпо таймауту — такие картинки в шаги не добавляйте. - Из
background-imageберётся первыйurl(), а уimage-set()— вариант под плотность экрана, как у браузера. Поддержку формата изtype()хелпер узнать не может: из вариантов одной плотности он берёт первый, поэтому ставьте первым формат, который понимают все ваши браузеры. Элемент без<img>и безurl()— например, с фоном из одного градиента — даётfalse; фоны::before/::afterне учитываются. Фоны иloadImageдержат одну картинку на адрес: битый адрес запрашивается снова не чаще раза в 5 секунд, так что опрос шага вtrackSloне шлёт запрос на каждом тике, а временный сбой сети не держит проверку вfalseдо перезагрузки страницы. checkRenderInnerValueиcheckEnabledButtonStateпроходят, если подходит хотя бы один элемент по селектору, — так учитываются и адаптивные дубли блока. Текст должен быть виден: скрытый блок или потомок (display: none,hidden, свёрнутый<details>,visibility: hidden), стили, скрипты, пробелы и невидимые символы (нулевой ширины, bidi-метки, мягкий перенос) не в счёт. Текст, спрятанный только визуально (opacity: 0, sr-only, за экраном), засчитывается; исключение — Safari: текст в контейнере нулевой высоты или ширины сoverflow: hiddenон не отдаёт, и проверка там не проходит. Текст в SVG проверяется так же, у<select>засчитывается текст выбранного варианта,<option>виден, если выбран, а у веб-компонента учитывается и текст его открытого shadow root.- Кнопку можно нажать, если она видна (в том числе не лежит в свёрнутом
<details>,hidden="until-found"илиcontent-visibility: hidden), не:disabled(в том числе через<fieldset disabled>), не внутриinert, безaria-disabled="true", классаdisabledиpointer-events: none. Перекрытие другим элементом (например, спиннером) иopacity: 0не проверяются. checkRender,getAmount,checkAmountиcheckGTEAmountучитывают и скрытые элементы. Пустой список селекторов не проходит; число элементов можно передать строкой, например изdata-*атрибута, но пустая строка или отсутствующее значение — не ноль, и проверка с ними не проходит.
Санитизация
Перед отправкой каждый бикон проходит через встроенные санитайзеры:
- URL страницы (
meta.page.url), атрибуты событий, значение которых целиком —http(s)-URL (ресурсы, нарушения CSP, навигация, ваши события), и файлы во фреймах стектрейса ошибок сокращаются до хоста и пути в нижнем регистре, а идентификаторы в пути (UUID любой версии, hex-строки от 12 символов, числа от 6 цифр, в том числе через_) заменяются на:id:https://shop.example.com/Orders/0190a6e2-7c3b-7d4e-9f00-1a2b3c4d5e6f?token=abc→shop.example.com/orders/:id. - В OTLP-транспорте обёртки метки метрик превращаются обратно в объект, чтобы в Grafana они были отдельными
атрибутами, а не JSON-строкой. Ваши
transportsполучают метки в формате Faro — JSON-строкой.
Не трогаются: текст логов и ошибок, контекст логов и измерений, имя и домен события, URL внутри строки или
вложенного объекта, meta.user, meta.view, атрибуты сессии. Их чистите своим санитайзером.
Свой санитайзер получает полную копию бикона (TransportItem из Faro) — её можно менять на месте — и обязан
вернуть бикон; санитайзеры выполняются по порядку после встроенных. Ваш beforeSend вызывается последним и
получает уже очищенный бикон; чтобы отбросить бикон, верните null. Если санитайзер или beforeSend бросает
исключение, а санитайзер — ничего не возвращает, не отправляется только этот бикон, а в консоль выводится одно
предупреждение (снова — после destroy() и init()).
faroService.addSanitizer((beacon) => {
delete beacon.meta.user?.email;
return beacon;
});Те же правила доступны как функции: sanitizeUrl(url) возвращает хост и очищенный путь (для строки, которая не
разбирается как URL, — очищенную часть до ? и #), sanitizePath(pathname) — очищенный путь для строки без
хоста, например для meta.view.name из роутера.
API
| FaroService | Описание |
| -------------------------- | ---------------------------------------------------------------- |
| init(config): Faro | Инициализирует Faro или снимает его с паузы после destroy() |
| destroy() | Ставит Faro на паузу и сбрасывает пользовательские санитайзеры |
| sendMetric(metric) | Отправляет метрику |
| trackSlo(config) | Запускает SLO-прогон и возвращает SloTracker, не бросает |
| addSanitizer(fn \| fn[]) | Добавляет санитайзеры после встроенных; не функция — TypeError |
| getInstance(): Faro | Возвращает инстанс Faro; вне init()…destroy() бросает ошибку |
| isInitialized | true между init() и destroy() |
config (тип FaroServiceConfig) — BrowserConfig из Faro плюс faroUrl, faroKey и необязательные
routerAdapter и enabled. Вне init()…destroy() sendMetric и завершившийся trackSlo не отправляют
метрику, а выводят предупреждение.
Кроме хелперов и санитайзеров пакет реэкспортирует из Faro getWebInstrumentations и TransportItemType — для
страниц без сборщика. Типы: FaroServiceConfig, FaroConfig, Sanitizer, Metric, MetricResult,
MetricUnit, MetricType, MetricLabels, SloConfig, SloTracker, SloRunState, StepCheck, StepConfig;
SloRunOptions, SloRunResult и StepResults устарели и уйдут в 2.0.
Миграция с 0.x
| Было | Стало |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| new MetricsService(faro).sendCustomMetric(m) | faroService.sendMetric(m); CustomMetricBase → Metric: метки плоские, description необязателен, поля status нет, value и buckets — только числа |
| MetricsCollector, addMetricStep, условия готовности, getStatus(), reset() | faroService.trackSlo({ name, failTime, steps }), шаг — предикат до true, dispose() |
| MetricFn, ReadyToCheckConditionFn, MetricsCollector* | StepCheck, StepConfig, SloConfig, SloTracker |
| paused в конфигурации | enabled: false; полей url и apiKey больше нет |
| unpkg и jsdelivr без пути отдавали dist/index.umd.js без Faro | отдают dist/index.umd.full.js с Faro внутри — отдельно Faro не подключайте |
Старый addMetricStep(name, check, ready) молча отбрасывал лишние аргументы — при переносе на trackSlo
объедините все проверки шага в одном предикате. Условие if (!params.duration) return перед отправкой
отбросит метрики с нулевой длительностью, в trackSlo оно не нужно.
Разработка
Как предложить изменение — в CONTRIBUTING.md; об уязвимостях сообщайте приватно, по SECURITY.md.
Нужен Node 22.18+ (конфиги сборки и тестов — TypeScript без загрузчика). Это требование только к разработке в репозитории, на установку пакета оно не влияет.
npm install
npm run dev # сборка всех бандлов и типов в watch-режиме
npm run check # формат, линт, типы, тесты, сборка, запуск бандлов, примеры README
npm run verify:peers-floor # типы и тесты на нижней границе peerDependencies
npm run verify:browsers # хелперы на настоящих картинках в Chromium, Firefox и WebKitРелиз — по git-тегу v*: npm version <patch|minor|major>, затем git push --follow-tags. Воркфлоу
Release прогоняет check, публикует пакет в npm через Trusted Publishing и создаёт GitHub Release.
Версии с суффиксом (1.1.0-beta.1) уходят под dist-tag next.
