Аналитика в продукте обычно ломается тихо. Вызовы событий разбросаны по компонентам, в названиях встречаются опечатки, параметры передают как придётся. Через полгода никто не скажет, что именно приложение отправляет, и можно ли верить отчётам.

В нашей системе аналитика нужна в двух местах: в мобильном приложении (AppMetrica) и в веб-версии (Яндекс.Метрика). Я сделал для них одну схему событий, из которой TypeScript сам выводит допустимые ключи и аргументы. Ниже расскажу, как это устроено, и чем отличаются две платформы.


Одна схема событий

Все события описаны одним объектом. У каждого события есть название, которое увидят аналитики, и функция buildPayload, которая собирает параметры. Из компонентов вызывается только один метод, reportEvent, и ему передаётся ключ события.

Схема событий задаёт типы, типы проверяют вызов reportEvent, а reportEvent отправляет событие в AppMetrica или в Яндекс.Метрику ANALYTICS_EVENTS схема событий EventKey · ArgsForEvent типы выводятся сами reportEvent единая точка входа AppMetrica мобильное приложение Яндекс.Метрика веб-версия

Так выглядит схема в мобильном приложении (показана часть событий):

// 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.reportEventreachGoal
События до загрузкиНе нужно, SDK уже внутри приложенияОчередь ym_queue
Переходы между экранамиНе нужноОтправка hit при смене маршрута
Группы событийРазделы и задачиТо же плюс схема, отчёты и команда
ТестыЕсть для схемы и для хука активацииСам сервис не покрыт, в тестах компонентов проверяется только вызов reportEvent

Итог

Событие теперь описывается один раз, в одном месте. TypeScript отлавливает опечатки и неправильные аргументы, пока код ещё не попал в сборку, а названия событий в отчётах всегда совпадают с тем, что записано в схеме. На мобильной и веб-платформе меняется только последний шаг, отправка, а остальная схема общая.