Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Type-Safe Telemetry & Analytics: проектируем надежную шину событий на TypeScript

Тимофей Ищенко

Коротко: Разбираем проектирование типобезопасной шины событий для аналитики на TypeScript и React: Event Catalog, Middleware, валидация через Zod и изоляция SDK.

Вызов analytics.track('btn_click', { id: 123 }) выглядит безобидно ровно до тех пор, пока аналитики не приходят с вопросом, почему половина событий order_completed приходит без обязательного поля currency, а часть конверсий в дашборде записана как OrderCompleted с заглавной буквы.

В масштабных фронтенд-приложениях аналитика и телеметрия нередко превращаются в неконтролируемый источник технического долга. Отсутствие строгих контрактов данных приводит к дрейфу схемы (schema drift), замусориванию Data Warehouse и тихой поломке продуктовых метрик при рефакторинге интерфейса. Решение этой проблемы — построение строго типизированной шины событий (Event Bus), которая превращает отправку аналитики из набора разрозненных сайд-эффектов в предсказуемый и надежный архитектурный слой.


Проблема «слепой» аналитики и цена отсутствия контрактов

В большинстве проектов отправка событий телеметрии реализуется через глобальный вызов метода SDK сторонней аналитики или через контекст с сигнатурой вида:

// Антипаттерн: отсутствие валидации имени и структуры полезной нагрузки
function track(eventName: string, properties?: Record<string, any>): void;

Такой подход порождает системные риски:

  1. Опечатки в названиях и рассинхронизация регистров: cart:item_added, cart_item_added и itemAdded будут расценены хранилищем данных как три совершенно разных события.
  2. Неявный контракт полезной нагрузки (payload): разработчик может случайно передать user_id как число вместо строки или пропустить обязательное поле price. Компилятор TypeScript не предупредит об ошибке.
  3. Хрупкость при рефакторинге: переименование компонента или изменение структуры локального стейта ломает отправляемую метрику без единого предупреждения в IDE.
  4. Жесткая связь UI-слоя с вендорами: прямой импорт сторонних SDK (Amplitude, Google Analytics 4, Яндекс Метрика) в компоненты React связывает интерфейсный код с внешними провайдерами.

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


Архитектурный фундамент: Каталог событий (Event Catalog)

Централизованный каталог событий — это единственный источник истины (Single Source of Truth) для всей телеметрии в проекте. Он определяет, какие события существуют в системе и какую полезную нагрузку они требуют.

Конвенция именования событий

Структурированное именование снижает риск коллизий и упрощает фильтрацию данных в BI-системах. Распространенный стандарт — шаблон namespace:object_action или domain.object.action:

  • auth:user_logged_in
  • checkout:order_placed
  • catalog:filter_applied

В TypeScript структуру имен можно зафиксировать с помощью Template Literal Types:

type AnalyticsDomain = 'auth' | 'checkout' | 'catalog' | 'navigation';
type AnalyticsAction = 'clicked' | 'submitted' | 'viewed' | 'failed' | 'succeeded';

// Шаблон вида "domain:string_action"
export type EventNamePattern = `${AnalyticsDomain}:${string}_${AnalyticsAction}`;

Контракт карты событий (Event Map)

Вместо плоского списка типов удобнее сопоставить имя события с его интерфейсом через Event Map:

export interface AuthUserLoggedInPayload {
  userId: string;
  method: 'email' | 'google' | 'github';
  isFirstLogin: boolean;
}

export interface CheckoutOrderPlacedPayload {
  orderId: string;
  totalAmount: number;
  currency: 'RUB' | 'USD' | 'EUR';
  itemCount: number;
}

export interface CatalogFilterAppliedPayload {
  category: string;
  appliedFilters: Record<string, string | number | boolean>;
  resultsCount: number;
}

// Карта событий: Ключ — имя события, Значение — структура payload
export interface TelemetryEventMap {
  'auth:user_logged_in': AuthUserLoggedInPayload;
  'checkout:order_placed': CheckoutOrderPlacedPayload;
  'catalog:filter_applied': CatalogFilterAppliedPayload;
}

Проектирование Type-Safe шины событий на TypeScript

Связующим звеном выступает шина событий (Event Bus), методы которой параметризованы ключами нашей карты событий TelemetryEventMap.

Generic-типизация эмиттера событий

Интерфейс шины должен гарантировать, что разработчик не сможет вызвать метод track с несуществующим именем или несовместимым payload:

export type TelemetryHandler<T> = (payload: T) => void | Promise<void>;

export interface ITelemetryBus<TMap extends Record<string, any>> {
  track<K extends keyof TMap>(event: K, payload: TMap[K]): void;
  subscribe<K extends keyof TMap>(event: K, handler: TelemetryHandler<TMap[K]>): () => void;
  subscribeToAll(handler: (event: keyof TMap, payload: TMap[keyof TMap]) => void): () => void;
}

