Аналитика в продукте обычно ломается тихо. Вызовы событий разбросаны по компонентам, в названиях встречаются опечатки, параметры передают как придётся. Через полгода никто не скажет, что именно приложение отправляет, и можно ли верить отчётам.
В нашей системе аналитика нужна в двух местах: в мобильном приложении (AppMetrica) и в веб-версии (Яндекс.Метрика). Я сделал для них одну схему событий, из которой TypeScript сам выводит допустимые ключи и аргументы. Ниже расскажу, как это устроено, и чем отличаются две платформы.
Одна схема событий
Все события описаны одним объектом. У каждого события есть название, которое увидят аналитики, и функция buildPayload, которая собирает параметры. Из компонентов вызывается только один метод, reportEvent, и ему передаётся ключ события.
Так выглядит схема в мобильном приложении (показана часть событий):
// mobile/src/6_shared/utils/metrica.ts
type SectionTab = 'Сегодня' | 'Контролирую' | 'Участвую' | 'Мои'
export const ANALYTICS_EVENTS = {
sections: {
openTasks: {
eventName: 'Раздел',
buildPayload: () => ({ Задачи: '(открытие)' })
},
selectTasksTab: {
eventName: 'Раздел',
buildPayload: (tabName: SectionTab) => ({ Задачи: tabName })
}
},
task: {
createStart: {
eventName: 'Задача',
buildPayload: () => ({ '(создание)': '(начало)' })
},
createEnd: {
eventName: 'Задача',
buildPayload: () => ({ '(создание)': '(конец)' })
}
}
} as const
Добавить событие значит дописать один блок в этом файле. В отчётах Метрики названия и параметры событий совпадают с записанными в схеме символ в символ, поэтому на вопрос аналитика «что значит эта строка» разработчик отвечает, открыв один файл, а не выясняя, из какого компонента она пришла.
Типы выводятся из схемы
Ключи событий и аргументы я не описываю вручную: их выводят из самого объекта. as const делает схему неизменяемой, поэтому TypeScript знает все группы и действия.
// Все ключи вида 'sections.openTasks' | 'task.createStart' | ...
type EventKey = {
[Group in keyof typeof ANALYTICS_EVENTS]: {
[Action in keyof (typeof ANALYTICS_EVENTS)[Group]]: `${Group &
string}.${Action & string}`
}[keyof (typeof ANALYTICS_EVENTS)[Group]]
}[keyof typeof ANALYTICS_EVENTS]
// Аргументы берутся прямо из сигнатуры buildPayload
type ArgsForEvent<K extends EventKey> =
GetEventConfig<K> extends { buildPayload: (...args: any) => any }
? Parameters<GetEventConfig<K>['buildPayload']>
: []
GetEventConfig по ключу достаёт нужный блок схемы по строке ключа. Результат такой: если в схему добавить событие или поменять параметры, подсказки и проверки в местах вызова обновятся сами.
Отправка в приложении
Единственная точка отправки в мобильном приложении называется reportEvent. Она находит событие по ключу, собирает параметры и отдаёт их SDK:
export const reportEvent = <K extends EventKey>(
eventKey: K,
...args: ArgsForEvent<K>
) => {
const [groupKey, actionKey] = eventKey.split('.')
const config = (ANALYTICS_EVENTS as any)[groupKey]?.[actionKey]
if (!config) {
console.warn(`Analytics event with key "${eventKey}" not found.`)
return
}
const { eventName, buildPayload } = config
AppMetrica.reportEvent(eventName, buildPayload(...args))
}
В компонентах вызов выглядит так, и редактор подсказывает и ключ, и аргумент:
reportEvent('sections.openTasks')
reportEvent('sections.selectTasksTab', 'Сегодня')
// Ошибки видны ещё при написании кода:
reportEvent('sections.open_tasks') // такого события нет
reportEvent('sections.selectTasksTab') // не передан обязательный аргумент
reportEvent('sections.selectTasksTab', 'Вчера') // значение не входит в SectionTab
SDK запускается один раз при старте приложения, в отдельном хуке. Ошибки активации не должны ронять приложение, поэтому вызов обёрнут в try/catch:
// mobile/src/6_shared/hooks/analytics/useInitializeAnalytics.ts
export function useInitializeAnalytics() {
useEffect(() => {
try {
AppMetrica.activate({
apiKey: APPMETRICA_API_KEY,
sessionTimeout: 120,
logs: true
})
} catch (error) {
console.warn('AppMetrica activation failed:', error)
}
}, [])
}
Тот же подход в вебе
В веб-версии на Next.js схема и типы остались прежними. Поменялся только последний шаг: вместо SDK приложения события уходят в Яндекс.Метрику как цели через reachGoal. Группы событий в вебе шире (добавились разделы «Функциональная схема», «Отчёты» и «Команда»), а параметры задачи вложены в один объект.
Скрипт Метрики подключается через next/script после интерактивности страницы. Компонент ничего не рисует, он только инициализирует счётчик и отправляет просмотры при смене маршрута:
// frontend/src/_shared/services/yandex-metrika/yandex-metrika.tsx
export const YandexMetrika = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!YM_ID) return;
const url = `${pathname}?${searchParams}`;
if (window.ym) {
window.ym(YM_ID, "hit", url);
}
}, [pathname, searchParams]);
if (!YM_ID) return null;
return <Script id="yandex-metrika" strategy="afterInteractive">{/* код счётчика */}</Script>;
};
Без этого хука Метрика видела бы только первую загрузку страницы: в Next.js переходы по ссылкам не перезагружают страницу, и счётчик сам о них не узнаёт.
Есть и вторая особенность. Пользователь может нажать кнопку раньше, чем скрипт Метрики загрузится. Поэтому reportEvent в вебе не отправляет событие сразу, а смотрит на флаг ym_loaded. Если скрипт ещё не готов, событие уходит в очередь, а компонент отправляет её после инициализации:
// frontend/src/_shared/services/yandex-metrika/index.ts
const reachGoal = (goalName: string, params?: Record<string, unknown>) => {
if (!YM_ID) return;
if (window.ym_loaded) {
window.ym(YM_ID, 'reachGoal', goalName, params);
return;
}
if (!window.ym_queue) {
window.ym_queue = [];
}
window.ym_queue.push({ eventName: goalName, params });
};
Если идентификатор счётчика пуст, события просто пропускаются, а в режиме разработки вместо отправки в консоль пишется строка.
Что отличается на двух платформах
| Мобильное приложение | Веб-версия | |
|---|---|---|
| Инструмент | AppMetrica | Яндекс.Метрика |
| Запуск | AppMetrica.activate в хуке при старте | Компонент со скриптом через next/script |
| Отправка | AppMetrica.reportEvent | reachGoal |
| События до загрузки | Не нужно, SDK уже внутри приложения | Очередь ym_queue |
| Переходы между экранами | Не нужно | Отправка hit при смене маршрута |
| Группы событий | Разделы и задачи | То же плюс схема, отчёты и команда |
| Тесты | Есть для схемы и для хука активации | Сам сервис не покрыт, в тестах компонентов проверяется только вызов reportEvent |
Итог
Событие теперь описывается один раз, в одном месте. TypeScript отлавливает опечатки и неправильные аргументы, пока код ещё не попал в сборку, а названия событий в отчётах всегда совпадают с тем, что записано в схеме. На мобильной и веб-платформе меняется только последний шаг, отправка, а остальная схема общая.