Перейти к содержимому

Разработка10 мин чтения

Как мы сделали свои события в appmetrica.online — аналитике без SDK

Свои события со свойствами в нашей открытой self-hosted аналитике: контракт, почему значения хранятся строками, аддитивная миграция ClickHouse, люди по значению свойства и ошибки, которые мы исправили по пути.

appmetrica.online — наша открытая аналитика для приложений, сайтов и ботов, которая работает без SDK: вы отправляете обычные HTTP-запросы, остальное делает сервер. Её можно развернуть у себя, лицензия AGPL-3.0, код лежит на GitHub. У инструмента есть своя страница проекта на этом сайте, а русская версия сервиса находится по адресу appmetrica.online/ru.

Коммиты со своими событиями датированы 3 ноября 2026 года, а эта заметка написана 4 ноября. В её основе код, история git и публичная страница документации. История следует порядку «сначала тест», о котором мы писали в статье про TDD: перед каждым feat(...) идёт коммит test(...).

Зачем свои события и почему по-прежнему без SDK

Продукт начинался только с сессий. Клиент отправляет session_start и session_end на одну точку входа, POST /v1/events, пачками до 500 событий, идемпотентно по insert_id, с офлайн-очередью до 7 дней. Без SDK рецепт для каждой платформы — несколько строк HTTP, поэтому контракт и есть продукт, а всё новое должно быть аддитивным: клиент, который шлёт только сессии, не должен меняться.

Из наших заметок о проектировании видно, что свои события откладывали, пока не появится вопрос, на который сессии не отвечают. Он появился: воронки наших собственных приложений (онбординг, первый результат, пейвол, покупка) жили в других аналитических сервисах, а мы хотели видеть их у себя. Первый вопрос — сколько людей из заданного источника дошли до первого результата, то есть одно событие и разбивка по свойству. Поэтому свои события делят с сессиями таблицу, точку входа и пачку и добавляют только собственное имя и необязательный объект properties.

Контракт

Имя должно соответствовать ^[a-z][a-z0-9_]{0,63}$: строчные латинские буквы, цифры и подчёркивания, до 64 символов. Префикс session_ зарезервирован для всего, кроме session_start и session_end. Причину называет документация: так в будущем можно добавлять системные события, не сталкиваясь с именами, которые уже кто-то использует.

properties — необязательный объект со следующими правилами:

  • до 50 ключей, каждый по тому же шаблону, что и имя события;
  • значение — строка до 256 символов, число или boolean;
  • null, массив или объект в качестве значения отклоняются, а "properties": null считается отсутствием поля.

session_id по-прежнему обязателен у каждого события. В нашем понимании заметок: профиль и контракт построены вокруг сессий, а события вне сессии (бэкенды, боты) — решение на потом.

Неподходящее имя и раньше отклонялось с кодом unknown_event_name, и мы использовали его повторно. Клиенты, которые ветвятся по этому коду, считают его неповторяемым отказом, а новый код сломал бы их ветвление. Изменился только текст: теперь он охватывает имя не по шаблону или зарезервированное. PII — новая ситуация, поэтому у неё новый код.

НарушениеКод
Имя не по шаблону или с префиксом session_unknown_event_name
Не объект, больше 50 ключей, плохой ключ, неверный тип значения, строка длиннее 256 символов или с U+0000invalid_field
Строка похожа на email или телефонproperty_looks_like_pii

Каждый отказ неповторяемый и касается только своего события. Остальная пачка принимается.

Нормализация и приватность

Значения хранятся строками. Отчёты разбивают событие по тексту значения, поэтому им нужен только текст, а числовых агрегатов в первой версии нет. Строки сохраняются как есть, без trim. Boolean превращается в true или false. Число получает кратчайшую десятичную запись: 12.5 остаётся 12.5, а -0 становится 0, чтобы не образовывать отдельную строку разбивки. Числа JSON читаются как 64-битные float, поэтому целые больше 2^53 теряют точность, и документация советует отправлять их строками.

