Введение
Архитектура
Обработка входящих запросов
Коммуникация сервисов
Руководство пользователя
Получение данных из сервисов
Использование Панели управления
Туториалы
Получение данных из других сервисов
Построение истории отдельного пользователя
Классические модели атрибуции
Вероятностное прохождение воронки
Атрибуция по индексу активности
Воронки
X
Выберите раздел

Слушатель событий

Слушатель получает каждое событие платформы после обогащения и обработки и делает с ним что-то своё: пишет в базу, отправляет во внешнюю систему, считает статистику. Front ничего от него не ждёт, поэтому слушатель не влияет на время ответа клиенту. Так устроены chwriter (ClickHouse) и kafkastream (Kafka).

Подписка

Слушатель подписывается на канал broadcast и регистрирует метод с тем же именем — именно его вызывает Front уведомлением notify('broadcast', 'broadcast', msg):

const channels = [config.rpc.name, BROADCAST];
// ...
async setup() {
  await this.writer.init();                     // сначала подключиться к приёмнику
  this.rpc.register(BROADCAST, this.writer.write);  // потом принимать события
}

Других действий не нужно: регистрироваться в Front слушателю не требуется.

Что приходит

В params приходит событие целиком. Его поля:

{
  "id": "6410748886712844288",
  "time": 1718000000123,
  "key": "in.gen.track.page",
  "service": "track",
  "name": "page",
  "channel": "track",
  "projectId": 1617632140,
  "uid": "6410748886712844300",
  "td": {
    "ip": "93.184.216.34",
    "ua": "Mozilla/5.0 ...",
    "fpid": "1234567890",
    "host": "example.com",
    "ref": "https://google.com/"
  },
  "data": {
    "page": { "url": "https://example.com/", "title": "Example" },
    "sess": { "type": "organic", "num": 3 },
    "mmgeo": { "country_iso": "RU", "city_en": "Moscow" }
  }
}
ПолеОписание
idsnowflake-идентификатор события, присваивает Front
timeвремя приёма, unix-миллисекунды UTC
keyключ маршрутизации in.gen.<service>.<name>
service, nameисточник и имя события, из них собран ключ
channelчерез какой канал пришли данные
projectIdидентификатор проекта
uid, uid_paramидентификатор посетителя и его исходное значение
tdтранспортные данные: IP, User-Agent, отпечаток, хост, referer
dataполезная нагрузка события; сюда же дописаны результаты обогатителей, каждый под своей service_group
pancakeдополнительные данные от Front

Обработчик возвращает {}: ответ на уведомление никуда не уходит.

Маршрутизация внутри слушателя

Решение, что делать с событием, принимается по key или по паре service/name. Оба подхода из рабочих сервисов:

chwriter выбирает таблицу по карте destinations из конфигурации, с запасными вариантами <service>/default и other:

clickhouse:
  destinations:
    track/default: events
    track/activity: activity
    track/scroll: activity
    pixel/default: pixels
    other: webhooks
const nameKey = msg.name.toLowerCase().replace(/\s/g, '_');
const table = this.dest[`${msg.service}/${nameKey}`]
  || this.dest[`${msg.service}/default`]
  || this.dest['other'];
if (!table) return {};

kafkastream идёт по списку префиксов ключа. Первое совпадение определяет топик и путь до поля, которое станет ключом сообщения Kafka; null означает «пропустить»:

kafkastream:
  mappings:
    - ['in.gen.track.ping', null]
    - ['in.gen.track.', [rstat.web, uid]]
    - ['in.gen.events_mp_ios.', [rstat.mobile, data.properties_s_device_id]]
for (const [prefix, target] of this.map) {
  if (target && key.startsWith(prefix)) {
    [topic, keyPath] = target;
    break;
  }
}

Буферизация

Событий много, а обработчик должен вернуть управление сразу. Оба сервиса не пишут в приёмник на каждое событие, а складывают его в буфер и сбрасывают по таймеру:

  • chwriter держит по буферу на таблицу и отправляет их в ClickHouse раз в uploadInterval секунд. Неудавшиеся пачки сохраняются в emergency_dir как JSON, чтобы данные не пропали.
  • kafkastream держит по массиву сообщений на топик и раз в 500 мс отправляет всё одним producer.sendBatch.

Схема одинаковая:

writers: { [topic: string]: Message[] } = {};

async init() {
  await this.producer.connect();
  setInterval(() => this.flush(), 500);
}

write = async (msg: HandyRecord) => {
  this.meter.tick('kafka.write.called');
  // ... выбор топика
  (this.writers[topic] ||= []).push({ key: kafkaKey, value: JSON.stringify(rest) });
  return {};
}

flush() {
  const topicMessages = Object.entries(this.writers)
    .filter(([, items]) => items.length > 0)
    .map(([topic, messages]) => { this.writers[topic] = []; return { topic, messages }; });
  if (topicMessages.length) {
    this.producer.sendBatch({ topicMessages }).catch(e => this.log.error(e));
  }
}

Даты для хранилища считайте из time, а не из текущего момента: событие могло задержаться в пути. chwriter и kafkastream пишут в data три поля: date (YYYY-MM-DD), dateTime (YYYY-MM-DD HH:MM:SS) и timestamp (исходные миллисекунды), все в UTC.

Новый слушатель за пять шагов

  1. Скопируйте kafkastream, замените имя в package.json, config/02_config.yml (rpc.name) и в Dockerfile (LABEL band.service.title).
  2. Опишите свою секцию конфигурации и её тип в src/types.ts.
  3. Напишите класс-приёмник с методами init() и write и буфером с таймером.
  4. В AppServer.setup() дождитесь init() и зарегистрируйте write на BROADCAST.
  5. Запустите локально с REDIS_DSN вашей инсталляции (npm run start:dev) и убедитесь в логах, что подписка на broadcast состоялась и события приходят. Дальше — сборка образа.

Минимальный приёмник, который просто печатает ключи событий:

import { Deps } from '@app/AppServer';
import { Logger, Meter } from '@rockstat/rock-me-ts';

export class Printer {
  log: Logger;
  meter: Meter;
  counter = 0;

  constructor(deps: Deps) {
    this.log = deps.log.for(this);
    this.meter = deps.meter;
  }

  async init() {
    setInterval(() => {
      this.log.info({ eps: this.counter }, 'events per second');
      this.counter = 0;
    }, 1000);
  }

  write = async (msg: { [k: string]: any }) => {
    this.counter++;
    this.meter.tick('printer.event');
    this.log.debug({ key: msg.key, uid: msg.uid }, 'event');
    return {};
  }
}