Если часть событий не требует полезной нагрузки (void или пустой объект), аргумент payload можно сделать опциональным через условные типы кортежей:

export type PayloadArgs<T> = T extends void | undefined 
  ? [] 
  : [payload: T];

export interface IFlexibleTelemetryBus<TMap extends Record<string, any>> {
  track<K extends keyof TMap>(event: K, ...args: PayloadArgs<TMap[K]>): void;
}

Реализация типобезопасного класса Event Bus

export class TelemetryBus<TMap extends Record<string, any>> implements ITelemetryBus<TMap> {
  private subscribers: {
    [K in keyof TMap]?: Set<TelemetryHandler<TMap[K]>>;
  } = {};

  private wildcardSubscribers: Set<(event: keyof TMap, payload: any) => void> = new Set();

  public track<K extends keyof TMap>(event: K, payload: TMap[K]): void {
    const handlers = this.subscribers[event];
    if (handlers) {
      handlers.forEach((handler) => {
        try {
          handler(payload);
        } catch (error) {
          console.error(`Ошибка выполнения обработчика события ${String(event)}:`, error);
        }
      });
    }

    this.wildcardSubscribers.forEach((handler) => {
      try {
        handler(event, payload);
      } catch (error) {
        console.error(`Ошибка выполнения wildcard-обработчика для события ${String(event)}:`, error);
      }
    });
  }

  public subscribe<K extends keyof TMap>(event: K, handler: TelemetryHandler<TMap[K]>): () => void {
    if (!this.subscribers[event]) {
      this.subscribers[event] = new Set();
    }
    
    this.subscribers[event]!.add(handler);

    return () => {
      this.subscribers[event]?.delete(handler);
    };
  }

  public subscribeToAll(handler: (event: keyof TMap, payload: any) => void): () => void {
    this.wildcardSubscribers.add(handler);
    return () => {
      this.wildcardSubscribers.delete(handler);
    };
  }
}

Архитектура адаптеров и Middleware: изоляция внешних SDK

Клиентский код UI-компонентов не должен напрямую зависеть от библиотек аналитики. Шина событий выступает центральным диспетчером, к которому подключаются независимые адаптеры.

[React UI Component] 
        │ (trackEvent)
        ▼
[Telemetry Event Bus]
        │
   [Middleware] ── (обогащение: timestamp, session_id, app_version)
        │
   ┌────┴──────────────────────────┐
   ▼                               ▼
[Amplitude Adapter]      [Internal Telemetry API]

Паттерн Middleware: обогащение системным контекстом

Перед передачей событий в системы аналитики полезную нагрузку часто необходимо дополнить метаданными окружения: меткой времени, версией релиза, идентификатором сессии и текущим URL.

export interface SystemContext {
  timestamp: number;
  environment: 'production' | 'staging' | 'development';
  appVersion: string;
  currentUrl: string;
}

export type EnrichedTelemetryPayload<T> = T & {
  _context: SystemContext;
};

export interface AnalyticsAdapter {
  name: string;
  send<K extends keyof TelemetryEventMap>(
    event: K, 
    payload: EnrichedTelemetryPayload<TelemetryEventMap[K]>
  ): void | Promise<void>;
}

Реализация адаптера для собственной телеметрии

export class InternalTelemetryAdapter implements AnalyticsAdapter {
  public name = 'InternalTelemetry';

  public async send<K extends keyof TelemetryEventMap>(
    event: K, 
    payload: EnrichedTelemetryPayload<TelemetryEventMap[K]>
  ): Promise<void> {
    const body = JSON.stringify({
      eventName: event,
      data: payload,
      timestamp: payload._context.timestamp,
    });

    if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
      navigator.sendBeacon('/api/v1/telemetry', body);
    } else {
      await fetch('/api/v1/telemetry', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body,
        keepalive: true,
      });
    }
  }
}

Интеграция в React и Next.js

Для удобного доступа к шине в иерархии компонентов используются React Context и кастомные хуки. Это изолирует логику отправки и упрощает тестирование компонентов с помощью моков.

React Context и Provider

import React, { createContext, useContext } from 'react';

const TelemetryContext = createContext<TelemetryBus<TelemetryEventMap> | null>(null);

export const TelemetryProvider: React.FC<{
  bus: TelemetryBus<TelemetryEventMap>;
  children: React.ReactNode;
}> = ({ bus, children }) => {
  return (
    <TelemetryContext.Provider value={bus}>
      {children}
    </TelemetryContext.Provider>
  );
};

export const useTelemetry = () => {
  const context = useContext(TelemetryContext);
  if (!context) {
    throw new Error('useTelemetry должен использоваться внутри TelemetryProvider');
  }
  return context;
};

Использование в UI-компонентах

import React from 'react';
import { useTelemetry } from '@/shared/telemetry/TelemetryProvider';

interface CheckoutButtonProps {
  orderId: string;
  total: number;
  count: number;
}