Свойства проверяются по ключам в порядке документа, и первое нарушение отклоняет событие целиком. Код читает объект потоком токенов, а не в Go-map, потому что порядок обхода map случаен, и сообщаемое нарушение менялось бы от запуска к запуску. Проверка значения в сокращённом виде:

properties.gogo
const (
	// MaxProperties — максимум ключей в properties события.
	MaxProperties = 50
	// MaxPropertyValueLength — максимум символов (рун) строкового значения свойства.
	MaxPropertyValueLength = 256
)

// propertyKeyRe — шаблон ключа. В контракте он описан только текстом,
// потому что в OpenAPI 3.0 нет propertyNames.
var propertyKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)

// ...

func propertyValue(index int, insertID string, p propertyPair) (string, *ingestapi.Rejection) {
	// ...
	switch first := p.raw[0]; {
	case first == '"':
		var s string
		if json.Unmarshal(p.raw, &s) != nil {
			return invalid(field + " должен быть строкой, числом или boolean.")
		}
		switch {
		case utf8.RuneCountInString(s) > MaxPropertyValueLength:
			return invalid(field + " длиннее 256 символов.")
		case strings.ContainsRune(s, 0):
			return invalid(field + " содержит символ U+0000.")
		case LooksLikePII(s):
			return "", reject(index, insertID, ingestapi.RejectionCodePropertyLooksLikePii, field,
				field+" похоже на email или телефон. Не передавайте персональные данные в свойствах.")
		}
		return s, nil
	// ...
	case first == '-' || (first >= '0' && first <= '9'):
		v, err := strconv.ParseFloat(string(p.raw), 64)
		if err != nil || math.IsInf(v, 0) {
			return invalid(field + ": число вне допустимого диапазона.")
		}
		if v == 0 {
			return "0", nil // -0 и 0 — одно значение для разбивки
		}
		return strconv.FormatFloat(v, 'f', -1, 64), nil
	// ...
	}
}

В репозитории сообщения написаны по-русски, в сниппете оставлены как есть.

PII-защита — та же проверка LooksLikePII, что охраняет user_id. Если строка похожа на email или телефон, всё событие отклоняется с кодом property_looks_like_pii. Альтернативой было выбросить свойство и принять событие. Мы отклоняем, потому что тихое выбрасывание прячет утечку от разработчика клиента, а отказ сразу виден на экране интеграции. Защита ловит только очевидные форматы, как признаёт и документация, поэтому та советует передавать непрозрачные ID и категории. Для отклонённых событий хранится образец тела запроса для отладки, и подозрительное значение под properties заменено там на [redacted].

Хранение

Свойства лежат в одной новой колонке существующей таблицы events, а миграция чисто аддитивная:

00004_event_properties.sqlsql
-- +goose NO TRANSACTION
-- +goose Up
-- +goose StatementBegin
ALTER TABLE events ADD COLUMN IF NOT EXISTS properties Map(LowCardinality(String), String);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
ALTER TABLE events DROP COLUMN IF EXISTS properties;
-- +goose StatementEnd

Map(LowCardinality(String), String) не требует экспериментальных настроек, а колонка добавляется последней и меняет только метаданные. Skip-индексов нет, потому что отчёты фильтруют по app_id, event_date и name. Нет и дневных агрегатов для событий: отчёты читают сырые события, так как объёмы малы, а пересчёт закрытых дней после смены часового пояса, удаления или слияния приложений — самая хрупкая часть нашего воркера. Агрегаты добавим, когда понадобится скорость.

Ловушка отката

Новая колонка ломает предыдущий образ. Старая вставка была голым INSERT INTO events, а AppendStruct в Go-драйвере ClickHouse требует поле структуры на каждую колонку таблицы. После ADD COLUMN старый образ писать не может. Наш интеграционный тест воспроизводит ошибку missing destination name "properties". Новая вставка перечисляет колонки явно, чтобы следующая колонка не ломала откат:

