From 8046e543d05d9fec8a8b1832589bc5a9a86b15f4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 30 Jul 2026 15:47:55 +0300 Subject: [PATCH] =?UTF-8?q?chore(repo):=20=D0=B7=D0=B0=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B9=20v2=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82,=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=B8=D1=81=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - спека «Боевой реализм стенда» исполняется в новом репозитории: предшественник замораживается как стабильный учебный стенд, 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 файлов - относительные ссылки спеки ведут на существующие файлы репозитория --- .gitignore | 45 ++ AGENTS.md | 67 ++ README.md | 36 + .../2026-07-26-yandex-clickstream-format.md | 723 ++++++++++++++++++ docs/specs/2026-07-30-stand-v2-realism.md | 585 ++++++++++++++ 5 files changed, 1456 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/research/2026-07-26-yandex-clickstream-format.md create mode 100644 docs/specs/2026-07-30-stand-v2-realism.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1968751 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..36c3748 --- /dev/null +++ b/AGENTS.md @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..e833cc9 --- /dev/null +++ b/README.md @@ -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) — контракт работы в репозитории. diff --git a/docs/research/2026-07-26-yandex-clickstream-format.md b/docs/research/2026-07-26-yandex-clickstream-format.md new file mode 100644 index 0000000..0b9280e --- /dev/null +++ b/docs/research/2026-07-26-yandex-clickstream-format.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) diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md new file mode 100644 index 0000000..be14e51 --- /dev/null +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -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; коды те же (1–4). +- **Наша честная добавка** — `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 — ~5–6 файлов | +| 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 не отдаёт».