export const CheckoutButton: React.FC<CheckoutButtonProps> = ({ orderId, total, count }) => {
  const telemetry = useTelemetry();

  const handleCheckout = async () => {
    try {
      // Бизнес-логика оформления заказа...

      // Типобезопасный трекинг: автодополнение полей и строгая проверка типов в IDE
      telemetry.track('checkout:order_placed', {
        orderId,
        totalAmount: total,
        currency: 'RUB',
        itemCount: count,
      });
    } catch (error) {
      console.error('Ошибка при оформлении заказа:', error);
    }
  };

  return (
    <button onClick={handleCheckout} className="btn-primary">
      Оформить заказ
    </button>
  );
};

Особенности Next.js (App Router / SSR)

В Server Components и Server Actions браузерные API и клиентская шина недоступны. Для серверной аналитики применяется отдельный серверный логгер, отправляющий телеметрию напрямую в брокер сообщений или бэкенд-эндпоинт без участия браузерного Event Bus.


Runtime-валидация данных с помощью Zod

TypeScript валидирует типы исключительно на этапе сборки. Если параметры события приходят из нетипизированных внешних источников или динамического пользовательского ввода, существует риск передать поврежденные данные (например, null вместо обязательной строки).

С помощью библиотеки Zod можно одновременно описать runtime-схему и вывести из нее строгий тип TypeScript:

import { z } from 'zod';

export const OrderPlacedEventSchema = z.object({
  orderId: z.string().uuid(),
  totalAmount: z.number().positive(),
  currency: z.enum(['RUB', 'USD', 'EUR']),
  itemCount: z.number().int().nonnegative(),
});

// Автоматический вывод типа из Zod-схемы
export type OrderPlacedPayload = z.infer<typeof OrderPlacedEventSchema>;

export const TelemetrySchemas = {
  'checkout:order_placed': OrderPlacedEventSchema,
} as const;

export function validateEventPayload<K extends keyof typeof TelemetrySchemas>(
  event: K,
  payload: unknown
) {
  const schema = TelemetrySchemas[event];
  if (!schema) return payload;

  const result = schema.safeParse(payload);
  if (!result.success) {
    console.error(`[Telemetry Validation Error] Событие ${event}:`, result.error.format());
  }
  return result.data;
}

Чек-лист перехода проекта на Type-Safe аналитику

  1. Зафиксируйте контракт: создайте модуль с TelemetryEventMap и строгим соглашением по именованию событий.
  2. Инкапсулируйте логику отправки: замените прямые вызовы внешних SDK на generic-метод bus.track().
  3. Разделите консьюмеров: вынесите внешние аналитические сервисы в отдельные адаптеры-подписчики.
  4. Автоматизируйте метаданные: используйте Middleware для автоматического обогащения событий контекстом (URL, сессия, устройство).
  5. Подключите валидацию: добавьте Zod-схемы для критически важных событий, чтобы перехватывать некорректные данные на этапе разработки.

FAQ (Часто задаваемые вопросы)

Зачем типизировать аналитику на фронтенде, если валидация настраивается в DWH?

Валидация в DWH (Data Warehouse) защищает хранилище, но отбрасывает некорректные события на этапе приема (ingestion). В итоге бизнес безвозвратно теряет данные о конверсиях. Типизация на фронтенде решает проблему в источнике: разработчик получает ошибку несоответствия схемы прямо в процессе написания кода.

Что предпочесть для именования событий: Union строковых литералов или Enum?

Рекомендуется использовать объединения строковых литералов (String Literal Union) в связке с интерфейсной картой (EventMap). Enum в TypeScript увеличивает runtime-размер бандла, усложняет сериализацию и создает лишние зависимости при импорте. Строковые литералы гарантируют типобезопасность без накладных расходов в рантайме.

Влияет ли runtime-валидация через Zod на производительность UI?

Валидация простых объектов в Zod занимает доли миллисекунды и незаметна для пользователя. Для высокочастотных событий (скролл, ввод текста, перемещение курсора) валидацию можно отключить или активировать только для тестового окружения и режима разработки (NODE_ENV === 'development').

Как гарантировать отправку аналитики при закрытии страницы?

Стандартный асинхронный fetch может быть оборван браузером при выгрузке страницы. В адаптерах шины для гарантированной доставки следует использовать navigator.sendBeacon(url, data) или вызов fetch с опцией keepalive: true.

Как масштабировать Event Catalog в монорепозитории или микрофронтендах?

Каталог событий выносится в общий shared-пакет внутри монорепозитория. Микрофронтенды импортируют базовые типы и расширяют TelemetryEventMap собственными доменными событиями через интерфейсное слияние (declaration merging) или композицию карт событий.


Заключение

Аналитика и телеметрия — это полноценная часть архитектурного контракта между интерфейсом, продуктовой командой и хранилищем данных. Переход от хаотичных вызовов analytics.track() к строго типизированной шине событий исключает дрейф схем данных, ускоряет разработку за счет автодополнения в IDE и обеспечивает бизнес чистой, достоверной аналитикой.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также