insert.gogo
// eventColumns — явный список колонок INSERT в порядке полей EventRow.
// Без списка AppendStruct требует поле на каждую колонку таблицы, и образ
// предыдущего релиза перестаёт писать после ADD COLUMN.
const eventColumns = ` (app_id, event_date, event_time, /* ... */ write_key_id, properties)`

Слияние приложений копирует события по собственным спискам колонок, поэтому новый интеграционный тест сверяет system.columns с обоими списками. Забытая колонка валит тест, а не пропадает молча.

Отчёты

Отчёты считают события как uniqExact(insert_id), а не count(). Таблица events — ReplacingMergeTree, она схлопывает дубли только при фоновом слиянии. Клиент, повторивший пачку, оставляет один и тот же insert_id в нескольких строках до этого момента, и count() посчитал бы каждую копию. Тест проверяет линтером весь SQL отчётов: под псевдонимами events и prev_events всё, кроме uniqExact(insert_id), не проходит.

Люди считаются так же, как в отчётах аудитории: один человек на user_id, если он есть, иначе на установку. Строка каждого человека несёт значения его событий, а ARRAY JOIN раскладывает её по ним. В разбивке три вида строк: топ значений (по умолчанию 20, не больше 100), одна строка остальные и строка нет свойства для событий без свойства. Пара (ключ есть, значение) отличает пустое значение от отсутствующего ключа. SQL в сокращённом виде:

events.gosql
-- people: строка на человека с groupUniqArray пар (есть ли ключ, значение)
-- его событий (строится peopleWhereCTE, здесь сокращено)
by_people AS (
    SELECT tupleElement(v, 1) AS has, tupleElement(v, 2) AS value, uniqExactState(person_key) AS people_state
    FROM people ARRAY JOIN vals AS v
    GROUP BY has, value
),
by_events AS (
    -- uniqExact(insert_id), а не count(): до слияния возможны дубли
    SELECT mapContains(e.properties, {property:String}) AS has, e.properties[{property:String}] AS value,
           uniqExact(e.insert_id) AS events
    FROM events AS e
    WHERE ...
    GROUP BY has, value
),
ranked AS (
    SELECT v.has AS has, v.value AS value, v.events AS value_events, p.people_state AS people_state,
           row_number() OVER (PARTITION BY v.has ORDER BY v.events DESC, v.value ASC) AS pos
    FROM by_events AS v
    INNER JOIN by_people AS p ON p.has = v.has AND p.value = v.value
)
SELECT multiIf(NOT has, 'missing', pos <= {limit:UInt32}, 'value', 'other') AS kind,
       if(kind = 'value', value, '') AS label,
       sum(value_events) AS events,
       -- люди строки other — объединение состояний, а не сумма
       uniqExactMerge(people_state) AS people
FROM ranked
GROUP BY kind, label
ORDER BY events DESC, label ASC

События строки остальные — простая сумма, потому что у события одно значение ключа и множества не пересекаются. С людьми иначе: у одного человека могут быть события с несколькими значениями. Если кто-то создавал заметки голосом и через «поделиться», и оба значения попали в остальные, этого человека нужно посчитать один раз. Поэтому запрос хранит uniqExactState по каждому значению и сливает состояния через uniqExactMerge: сумма людей по значениям посчитала бы его дважды.

Что пошло не по плану

Первый план отката оказался неверным. Найдя ловушку, мы записали в заметках откат командой appmetrica migrate вниз до версии 3. В команде есть только up и status, и down мы решили не добавлять. Коммит 931a970 переписал ранбук как ручную процедуру: остановить ingest и воркер, снять колонку, удалить строку версии 4 из таблицы goose, затем выкатить предыдущий образ. Строка важна. Без её удаления повторная выкатка сочла бы миграцию 4 применённой и не вернула бы колонку. Следующий тест выполняет эти запросы на настоящем ClickHouse и подтверждает оба утверждения.

Внимание

Откат за этот релиз стирает все свойства событий. События и имена остаются, значения пропадают. Сначала сделайте свежий бэкап.

