chore(repo): заложен репозиторий v2 — контракт, спека, исследование

- Зачем:
  - спека «Боевой реализм стенда» исполняется в новом репозитории:
    предшественник замораживается как стабильный учебный стенд,
    v2 стартует пустым и переносит только нужное
- Что:
  - README: что это, статус «строится по спеке», ссылки на спеку и на
    репозиторий-предшественник
  - AGENTS.md написан заново, а не скопирован: язык, uv, обязательная
    проверка API через MCP Context7, контракт трекера Gitea (спека —
    источник истины, корневой issue тонкий), метки триажа, новые доки в docs/
  - .gitignore: Python и uv, .env, секреты, IDE, логи
  - docs/specs/2026-07-30-stand-v2-realism.md перенесена из v1; содержание
    не менялось, поправлены только ссылки: добавлена строка о переезде,
    ссылка на generator-realism.md переведена на абсолютный URL v1
  - docs/research/2026-07-26-yandex-clickstream-format.md перенесено:
    источник истины по формату широкого события
  - лицензии у предшественника нет, переносить нечего
- Проверка:
  - git show --stat: 5 файлов
  - относительные ссылки спеки ведут на существующие файлы репозитория
This commit is contained in:
Dmitry Dementiev
2026-07-30 15:47:55 +03:00
commit 8046e543d0
5 changed files with 1456 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Python и uv
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
.python-version
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Настройки окружения: в git попадает только образец .env.example
.env
.env.local
*.env.local
# Секреты
*.pem
*.key
*.crt
secrets/
credentials/
# Редакторы и IDE
.idea/
*.iml
.vscode/*
!.vscode/extensions.json
*.code-workspace
# Логи и временные файлы
logs/
*.log
*.tmp
*.bak
*~
# Файлы операционных систем
.DS_Store
Thumbs.db
# Локальные настройки Claude Code
.claude/*
!.claude/agents/
+67
View File
@@ -0,0 +1,67 @@
# AGENTS.md
Короткий контракт для работы в репозитории учебной дата-платформы кликстрима.
## Цель репозитория
Проект учебный: учебная ценность разработок — одна из его базовых ценностей.
Стенд строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
## Язык
Пиши на ясном русском языке. Иностранные слова оставляй только там, где у
термина нет устоявшегося русского аналога: имена технологий (Kafka, ClickHouse,
Airflow) и названия из кода. Если для понятия есть обычное русское слово —
используй его, не выдумывай транслитерации.
Документы и комментарии держи короткими и понятными читателю, который их не
писал: простые слова, короткие фразы, сложную мысль поясняй при первом
упоминании. Если понятность и буквальная точность спорят — выбирай понятность.
Внутренние рассуждения и промежуточные пометки по ходу работы веди на
английском — он экономнее по токенам. На русском остаётся всё, что видит и
хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения
коммитов.
## Код и данные
- Python — только через `uv`.
- Изменения держать минимальными и в границах задания.
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
- При изменении инфраструктуры или DDL обновлять документацию тем же PR.
- Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на
русском по схеме Зачем / Что / Проверка.
- «Грязные» записи не должны валить пайплайн: ошибки разбора уходят в таблицы
`*_errors`.
- Данные не читать целиком без необходимости: по умолчанию малый срез. Для
демо и тестов быстрый повторяемый прогон важнее полноты данных.
## Проверка API через MCP Context7 (обязательно)
Для спорных или меняющихся API (Airflow и провайдеры, DDL ClickHouse) сначала
уточнять актуальную версию: `resolve-library-id` -> `query-docs`. Принятое
решение кратко фиксировать в коде или документации: что проверили и почему
выбрали этот вариант.
## Задачи
Трекер — Gitea на `git.dementev.space`, работа через CLI `tea` (логин по
умолчанию настроен, репозиторий определяется по git remote). Команды и
подводные камни описаны в
[доке предшественника](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/agents/issue-tracker.md).
- Спека фичи — файл в `docs/specs/`, источник истины, версионируется с кодом.
- Корневой issue фичи — тонкий: ссылка на спеку и чек-лист дочерних issues
(`- [ ] #NN`). Содержание спеки в issue не дублируется.
- Дочерние issues — самодостаточные постановки: цель, критерии приёмки
чекбоксами, границы, «сначала прочитать», команды проверки.
- Итоговые решения переносятся в спеку или ADR тем же PR.
- Метки триажа — пять ролей: `needs-triage`, `needs-info`, `ready-for-agent`,
`ready-for-human`, `wontfix`. Карта и её тикеты — метки `wayfinder:*`.
## Структура
- Новые документы — в `docs/` или в профильных подпапках, не в корне.
- Состав доков v2 определяется по ходу этапов, набор предшественника не
копируется (спека, раздел 12).
+36
View File
@@ -0,0 +1,36 @@
# Учебная дата-платформа кликстрима
Стенд для работы с кликстримом: Kafka, кластер ClickHouse (2 шарда и
clickhouse-keeper), Airflow, Superset. Преемник учебного стенда
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
## Статус
Репозиторий строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
Работающего стенда пока нет: здесь только спека, исследование формата данных и
контракт работы ([AGENTS.md](AGENTS.md)). Быстрый старт появится вместе с
этапом «Каркас стенда».
## Что здесь будет
- одно широкое событие кликстрима по образцу выгрузки Яндекс Метрики вместо
четырёх топиков;
- второй источник — заказы бэкенда, ежедневным слепком в ту же Kafka;
- сверка клиентской покупки с заказом бэкенда: деньги считаем по бэкенду,
поведение и атрибуцию — по трекеру;
- анонимный кликстрим и склейка кука↔пользователь через покупки;
- ClickHouse кластером как единственным режимом.
## Чем отличается от предшественника
Предшественник остаётся стабильным учебным стендом и заморожен для новых фич:
там событие разрезано на четыре топика, есть только просмотры страниц,
посетители опознаны по email, ClickHouse — одна нода. Развитие идёт здесь.
## Документация
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
- [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события.
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
@@ -0,0 +1,723 @@
# Формат кликстрима Яндекса: что копировать стенду
Status: Research
Дата: 2026-07-26
Тикет: [#27](https://github.com/dementev-dev/clickstream-ch-kafka-superset-demo/issues/27)
## Зачем это исследование
Владелец стенда выбирает целевую модель данных. Сейчас событие разрезано на
четыре топика (`browser`, `location`, `device`, `geo`), которые склеиваются по
`event_id` и `click_id`. Вопрос: переходить ли на одно широкое событие и по
какому образцу его строить.
Образец выбран — Яндекс. В России Метрика и AppMetrica — основной источник
кликстрима, и менти должен узнать формат, когда столкнётся с ним на работе.
Всё ниже — по документации Яндекса, ClickHouse, Snowplow, Segment и Amplitude.
Где источник найти не удалось, это написано прямо.
## Коротко: ответы на главные вопросы
**Форма — плоское ядро с массивами, не вложенный JSON.** У Яндекса нет
вложенных объектов вроде `context` или `properties`. Есть примерно 140 плоских
колонок на хит, и всё, что бывает «много раз в одном событии» (цели, товары,
покупки, свои параметры), лежит в **параллельных массивах**: `productID`,
`productName`, `productPrice` — три отдельных массива одной длины, а не массив
объектов. Именно эту форму стенду и стоит копировать.
**Доставка — батч для всех и поток для платных.** Обычный путь (Logs API) —
батч: запрос готовится, потом скачивается TSV-файл. Данные за текущий день
недоступны. В тарифе «Метрика Про» есть поток в управляемый ClickHouse с
задержкой до 15 минут. Kafka в этой картине нет ни в одном официальном
варианте.
**Kafka на стенде — учебная замена, и так это и надо называть.** Подробный
разбор — в разделе «Батч или поток».
Главные находки:
1. Метрика отдаёт **две разные сущности**: хиты (`hits`, просмотры страниц) и
визиты (`visits`, сессии). Визит — уже свёрнутая сессия с массивами внутри.
2. **56 полей хитов — массивы** (по подсчёту на странице полей Logs API).
Массивы — основной способ Яндекса выразить «много значений в одном событии».
3. **User agent строкой Метрика не отдаёт.** Отдаются уже разобранные поля:
`browser`, `browserMajorVersion`, `operatingSystem`, `deviceCategory`.
Поле `browser_user_agent` на стенде — наша выдумка, у Яндекса аналога нет.
4. **Координат в выгрузке Метрики нет.** Гео — это `regionCountry`,
`regionCity` и числовые `regionCountryID`, `regionCityID`. Наши
`geo_latitude` и `geo_longitude` тоже без аналога.
5. **Метки времени: серверная одна.** В хитовой выгрузке в облако есть
`UTCEventTime` и смещение часового пояса клиента `ClientTimeZone`
минутах). Отдельной клиентской метки времени нет. Разделение «время у
клиента» и «время приёма на сервере» есть у AppMetrica
(`event_datetime` против `event_receive_datetime`), не у Метрики.
6. **Поток даёт версии одной записи.** В потоковой выгрузке есть колонки `Sign`
(Int8) и `HitVersion` / `VisitVersion`. Старая версия приходит со `Sign =
-1`, новая с `Sign = 1`; складывать надо через `sum(Sign)`. Это ровно та же
механика, что у движка `CollapsingMergeTree` в ClickHouse.
7. **Ecommerce и выручка в выгрузке есть, но плоско.** Сумма заказа —
`purchaseRevenue` типа `Array(Float64)`, валюта — `purchaseCurrency` типа
`Array(String)`. Плюс отдельно лежит поле `ecommerce` типа `String` — сырой
JSON события ecommerce.
## 1. Logs API Метрики: состав данных
Источники: [список полей — хиты](https://yandex.ru/dev/metrika/ru/logs/fields/hits),
[список полей — визиты](https://yandex.ru/dev/metrika/ru/logs/fields/visits),
[введение в Logs API](https://yandex.ru/dev/metrika/ru/logs/).
### Две сущности вместо одной
- **Хит (`hits`)** — одно действие: просмотр страницы, клик по ссылке, скачивание
файла. Имена полей начинаются с `ym:pv:`.
- **Визит (`visits`)** — сессия целиком. Имена полей начинаются с `ym:s:`.
Внутри визита лежит массив идентификаторов его хитов: `ym:s:watchIDs` типа
`Array(UInt64)`, не более 500 просмотров.
Это важно для стенда: у Яндекса «широкое событие» — это хит, а сессия —
отдельная широкая запись, собранная Яндексом на своей стороне. У нас сессии
считаются в DDS/DM; у Яндекса они приходят готовыми.
### Идентификаторы
| Поле | Тип | Что это |
|---|---|---|
| `ym:pv:watchID` | UInt64 | Идентификатор хита |
| `ym:pv:pageViewID` | UInt32 | Идентификатор просмотра страницы |
| `ym:pv:visitID` | UInt64 | Идентификатор визита (в хитах доступен с 10.10.2025) |
| `ym:pv:counterID` | UInt32 | Номер счётчика (то есть сайта) |
| `ym:pv:clientID` | UInt64 | Анонимный идентификатор посетителя через свою cookie |
| `ym:pv:counterUserIDHash` | UInt64 | Идентификатор посетителя для подсчёта уникальных |
| `ym:s:visitID`, `ym:s:clientID`, `ym:s:counterUserIDHash` | те же типы | То же на уровне визита |
Все идентификаторы — **числа**, не UUID.
Про `UserID` — свой идентификатор пользователя со стороны сайта. По справке
Метрики он передаётся методом `setUserID` и связывается с `ClientID`, если
метод вызвали во время сессии
([setUserID](https://yandex.com/support/metrica/en/objects/set-user-id.html)).
Поля `UserID` **в списке выгружаемых полей Logs API найти не удалось** — ни в
хитах, ни в визитах. В выгрузке хитов в облако его тоже нет. Считаем: свой
идентификатор пользователя в кликстриме Метрики напрямую не выдаётся.
Про рекламные метки:
- `ym:pv:hasGCLID` (UInt8) и `ym:pv:GCLID` (String) — метка клика Google.
- `ym:pv:hasSBCLID` (UInt8) и `ym:pv:SBCLID` (String) — метка SBCLID.
- **`YCLID` в списке полей Logs API не найден.** Он есть в примерном датасете
ClickHouse как колонка `YCLID UInt64` (см. раздел 5) и упоминается в справке
Метрики как идентификатор клика по объявлению Яндекс Директа. Вывод: в
выгрузке Logs API поле `yclid` не подтверждено; в схеме ClickHouse оно есть.
### Метки времени и часовой пояс
| Поле | Тип | Что это |
|---|---|---|
| `ym:pv:date` | Date | Дата события |
| `ym:pv:dateTime` | DateTime | Дата и время события **в часовом поясе счётчика** |
| `ym:pv:clientTimeZone` | Int16 | Смещение часового пояса посетителя от UTC **в минутах** |
| `ym:s:dateTime` | DateTime | Время визита в часовом поясе счётчика |
| `ym:s:dateTimeUTC` | DateTime | Время визита в UTC+3 |
Две вещи, которые стоит запомнить:
- Часовой пояс — не UTC по умолчанию. У хита это «пояс счётчика», у визита есть
и вторая колонка в UTC+3 (то есть по московскому времени, а не по нулевому
меридиану).
- Клиентское время события отдельным полем не приходит. Приходит только
**смещение пояса клиента**. То есть «две метки времени, клиентская и
серверная» — это про AppMetrica и про западные трекеры, но не про Метрику.
### Массивы
Массивов очень много. Страница полей хитов помечает **56 полей как массивы**.
Основные группы:
- **Цели:** `ym:pv:goalsID``Array(UInt32)`. У визита целая группа
параллельных массивов: `ym:s:goalsID`, `ym:s:goalsSerialNumber`,
`ym:s:goalsDateTime`, `ym:s:goalsPrice`, `ym:s:goalsOrder`,
`ym:s:goalsCurrency`.
- **Свои параметры:** `ym:pv:parsedParamsKey1``parsedParamsKey10`, каждый —
`Array(String)`. Это десять уровней вложенности, разложенные по десяти
массивам. Плюс поле `ym:pv:params` типа `String` — исходный JSON параметров.
- **Покупки:** `ym:pv:purchaseID` `Array(String)`, `ym:pv:purchaseRevenue`
`Array(Float64)`, `ym:pv:purchaseCurrency` `Array(String)` и другие (в хитах
доступны с 19.06.2025).
- **Товары:** `ym:pv:productID`, `productName`, `productBrand`,
`productCategory`, `productCategoryLevel1``Level5`, `productPrice`
`Array(Int64)`, `productQuantity` `Array(UInt64)`, `productEventType`
`Array(String)` со значениями `view_item_list`, `click`, `detail`, `add`,
`purchase`, `remove`.
- **Промоакции:** `ym:pv:promotionID`, `promotionName`, `promotionCreative`,
`promotionEventType`.
У визита к этому добавляются ещё три больших блока массивов:
`ym:s:purchasedProduct*` (что купили), `ym:s:impressions*` (что показали) и
`ym:s:offlineCall*` (звонки).
Ключевое наблюдение: **это параллельные массивы, а не массив объектов**. Третий
товар в событии — это третий элемент в каждом из массивов `productID`,
`productName`, `productPrice`. Собирается такое в ClickHouse через `ARRAY JOIN`.
### Что ещё есть в хите (короткий список категорий)
- Страница: `ym:pv:URL`, `ym:pv:referer`, `ym:pv:title`, `ym:pv:pageCharset`.
- Атрибуция: `UTMSource`, `UTMMedium`, `UTMCampaign`, `UTMContent`, `UTMTerm`;
`lastTrafficSource`, `lastSearchEngineRoot`, `lastSearchEngine`,
`lastAdvEngine`, `lastSocialNetwork`; четыре поля Openstat; `ym:pv:from`.
- Браузер: `browser`, `browserMajorVersion`, `browserMinorVersion`,
`browserEngine` и четыре части его версии, `browserLanguage`,
`browserCountry`, `cookieEnabled`, `javascriptEnabled`.
- Устройство и экран: `deviceCategory` (1 = десктоп, 2 = телефон, 3 = планшет,
4 = TV), `mobilePhone`, `mobilePhoneModel`, `operatingSystem`,
`operatingSystemRoot`, `screenWidth`, `screenHeight`, `physicalScreenWidth`,
`physicalScreenHeight`, `windowClientWidth`, `windowClientHeight`,
`screenColors`, `screenFormat`, `screenOrientation`.
- Гео и сеть: `ipAddress`, `regionCountry` (код ISO), `regionCity` (название
по-английски), `regionCountryID`, `regionCityID`.
- Признаки: `isPageView`, `isTurboPage`, `iFrame`, `link`, `download`,
`notBounce`, `artificial`, `httpError`.
### Ограничения выгрузки
Со страницы [введения в Logs API](https://yandex.ru/dev/metrika/ru/logs/):
- Данные за **текущий день недоступны** — они могут быть неполными.
- Данные «доформировываются» примерно **до 3 дней**; запрашивать рекомендуют
начиная с предыдущих дней.
- Максимальный период одного запроса — **1 год**.
- Параметр со списком полей — **не более 3000 символов**.
- Общая квота на объём подготовленных логов — **10 ГБ**. Подготовленные файлы
надо регулярно удалять, иначе квота кончится. Квоту расширяет тариф
«Метрика Про».
- Результат Logs API может расходиться с интерфейсом Метрики из-за разных
алгоритмов обработки и округления чисел с плавающей точкой.
Ограничение «не более 1 ГБ на файл» встречается в поиске по документации
Метрики, но дословно подтвердить его на официальной странице ограничений не
удалось — **не подтверждено**.
Порядок работы (по официальным описаниям API):
1. `POST /management/v1/counter/{counterId}/logrequests` — создать запрос.
2. `GET /management/v1/counter/{counterId}/logrequest/{requestId}` — проверить
статус; статус `processed` означает, что лог готов.
3. `GET .../logrequest/{requestId}/part/{partNumber}/download` — скачать
часть.
Формат выгрузки — **TSV**. Дословно из официального блога Метрики: «Сырые
данные передаются в стандартном формате tsv»
([блог Метрики про Logs API](https://yandex.ru/blog/metrika/vygruzhayte-syrye-dannye-iz-metriki-cherez-logs-api)).
Там же прямо сказано, что типовой приёмник таких данных — ClickHouse.
## 2. Батч или поток
Коротко: **у Метрики есть оба пути, но Kafka нет ни в одном.**
### Путь 1. Logs API — батч
Запрос готовится, потом скачивается TSV-файл частями. Текущий день недоступен.
Это не поток ни в каком смысле: минимальная задержка — сутки.
### Путь 2. Data Streaming в Yandex Cloud — поток, но не Kafka
Источник: [Data Streaming (интеграция с Yandex
Cloud)](https://yandex.ru/support/metrica/ru/uploading-data/cloud), [как
работать с данными](https://yandex.ru/support/metrica/ru/pro/data-work),
[учебник Yandex
Cloud](https://yandex.cloud/ru/docs/tutorials/dataplatform/metrika-to-clickhouse).
Что там есть:
- Неагрегированные данные Метрики попадают в **свой управляемый
ClickHouse-кластер** в Yandex Cloud.
- Задержка от события до записи в ClickHouse — **до 15 минут**.
- Перенос делает **Yandex Data Transfer**, тип трансфера — «Репликация».
- Хиты и визиты переносятся отдельными таблицами.
- Историю до создания коннектора эта версия не переносит. Если трансфер
выключить и включить, данные за простой потеряются.
- Нужен тариф **«Метрика Про»**.
- Визит меняется по мере поступления новых событий, поэтому в выгрузке лежат
**разные версии одного визита**. Разбираются они через `Sign`: старая версия
приходит со `Sign = -1`, новая со `Sign = 1`, считать надо через `sum(Sign)`
в `GROUP BY`. Можно использовать модификатор `FINAL`, но он медленнее.
Приёмник здесь — ClickHouse напрямую. **Экспорт кликстрима Метрики в Object
Storage или в Yandex Data Streams (сервис с Kafka-совместимым интерфейсом)
официальной документацией не подтверждён.**
### Путь 3. AppMetrica Data Stream — пятиминутные окна
Источник: [Data Stream API,
описание](https://appmetrica.yandex.ru/docs/ru/mobile-api/datastream/about).
Поток представлен последовательностью **пятиминутных окон**, каждое окно
скачивается запросом. Задержка — не меньше 10 минут. Хранение — 7 дней.
Формат — CSV (RFC 4180) или JSON.
### Честно ли рассказывать про Kafka
Да, если называть вещи своими именами. Формулировка для курса:
> Kafka на стенде — учебная замена реальному транспорту. Основной путь выгрузки
> у Яндекс Метрики — батч: Logs API отдаёт TSV-файл, и данных за сегодня в нём
> нет. Поток у Метрики есть только в платном тарифе «Метрика Про» и идёт не
> через Kafka, а через Yandex Data Transfer прямо в управляемый ClickHouse, с
> задержкой до 15 минут. Kafka в этой схеме не участвует.
Почему Kafka всё-таки уместна на стенде:
- Задачи, которые она ставит перед менти, реальные: чтение из потока,
контроль смещений, дубли, опоздавшие события, обратное давление. Такие задачи
есть в любом продуктовом дата-контуре — просто перед Kafka там стоит свой
коллектор, а не Метрика.
- Механика «поток + версии одной записи» у Метрики Про (`Sign`, `HitVersion`,
`VisitVersion`) ближе к потоковому приёму, чем к «скачал файл раз в сутки».
Разговор про `ReplacingMergeTree` и `CollapsingMergeTree`, который стенд уже
ведёт, попадает в реальную практику точно.
Чего делать нельзя: говорить менти «Яндекс отдаёт кликстрим в Kafka». Это
неправда.
## 3. AppMetrica
Источник: [ресурсы Logs
API](https://appmetrica.yandex.ru/docs/ru/mobile-api/logs/endpoints).
Главное отличие от Метрики: **AppMetrica отдаёт много узких таблиц вместо двух
широких.** Каждый вид данных — свой эндпоинт: `events`, `installations`,
`sessions_starts`, `ecommerce_events`, `revenue_events`, `ad_revenue_events`,
`crashes`, `errors`, `clicks`, `postbacks`, `deeplinks`, `push_tokens`,
`profiles_v2`. Формат — CSV или JSON.
Поле-строка с JSON **есть**: `event_json` — «атрибуты, сериализованные в JSON».
То есть у мобильного трекера Яндекса произвольные свойства события лежат
единым JSON в одной колонке — не так, как в Метрике с её массивами.
Метки времени у события — четыре, и это ровно то разделение, которого не
хватает Метрике:
| Поле | Что это |
|---|---|
| `event_datetime` | Время события, `yyyy-mm-dd hh:mm:ss` |
| `event_timestamp` | То же в unix-времени |
| `event_receive_datetime` | Время приёма на сервере (расходится с `event_datetime` из-за сети) |
| `event_receive_timestamp` | То же в unix-времени |
Идентификаторы: `appmetrica_device_id`, `installation_id`, `session_id`,
`profile_id`. Устройство и гео: `device_manufacturer`, `device_model`,
`device_type`, `os_name`, `os_version`, `city`, `country_iso_code`,
`google_aid`, `ios_ifa`, `ios_ifv`.
Потоковые возможности — Data Stream API (см. выше): пятиминутные окна,
задержка от 10 минут, хранение 7 дней, до 50 000 запросов в сутки против
5 000 в сутки у Logs API.
## 4. Ecommerce и выручка
### Как это описывается на сайте
Источник: [передача данных
ecommerce](https://yandex.ru/support/metrica/ru/ecommerce/data). Магазин кладёт
в `dataLayer` объект такой формы:
```javascript
{
"ecommerce": {
"currencyCode": "RUB",
"purchase": {
"actionField": { "id": "TRX987", "revenue": 12300, "coupon": "SALE10" },
"products": [
{ "id": "SKU-1", "name": "Кружка", "price": 4100,
"brand": "Acme", "category": "Посуда/Кружки",
"variant": "белая", "quantity": 3 }
]
}
}
}
```
Виды действий: `impressions`, `click`, `detail`, `add`, `remove`, `purchase`,
`promoView`, `promoClick`. У покупки `actionField.id` обязателен; `revenue`
считается автоматически, если его не передать. У товара обязателен `id` или
`name`; остальное — `price`, `quantity`, `brand`, `category`, `variant`,
`coupon`, `list`, `position`, `discount`.
### Что из этого попадает в выгрузку
**Не JSON, а плоские массивы.** Один заказ на 3 товара превращается не в
вложенный объект, а в набор массивов одинаковой длины:
| Поле выгрузки | Тип | Что это |
|---|---|---|
| `ym:pv:purchaseID` | Array(String) | Идентификатор покупки |
| `ym:pv:purchaseRevenue` | Array(Float64) | **Сумма заказа** |
| `ym:pv:purchaseCurrency` | Array(String) | Валюта |
| `ym:pv:purchaseCoupon` | Array(String) | Промокод на весь заказ |
| `ym:pv:purchaseTax`, `purchaseShipping` | Array(String) | Налоги, доставка |
| `ym:pv:purchaseProductQuantity` | Array(UInt64) | Число товаров в покупке |
| `ym:pv:productID`, `productName`, `productBrand`, `productCategory` | Array(String) | Товар |
| `ym:pv:productPrice` | Array(Int64) | Цена товара |
| `ym:pv:productQuantity` | Array(UInt64) | Количество |
| `ym:pv:productEventType` | Array(String) | `view_item_list`, `click`, `detail`, `add`, `purchase`, `remove` |
| `ym:pv:ecommerce` | String | Сырое событие ecommerce (одна строка) |
Обратите внимание на две вещи. Первая: `purchaseTax` и `purchaseShipping` в
хитах имеют тип `Array(String)`, хотя это денежные суммы, — так в документации.
Вторая: рядом с разобранными массивами лежит поле `ym:pv:ecommerce` типа
`String`. То есть Яндекс отдаёт и разобранное, и сырое.
На уровне визита к этому добавляются `ym:s:purchaseDateTime`
`Array(DateTime)`, `ym:s:purchaseAffiliation`, весь блок
`ym:s:purchasedProduct*` (около 20 массивов про купленные товары) и
`ym:s:impressions*` (около 18 массивов про показы).
## 5. Как это кладут в ClickHouse
Здесь два первоисточника, и оба полезны.
### 5.1. Примерный датасет в документации ClickHouse
У ClickHouse корни в Метрике, и в его документации до сих пор лежит
обезличенный датасет Метрики: `hits_v1` (8 873 898 строк) и `visits_v1`
(1 680 609 строк). Источники:
[страница датасета](https://clickhouse.com/docs/getting-started/example-datasets/metrica),
DDL получен через MCP Context7 (`/clickhouse/clickhouse-docs`, файл
`docs/getting-started/example-datasets/anon_web_analytics_metrica.md`).
`hits_v1` — примерно 130 плоских колонок:
```sql
CREATE TABLE datasets.hits_v1
(
WatchID UInt64, JavaEnable UInt8, Title String, GoodEvent Int16,
EventTime DateTime, EventDate Date, CounterID UInt32,
ClientIP UInt32, ClientIP6 FixedString(16), RegionID UInt32,
UserID UInt64, CounterClass Int8, OS UInt8, UserAgent UInt8,
URL String, Referer String, URLDomain String, RefererDomain String,
IsRobot UInt8, RefererCategories Array(UInt16), URLRegions Array(UInt32),
ResolutionWidth UInt16, ResolutionHeight UInt16,
ClientTimeZone Int16, ClientEventTime DateTime, UTCEventTime DateTime,
Params String, GoalsReached Array(UInt32),
UTMSource String, UTMMedium String, UTMCampaign String,
UTMContent String, UTMTerm String, FromTag String,
HasGCLID UInt8, RefererHash UInt64, URLHash UInt64,
CLID UInt32, YCLID UInt64,
ParsedParams Nested(Key1 String, Key2 String, Key3 String,
Key4 String, Key5 String, ValueDouble Float64),
...
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(EventDate)
ORDER BY (CounterID, EventDate, intHash32(UserID))
SAMPLE BY intHash32(UserID)
```
Что тут стоит заметить:
- **Три метки времени:** `EventTime`, `ClientEventTime`, `UTCEventTime`. Плюс
`ClientTimeZone`. Во внутренней схеме разделение клиента и сервера есть, хотя
в публичную выгрузку хитов оно не попадает.
- **Ключ сортировки** — `(CounterID, EventDate, intHash32(UserID))`: сначала
сайт, потом дата, потом пользователь. Не идентификатор события. Это ключ под
типовые запросы «по сайту за период», а не под точечный поиск.
- **Ключ семплирования** `intHash32(UserID)` — чтобы считать по 10% данных и
получать корректные метрики по пользователям.
- **Nested** вместо параллельных массивов: `ParsedParams Nested(Key1 String,
…, ValueDouble Float64)`. В ClickHouse `Nested` — это синтаксический сахар
над теми же параллельными массивами: колонка `ParsedParams.Key1` физически и
есть `Array(String)`.
- Есть `YCLID UInt64` и `CLID UInt32` — метки Яндекс Директа.
- Есть хеши (`URLHash`, `RefererHash`, `NormalizedRefererHash`) — внутренняя
оптимизация Яндекса под быстрые сравнения.
`visits_v1` — около 190 колонок и другой движок:
```sql
CREATE TABLE datasets.visits_v1
(
CounterID UInt32, StartDate Date, Sign Int8, IsNew UInt8,
VisitID UInt64, UserID UInt64, StartTime DateTime, Duration UInt32,
UTCStartTime DateTime, PageViews Int32, Hits Int32, IsBounce UInt8,
...
Goals Nested(ID UInt32, Serial UInt32, EventTime DateTime,
Price Int64, OrderID String, CurrencyID UInt32),
WatchIDs Array(UInt64),
TraficSource Nested(ID Int8, SearchEngineID UInt16, AdvEngineID UInt8,
PlaceID UInt16, SocialSourceNetworkID UInt8,
Domain String, SearchPhrase String,
SocialSourcePage String),
ParsedParams Nested(Key1 String, ..., ValueDouble Float64),
Market Nested(Type UInt8, GoalID UInt32, OrderID String,
OrderPrice Int64, PP UInt32, ...,
GoodID String, GoodName String,
GoodQuantity Int32, GoodPrice Int64),
...
)
ENGINE = CollapsingMergeTree(Sign)
PARTITION BY toYYYYMM(StartDate)
ORDER BY (CounterID, StartDate, intHash32(UserID), VisitID)
SAMPLE BY intHash32(UserID)
```
Здесь `CollapsingMergeTree(Sign)` — тот самый механизм версий визита, о котором
пишет справка Метрики Про. Ecommerce лежит в `Nested`-блоке `Market` с полями
`GoodID`, `GoodName`, `GoodQuantity`, `GoodPrice`.
### 5.2. Официальные схемы потоковой выгрузки
Списки колонок для выгрузки в свой ClickHouse: [поля
хитов](https://yandex.ru/support/metrica/ru/pro/hits), [поля
визитов](https://yandex.ru/support/metrica/ru/pro/visits).
Существенное: **в облачной выгрузке имена колонок не в стиле API, а в стиле
ClickHouse.** Не `ym:s:visitID`, а `VisitID`. Не `ym:pv:watchID`, а `WatchID`.
Префиксы `ym:pv:` и `ym:s:` — это про язык API, в таблице их нет.
Колонки хитов, которые видит инженер:
| Колонка | Тип |
|---|---|
| `WatchID` | UInt64 |
| `pageViewID` | UInt32 |
| `VisitID` | UInt64 (с 10.10.2025) |
| `CounterID` | UInt32 |
| `ClientID` | UInt64 |
| `CounterUserIDHash` | UInt64 |
| `EventDate` | Date |
| `UTCEventTime` | DateTime |
| `ClientTimeZone` | Int16 |
| `Sign` | Int8 — признак статуса записи в инкрементальном логе |
| `HitVersion` | UInt32 |
| `URL`, `Title` | String |
| `GoalsReached` | Array(UInt32) |
| `ecommerce` | String |
Колонок в хитовой выгрузке около 140, в визитной — около 200. В визитной есть
`Sign` (Int8) и `VisitVersion` (UInt32).
Прицельная проверка по хитовой выгрузке: колонок `EventTime`,
`ClientEventTime`, `LocalEventTime`, `UserID` и `UserIDHash` там **нет**.
Единственная метка времени — `UTCEventTime`, а часовой пояс клиента — отдельным
числом `ClientTimeZone`.
## 6. Сравнение с западными трекерами
**Snowplow — плоское ядро.** Обогащённое событие — строка TSV из 131 колонки.
Таблица `atomic.events` описана как «широкая», и «отдельные поля хранятся в
своих колонках». Самоописываемые события и сущности в BigQuery, Snowflake и
Databricks добавляются как дополнительные колонки той же таблицы; в Redshift —
отдельными таблицами со связью один-к-одному по `event_id`. Источник:
[введение в таблицу atomic
events](https://docs.snowplow.io/docs/fundamentals/warehouse-tables/).
**Segment — вложенный JSON.** Событие `track` — это JSON, где `properties` и
`context` — вложенные объекты. Верхний уровень: `anonymousId`, `userId`,
`event`, `properties`, `context`, `type`, `messageId`, `timestamp`,
`originalTimestamp`, `sentAt`, `receivedAt`, `integrations`. Источник:
[Spec: Track](https://www.twilio.com/docs/segment/connections/spec/track).
**Amplitude — вложенный JSON.** В Export API `event_properties`,
`user_properties`, `group_properties`, `groups`, `data` — словари. Метки
времени: `client_event_time`, `client_upload_time`, `event_time`,
`server_upload_time`, `server_received_time`, `processed_time`. Источник:
[Export API](https://amplitude.com/docs/apis/analytics/export).
**Итог сравнения.** Яндекс ближе к Snowplow: плоское широкое ядро, всё
переменное — в массивах. Разница в том, что Snowplow добавляет колонки под
каждую схему события, а Яндекс держит фиксированный набор массивов
(`parsedParamsKey1..10`, `product*`, `purchase*`) и отдельное сырое поле
(`params`, `ecommerce`). До Segment и Amplitude, где `properties` — свободный
JSON-объект, Яндексу далеко.
## Вывод: что копировать стенду
### Форма
**Плоское широкое событие с массивами.** Одно событие = одна строка. Внутри —
плоские колонки. Всё, что бывает «много раз внутри одного события», — набор
параллельных массивов одной длины (или `Nested`, что в ClickHouse то же самое).
Свободного вложенного JSON вроде `context` или `properties` не делать: в РФ
менти его не встретит.
Стенду стоит воспроизводить именно **хит** — одно действие с полным контекстом.
Визит оставить тем, чем он сейчас является: результатом сборки в DDS/DM. Так
менти сам делает то, что Метрика делает за него, и понимает, откуда берётся
`visitDuration` и `pageViews`.
Отдельно рекомендуется добавить **одно сырое поле-строку с JSON** — как
`ym:pv:ecommerce` у Метрики и `event_json` у AppMetrica. Это даёт честное
упражнение «разобрать JSON внутри колонки», которое в бою встречается постоянно.
### Список полей для широкого события стенда
Колонка «есть» — про текущий стенд (`sql/ddl/ods/20_ods.sql`,
`sql/ddl/dds/30_dds.sql`). «нет» означает: поля у нас сейчас нет.
| № | Имя | Тип | Откуда взято | Есть у нас |
|---|---|---|---|---|
| 1 | `WatchID` | UInt64 | `ym:pv:watchID`, `hits_v1.WatchID` | ~ (есть `event_id` UUID, тип другой) |
| 2 | `VisitID` | UInt64 | `ym:pv:visitID`, `visits_v1.VisitID` | ~ (есть `click_id` UUID, тип другой) |
| 3 | `pageViewID` | UInt32 | `ym:pv:pageViewID` | **нет** |
| 4 | `CounterID` | UInt32 | `ym:pv:counterID` | **нет** |
| 5 | `ClientID` | UInt64 | `ym:pv:clientID` — анонимный id браузера | **нет** ← важное |
| 6 | `CounterUserIDHash` | UInt64 | `ym:pv:counterUserIDHash` | **нет** |
| 7 | `UTCEventTime` | DateTime | `Метрика Про, хиты` | ~ (есть `event_ts`, одна метка) |
| 8 | `EventDate` | Date | `Метрика Про, хиты` | есть (`event_date`) |
| 9 | `ClientTimeZone` | Int16 (минуты) | `ym:pv:clientTimeZone` | **нет** ← важное |
| 10 | `ClientEventTime` | DateTime | `hits_v1.ClientEventTime`; у AppMetrica — `event_datetime` против `event_receive_datetime` | **нет** ← важное |
| 11 | `URL` | String | `ym:pv:URL` | есть (`page_url`) |
| 12 | `Title` | String | `ym:pv:title` | **нет** |
| 13 | `Referer` | String | `ym:pv:referer` | есть (`referer_url`) |
| 14 | `UTMSource` | String | `ym:pv:UTMSource` | есть |
| 15 | `UTMMedium` | String | `ym:pv:UTMMedium` | есть |
| 16 | `UTMCampaign` | String | `ym:pv:UTMCampaign` | есть |
| 17 | `UTMContent` | String | `ym:pv:UTMContent` | есть |
| 18 | `UTMTerm` | String | `ym:pv:UTMTerm` | **нет** |
| 19 | `LastTrafficSource` | String | `ym:pv:lastTrafficSource` | ~ (есть `referer_medium`, смысл близкий) |
| 20 | `LastSearchEngineRoot` | String | `ym:pv:lastSearchEngineRoot` | **нет** |
| 21 | `HasGCLID` | UInt8 | `ym:pv:hasGCLID` | **нет** |
| 22 | `YCLID` | UInt64 | `hits_v1.YCLID` (в Logs API не найден) | **нет** |
| 23 | `Browser` | String | `ym:pv:browser` | есть (`browser_name`) |
| 24 | `BrowserMajorVersion` | UInt16 | `ym:pv:browserMajorVersion` | **нет** |
| 25 | `BrowserLanguage` | String | `ym:pv:browserLanguage` | есть (`browser_language`) |
| 26 | `OperatingSystem` | String | `ym:pv:operatingSystem` | есть (`os`) |
| 27 | `OperatingSystemRoot` | String | `ym:pv:operatingSystemRoot` | есть (`os_name`) |
| 28 | `DeviceCategory` | String (1..4) | `ym:pv:deviceCategory` | есть (`device_type`) |
| 29 | `MobilePhoneModel` | String | `ym:pv:mobilePhoneModel` | **нет** |
| 30 | `ScreenWidth`, `ScreenHeight` | UInt16 | `ym:pv:screenWidth/Height` | **нет** |
| 31 | `IPAddress` | String | `ym:pv:ipAddress` | есть (`ip_address`) |
| 32 | `RegionCountry` | String (ISO) | `ym:pv:regionCountry` | есть (`geo_country`) |
| 33 | `RegionCity` | String | `ym:pv:regionCity` | ~ (есть `geo_region_name`) |
| 34 | `RegionCountryID`, `RegionCityID` | UInt32 | `ym:pv:regionCountryID/CityID` | **нет** ← важное |
| 35 | `IsPageView` | UInt8 | `ym:pv:isPageView` | **нет** |
| 36 | `NotBounce` | UInt8 | `ym:pv:notBounce` | **нет** |
| 37 | `HTTPError` | String | `ym:pv:httpError` | **нет** |
| 38 | `GoalsReached` | Array(UInt32) | `ym:pv:goalsID`, `hits_v1.GoalsReached` | **нет** ← ключевое |
| 39 | `ParsedParams` | Nested(Key1..Key5 String, ValueDouble Float64) | `hits_v1.ParsedParams`; в Logs API — `parsedParamsKey1..10` | **нет** ← ключевое |
| 40 | `Params` | String (сырой JSON) | `ym:pv:params` | **нет** |
| 41 | `ecommerce` | String (сырой JSON) | `ym:pv:ecommerce`; у AppMetrica — `event_json` | **нет** ← ключевое |
| 42 | `purchaseID` | Array(String) | `ym:pv:purchaseID` | **нет** ← ключевое |
| 43 | `purchaseRevenue` | Array(Float64) | `ym:pv:purchaseRevenue` — **выручка** | **нет** ← ключевое |
| 44 | `purchaseCurrency` | Array(String) | `ym:pv:purchaseCurrency` | **нет** |
| 45 | `purchaseCoupon` | Array(String) | `ym:pv:purchaseCoupon` | **нет** |
| 46 | `productID` | Array(String) | `ym:pv:productID` | **нет** ← ключевое |
| 47 | `productName` | Array(String) | `ym:pv:productName` | **нет** |
| 48 | `productCategory` | Array(String) | `ym:pv:productCategory` | **нет** |
| 49 | `productPrice` | Array(Int64) | `ym:pv:productPrice` | **нет** ← ключевое |
| 50 | `productQuantity` | Array(UInt64) | `ym:pv:productQuantity` | **нет** ← ключевое |
| 51 | `productEventType` | Array(String) | `ym:pv:productEventType`: `detail`, `add`, `remove`, `purchase`… | **нет** ← ключевое |
| 52 | `Sign` | Int8 | Метрика Про, хиты и визиты | **нет** ← ключевое |
| 53 | `HitVersion` | UInt32 | Метрика Про, хиты | **нет** |
Поля 38–53 — то, чего стенду не хватает сильнее всего: цели, свои параметры,
ecommerce с выручкой и механика версий записи. Ровно про это спрашивают на
работе в первую очередь, и ровно этого сейчас у нас нет
(см. `docs/generator-realism.md`, раздел «Что упрощено»).
Ключ сортировки для широкой таблицы стенда стоит взять по образцу Метрики:
`ORDER BY (CounterID, EventDate, intHash32(ClientID))`, а не по идентификатору
события. Это заодно повод объяснить менти, зачем ключ сортировки строится под
запросы, а не под уникальность.
### Чего воспроизводить не стоит
1. **Полный набор полей.** 140 колонок в хитах и 200 в визитах — это шум.
Учебной ценности в 56 массивах нет никакой, а поддерживать их дорого.
Хватит 40–50 колонок из таблицы выше.
2. **Отдельную сущность «визит» из Яндекса.** Если брать и хиты, и визиты,
стенд потеряет главное упражнение — сборку сессий своими руками. Берём
только хиты.
3. **Полную механику `CollapsingMergeTree` с пересчётом визита.** Колонку
`Sign` добавить полезно — это узнаваемо и объясняет `sum(Sign)`. А вот
пересчитывать визит и присылать по нему пять версий — сложность, которая
съест урок целиком. Достаточно показать `Sign` на хитах.
4. **Устаревшие технологии из датасета ClickHouse.** `FlashMajor`,
`SilverlightVersion1..4`, `NetMajor` — следы 2013 года. Живой аналог
сегодня им не соответствует, копировать нечего.
5. **Хеши** (`URLHash`, `RefererHash`, `NormalizedStartURLHash`). Это
внутренняя оптимизация Яндекса. Менти без контекста примет их за содержимое.
6. **Социально-демографические поля** (`Age`, `Sex`, `Income`, `Interests`,
`GeneralInterests`, `Robotness`). Это внутренние оценки Яндекса, вне Яндекса
их не получить. Ставить их в генератор — учить менти работать с данными,
которых у него не будет.
7. **Openstat и поля Яндекс Директа** (`openstatAd`, `openstatCampaign`,
`DirectClickOrder`, `DirectBannerGroup`, `DirectPhraseOrCond` и десяток
соседних). Openstat — устаревший стандарт метки трафика. Поля Директа
осмысленны только при связке аккаунтов. Ставим одну метку `YCLID` и одну
`HasGCLID` — этого хватит для разговора об атрибуции.
8. **Пять уровней категорий товара** (`productCategoryLevel1..5`) и блоки
`purchasedProduct*` (~20 массивов), `impressions*` (~18 массивов),
`promotion*`, `offlineCall*`. Механика та же, что у `product*`; повторять её
четыре раза — только объём.
9. **Префиксы `ym:pv:` и `ym:s:` в именах колонок.** Это язык API, а не имена
в базе. Сам Яндекс в облачной выгрузке их не использует.
10. **Смену UUID на UInt64 у наших `event_id` и `click_id`.** У Яндекса
идентификаторы числовые, но переделывать под это весь стенд — работа без
учебной отдачи. Достаточно добавить `ClientID` типа UInt64 — это самое
узнаваемое поле Метрики, и его отсутствие у нас заметнее всего.
11. **`browser_user_agent` как «поле Метрики».** У Яндекса строки user agent в
выгрузке нет — отдаются уже разобранные поля. Само поле на стенде оставить
можно (разбор user agent — реальная задача), но нельзя выдавать его за
формат Яндекса.
12. **`geo_latitude` и `geo_longitude` как «поля Метрики».** Координат в
выгрузке Метрики нет. Если они нужны для карт в Superset, надо честно
сказать, что это наша добавка.
## Что осталось неподтверждённым
- Поле `UserID` (свой идентификатор пользователя со стороны сайта) в списках
выгружаемых полей Logs API и в облачной выгрузке хитов найти не удалось.
- Поле `yclid` в Logs API не найдено. Колонка `YCLID UInt64` есть в примерном
датасете ClickHouse.
- Ограничение «не более 1 ГБ на один файл выгрузки» встречается в поиске по
документации Метрики, но дословно на официальной странице не подтверждено.
- Сколько времени хранится подготовленный лог до удаления — на изученных
страницах не сказано; сказано только, что удалять их надо самому, иначе
кончится квота 10 ГБ.
- Экспорт кликстрима Метрики в Yandex Object Storage или в Yandex Data Streams
(сервис с Kafka-совместимым интерфейсом) официальной документацией не
подтверждён. Единственный подтверждённый потоковый приёмник — управляемый
ClickHouse через Yandex Data Transfer.
- Точное число колонок в облачной выгрузке (около 140 для хитов, около 200 для
визитов) — оценка по объёму страниц, а не цифра из документации.
## Источники
Яндекс Метрика, Logs API:
- [Введение в Logs API](https://yandex.ru/dev/metrika/ru/logs/)
- [Поля хитов](https://yandex.ru/dev/metrika/ru/logs/fields/hits)
- [Поля визитов](https://yandex.ru/dev/metrika/ru/logs/fields/visits)
- [Блог Метрики: выгружайте сырые данные через Logs API](https://yandex.ru/blog/metrika/vygruzhayte-syrye-dannye-iz-metriki-cherez-logs-api)
- [setUserID](https://yandex.com/support/metrica/en/objects/set-user-id.html)
Яндекс Метрика, выгрузка в облако:
- [Data Streaming (интеграция с Yandex Cloud)](https://yandex.ru/support/metrica/ru/uploading-data/cloud)
- [Метрика Про: как работать с данными](https://yandex.ru/support/metrica/ru/pro/data-work)
- [Метрика Про: поля хитов](https://yandex.ru/support/metrica/ru/pro/hits)
- [Метрика Про: поля визитов](https://yandex.ru/support/metrica/ru/pro/visits)
- [Yandex Cloud: репликация данных Метрики в ClickHouse](https://yandex.cloud/ru/docs/tutorials/dataplatform/metrika-to-clickhouse)
Ecommerce:
- [Передача данных ecommerce](https://yandex.ru/support/metrica/ru/ecommerce/data)
AppMetrica:
- [Logs API: ресурсы и поля](https://appmetrica.yandex.ru/docs/ru/mobile-api/logs/endpoints)
- [Data Stream API: описание](https://appmetrica.yandex.ru/docs/ru/mobile-api/datastream/about)
ClickHouse:
- [Примерный датасет Метрики](https://clickhouse.com/docs/getting-started/example-datasets/metrica)
- DDL таблиц `hits_v1` и `visits_v1` получен через MCP Context7
(`/clickhouse/clickhouse-docs`, файл
`docs/getting-started/example-datasets/anon_web_analytics_metrica.md`)
Западные трекеры:
- [Snowplow: введение в таблицу atomic events](https://docs.snowplow.io/docs/fundamentals/warehouse-tables/)
- [Segment Spec: Track](https://www.twilio.com/docs/segment/connections/spec/track)
- [Amplitude Export API](https://amplitude.com/docs/apis/analytics/export)
+585
View File
@@ -0,0 +1,585 @@
# Боевой реализм стенда (v2): широкое событие, заказы, кластер, анонимность
Статус: Accepted (2026-07-30). Три помеченных отступления подтверждены
владельцем на приёмке: порядок страховочных срезов (раздел 9), `Sign` как
колонка без механики (раздел 1.1), `VisitID` как эталон самопроверки (1.2).
Дата: 2026-07-30. Тикет: #17 (сборка карты #10).
Переехала из v1
(https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/specs/2026-07-30-stand-v2-realism.md);
номера тикетов #NN в тексте — из трекера v1.
Источники: резолюции #18 (модель данных), #15 (`purchase` и заказы),
#14 (кластер), #13 (цена кластера), #16 (анонимность и склейка); исследование
[формата кликстрима Яндекса](../research/2026-07-26-yandex-clickstream-format.md);
[docs/generator-realism.md](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/generator-realism.md).
## Зачем
Менти должен узнавать в стенде тот кликстрим и тот дата-контур, с которыми
столкнётся на работе. Сейчас стенд упрощён в четырёх местах: событие разрезано
на четыре топика, есть только просмотры страниц (нет денег), весь трафик
идентифицирован по email, ClickHouse — одна нода. Карта #10 приняла четыре
решения, которые эти упрощения снимают. Эта спека собирает их в одну целевую
картину и оценивает объём исполнения.
Исполнение — **новый репозиторий**, не переработка этого (решение карты #10
от 2026-07-23): v1 замораживается как стабильный стенд для менти, v2 стартует
пустым с осознанным первым коммитом — переносим только нужное, генератор
переписывается, переиспользуются идеи. Рабочее имя — `clickstream-data-platform`
(финальное закрепление — при решении тикета о нише). Карта и открытые тикеты
переедут туда после создания. Предусловие — задача «Редизайн пути менти»
(#9) — выполнено, задача закрыта.
## Целевая картина одним взглядом
- **Одно широкое событие** по образцу Яндекс Метрики: плоское ядро,
параллельные массивы, сырое поле `ecommerce`. Таксономия `EventType`:
`pageview`, `add_to_cart`, `purchase`. Четыре топика уходят.
- **Второй источник — заказы бэкенда**: та же Kafka, но ежедневный полный
слепок окна изменяемости, со статусами и JSON-позициями. Одна труба,
два режима.
- **Каталог товаров** — словарь ClickHouse из CSV в репозитории.
- **Сверка** клиентского `purchase` против заказа: четыре конструируемых
расхождения плюс опоздание. Деньги в витринах — только по бэкенду.
- **Кликстрим анонимный**: у события только `ClientID` (кука). Склейка
идентичностей — через мост `purchase`↔заказ; часть покупателей — с двух кук.
- **Кластер единственным режимом**: 2 шарда × 1 реплика + clickhouse-keeper,
`make up` поднимает сразу кластер. Superset — на ноду 2.
## 1. Широкое событие кликстрима
Форма — хит Метрики из облачной выгрузки: одно событие = одна строка,
многозначное — в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Сессий в потоке нет — их менти собирает сам в DDS.
### 1.1 Решения по именам и типам
- **Имена колонок — как в облачной выгрузке Метрики** (`ClientID`,
`UTCEventTime`, `purchaseID`…). Сырой слой хранит имена источника; свои
snake_case-имена появляются в DDS/DM. Это учебный пункт: у каждого
источника — свой стиль, нормализует его склад, а не трекер.
- **Идентификаторы — числовые UInt64** (`WatchID`, `VisitID`, `ClientID`),
UUID уходят. Исследование советовало UUID не трогать, но тот совет исходил
из цены переделки текущего генератора; v2 пишет генератор заново, цена
нулевая, а числовые id — самая узнаваемая черта формата Метрики. Генератор
держит значения id ниже 2^53: выше этой границы double-числа в JSON (jq,
консоль браузера) искажают id при округлении. Настоящая Метрика так не
делает — её id длиннее.
- **Убираем наши выдумки**: `geo_latitude`, `geo_longitude` — координат в
выгрузке Метрики нет (гео — регион и его числовой id). `browser_user_agent`
тоже не берём, но это наш выбор, а не запрет источника: исследование
запрещало только выдавать это поле за формат Яндекса, оставить разрешало.
Разбор строки user agent — не урок этого стенда.
- **`Sign` берём как колонку формата, без механики** (решение владельца на
приёмке спеки): генератор всегда пишет `Sign = 1`, исправлений записей не
шлёт — движки и запросы не меняются. Сама механика версий
(CollapsingMergeTree, пара `HitVersion`) — кандидат на потом, по #15.
Честность: комментарий в DDL и абзац в документе о реализме («в бою здесь
бывают −1/+1, считают через `sum(Sign)`»); в лекции — крючок про
CollapsingMergeTree (частый вопрос на собеседованиях).
- **Не берём** `ClientEventTime` (в выгрузке Метрики нет клиентской метки;
расхождение часов — тема тумана «грязь»), `Params` (второй сырой JSON не
нужен: этот навык уже несут заказы), `LastSearchEngineRoot`, `IsPageView`,
`NotBounce`, `HTTPError`, `pageViewID`, `CounterUserIDHash`, Openstat
и соцдем-поля (см. «чего не воспроизводить» в исследовании).
- **Отступление по типу**: `DeviceCategory` берём как UInt8, у Метрики это
String; коды те же (14).
- **Наша честная добавка** — `EventType`: у Метрики такого поля нет
(там `isPageView` + `productEventType`), стенду таксономия нужна явно.
### 1.2 Состав полей (47 колонок)
Идентификаторы и время:
| Колонка | Тип | Комментарий |
|---|---|---|
| `WatchID` | UInt64 | id события (хита) |
| `VisitID` | UInt64 | id визита от генератора — эталон самопроверки лабы сессий («собери сам, потом сравни») |
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
| `CounterID` | UInt32 | константа стенда (один сайт) |
| `EventDate` | Date | дата события |
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики |
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
Правила резки визитов в генераторе документируются и совпадают с лабной
логикой (30-минутный таймаут).
Страница и атрибуция: `URL`, `Referer`, `Title`, `UTMSource`, `UTMMedium`,
`UTMCampaign`, `UTMContent`, `UTMTerm`, `LastTrafficSource`, `HasGCLID`
(UInt8), `YCLID` (UInt64) — 11 колонок: все String, кроме `HasGCLID`
(UInt8) и `YCLID` (UInt64).
Браузер, устройство, гео: `Browser`, `BrowserMajorVersion` (UInt16),
`BrowserLanguage`, `OperatingSystem`, `OperatingSystemRoot`, `DeviceCategory`
(UInt8, коды 1–4 как у Метрики), `MobilePhoneModel`, `ScreenWidth`,
`ScreenHeight` (UInt16), `IPAddress`, `RegionCountry`, `RegionCity`,
`RegionCountryID`, `RegionCityID` (UInt32) — 14 колонок.
Массивы и параметры: `GoalsReached` Array(UInt32) (две цели: корзина и
покупка — цели в бою дублируют события, это нормально), `ParsedParamsKey1`
Array(String) (свои параметры сайта, один уровень, например вариант
A/B-теста; Key2..10 не берём).
Ecommerce (заполнены только у торговых событий):
| Колонка | Тип |
|---|---|
| `purchaseID` | Array(String) |
| `purchaseRevenue` | Array(Float64) |
| `purchaseCurrency` | Array(String) |
| `purchaseCoupon` | Array(String) |
| `productID`, `productName`, `productCategory` | Array(String) |
| `productPrice` | Array(Int64) |
| `productQuantity` | Array(UInt64) |
| `productEventType` | Array(String) |
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики:
это не недосмотр, а часть урока о расхождениях (см. раздел 4).
### 1.3 Ключи и движки
- Партиции — **по дням** (`PARTITION BY EventDate`): дневная партиция —
единица переобработки (решение #14). Отступление от Метрики (там месяц) —
зафиксировать комментарием в DDL.
- `ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)` — ключ под
запросы «по сайту за период по посетителю», хвост `WatchID` даёт
дедупликацию в ReplacingMergeTree. Точную форму проверить при исполнении.
`SAMPLE BY intHash32(ClientID)` — семплирование по тому же выражению;
учебный вопрос к лабе: почему `SAMPLE 0.1` не портит uniq-метрики.
- В DDL комментарием зафиксировать вырождение ключа как учебный факт:
`CounterID` — константа стенда (один сайт), `EventDate` — константа внутри
дневной партиции; реальная сортировка идёт по посетителю и событию
(`intHash32(ClientID)`, `WatchID`).
- Шардирование — **по `cityHash64(ClientID)`, не по сырому `ClientID`**:
структурированный числовой id перекашивает остаток по модулю числа шардов,
хеш — нет. Сессионизация, склейка идентичностей и uniq-метрики остаются
локальными на шарде. У анонимов кука есть — перекоса в NULL нет.
- Предупреждение-урок из v1: колонка версии не должна попадать ни в
партицию, ни в ключ сортировки ReplacingMergeTree — иначе версии одной
строки никогда не окажутся рядом и не склеятся при мерже.
### 1.4 Схема как контракт
Одно машинное описание схемы события (python-модуль или YAML) — источник
истины: из него выводятся DDL и валидация генератора, а не наоборот. 47
колонок повторяются примерно в семи местах (генератор, DDL, SELECT матвью,
трансформации, витрины, манифест, доки) — без контракта они расходятся
молча. Заодно это учебный артефакт: менти видит на живом примере, что такое
«схема как контракт».
## 2. Заказы бэкенда
Второй источник и вторая версия правды о покупке. Транспорт — та же Kafka
(топик `orders`), но **пачками**: раз в модельный день бэкенд выгружает
**полный слепок заказов окна изменяемости K дней**.
- **K = 7 модельных дней, константа мира** (страховочный срез 3 из #15
применён — см. раздел 9). За окном заказ неизменяем, возить его незачем;
выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у
трекеров: лог Метрики «доформировывается» ещё около трёх дней.
- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт
бэкенда в snake_case:
| Поле | Тип | Комментарий |
|---|---|---|
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
| `status` | String | `created``paid``cancelled` |
| `created_at`, `updated_at` | DateTime | |
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
| `items` | String | позиции вложенным JSON: `[{sku, qty, price}]` |
| `snapshot_date` | Date | дата слепка (день выгрузки) |
- Приём идемпотентный, но дедуп расщеплён на два слоя:
- `ods.order_snapshot` — партиция по `snapshot_date`, **без дедупа**,
хранит «как приехало»; идемпотентность повторного прогона — заменой
партиции дня слепка, а не ReplacingMergeTree.
- Дедуп до последней версии — **argMax** в трансформации при сборке
`dds.order`. `dds.order` — единственная дедуплицированная таблица:
партиция по дню заказа (`toDate(created_at)`),
ReplacingMergeTree(`updated_at`), `ORDER BY order_id` — заказ всегда
лежит в одной партиции, дедуп работает.
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
- Разбор JSON-позиций — **один раз**, в трансформации ODS → DDS; дальше
витрины работают с плоскими массивами `dds.order`: `item_sku`
Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2))
— одной длины, порядок как в JSON. Это единственный носитель навыка
«вложенный JSON в ClickHouse» на стенде.
- Статусы держим все три: смена `created``paid` и есть причина «дыхания»
выручки внутри окна; сужение до двух — резервный срез 1.
## 3. Каталог товаров
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
разговор о политике обновления словаря. На кластере файл монтируется в обе
ноды, словарь создаётся ON CLUSTER.
## 4. Сверка `purchase` против заказов
Ключ: клиентский `purchaseID` = `order_id` бэкенда (магазин знает номер
заказа на `/confirmation`). У события `purchase` массив `purchaseID` несёт
ровно один элемент (одно подтверждение — один заказ), сверка соединяет по
`purchaseID[1]`; правило зафиксировать комментарием в SQL сверки.
Расхождения — перечислимый список,
детерминированный от seed, не хаос:
| | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого: округления Float64, вероятность в генераторе | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
`duplicate_event` > `amount_delta` > `match`.
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
манифест хранит точные счётчики по каждому классу расхождений (отмены,
потери, дубли).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B.
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
бэкенду**. Единственное разрешённое исключение — клиентская оценка выручки
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
слово `declared` в имени — сигнал «это заявка клиента, не деньги
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
## 5. Анонимность и склейка идентичностей
- Email из кликстрима исчезает полностью: у события только `ClientID`.
Это честно к Logs API Метрики (UserID не выгружается). Отдельная «доля
анонимов» не нужна: мы знаем ровно тех, кто купил, — это сам урок.
- **Карта соответствий кука↔пользователь** строится трансформацией из уже
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
- Форма и место карты: таблица `dds.identity_map`
(`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime),
ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по
`cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации
нет); ко-локация делает обогащение витрин локальным;
заказы при сборке карты подтягиваются через GLOBAL JOIN.
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
параметр мира; значение фиксирует эта спека: 15%, детерминировано
от seed. Ядро лабы:
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
Константа мира: каждый двухкуковый покупатель делает минимум по одному
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
(она строится только из покупок) и лаба не воспроизводится. Манифест
хранит число именно таких пар.
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
## 6. Кластер
Соседний `clickhouse-learning-cluster` остаётся разминкой при курсе: там
концепции, здесь жизнь — забыть ON CLUSTER, получить ошибку, починить.
Топология и режим — по резолюции #14:
- **2 шарда × 1 реплика + отдельный clickhouse-keeper**, единственный режим:
`make up` поднимает сразу кластер, выключателя нет. Страховка от «слишком
сложно» — отсутствие реплик и runbook, а не профиль без кластера.
- Движки локальных таблиц — `Replicated*` (макросы `{shard}`/`{replica}`,
пути keeper, готовность к будущей реплике). Без HAProxy — балансировать
нечего; в доках абзац «в бою здесь LB».
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
VIEW поверх локальной таблицы проявляются в дашборде сами.
- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group,
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
`cityHash64(order_id)`, сырьё STG —
`cityHash64(сырой строки)`. Урок: «какая нода читала топик — меняется между
прогонами, куда легли данные — нет».
- **Приём строгий**: `input_format_skip_unknown_fields = 0`, обязательные
поля — без значений по умолчанию. Контракт присутствия: генератор выдаёт
**все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка
или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с
полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
опечатка иначе не падает.
- **Политика соединений**: по ключу ко-локации — обычное соединение с
комментарием, почему локальный результат корректен; по любому другому ключу
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
`purchase`↔заказ —
легитимная GLOBAL-витрина (заказы малы).
- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп
через argMax); батчевая переобработка — по дневным партициям
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` остаётся
только в `make reset`. `DROP/REPLACE PARTITION` работает только по
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
осознанная цена, не баг).
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
партиция дня слепка, без переделки старого); материализованное ниже —
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
Сессии перестраиваются только за текущий день: правило мира — сессия
режется по границе модельных суток, дневная партиция самодостаточна.
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
`insert_distributed_sync`), иначе проверки видят неполные данные.
- Все контрольные суммы и dq-проверки считают через `argMax`/`GROUP BY`/
`FINAL` — голый `count()` по ReplacingMergeTree зависит от того, сколько
мержей уже прошло.
- **Проверки и контрольные суммы — только по Distributed-таблицам**:
агрегаты от раскладки не зависят; раскладка по шардам нигде не фиксируется,
пошардовые наблюдения — исследовательские, в лабах.
### Ресурсный бюджет (#10)
Расчёт на ноутбук менти с 16 ГБ памяти; у кого 8 ГБ — берёт VDS за свой счёт.
Полный стенд в покое ≈3,4 ГБ. Топология 2×1 добавляет ≈0,6–0,8 ГБ — влезает
свободно. Топология 2×2 добавила бы ≈1,7–1,9 ГБ и упёрлась бы в дефолтный
бюджет WSL2 (~8 ГБ) — это второй довод против реплик, рядом с главным
(репликационная эксплуатация — отдельный операционный домен). Координатор —
clickhouse-keeper, а не ZooKeeper, в том числе из-за этого бюджета.
## 7. Слои: карта таблиц v2
| Слой | Объект | Что это |
|---|---|---|
| Kafka | `hits`, `orders` | два топика, по 2 партиции |
| STG | `stg.kafka_hits`, `stg.hits_raw` + MV; то же для orders | сырые строки, Kafka Engine на обеих нодах |
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
| DDS | `dds.v_event` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке |
| DDS | `dds.identity_map` | карта кука↔пользователь |
| DDS | словарь `products` | каталог из CSV |
| DM | витрины `dm.v_*`, `dm.dq_summary` | см. ниже |
Служебные колонки: `ods.event` и `ods.order_snapshot` получают метку приёма
`_ingested_at`; у `ods.event` та же колонка — колонка версии
ReplacingMergeTree. Таблицы `stg.*_raw` хранят виртуальные колонки Kafka
(`_topic`, `_partition`, `_offset`, `_timestamp`) — без них урок «какая нода
читала топик» ненаблюдаем. `stg.hits_raw` дополнительно хранит извлечённый
`event_date` — им кормится переобработка дня X (при исчерпании retention
Kafka переобработка возможна только из эталонного артефакта).
`dds.v_event` — первый на стенде пример правила «слой — это контракт, а не
обязательно копия данных».
Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже
широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ,
идентичность). «Грязные» записи по-прежнему уходят в `*_errors`, не валят
пайплайн.
### Витрины DM
- **`v_revenue_daily`** (выручка, только от заказов): `report_date`,
`product_category` (через `dictGet` каталога + ARRAY JOIN позиций),
`orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе
`paid`; внутри окна K число дня «дышит».
- **`v_purchase_vs_orders`** (сверка): FULL OUTER GLOBAL JOIN по
`purchaseID = order_id`; колонки: `order_day`, `order_id`,
`declared_revenue` (клиент), `items_total` (бэкенд, сравнимая база — не
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
в порядке приоритета — классы пересекаются, побеждает более ранний).
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
после приведения к сравнимой базе (округления Float64, ~1–2% заказов).
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
слепка, а не расхождение: она получает служебный класс `awaiting_order`
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
закрытия окна K таких строк не остаётся — сироты исключены построением
(раздел 4).
- **`v_utm_effectiveness`** — остаётся клиентской (атрибуция по трекеру);
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
`declared_revenue` по UTM.
- **`v_daily_traffic`** — расширяется парой «посетители» / «известные
пользователи» (обогащение через `dds.identity_map`, локальное соединение
по ключу ко-локации).
- `v_events_enriched`, `v_top_pages_daily`, `v_session_overview`,
`v_dq_errors_daily` — переезжают на новую модель без смены роли: источник —
`dds.v_event`, не `ods.event`.
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену
(политика «без TRUNCATE»).
Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
таблица сверки с классами расхождений, пара посетители/известные.
Ландшафт итогом: Kafka — единственная труба (кликстрим потоком, заказы
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
ландшафта выражена режимами и частотами, а не второй трубой.
## 8. Эталонный мир и манифест
Пересборка артефакта `data/startup_history/` неизбежна и оплачена решением
#18 один раз — все изменения генератора съезжаются в одну пересборку.
Манифест расширяется контрольными числами:
- заказная сторона: заказы и выручка по дням; манифест хранит точные
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
самопроверка лабы сверки;
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
покупателей — лаба склейки получает самопроверку.
Артефакт вырастет (ecommerce-массивы, заказы) — размер проверить при
пересборке. Политика версионирования артефакта здесь не решается (туман
карты #10).
## 9. Оценка объёма исполнения
v2 стартует пустым, поэтому объём ниже — это новый код, а не правка на
месте; v1 служит источником идей и образцов (масштаб оценён по нему).
| Направление | Что строим | Объём |
|---|---|---|
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
| SQL | 5 DDL-файлов (ON CLUSTER, Replicated*, Distributed) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~56 файлов |
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
| Эталонный мир | сборка артефакта v2, манифест-счётчики, чек-скрипты | M–L |
| Мониторинг | Prometheus/Grafana: цели двух нод и keeper | S — 2–4 конфига |
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
Итого ~80–110 файлов нового репозитория (посчитаны конфиги по нодам,
документация, экспорт Superset — дерево YAML, CI и артефакты данных);
тяжёлое — генератор и SQL. Это крупный релиз, но он режется на этапы с
работающим стендом после каждого. Согласуется с оценкой исследования #13
(~15–20 файлов только на кластерную часть).
### Решение по страховочным срезам (#15)
- **Срез 3 применён**: окно K — константа мира (7 дней), не параметр.
- **Срез 2 применён как порядок, не как отказ**: расхождения A+C входят в
этап сверки, B+D — отдельным следующим этапом.
- **Срез 1 в резерве**: статусы держим все три (`created`/`paid`/
`cancelled`) — на статусе `paid` стоит «дыхание» выручки; сужение до пары
`created`/`cancelled` — запасной ход, если генератор заказов окажется дороже
ожиданий. Связка: если срез 1 сработает, определение выручки в
`v_revenue_daily` придётся сменить с «заказы в статусе `paid`» на «все
неотменённые заказы».
- **Отступление от порядка #15**: резолюция предписывала резать в порядке
1 → 2 → 3, спека применяет 3 и 2, а 1 держит в резерве. Довод: срезы 3 и 2
ничего не отнимают у уроков (окно и так одно, расхождения и так вводятся
этапами), а срез 1 убирает статус `paid` — вместе с ним ушло бы «дыхание»
выручки.
### Этапы для /to-tickets (черновик)
0. Рождение v2: создать репозиторий (рабочее имя
`clickstream-data-platform`), осознанный первый коммит (скелет доков,
AGENTS.md, лицензия), переезд карты #10 и открытых тикетов.
1. Каркас стенда: кластерный compose (2×CH + keeper + Kafka, Airflow,
Superset, мониторинг), конфиги, `make up`, smoke-проверка ON CLUSTER.
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
целиком). В конце этапа фиксируется маленький «зерновой» мир для
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
отдельный этап 7).
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
батча заказов — сенсор/Datasets).
6. Расхождения B+D и опоздания; счётчики манифеста.
7. Эталонный мир: пересборка артефакта, чек-скрипты.
8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди».
Критерий приёмки этапа — честный: `make up` работает и проходят
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои
наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа
(правило AGENTS.md).
## 10. Границы: чего не делаем
- «Грязь» в данных: боты, дубли на транспорте, опоздавшие мобильные батчи,
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Политика версионирования эталонного артефакта — туман карты.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Спека даёт будущим лабам только опорные точки — контрольные
числа манифеста (сверка, идентичность). Явное следствие: после этапа 9
стенд работает, но учебного пути на нём ещё нет.
- Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- Полный словарь торговых событий Метрики (detail, remove, impressions),
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
визитов Метрики Про); сама колонка `Sign` уже в схеме, статикой (см. 1.1).
- Событийный лог заказов, CDC/Debezium, HTTP-сервис заказов, шапка+строки,
отдельный поток возвратов — отклонены в #15/#18.
- Файловые дропы как источник — зона следующего стенда (Lakehouse), у нас
остаются теорией; каталог отдельным топиком Kafka — в бою так не делают
(оба отклонения — из #18).
- Вероятностная склейка, identity graph, кука 1:N («семейный планшет») —
тема лекции, не лабы.
- Эмуляция `setUserID` и сюжет «логин посреди сессии» через свои параметры —
отклонены в #16: нечестно к формату выгрузки Метрики.
## 11. Проверить при исполнении
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode`
эмпирически на стенде (хвост #14).
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
прогонами, отсутствие дублей при штатной работе.
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
ReplacingMergeTree).
- Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
сверять через MCP Context7 (правило AGENTS.md).
- Генератор: рабочее решение — Python с производительной архитектурой
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
распараллеливание по модельным дням). Читаемость генератора для менти —
не довод при выборе языка: он в любом случае сложнее уровня DE-джуна.
До этапа 3 зафиксировать требования производительности (пересборка
эталонного мира, живой поток ×60); переход на компилируемый язык
(Rust/Go) — только если замеры покажут, что Python приемлемой скорости
не даёт.
## 12. Влияние на документацию
Состав и структура доков v2 проектируются заново: набор документов v1
сложился исторически и не копируется. Какие документы нужны v2 — решение
этапа 0. Обязательный минимум по содержанию (не по списку файлов):
быстрый старт, архитектура слоёв, операционка с runbook keeper/DDL,
словарь терминов (широкое событие, слепок, окно изменяемости,
посетители/известные пользователи, ко-локация).
Документ о реализме (`generator-realism.md`) переезжает в v2 и получает
крупное обновление: Kafka — учебная замена батчевого Logs API («настоящий
Logs API — это скачанный TSV»); trade-off «инкремент экономнее, слепок
надёжнее» + сноска про compacted topic; identity stitching перестаёт быть
чистой теорией; «прямое чтение прод-базы — анти-приём, в бою — реплика или
выгрузка»; колонка `Sign` без механики — честное ограничение стенда
(в бою 1/+1 и `sum(Sign)`); полный словарь торговых событий — теория.
В v1 при заморозке — указатель на v2 в README (форму решить при рождении
v2, этап 0).
### Опорные точки для будущих лекций и лаб
Лабы и курс — вне скоупа спеки (см. раздел 10). Список нужен только затем,
чтобы хвосты резолюций не потерялись:
- анти-паттерны ключа шардирования (`rand()`, `toDate`) — как отрицательные
примеры;
- «как выбирают топологии в бою»: часто 1 шард × N реплик, шардирование — про
рост;
- сцена «разные consumer groups → дубли»;
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
`RegionCityID`);
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
синтетическая постановка — осознанный приём;
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
`count()`, а в бою — нет; частый вопрос на собеседованиях;
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
детерминированная против вероятностной склейки, identity graph,
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
user id не отдаёт».