В маскировании образцов тела запроса были дыры. Первая версия сравнивала ключ properties с учётом регистра. Одно исправление сделало сравнение без учёта регистра. Другое охватило вложенные объекты, массивы и необъектные значения под properties. Валидатор такие формы отклоняет, но образец отклонённого события хранится 7 дней, и в нашем понимании сырого email в нём быть не должно.

Список событий терял имена, упавшие до нуля. Запрос списка фильтровал по events > 0. При включённом сравнении событие, которое было в прошлом периоде, а сейчас не встречается, исчезало, а не показывалось как 0 и −100%. Исправление оставляет имя, если события есть хотя бы в одном из периодов. Похожая правка заставила профиль человека показывать события всех окружений с пометкой development, а не только production (коммит).

Как этим пользоваться

Пачка с сессией и событием purchase, как в публичной документации:

bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
INSTALL=$(uuidgen | tr A-Z a-z)
SESSION=$(uuidgen | tr A-Z a-z)
curl -sS https://i.analytics.example.com/v1/events \
  -H "Content-Type: application/json" \
  -H "X-App-Key: $AM_PK" \
  --data-binary @- <<JSON
{
  "sent_at": "$NOW",
  "context": { "platform": "server", "environment": "development", "ip": "none" },
  "events": [
    {
      "insert_id": "$(uuidgen | tr A-Z a-z)",
      "name": "session_start",
      "timestamp": "$NOW",
      "install_id": "$INSTALL",
      "session_id": "$SESSION"
    },
    {
      "insert_id": "$(uuidgen | tr A-Z a-z)",
      "name": "purchase",
      "timestamp": "$NOW",
      "install_id": "$INSTALL",
      "session_id": "$SESSION",
      "properties": { "plan": "pro", "amount": 12.5, "trial": false }
    },
    {
      "insert_id": "$(uuidgen | tr A-Z a-z)",
      "name": "session_end",
      "timestamp": "$NOW",
      "install_id": "$INSTALL",
      "session_id": "$SESSION",
      "duration_ms": 4200
    }
  ]
}
JSON

В дашборде вкладка «События» показывает свои события: количество, людей и изменение к прошлому периоду. На странице события есть график, итоги и разбивка по выбранному свойству. Профиль человека показывает его события, а лента realtime — под их собственными именами. В Read API четыре операции: listEvents, getEventReport, getEventBreakdown и listProfileEvents. Чтобы запустить сервис у себя, следуйте шагам self-hosting из README репозитория: Docker Compose или Dokploy, ClickHouse для событий и PostgreSQL для метаданных.

Помните об ограничениях. Отчёты считают события и людей по значениям и не складывают числа, поэтому amount виден как разбивка по значениям, а не как сумма выручки. Воронки, сегменты по свойству и события без сессии наши заметки тоже относят к отдельным решениям на потом. И наконец, выкатывайте сервер раньше сборки приложения, которая шлёт события: старый ingest отклонит их как unknown_event_name, и они потеряются.

Что делать

  • Отправляйте свои события внутри сессии, с тем же session_id, что у session_start.
  • Берите стабильные имена в snake_case, не начинающиеся с session_. Переименованное событие начинает новый ряд.
  • Кладите значения в свойства, а не в имена. opened_note_4821 — имя с зашитым значением.
  • Держите тип свойства стабильным между релизами; огромные целые отправляйте строками.
  • Не передавайте в свойствах email, телефоны и имена. Берите непрозрачные ID и категории.
  • Добавляя колонку в таблицу событий, перечисляйте колонки явно во вставке и заранее записывайте откат.
  • Считайте события по ключу с дедупликацией, а людей — как объединение, а не сумму.

Источники

ПоделитьсяXLinkedIn

ONLYAPPS Team

Нови-Сад, Сербия · onlyapps.org

Контакты

Аналитические cookie мы используем только с вашего согласия. Необходимые cookie обеспечивают работу сайта. Политика конфиденциальности