From dc0e6f9452312c2a0c685f899ca52adf9d7c12e5 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 1 Jun 2026 22:28:14 +0300 Subject: [PATCH 01/38] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B2=D0=B8?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8B=D0=B9=20=D0=BA=D1=83=D1=80=D1=81=20?= =?UTF-8?q?=C2=AB=D0=9A=D0=BB=D0=B8=D0=BA=D1=81=D1=82=D1=80=D0=B8=D0=BC=20?= =?UTF-8?q?=D0=BD=D0=B0=20ClickHouse=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - превратить стенд в самостоятельный учебный материал (трек «со звёздочкой») для продвинутых менти. - Что: - docs/course/: PRD, LEARNING_PLAN, LESSON_STANDARD и README-индекс. - AGENTS.md: ссылка на курс в разделе навигации. - CLAUDE.md: @-include AGENTS.md для контекста агента. - Проверка: - открыть docs/course/README.md и пройти по ссылкам на PRD/план/стандарт. --- AGENTS.md | 1 + CLAUDE.md | 1 + docs/course/LEARNING_PLAN.md | 57 ++++++++++++++++ docs/course/LESSON_STANDARD.md | 65 ++++++++++++++++++ docs/course/PRD.md | 120 +++++++++++++++++++++++++++++++++ docs/course/README.md | 25 +++++++ 6 files changed, 269 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/course/LEARNING_PLAN.md create mode 100644 docs/course/LESSON_STANDARD.md create mode 100644 docs/course/PRD.md create mode 100644 docs/course/README.md diff --git a/AGENTS.md b/AGENTS.md index f071ffc..2d366f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ - [docs/OPERATIONS.md](./docs/OPERATIONS.md) — запуск, DAG-параметры, проверки и troubleshooting. - [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — детали по слоям STG/ODS/DDS/DM. - [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов. +- [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md). - [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины). ## Ограничения по структуре diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md new file mode 100644 index 0000000..7349f04 --- /dev/null +++ b/docs/course/LEARNING_PLAN.md @@ -0,0 +1,57 @@ +# План обучения: курс «Кликстрим на ClickHouse» + +> Статус: черновик (скелет). Дата: 2026-06-01. +> Назначение: высокоуровневый маршрут менти по курсу — карта уроков, порядок, +> результаты аудита эталонных путей. Рамка курса (зачем/что/скоуп) — в `PRD.md`; +> как устроен отдельный урок — в `LESSON_STANDARD.md`. +> +> Этот файл — стартовый скелет. Детальный аудит путей и финальные вердикты +> дописываются перед стартом уроков. + +--- + +## 1. Маршрут + +Порядок линейный — уроки 0→4 идут цепочкой, повторяя сам пайплайн. Мониторинг (4) +и Superset (5) более самостоятельны и работают как надстройки. + +**Что нужно знать заранее по Kafka:** перед уроком 0 менти смотрит обзорное видео по +Kafka из роадмапа — оно даёт словарь терминов. Урок 0 связывает этот словарь с тем, +что менти сразу видит в живом стенде. + +## 2. Карта уроков + +| # | Урок | Эталонный путь (файлы стенда) | Статус | Режим | +|---|------|-------------------------------|--------|-------| +| 0 | Вводный урок по Kafka | Kafka UI: топики, партиции, offset'ы, consumer-группы, отставание (lag) | обязательный | наблюдение | +| 1 | Заземление Kafka → ClickHouse | `sql/ddl/stg/10_stg.sql` (Kafka engine → MV → MergeTree) | обязательный | руки | +| 2 | Слоёный ETL: где MV, а где батч | `sql/ods/*`, `sql/dds/*`, `sql/dm/*` (батч поверх стримингового STG) | обязательный | руки | +| 3 | Оркестрация в Airflow | `airflow/dags/etl_pipeline_dag.py` (зависимости, проверки, остановка при нарушениях) | обязательный | руки | +| 4 | Мониторинг | Prometheus + Grafana + экспортёры | обязательный | наблюдение | +| 5 | BI-витрина | Superset поверх ClickHouse | опциональный | руки | + +Урок 0 — обязательная разминка (без правок кода, только наблюдение); уроки 1–3 — +с управляемыми правками; урок 4 ближе к наблюдению (глубину уточняем, см. +«Открытые вопросы» в `PRD.md`). + +## 3. Аудит эталонных путей + +Вердикт по каждому пути — один из трёх: **годно как есть / точечно править / +переписать** (критерии «учебного качества» — в `LESSON_STANDARD.md`). + +Предварительная оценка (до детального аудита): + +| Путь | Файлы | Вердикт (предв.) | Заметки | +|------|-------|------------------|---------| +| STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | годно как есть | внятные русские комментарии, метаданные доставки, обработка ошибок, партиционирование | +| ETL слои | `sql/ods/*`, `sql/dds/*`, `sql/dm/*` | точечно править | логика и комментарии крепкие; проверить «тест одного прохода» | +| Airflow DAG | `airflow/dags/etl_pipeline_dag.py` | под вопросом | нужен детальный аудит | +| Мониторинг | `configs/*`, экспортёры | под вопросом | нужен детальный аудит | + +Финальные вердикты и список конкретных правок — дописать перед стартом уроков. + +## 4. Что дальше + +После согласования этого плана — пишем уроки по одному, по шаблону из +`LESSON_STANDARD.md`, начиная с урока 1 (Kafka→CH). Урок 0 (разминка на Kafka UI) +можно готовить параллельно — он не зависит от полировки кода. diff --git a/docs/course/LESSON_STANDARD.md b/docs/course/LESSON_STANDARD.md new file mode 100644 index 0000000..e10721f --- /dev/null +++ b/docs/course/LESSON_STANDARD.md @@ -0,0 +1,65 @@ +# Стандарт уроков курса «Кликстрим на ClickHouse» + +> Статус: черновик. Дата: 2026-06-01. +> Назначение: рабочий чеклист, по которому пишется **каждый** урок. Открывается при +> создании урока. Рамка курса (зачем/что/скоуп) — в `PRD.md`; карта уроков и +> маршрут — в `LEARNING_PLAN.md`. + +--- + +## 1. Шаблон урока + +Каждый урок строится по единому шаблону: + +1. **Зачем и где в проде** — что за паттерн и где он встречается в обычной + кликстрим-аналитике (без привязки к конкретной компании). +2. **Руки** — что запустить и что понаблюдать (Kafka UI / ClickHouse / Grafana). +3. **Загляни внутрь** — чтение отполированного эталонного кода с пояснениями. +4. **Управляемая правка** — одно-два маленьких изменения с **видимым результатом**. + Например: добавить поле в Materialized View и увидеть его в `*_raw`; «сломать» + запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name` + и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет. + (Урок 0 — без этого шага, только наблюдение.) +5. **Проверь себя** — самопроверка (раздел 3). +6. **Принеси на сессию** — что доложить ментору и какие вопросы задать. + +## 2. Стандарт качества эталонного кода + +Эталонные пути полируем до **учебного качества**; остальной код стенда остаётся под +капотом — его не трогаем. + +**Чеклист (минимум).** Файл доведён, если: +1. комментарии на русском объясняют **зачем**, а не пересказывают код; +2. шаги явные и названы своими именами, без неочевидных трюков; +3. в начале файла — поток данных одной строкой (как в STG: + `Kafka → kafka_*_raw → MV → *_raw`); +4. ошибки и проверки качества данных видны — понятно, куда смотреть; +5. нет мусора: мёртвого кода, закомментированных экспериментов, разнобоя в именах. + +**Тест одного прохода (главный критерий и защита от громоздкости).** +> Менти читает файл один раз сверху вниз и может пересказать своими словами, +> *что* происходит и *зачем* — не прыгая по файлу, не гугля, не расшифровывая трюки. + +Если тест не проходит — правим строго одним из трёх способов: (1) упростить код; +(2) добавить ровно одну строку «зачем» там, где код честно неочевиден; (3) убрать +лишнее под капот или в короткую пометку. **Добавить ещё комментариев — не способ.** + +**Две защиты от раздувания:** +- комментируем только неочевидное «зачем» (очевидную строку комментировать нельзя — + лишний текст сам по себе мешает понимать); +- «близко к проду» ≠ «вся прод-сложность прямо в коде урока»: один прод-паттерн на + урок показываем чисто, остальные прод-заботы — короткой пометкой «в проде иначе: …». + +Вердикты аудита по конкретным путям — в `LEARNING_PLAN.md`. + +## 3. Самопроверка (по-простому) + +Отдельный тест-фреймворк не строим. Опираемся на то, что в стенде уже есть: +- штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`; +- встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или + нарушении целостности); +- `make clean/up/ddl/data/transform` для сброса и повтора. + +В каждом уроке — маленькая табличка самопроверки в формате +**действие → где смотреть → что ожидать**, а для управляемой правки — какой +видимый результат должен появиться. diff --git a/docs/course/PRD.md b/docs/course/PRD.md new file mode 100644 index 0000000..cf5c573 --- /dev/null +++ b/docs/course/PRD.md @@ -0,0 +1,120 @@ +# PRD: продвинутый курс «Кликстрим на ClickHouse» (со звёздочкой) + +> Статус: черновик (прообраз PRD). Дата: 2026-06-01. +> Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем +> он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план +> реализации и не стандарт уроков (см. раздел «Связанные документы»). +> +> Язык документа: уровни называем единообразно — **курс** (вся программа) состоит +> из **уроков**. Соседние программы (например, Lakehouse) — отдельные **курсы**. + +--- + +## 1. Контекст и назначение + +Репозиторий — рабочий сквозной стенд кликстрим-DWH: + +``` +data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset + ↘ Prometheus / Grafana (мониторинг) +``` + +Стенд близок к продакшену и показывает несколько паттернов инженерии данных на +связке Kafka + ClickHouse + Airflow + мониторинг. ClickHouse не входит в базовую +программу обучения — это **продвинутый курс «со звёздочкой»** для менти, уже +прошедших базу (SQL, моделирование, Python, Git, Docker, Airflow). + +Цель курса — превратить стенд в **самостоятельный учебный материал**, по которому +продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычной +еженедельной сессии (что получилось / что нет / вопросы / план на неделю). Долгий +разбор кода вживую форматом не предусмотрен — поэтому материал обязан быть +самодостаточным. + +## 2. Цели (чему учим) + +К концу курса менти умеет: + +1. **Заземлять поток из Kafka в ClickHouse** через Kafka engine и Materialized View + (ClickHouse сам забирает сообщения из топика), понимая роль топиков, партиций, + offset'ов и consumer-групп. +2. Строить **слоёный ETL** (STG → ODS → DDS → DM) и объяснять, **где уместен + Materialized View** (стриминговое приземление данных), **а где батч** (так + удобнее управлять и наблюдать за пересчётом). +3. Читать и объяснять **оркестрацию в Airflow**: DAG, зависимости задач, проверки + качества данных, остановку пайплайна при нарушениях. +4. Понимать, **как устроен мониторинг** пайплайна (метрики, экспортёры, дашборды). +5. (Опционально) Подключать **BI-витрину** поверх ClickHouse (Superset). + +Сквозная цель — не «посмотреть, как работает», а **уметь пересказать паттерн +своими словами и привязать его к обычной кликстрим-аналитике** (трекер событий → +Kafka → ClickHouse → BI). + +## 3. Аудитория и режим + +- **Аудитория:** продвинутые менти, прошедшие базовую программу. Пишем обобщённо, + но затачиваем под реальный первый прогон, а не под гипотетических будущих менти. +- **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, поднимает + стенд у себя (`make up`) и идёт по урокам из `docs/course/` рядом с кодом. +- **Роль ментора:** еженедельная сессия-сверка, без построчного разбора кода. +- **Железо:** стек тяжёлый (Kafka + ClickHouse + Airflow + Superset + Prometheus + + Grafana одновременно). Считаем наличие подходящего железа данностью; стек не режем + на части — это усложнило бы жизнь и менти, и автору материала. + +## 4. Скоуп + +### Входит +- **Уроки 0–4 (обязательные):** вводный урок по Kafka, заземление Kafka→CH, слоёный + ETL (где Materialized View, а где батч), Airflow, мониторинг. +- **Аудит и точечная полировка эталонных путей** этих уроков до учебного качества + (стандарт — в `LESSON_STANDARD.md`). +- Учебная часть вокруг каждого эталонного пути по единому шаблону урока. + +Подробная карта уроков (файлы стенда, статус, режим, вердикты аудита) — в плане +обучения `LEARNING_PLAN.md`. + +### Опционально +- **Урок 5: Superset (BI-витрина).** Делаем, если останется ресурс; обязательные + уроки он не блокирует. + +### Не входит +- Переписывание всего стенда: полируем только эталонные пути обязательных уроков, + остальной код стенда остаётся под капотом. +- Lakehouse (Spark / Iceberg / Trino) — отдельный стенд, отдельный курс. +- Подготовка к трудоустройству: резюме, легенда, мок-собесы, привязка к конкретному + работодателю. Любые материалы под конкретного менти — вне этого репозитория + (репозиторий публичный; приватное — в менторской базе). +- Доведение стенда до промышленной надёжности (отказоустойчивость, безопасность, + масштабирование) — кроме коротких пометок «в проде иначе». + +## 5. Критерии успеха + +- Менти проходит урок за неделю **сам**, без построчного разбора с ментором. +- Может своими словами объяснить паттерн урока и привязать его к обычной + кликстрим-аналитике. +- На сессии приносит осмысленные вопросы по сути, а не «застрял на запуске». +- Эталонный код проходит «тест одного прохода» (см. `LESSON_STANDARD.md`). + +## 6. Связанные документы и порядок работ + +Порядок создания артефактов: + +1. **`PRD.md`** (этот документ) — рамка: что, зачем, скоуп. **Замороженный** документ. +2. **`LEARNING_PLAN.md`** — высокоуровневый план обучения: карта уроков, маршрут + менти, результаты аудита путей. Пишется после согласования PRD. +3. **`LESSON_STANDARD.md`** — стандарт уроков: шаблон урока, стандарт качества кода, + самопроверка. Рабочий чеклист, открывается при написании каждого урока. +4. **Уроки** — по одному, по шаблону из `LESSON_STANDARD.md`, начиная с урока 1 + (Kafka→CH). + +Навигация по всем файлам курса — в `docs/course/README.md`. + +Материалы под конкретного менти (под кого первый прогон, привязка к работодателю, +подготовка к собесам) — **вне этого репозитория**, в приватной менторской базе. + +## 7. Открытые вопросы / на будущее + +- Глубина урока 4 (мониторинг): сколько внутреннего устройства показывать против + «просто наблюдай дашборд». +- Нужна ли BI-витрина (Superset) уже в первой версии или переносим на следующую. +- Переиспользование: после первого прогона — ретроспектива и обобщение материала + под других менти. diff --git a/docs/course/README.md b/docs/course/README.md new file mode 100644 index 0000000..2dde4a1 --- /dev/null +++ b/docs/course/README.md @@ -0,0 +1,25 @@ +# Курс «Кликстрим на ClickHouse» (со звёздочкой) + +Продвинутый курс для менти, уже прошедших базовую программу: ключевые паттерны +инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг. Самостоятельный +учебный материал; ментор подключается на еженедельной сессии-сверке. + +## Что где искать + +| Файл | Что это | Когда открывать | +|------|---------|-----------------| +| [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | +| [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | +| [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | +| `lessons/` *(будет)* | Сами уроки, по одному файлу | Прохождение курса менти | + +## Порядок чтения + +1. Новому участнику (или будущей сессии): начать с `PRD.md`, затем `LEARNING_PLAN.md`. +2. Перед написанием урока: держать открытым `LESSON_STANDARD.md`. + +## Границы + +Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к +собесам) в этот репозиторий **не кладём** — репозиторий публичный, приватное живёт +в менторской базе. From aecbf392d933897654eeabbcad29ad4fbf693f60 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 1 Jun 2026 23:29:50 +0300 Subject: [PATCH 02/38] =?UTF-8?q?docs(agents):=20=D0=BF=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B0=20doc/issue-=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B4=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D1=8F=20Pocock?= =?UTF-8?q?=20=D0=B8=20=D0=B7=D0=B0=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - развести документацию по времени жизни: транзиентные спеки отдельно от долговечных решений (ADR) и доменного словаря (CONTEXT.md), вместо одного громоздкого spec-документа. - Что: - добавлен блок Agent skills в AGENTS.md (issue tracker / triage / domain docs). - созданы docs/agents/{issue-tracker,triage-labels,domain}.md: локальный markdown-трекер в .scratch/, дефолтные triage-метки, single-context раскладка. - зафиксировано решение как docs/adr/0001-spec-adr-issue-layout.md. - Проверка: - git show --stat HEAD; прочитать AGENTS.md и docs/adr/0001-spec-adr-issue-layout.md. --- AGENTS.md | 16 ++++++++++ docs/adr/0001-spec-adr-issue-layout.md | 40 ++++++++++++++++++++++++ docs/agents/domain.md | 43 ++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 22 +++++++++++++ docs/agents/triage-labels.md | 14 +++++++++ 5 files changed, 135 insertions(+) create mode 100644 docs/adr/0001-spec-adr-issue-layout.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/AGENTS.md b/AGENTS.md index 2d366f5..ca3d65f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,22 @@ - Минимальный порядок: `resolve-library-id` -> `query-docs`. - Принятое решение фиксировать в коде и/или документации (кратко: что проверили и почему выбрали именно этот вариант). +## Agent skills + +Конфигурация для инженерных скиллов (набор Matt Pocock). Подробности — в `docs/agents/`. + +### Issue tracker + +Локальный markdown: задачи и PRD живут файлами в `.scratch//` (коммитятся, `.scratch/` не в `.gitignore`). GitHub Issues не используются. См. `docs/agents/issue-tracker.md`. + +### Triage labels + +Пять канонических ролей, строки совпадают с именами (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: `CONTEXT.md` и `docs/adr/` в корне репозитория. См. `docs/agents/domain.md`. + ## Навигация по документации - [README.md](./README.md) — пользовательский quick start и обзор. diff --git a/docs/adr/0001-spec-adr-issue-layout.md b/docs/adr/0001-spec-adr-issue-layout.md new file mode 100644 index 0000000..3376196 --- /dev/null +++ b/docs/adr/0001-spec-adr-issue-layout.md @@ -0,0 +1,40 @@ +# ADR-0001: Раскладка спецификаций, решений и задач + +Принято: 2026-06-01 + +## Решение + +Рабочую документацию разносим по времени жизни: + +- **Транзиентные спеки фич** — markdown в `.scratch//PRD.md`, коммитятся + (`.scratch/` не в `.gitignore`). Снимок понимания на момент работы, не для + перечитывания. Продюсер — `to-prd`. +- **Долговечные решения** — ADR в `docs/adr/NNNN-.md`. Глубина по весу + решения: однострочник для обратимого, с вариантами и последствиями — для + необратимого. +- **Доменный язык** — глоссарий `CONTEXT.md` в корне. ADR и `CONTEXT.md` ведёт + `grill-with-docs`. +- **Issue-tracker** — локальный markdown в `.scratch/`, без GitHub Issues. Триаж + пробуем с дефолтными метками; `to-issues` пока не применяем — для текущего + объёма работ задачи редко распадаются на независимые вертикальные срезы. + +## Контекст + +Раньше и спеку, и решение нёс один громоздкий spec-документ: он плохо заменял +ADR — слишком объёмен и привязан к фиче. Разделение слоёв: спека — «что строим +сейчас», ADR — «какое решение приняли и почему», глоссарий — «как называем +сущности». + +## Рассмотренные варианты + +- **`docs/specs/` закоммичено** — спеки среди курируемых доков. Отклонено: + транзиентные артефакты засоряют `docs/`, который навигирует читатель. +- **`.scratch/` в `.gitignore`** — спеки эфемерны. Отклонено: по необратимым + решениям рассуждение за ними надо сохранить, а минимальный ADR его не держит. +- **GitHub Issues как трекер** — отклонено пока: поток держим локально, файлами. + +## Последствия + +- `.scratch/` **не** вносим в `.gitignore` — иначе теряется история спек и задач. +- Если ADR окажутся самодостаточными, спеки можно будет гитигнорить — переключение + одной строкой, без слома схемы. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..d8a50a7 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,43 @@ +# Domain docs + +Как инженерные скиллы должны читать доменную документацию этого репозитория при +исследовании кода. + +## Перед исследованием кода прочитать + +- `CONTEXT.md` в корне — глоссарий проекта (доменный язык). +- `docs/adr/` — ADR, затрагивающие область, в которой собираешься работать. + +Если файла нет — **молча продолжай**. Не сигналить об отсутствии и не предлагать +создать заранее. Продюсер (`grill-with-docs`) создаёт их лениво, когда термин или +решение реально кристаллизуются. + +## Раскладка + +Single-context (один контекст на репозиторий): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-.md +│ └── 0002-.md +└── ... +``` + +## Использовать лексику глоссария + +Когда вывод называет доменное понятие (заголовок спеки, гипотеза, имя теста, +рефактор-предложение) — использовать термин так, как он определён в `CONTEXT.md`. +Не уходить в синонимы, которые глоссарий помечает `_Avoid_`. + +Если нужного понятия в глоссарии ещё нет — это сигнал: либо изобретаешь язык, +которого в проекте нет (пересмотреть), либо реальный пробел (отметить для +`grill-with-docs`). + +## Флагать конфликты с ADR + +Если вывод противоречит существующему ADR — явно об этом сказать, а не молча +переопределять: + +> _Противоречит ADR-0003 (<тема>) — но стоит переоткрыть, потому что…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..7a1fa05 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: локальный markdown + +Задачи и PRD этого репозитория живут markdown-файлами в `.scratch/`. GitHub Issues +не используются — не вызывать `gh issue create`. Файлы в `.scratch/` коммитятся +(`.scratch/` не в `.gitignore`). + +## Соглашения + +- Одна фича — один каталог: `.scratch//` +- PRD фичи — `.scratch//PRD.md` (по сути транзиентная спека: снимок + понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`) +- Задачи реализации — `.scratch//issues/-.md`, нумерация с `01` +- Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`) +- Комментарии и история — в конец файла под заголовком `## Comments` + +## Когда скилл говорит «опубликовать в issue tracker» + +Создать файл в `.scratch//` (каталог создать при необходимости). + +## Когда скилл говорит «достать тикет» + +Прочитать файл по указанному пути. Обычно путь или номер задачи передаёт пользователь. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..72c7dc3 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,14 @@ +# Triage labels + +Скиллы оперируют пятью каноническими ролями триажа. Здесь они сопоставлены со +строками, которые реально используются в этом репозитории (в строке `Status:` файла задачи). + +| Роль в mattpocock/skills | Строка у нас | Значение | +| ------------------------ | ----------------- | ---------------------------------------------- | +| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить задачу | +| `needs-info` | `needs-info` | Ждём от репортёра дополнительную информацию | +| `ready-for-agent` | `ready-for-agent` | Полностью специфицировано, можно отдать агенту | +| `ready-for-human` | `ready-for-human` | Нужна ручная реализация человеком | +| `wontfix` | `wontfix` | Не будет сделано | + +Правый столбец можно поменять под свою лексику. Сейчас — дефолт (строка = имя роли). From d8c5e61a3a8aa38b01b48139dbc749c854e95c38 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 3 Jun 2026 21:22:07 +0300 Subject: [PATCH 03/38] =?UTF-8?q?docs(course):=20=D1=80=D0=B0=D1=81=D1=89?= =?UTF-8?q?=D0=B5=D0=BF=D0=BB=D1=91=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=BF=D1=83=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - середина пайплайна перегружала один урок тремя паттернами; нужны честный такт и разведённые слои. - Что: - середина расщеплена: ODS и DDS — отдельные уроки (один паттерн на урок), DM демотирован в поверхность потребления; всего 7 уроков. - зафиксированы финальные вердикты аудита и список правок по урокам (LEARNING_PLAN §3/§3.1), включая гейт целостности DAG. - в LESSON_STANDARD добавлены шаг отката «верни как было» и артефакт на сессию; в PRD обновлены скоуп и такт ~день на урок. - Проверка: - вычитка docs/course/{PRD,LEARNING_PLAN,LESSON_STANDARD}.md: номера уроков, скоуп и перекрёстные ссылки сходятся. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/course/LEARNING_PLAN.md | 125 +++++++++++++++++++++++++++------ docs/course/LESSON_STANDARD.md | 8 ++- docs/course/PRD.md | 30 +++++--- 3 files changed, 132 insertions(+), 31 deletions(-) diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md index 7349f04..ce2dcdb 100644 --- a/docs/course/LEARNING_PLAN.md +++ b/docs/course/LEARNING_PLAN.md @@ -1,19 +1,26 @@ # План обучения: курс «Кликстрим на ClickHouse» -> Статус: черновик (скелет). Дата: 2026-06-01. +> Статус: черновик. Дата: 2026-06-03 (аудит путей выполнен; середина расщеплена — +> один паттерн на урок, всего 7 уроков, см. §1–2). > Назначение: высокоуровневый маршрут менти по курсу — карта уроков, порядок, > результаты аудита эталонных путей. Рамка курса (зачем/что/скоуп) — в `PRD.md`; > как устроен отдельный урок — в `LESSON_STANDARD.md`. > -> Этот файл — стартовый скелет. Детальный аудит путей и финальные вердикты -> дописываются перед стартом уроков. +> Раздел 3 содержит финальные вердикты аудита и конкретный список правок — +> их применяем при написании соответствующих уроков, а не отдельным забегом. --- ## 1. Маршрут -Порядок линейный — уроки 0→4 идут цепочкой, повторяя сам пайплайн. Мониторинг (4) -и Superset (5) более самостоятельны и работают как надстройки. +Порядок линейный — уроки 1→4 повторяют сам пайплайн (STG → ODS → DDS → оркестрация), +а урок 0 — разминка перед ним. Мониторинг (5) и Superset (6) более самостоятельны и +работают как надстройки поверх готовых данных. + +Принцип нарезки — **один прод-паттерн на урок** (`LESSON_STANDARD` §2). Поэтому +середина пайплайна разнесена: типизация+DQ (ODS) и сборка сущностей (DDS) — разные +уроки. Витрины DM **не отдельный урок**: их показываем в деле там, где их потребляют — +в мониторинге (DQ-витрины) и BI (traffic/utm-витрины). **Что нужно знать заранее по Kafka:** перед уроком 0 менти смотрит обзорное видео по Kafka из роадмапа — оно даёт словарь терминов. Урок 0 связывает этот словарь с тем, @@ -25,33 +32,107 @@ Kafka из роадмапа — оно даёт словарь терминов. |---|------|-------------------------------|--------|-------| | 0 | Вводный урок по Kafka | Kafka UI: топики, партиции, offset'ы, consumer-группы, отставание (lag) | обязательный | наблюдение | | 1 | Заземление Kafka → ClickHouse | `sql/ddl/stg/10_stg.sql` (Kafka engine → MV → MergeTree) | обязательный | руки | -| 2 | Слоёный ETL: где MV, а где батч | `sql/ods/*`, `sql/dds/*`, `sql/dm/*` (батч поверх стримингового STG) | обязательный | руки | -| 3 | Оркестрация в Airflow | `airflow/dags/etl_pipeline_dag.py` (зависимости, проверки, остановка при нарушениях) | обязательный | руки | -| 4 | Мониторинг | Prometheus + Grafana + экспортёры | обязательный | наблюдение | -| 5 | BI-витрина | Superset поверх ClickHouse | опциональный | руки | +| 2 | STG → ODS: типизация и DQ-split | `sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql` | обязательный | руки | +| 3 | ODS → DDS: сборка сущностей | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql` (argMax, UNION, сироты) | обязательный | руки | +| 4 | Оркестрация в Airflow | `airflow/dags/etl_pipeline_dag.py` (зависимости, гейты, остановка при нарушениях) | обязательный | руки | +| 5 | Мониторинг | Prometheus + Grafana + экспортёры | обязательный | наблюдение | +| 6 | BI-витрина | Superset поверх ClickHouse | опциональный | руки | -Урок 0 — обязательная разминка (без правок кода, только наблюдение); уроки 1–3 — -с управляемыми правками; урок 4 ближе к наблюдению (глубину уточняем, см. +Урок 0 — обязательная разминка (без правок кода, только наблюдение); уроки 1–4 — +с управляемыми правками; урок 5 ближе к наблюдению (глубину уточняем, см. «Открытые вопросы» в `PRD.md`). +**Где «MV vs батч»:** контраст из цели №2 PRD — это мост уроков 1→2. Урок 1 (STG) +заканчивается вопросом «мы приземлили поток через MV — почему дальше не MV?»; урок 2 +(ODS) отвечает: батч ради наблюдаемости и управляемости пересчёта. Отдельным уроком +контраст не выделяем — он живёт на стыке. + +**Витрины DM** показываем в деле в уроках 5–6; единственную концепцию DM («VIEW +сейчас, материализуем если затормозит») даём короткой заметкой в финале урока 3. + ## 3. Аудит эталонных путей Вердикт по каждому пути — один из трёх: **годно как есть / точечно править / переписать** (критерии «учебного качества» — в `LESSON_STANDARD.md`). -Предварительная оценка (до детального аудита): +Финальная оценка (детальный аудит от 2026-06-03): -| Путь | Файлы | Вердикт (предв.) | Заметки | -|------|-------|------------------|---------| -| STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | годно как есть | внятные русские комментарии, метаданные доставки, обработка ошибок, партиционирование | -| ETL слои | `sql/ods/*`, `sql/dds/*`, `sql/dm/*` | точечно править | логика и комментарии крепкие; проверить «тест одного прохода» | -| Airflow DAG | `airflow/dags/etl_pipeline_dag.py` | под вопросом | нужен детальный аудит | -| Мониторинг | `configs/*`, экспортёры | под вопросом | нужен детальный аудит | +| Урок | Путь | Файлы | Вердикт | +|------|------|-------|---------| +| 1 | STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | **годно как есть** (+1 строка «зачем») | +| 2 | STG→ODS | `sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql` | **точечно править** | +| 3 | ODS→DDS (+ демоут DM) | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql`; DM `sql/dm/40_dds_to_dm.sql` + `sql/ddl/dm/40_dm.sql` | **точечно править** | +| 4 | Airflow DAG | `airflow/dags/etl_pipeline_dag.py` | **точечно править** | +| 5 | Мониторинг | `configs/*`, экспортёры | **годно как есть** (для режима наблюдения) | -Финальные вердикты и список конкретных правок — дописать перед стартом уроков. +> Карты путей раздела 2 включают и DDL целевых таблиц (`sql/ddl/{ods,dds}/*`), а не +> только батч-трансформации: без формы целевых таблиц слой читается неполно. + +### 3.1. Список правок (применяем при написании урока) + +Правки точечные, делаем не отдельным забегом, а в составе соответствующего урока. + +**Урок 1 — STG (`sql/ddl/stg/10_stg.sql`):** +- [ ] Добавить одну строку «зачем» к `fromUnixTimestamp64Milli(toInt64(_timestamp_ms))` + в MV — это единственный неочевидный трюк файла (конвертация Kafka-таймстампа). +- Колоночные комментарии-пересказы (`-- Партиция`, `-- Имя топика`) безвредны — не трогаем. +- В финале урока — мост к уроку 2: вопрос «почему дальше не MV?». + +**Урок 2 — STG→ODS, типизация + DQ-split (`sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql`):** +- [ ] Добавить однострочный «поток данных» в шапку `20_stg_to_ods.sql` + (`stg.*_raw → ods.* + ods.*_errors`) — п.3 чеклиста. +- [ ] Добавить строку «зачем»: основная таблица = валидный ключ (но может иметь + `parse_errors` по неключевым полям), а `*_errors` = любая ошибка. Без этого + непонятно, почему одна строка попадает в оба места (двойной учёт) — проваливается + тест одного прохода. +- [ ] Убрать мусор: в `sql/ddl/ods/20_ods.sql` (стр. ~15–25) — восемь + `DROP TABLE IF EXISTS stg.mv_*_to_ods` (чистка legacy-MV). Шум прошлой + архитектуры, мента читает его раньше сути. Вынести из учебного файла. +- [ ] Минор: в `20_ods.sql` разнобой партиционирования (`browser` по `event_date`, + остальные по `src_ingest_ts`) — одна строка «почему» либо унифицировать. +- В «Зачем» урока — ответ на мост из урока 1: батч ради наблюдаемости/управляемости. + +**Урок 3 — ODS→DDS, сборка сущностей (`sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql`):** +- [ ] Добавить однострочный «поток данных» в шапку `30_ods_to_dds.sql` + (`ods.* → dds.click + dds.event`) — п.3 чеклиста. +- [ ] В финале урока — **recap всей цепочки** STG→ODS→DDS: защита от фрагментации после + расщепления середины (менти должен собрать сквозную модель, а не три изолированных слоя). +- [ ] **Демоут DM** оформляем здесь: убрать мусор в `sql/dm/40_dds_to_dm.sql` + (стр. ~14–37, закомментированный «пример материализации витрины») и превратить его + в короткую заметку «VIEW сейчас, материализуем если затормозит, см. `docs/ARCHITECTURE.md`». + Добавить «поток данных» в шапку `40_dds_to_dm.sql`. +- [ ] Минор: `sql/ddl/dm/40_dm.sql` — магическое `1919` в `groupArraySample` + (строка «зачем» или упростить). +- Сами витрины DM показываем в деле в уроках 5–6, отдельного разбора не делаем. + +**Урок 4 — Airflow DAG (`airflow/dags/etl_pipeline_dag.py`):** +- [ ] **Главная правка (код↔доки):** сделать `check_dds_integrity` честным гейтом — + добавить `assert_dds_integrity` (PythonOperator), роняющий DAG при + `orphan_events > 0`. Сейчас «проверка» только считает сирот в xcom и пишет их в + `dq_summary`, но DAG остаётся зелёным, хотя `LESSON_STANDARD` §3 и `PRD` §2 (цель 3) + обещают остановку при нарушении целостности. После правки доки и код сходятся. +- [ ] **Управляемая правка урока 4** строится на этом гейте: менти намеренно ломает + целостность (вставляет «осиротевшее» событие) и видит, как DAG краснеет на + `assert_dds_integrity`. Опирается на понятие сирот из урока 3. +- [ ] Добавить ASCII-поток задач в docstring DAG + (`precheck → transform: wait → ods → dq → branch → dds → integrity → dm → validate`) + — для теста одного прохода. +- Замечание: `check_ods_quality` тоже measure-only (метрики в xcom, без гейта) — это + ок и намеренно (DQ по `parse_errors` информативен, но не блокирует). В тексте + урока развести: какие проверки **гейтят** пайплайн, а какие только **измеряют**. + +**Урок 5 — мониторинг (`configs/*`):** +- Конфиги (`prometheus.yml`, экспортёры, provisioning Grafana) полировать под «учебное + качество кода» не нужно: дашборды/JSON не читаются построчно. Вся учебная нагрузка + ложится на **текст урока** (маршрут по дашбордам + смысл панелей). +- [ ] При написании проверить, что имена дашбордов/панелей/метрик в тексте совпадают + с реальными (`configs/grafana/provisioning/dashboards/*.json`). +- [ ] Кандидат на мини-правку (зеркало урока 4): погасить сервис → увидеть, как панель/ + алерт в Grafana краснеет. Решает открытый вопрос PRD про глубину урока (даёт + «сломал-увидел» вместо чистого наблюдения) — обсудить при написании. ## 4. Что дальше -После согласования этого плана — пишем уроки по одному, по шаблону из -`LESSON_STANDARD.md`, начиная с урока 1 (Kafka→CH). Урок 0 (разминка на Kafka UI) -можно готовить параллельно — он не зависит от полировки кода. +Пишем уроки по одному, по шаблону из `LESSON_STANDARD.md`, начиная с урока 1 +(Kafka→CH). Урок 0 (разминка на Kafka UI) можно готовить параллельно — он не зависит +от полировки кода. Правки кода из §3.1 применяем в составе соответствующего урока. diff --git a/docs/course/LESSON_STANDARD.md b/docs/course/LESSON_STANDARD.md index e10721f..1df84de 100644 --- a/docs/course/LESSON_STANDARD.md +++ b/docs/course/LESSON_STANDARD.md @@ -19,9 +19,15 @@ Например: добавить поле в Materialized View и увидеть его в `*_raw`; «сломать» запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name` и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет. + **Верни как было** — каждая правка завершается явным шагом отката к чистому + состоянию (откатить изменение либо `make clean/up/ddl/data/transform`), чтобы + самостоятельный менти не застрял со сломанным стендом без ментора. (Урок 0 — без этого шага, только наблюдение.) 5. **Проверь себя** — самопроверка (раздел 3). -6. **Принеси на сессию** — что доложить ментору и какие вопросы задать. +6. **Принеси на сессию** — **конкретный артефакт** плюс вопросы: скрин видимого + результата правки (например, красный DAG или `+1` в таблице ошибок), один абзац + «своими словами» про паттерн урока или запрос, который пришлось написать. Артефакт + делает самопроверку проверяемой, а сверку — предметной (критерий успеха `PRD` §5). ## 2. Стандарт качества эталонного кода diff --git a/docs/course/PRD.md b/docs/course/PRD.md index cf5c573..34ef49c 100644 --- a/docs/course/PRD.md +++ b/docs/course/PRD.md @@ -1,6 +1,11 @@ # PRD: продвинутый курс «Кликстрим на ClickHouse» (со звёздочкой) > Статус: черновик (прообраз PRD). Дата: 2026-06-01. +> Поправка 2026-06-03 (разморозка по делу): середина пайплайна расщеплена — ODS и DDS +> теперь разные уроки (принцип «один паттерн на урок»), витрины DM демотированы в +> поверхность потребления. Обязательных уроков стало 0–5, опциональный Superset — урок 6. +> Затронуты §4 (скоуп) и §3/§5 (ожидаемый такт — ~день на урок). Это изменение рамки, +> а не план реализации. > Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем > он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план > реализации и не стандарт уроков (см. раздел «Связанные документы»). @@ -55,7 +60,9 @@ Kafka → ClickHouse → BI). но затачиваем под реальный первый прогон, а не под гипотетических будущих менти. - **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, поднимает стенд у себя (`make up`) и идёт по урокам из `docs/course/` рядом с кодом. -- **Роль ментора:** еженедельная сессия-сверка, без построчного разбора кода. + Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**. +- **Роль ментора:** еженедельная сессия-сверка (покрывает несколько уроков), без + построчного разбора кода. - **Железо:** стек тяжёлый (Kafka + ClickHouse + Airflow + Superset + Prometheus + Grafana одновременно). Считаем наличие подходящего железа данностью; стек не режем на части — это усложнило бы жизнь и менти, и автору материала. @@ -63,8 +70,12 @@ Kafka → ClickHouse → BI). ## 4. Скоуп ### Входит -- **Уроки 0–4 (обязательные):** вводный урок по Kafka, заземление Kafka→CH, слоёный - ETL (где Materialized View, а где батч), Airflow, мониторинг. +- **Уроки 0–5 (обязательные):** вводный урок по Kafka, заземление Kafka→CH, STG→ODS + (типизация + DQ), ODS→DDS (сборка сущностей), Airflow, мониторинг. Принцип нарезки — + один прод-паттерн на урок; контраст «где Materialized View, а где батч» проходит + мостом уроков 1→2. +- Витрины **DM — не отдельный урок**: их показываем в деле там, где их потребляют + (мониторинг и BI). См. `LEARNING_PLAN.md` §1–2. - **Аудит и точечная полировка эталонных путей** этих уроков до учебного качества (стандарт — в `LESSON_STANDARD.md`). - Учебная часть вокруг каждого эталонного пути по единому шаблону урока. @@ -73,7 +84,7 @@ Kafka → ClickHouse → BI). обучения `LEARNING_PLAN.md`. ### Опционально -- **Урок 5: Superset (BI-витрина).** Делаем, если останется ресурс; обязательные +- **Урок 6: Superset (BI-витрина).** Делаем, если останется ресурс; обязательные уроки он не блокирует. ### Не входит @@ -88,7 +99,8 @@ Kafka → ClickHouse → BI). ## 5. Критерии успеха -- Менти проходит урок за неделю **сам**, без построчного разбора с ментором. +- Менти проходит урок **сам**, без построчного разбора с ментором (ожидаемый срок — + около дня на урок: уроки короткие и односоставные). - Может своими словами объяснить паттерн урока и привязать его к обычной кликстрим-аналитике. - На сессии приносит осмысленные вопросы по сути, а не «застрял на запуске». @@ -98,7 +110,8 @@ Kafka → ClickHouse → BI). Порядок создания артефактов: -1. **`PRD.md`** (этот документ) — рамка: что, зачем, скоуп. **Замороженный** документ. +1. **`PRD.md`** (этот документ) — рамка: что, зачем, скоуп. По умолчанию **заморожен**; + меняется только осознанной поправкой с датой и причиной в шапке (как 2026-06-03). 2. **`LEARNING_PLAN.md`** — высокоуровневый план обучения: карта уроков, маршрут менти, результаты аудита путей. Пишется после согласования PRD. 3. **`LESSON_STANDARD.md`** — стандарт уроков: шаблон урока, стандарт качества кода, @@ -113,8 +126,9 @@ Kafka → ClickHouse → BI). ## 7. Открытые вопросы / на будущее -- Глубина урока 4 (мониторинг): сколько внутреннего устройства показывать против - «просто наблюдай дашборд». +- Глубина урока 5 (мониторинг): сколько внутреннего устройства показывать против + «просто наблюдай дашборд». Кандидат — мини-правка «погаси сервис → алерт краснеет» + (зеркало урока 4), см. `LEARNING_PLAN.md` §3.1. - Нужна ли BI-витрина (Superset) уже в первой версии или переносим на следующую. - Переиспользование: после первого прогона — ретроспектива и обобщение материала под других менти. From f4d3118a428d49ce38d27f21a643470612b58bef Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 3 Jun 2026 22:34:12 +0300 Subject: [PATCH 04/38] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=201=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B0=D0=BC=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B2=D0=BE=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен первый урок курса по эталонному пути STG, а рамка курса описывала сопровождение как «сессию-сверку», хотя по факту это самостоятельная работа + еженедельный созвон. - Что: - добавлен docs/course/lessons/01_kafka_to_clickhouse.md (Kafka → ClickHouse, слой STG) по шаблону LESSON_STANDARD. - в sql/ddl/stg/10_stg.sql исправлен баг kafka_ts во всех 4 MV: toInt64(DateTime64) срезал миллисекунды, kafka_ts по всему стенду был 1970-01-21; теперь _timestamp_ms присваивается напрямую (downstream на kafka_ts не опирается). - урок 1 §3/§4 приведены к исправленному коду; врезка про рассинхрон MV↔таблица описывает реальное поведение (молчаливый сброс лишней колонки, не ошибка). - «сессия/сессия-сверка» → «созвон» в PRD (датированная поправка), LESSON_STANDARD §6 (секция «Что должно получиться») и README курса; добавлена ссылка на открытый DE-роадмап. - в LEARNING_PLAN исправлен вердикт аудита урока 1 (баг найден прогоном), war-story про toInt64(DateTime64) припаркована в урок 2. - Проверка: - прогон на стенде: make up && make ddl && LIMIT=50 make data → kafka_ts = 2026-… с миллисекундами; правка §4 (ALTER + пересоздание MV + TRUNCATE + перезаливка) и откат отработали. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/course/LEARNING_PLAN.md | 13 +- docs/course/LESSON_STANDARD.md | 10 +- docs/course/PRD.md | 11 +- docs/course/README.md | 15 +- docs/course/lessons/01_kafka_to_clickhouse.md | 244 ++++++++++++++++++ sql/ddl/stg/10_stg.sql | 8 +- 6 files changed, 282 insertions(+), 19 deletions(-) create mode 100644 docs/course/lessons/01_kafka_to_clickhouse.md diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md index ce2dcdb..db33bd6 100644 --- a/docs/course/LEARNING_PLAN.md +++ b/docs/course/LEARNING_PLAN.md @@ -59,7 +59,7 @@ Kafka из роадмапа — оно даёт словарь терминов. | Урок | Путь | Файлы | Вердикт | |------|------|-------|---------| -| 1 | STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | **годно как есть** (+1 строка «зачем») | +| 1 | STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | **точечно править** (баг конвертации `kafka_ts`, найден на стенде) | | 2 | STG→ODS | `sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql` | **точечно править** | | 3 | ODS→DDS (+ демоут DM) | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql`; DM `sql/dm/40_dds_to_dm.sql` + `sql/ddl/dm/40_dm.sql` | **точечно править** | | 4 | Airflow DAG | `airflow/dags/etl_pipeline_dag.py` | **точечно править** | @@ -73,12 +73,19 @@ Kafka из роадмапа — оно даёт словарь терминов. Правки точечные, делаем не отдельным забегом, а в составе соответствующего урока. **Урок 1 — STG (`sql/ddl/stg/10_stg.sql`):** -- [ ] Добавить одну строку «зачем» к `fromUnixTimestamp64Milli(toInt64(_timestamp_ms))` - в MV — это единственный неочевидный трюк файла (конвертация Kafka-таймстампа). +- [x] **Баг конвертации `kafka_ts` (найден прогоном на стенде 2026-06-03).** + Было `fromUnixTimestamp64Milli(toInt64(_timestamp_ms))` во всех 4 MV: `_timestamp_ms` + это `DateTime64(3)`, `toInt64()` срезает его до **секунд**, и `fromUnixTimestamp64Milli` + читает секунды как миллисекунды → `kafka_ts` = `1970-01-21` по всему стенду. + Исправлено на `_timestamp_ms AS kafka_ts` (прямое присвоение, мс сохраняются). + Прежний аудит ошибочно пометил путь «годно как есть» — потому что его не прогоняли. - Колоночные комментарии-пересказы (`-- Партиция`, `-- Имя топика`) безвредны — не трогаем. - В финале урока — мост к уроку 2: вопрос «почему дальше не MV?». **Урок 2 — STG→ODS, типизация + DQ-split (`sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql`):** +- [ ] **Кандидат на war-story по типам:** баг `kafka_ts` из урока 1 (`toInt64(DateTime64)` + молча срезал миллисекунды → 1970). Это идеальная иллюстрация темы урока — «тихая + потеря данных на неверном типе». Решить при написании, давать ли как пример. - [ ] Добавить однострочный «поток данных» в шапку `20_stg_to_ods.sql` (`stg.*_raw → ods.* + ods.*_errors`) — п.3 чеклиста. - [ ] Добавить строку «зачем»: основная таблица = валидный ключ (но может иметь diff --git a/docs/course/LESSON_STANDARD.md b/docs/course/LESSON_STANDARD.md index 1df84de..a4d97a6 100644 --- a/docs/course/LESSON_STANDARD.md +++ b/docs/course/LESSON_STANDARD.md @@ -24,10 +24,12 @@ самостоятельный менти не застрял со сломанным стендом без ментора. (Урок 0 — без этого шага, только наблюдение.) 5. **Проверь себя** — самопроверка (раздел 3). -6. **Принеси на сессию** — **конкретный артефакт** плюс вопросы: скрин видимого - результата правки (например, красный DAG или `+1` в таблице ошибок), один абзац - «своими словами» про паттерн урока или запрос, который пришлось написать. Артефакт - делает самопроверку проверяемой, а сверку — предметной (критерий успеха `PRD` §5). +6. **Что должно получиться** — **конкретный видимый результат**, который менти проверяет + сам: скрин эффекта правки (например, красный DAG или `+1` в таблице ошибок) плюс один + абзац «своими словами» про паттерн урока или запрос, который пришлось написать. Это и + есть самопроверка (раздел 3); те же вопросы «своими словами» менти разбирает с ментором + на еженедельном созвоне. (Урок 0 — без правки, поэтому результат здесь — что менти + увидел при наблюдении.) ## 2. Стандарт качества эталонного кода diff --git a/docs/course/PRD.md b/docs/course/PRD.md index 34ef49c..f661f19 100644 --- a/docs/course/PRD.md +++ b/docs/course/PRD.md @@ -6,6 +6,9 @@ > поверхность потребления. Обязательных уроков стало 0–5, опциональный Superset — урок 6. > Затронуты §4 (скоуп) и §3/§5 (ожидаемый такт — ~день на урок). Это изменение рамки, > а не план реализации. +> Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не +> «сессия»; менти проходит материал сам и ничего «не приносит», а на созвоне ментор +> разбирает затыки и проверяет глубину понимания. Затронуты §1, §3, §5. > Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем > он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план > реализации и не стандарт уроков (см. раздел «Связанные документы»). @@ -30,8 +33,8 @@ data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ET прошедших базу (SQL, моделирование, Python, Git, Docker, Airflow). Цель курса — превратить стенд в **самостоятельный учебный материал**, по которому -продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычной -еженедельной сессии (что получилось / что нет / вопросы / план на неделю). Долгий +продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычном +еженедельном созвоне (что получилось / что нет / вопросы / план на неделю). Долгий разбор кода вживую форматом не предусмотрен — поэтому материал обязан быть самодостаточным. @@ -61,7 +64,7 @@ Kafka → ClickHouse → BI). - **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, поднимает стенд у себя (`make up`) и идёт по урокам из `docs/course/` рядом с кодом. Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**. -- **Роль ментора:** еженедельная сессия-сверка (покрывает несколько уроков), без +- **Роль ментора:** еженедельный созвон-сверка (покрывает несколько уроков), без построчного разбора кода. - **Железо:** стек тяжёлый (Kafka + ClickHouse + Airflow + Superset + Prometheus + Grafana одновременно). Считаем наличие подходящего железа данностью; стек не режем @@ -103,7 +106,7 @@ Kafka → ClickHouse → BI). около дня на урок: уроки короткие и односоставные). - Может своими словами объяснить паттерн урока и привязать его к обычной кликстрим-аналитике. -- На сессии приносит осмысленные вопросы по сути, а не «застрял на запуске». +- На созвоне задаёт осмысленные вопросы по сути, а не «застрял на запуске». - Эталонный код проходит «тест одного прохода» (см. `LESSON_STANDARD.md`). ## 6. Связанные документы и порядок работ diff --git a/docs/course/README.md b/docs/course/README.md index 2dde4a1..012c506 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -1,8 +1,15 @@ # Курс «Кликстрим на ClickHouse» (со звёздочкой) -Продвинутый курс для менти, уже прошедших базовую программу: ключевые паттерны -инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг. Самостоятельный -учебный материал; ментор подключается на еженедельной сессии-сверке. +Продвинутый курс для менти, уже прошедших базовую программу: основные паттерны +инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг. Менти проходит +материал самостоятельно, а на еженедельном созвоне с ментором разбирает затыки и отвечает +на вопросы по теме — чтобы проверить глубину понимания. + +Сам курс — это блок «со звёздочкой» открытого роадмапа Data Engineer +([de.dementev.space](https://de.dementev.space/), он же +[на GitHub](https://github.com/dementev-dev/de-roadmap)). Роадмап открыт и устроен так, +что идти по нему можно в одиночку; с ментором — ощутимо короче дорога: добавляются +персональный план, разбор домашек и код-ревью. ## Что где искать @@ -11,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| `lessons/` *(будет)* | Сами уроки, по одному файлу | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: урок 1) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/01_kafka_to_clickhouse.md b/docs/course/lessons/01_kafka_to_clickhouse.md new file mode 100644 index 0000000..742ce0e --- /dev/null +++ b/docs/course/lessons/01_kafka_to_clickhouse.md @@ -0,0 +1,244 @@ +# Урок 1. Заземление Kafka → ClickHouse (слой STG) + +> Статус: черновик. Режим: **руки**. +> Пререквизит: пройден урок 0 (словарь Kafka — топик, партиция, offset, consumer-группа — +> уже знаком и виден в Kafka UI). +> Эталонный путь: [`sql/ddl/stg/10_stg.sql`](../../../sql/ddl/stg/10_stg.sql). +> +> Поток данных одной строкой: +> `Kafka → kafka_*_raw (ENGINE=Kafka) → MV → *_raw (MergeTree)` + +--- + +## 1. Зачем и где в проде + +Первое, что делаем с потоком событий — складываем его в таблицу как есть, а рядом пишем +метаданные доставки: из какого топика и партиции пришло сообщение, с каким offset'ом и +временем. Это слой STG — тот самый staging, знакомый тебе по курсовой. + +Зачем хранить сырой JSON строкой и не парсить его сразу: + +- чтобы отладке было на что опереться: когда дальше по пайплайну что-то «не сходится», + всегда есть исходное сообщение для сверки; +- чтобы любой следующий слой можно было пересобрать из STG, не вычитывая Kafka заново; +- и, главное, чтобы приём не падал из-за одного кривого поля. Разбор JSON и проверки + качества — это уже следующий слой (урок 2), а STG принимает всё подряд. + +`kafka_offset` здесь не просто метаданные: пара «партиция + offset» однозначно указывает +на конкретное сообщение в топике — по ней всегда понятно, та же это запись или другая. +Сама таблица повторы при этом не отсеивает (`MergeTree` ничего не дедуплицирует) — если +понадобится, дубли убирают уже на следующих слоях. + +> **В проде иначе.** На потоке в десятки тысяч сообщений в секунду читателей будет +> несколько, и Kafka сама делит работу между ними. В этом уроке — один читатель +> (`kafka_num_consumers = 1`) и маленький срез данных: нам важно понять, как поток вообще +> попадает в базу, а не выжимать скорость. + +--- + +## 2. Руки: убедись, что данные текут + +Поднимаем стенд, создаём схему и заливаем **малый срез** (50 строк на топик — этого +хватает, чтобы всё увидеть, и прогон быстрый): + +```bash +make up # поднять инфраструктуру (Kafka, ClickHouse, ...) +make ddl # создать базы и таблицы в ClickHouse (в т.ч. слой STG) +LIMIT=50 make data # залить по 50 строк каждого файла в Kafka-топики +``` + +Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль: +`http://localhost:9123/play` (или Kafka UI на `http://localhost:8082`, чтобы тем же +взглядом, что в уроке 0, увидеть те же сообщения «с другого конца»). + +```sql +-- Сколько сырых событий браузера приземлилось +SELECT count() FROM stg.browser_raw; + +-- Как выглядит приземлённая строка: метаданные доставки + сырой JSON +SELECT kafka_topic, kafka_partition, kafka_offset, kafka_ts, raw +FROM stg.browser_raw +ORDER BY kafka_offset +LIMIT 5; +``` + +Что важно заметить: + +- `raw` — это **целый JSON строкой**, никто его пока не разбирал; +- поля `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_ts` заполнены: видно, + откуда именно приехала запись; +- offset'ы идут по возрастанию и не повторяются. + +--- + +## 3. Загляни внутрь (`sql/ddl/stg/10_stg.sql`) + +Весь слой STG собран из **трёх кирпичей**, и каждый топик повторяет одну и ту же тройку: + +| Кирпич | Объект | Движок | Что делает | +|--------|--------|--------|------------| +| 1 | `stg.browser_raw` | `MergeTree` | **хранит** сырьё + метаданные, постоянно | +| 2 | `stg.kafka_browser_raw` | `ENGINE = Kafka` | **читает** топик, ничего не хранит | +| 3 | `stg.mv_kafka_browser_to_stg` | `MATERIALIZED VIEW` | **перекладывает** из (2) в (1) на лету | + +Главное: таблица с `ENGINE = Kafka` — это не хранилище, а «кран» к топику. Сама по себе +она данные не копит; данные забирает Materialized View и складывает их в обычную +`MergeTree`-таблицу. Сообщение появилось в топике → MV тут же положило его в `*_raw`. + +Стоит задержаться на двух местах файла. + +**Таблица-источник Kafka (`kafka_*_raw`)** — здесь живёт вся настройка чтения: + +```sql +ENGINE = Kafka +SETTINGS + kafka_broker_list = 'kafka:29092', -- адрес брокера внутри Docker-сети + kafka_topic_list = 'browser_events', -- какой топик читаем + kafka_group_name = 'ch_stg_browser', -- consumer-группа: по ней Kafka помнит offset'ы + kafka_format = 'JSONAsString', -- берём сообщение целиком, как строку + kafka_num_consumers = 1, + kafka_handle_error_mode = 'stream'; -- кривое сообщение не рвёт чтение топика +``` + +`JSONAsString` — почему мы и можем класть `raw` строкой: ClickHouse не пытается разобрать +JSON на этом этапе. `kafka_handle_error_mode = 'stream'` — ровно то «STG принимает всё» +из секции 1: битое сообщение не уронит консьюмера. + +**Materialized View** — здесь сообщение превращается в строку таблицы. Метаданные берутся +из виртуальных колонок Kafka-движка (`_topic`, `_partition`, `_offset`, `_timestamp_ms`): + +```sql +SELECT + now64(3) AS ingest_ts, + _topic AS kafka_topic, + _partition AS kafka_partition, + _offset AS kafka_offset, + _timestamp_ms AS kafka_ts, + raw +FROM stg.kafka_browser_raw; +``` + +Тут без фокусов: каждая виртуальная колонка ложится в свою. `_timestamp_ms` — это уже +готовый `DateTime64(3)` (время сообщения с точностью до миллисекунд), поэтому идёт в +`kafka_ts` как есть, без преобразований. В секции 4 ты положишь рядом ещё одно время из +Kafka и увидишь, чем они отличаются. + +--- + +## 4. Управляемая правка: протащи ещё одно поле метаданных + +Задача: добавить в `stg.browser_raw` ещё одну колонку — то же время сообщения, но из +другой виртуальной колонки Kafka: `_timestamp` (тип `DateTime`, точность до секунды) — +и увидеть её заполненной. + +Соль урока — в **порядке из двух шагов**: MV не «дотянет» новое поле само, его сначала +нужно добавить в таблицу-приёмник. Схема приёмника и MV меняются вместе. + +**Шаг 1 — добавляем колонку в таблицу-приёмник:** + +```sql +ALTER TABLE stg.browser_raw ADD COLUMN kafka_msg_ts DateTime; +``` + +**Шаг 2 — пересоздаём MV, чтобы оно заполняло новую колонку** (поменять SELECT можно и +«на месте» через `ALTER TABLE ... MODIFY QUERY`, но для наглядности пересоздадим целиком): + +```sql +DROP VIEW stg.mv_kafka_browser_to_stg; + +CREATE MATERIALIZED VIEW stg.mv_kafka_browser_to_stg +TO stg.browser_raw +AS +SELECT + now64(3) AS ingest_ts, + _topic AS kafka_topic, + _partition AS kafka_partition, + _offset AS kafka_offset, + _timestamp_ms AS kafka_ts, + _timestamp AS kafka_msg_ts, -- ещё одно время из Kafka, но с точностью только до секунды + raw +FROM stg.kafka_browser_raw; +``` + +**Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим таблицу — иначе +рядом останутся строки из секции 2, вставленные ещё *до* `ADD COLUMN`, и в них +`kafka_msg_ts` будет пустой (`1970-01-01`): + +```sql +TRUNCATE TABLE stg.browser_raw; +``` + +Затем перезаливаем — с пересозданием топиков, чтобы Kafka-движок перечитал сообщения +с начала (без этого он считает их уже прочитанными и ничего нового не подхватит): + +```bash +LIMIT=50 RESET_TOPICS=1 make data +``` + +**Смотрим результат:** + +```sql +SELECT kafka_ts, kafka_msg_ts +FROM stg.browser_raw +ORDER BY kafka_offset +LIMIT 5; +``` + +Видно два времени одного и того же сообщения: `kafka_ts` (из `_timestamp_ms`) — с +миллисекундами, `kafka_msg_ts` (из `_timestamp`) — округлённое до секунды. Момент тот же, +точность разная: Kafka отдаёт эту метку через две виртуальные колонки сразу. + +> **Порядок важен, а ClickHouse молчит.** Пересоздай MV с `_timestamp`, **не добавив** +> колонку `kafka_msg_ts` в таблицу, — ошибки не будет. ClickHouse сопоставляет колонки MV +> с таблицей по имени и лишнюю просто отбрасывает: данные текут, а твоё новое поле молча +> теряется. Обратный случай — колонку добавил, а в MV не указал — тоже не упадёт: поле +> заполнится дефолтом. Вывод: за синхронность схемы и MV отвечаешь ты, а не движок. + +**Верни как было** (откат — тоже две операции, и порядок важен): + +```sql +DROP VIEW stg.mv_kafka_browser_to_stg; -- сначала MV, что ссылается на колонку +ALTER TABLE stg.browser_raw DROP COLUMN kafka_msg_ts; +``` + +```bash +make ddl # пересоздаёт эталонный MV из 10_stg.sql — схема снова как в репозитории +``` + +Если запутался в состоянии — всегда есть полный сброс: `make clean && make up && make ddl && LIMIT=50 make data`. + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| `LIMIT=50 make data` | `SELECT count() FROM stg.browser_raw` | счётчик > 0 и растёт после загрузки | +| глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный | +| глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей | +| правка из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw LIMIT 5` | колонка заполнена временем сообщения | + +--- + +## 6. Что должно получиться + +После урока у тебя на руках — видимый результат (одно на выбор): + +- строка `stg.browser_raw` с заполненными `kafka_topic`, `kafka_partition`, `kafka_offset`, + `kafka_ts` и `raw`; +- либо результат твоей правки — две колонки времени, `kafka_ts` и `kafka_msg_ts`, рядом. + +И проверь себя на словах: сможешь объяснить, чем таблица с `ENGINE = Kafka` отличается от +`MergeTree` и зачем между ними нужен Materialized View? Короткая зацепка для ответа — что +случится с данными, если MV убрать? Примерно такие вопросы по теме урока всплывут на +еженедельном созвоне. + +--- + +## Мост к уроку 2 + +Мы приземлили поток через Materialized View — почти «в реальном времени». Возникает +честный вопрос: **раз MV так удобно перекладывает данные, почему дальше, в ODS, мы не +продолжаем на MV, а уходим в батч?** Ответ — в уроке 2: когда доходит до типизации и +проверок качества, важнее видеть и держать под контролем каждый пересчёт, чем экономить +доли секунды на задержке. diff --git a/sql/ddl/stg/10_stg.sql b/sql/ddl/stg/10_stg.sql index cc8a821..1f03d60 100644 --- a/sql/ddl/stg/10_stg.sql +++ b/sql/ddl/stg/10_stg.sql @@ -128,7 +128,7 @@ SELECT _topic AS kafka_topic, _partition AS kafka_partition, _offset AS kafka_offset, - fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts, + _timestamp_ms AS kafka_ts, raw FROM stg.kafka_browser_raw; @@ -140,7 +140,7 @@ SELECT _topic AS kafka_topic, _partition AS kafka_partition, _offset AS kafka_offset, - fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts, + _timestamp_ms AS kafka_ts, raw FROM stg.kafka_location_raw; @@ -152,7 +152,7 @@ SELECT _topic AS kafka_topic, _partition AS kafka_partition, _offset AS kafka_offset, - fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts, + _timestamp_ms AS kafka_ts, raw FROM stg.kafka_device_raw; @@ -164,6 +164,6 @@ SELECT _topic AS kafka_topic, _partition AS kafka_partition, _offset AS kafka_offset, - fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts, + _timestamp_ms AS kafka_ts, raw FROM stg.kafka_geo_raw; From b15ee9093924bd52a92feb97890209e767e5013a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 3 Jun 2026 23:25:23 +0300 Subject: [PATCH 05/38] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=200=20(?= =?UTF-8?q?=D0=B2=D0=B2=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=20Kaf?= =?UTF-8?q?ka,=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - курсу нужна разминка перед уроком 1: связать словарь из обзорного видео по Kafka (топик, партиция, offset, группа, lag) с живым стендом. - Что: - добавлен lessons/00_kafka_intro.md по LESSON_STANDARD в режиме наблюдения (без управляемой правки и отката), эталон голоса — урок 1. - честная врезка про lag: у групп ch_stg_* колонки offset/lag в Kafka UI пустые, прогресс чтения смотреть в ClickHouse (system.kafka_consumers). - README курса: в строке lessons теперь указаны уроки 0 и 1. - Проверка: - факты сверены на живом кластере: 4 топика *_events по 1 партиции, 4 группы ch_stg_* (STABLE, 1 участник); прогоном подтверждено, что новая группа читает топик с начала (auto.offset.reset=earliest). --- docs/course/README.md | 2 +- docs/course/lessons/00_kafka_intro.md | 143 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 docs/course/lessons/00_kafka_intro.md diff --git a/docs/course/README.md b/docs/course/README.md index 012c506..e416c77 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -18,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: урок 1) | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0 и 1) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/00_kafka_intro.md b/docs/course/lessons/00_kafka_intro.md new file mode 100644 index 0000000..aff8b1e --- /dev/null +++ b/docs/course/lessons/00_kafka_intro.md @@ -0,0 +1,143 @@ +# Урок 0. Вводный по Kafka (Kafka UI, наблюдение) + +> Статус: черновик. Режим: **наблюдение** (ничего не меняем, только смотрим). +> Пререквизит: обзорное видео по Kafka из роадмапа — оттуда ты уже знаешь слова +> «топик», «партиция», «offset», «consumer-группа», «lag». Этот урок связывает их +> с живым стендом, чтобы они перестали быть просто словами. +> Эталонный путь: сам Kafka UI на `http://localhost:8082`. +> +> Поток данных одной строкой: +> `make data → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает` + +--- + +## 1. Зачем и где в проде + +В обычной кликстрим-аналитике между тем, кто **порождает** события (трекер на сайте, в +приложении), и тем, кто их **считает** (хранилище, BI), почти всегда стоит Kafka. Она — +буфер и общий журнал: producer пишет события, не зная и не дожидаясь, кто их прочитает; +consumer читает в своём темпе, не трогая producer'а. Если читатель отстал или прилёг — +события не теряются, они лежат в топике и ждут. + +На нашем стенде роли уже расставлены: `make data` играет роль трекера и заливает события +в топики, а ClickHouse — это consumer, который их вычитывает. В этом уроке мы не запускаем +пайплайн и ничего не меняем — мы открываем Kafka UI и **узнаём в живом кластере** те самые +понятия из видео. Это разминка: в уроке 1 ты уже руками увидишь, как эти же сообщения +доезжают до таблиц ClickHouse. + +> **В проде так же, только крупнее.** Здесь один брокер и крошечный срез данных. В бою +> брокеров несколько, топик разбит на много партиций, читателей в группе — тоже несколько, +> и Kafka сама делит партиции между ними. Понятия при этом ровно те же, что мы сейчас +> разглядим на маленьком кластере. + +--- + +## 2. Наблюдай: открой Kafka UI + +Стенд уже должен быть поднят (`make up`) и в топиках должны лежать события +(`LIMIT=50 make data` из урока 1 — или любой прошлый прогон). Открой Kafka UI: +`http://localhost:8082`. Ходи по нему свободно — это режим чтения, сломать тут ничего нельзя. + +Пройди по трём экранам и просто посмотри. + +**Топики** (раздел *Topics*). Увидишь четыре топика событий — по одному на тип: + +- `browser_events` — события браузера (pageview и т.п.); +- `device_events` — про устройство; +- `geo_events` — гео; +- `location_events` — местоположение. + +Рядом может быть служебный топик `__consumer_offsets` (если включён показ внутренних +топиков) — его Kafka использует сама, мы его не трогаем. У каждого нашего топика в колонке +с партициями стоит **1**: топик маленький, делить не на что. + +**Сообщения в топике.** Открой `browser_events` → вкладку *Messages*. Это и есть события, +которые залил `make data`. У каждого сообщения видно: + +- **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …); +- **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события); +- **Value** — тело: JSON события целиком, например: + +```json +{"event_id": "8cca1c7d-...", "event_timestamp": "2022-11-28 20:51:05.627882", + "event_type": "pageview", "browser_name": "Chrome", "browser_language": "sat_IN"} +``` + +Загляни внутрь Value: у события есть своё `event_timestamp` (когда оно случилось, +здесь — 2022 год), и оно отличается от Kafka-Timestamp (когда оно попало в топик — при заливке стенда). +Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на +стороне ClickHouse. + +**Consumer-группы** (раздел *Consumers*). Здесь видно, **кто читает** топики. Найдёшь +четыре группы — по одной на топик: + +- `ch_stg_browser`, `ch_stg_device`, `ch_stg_geo`, `ch_stg_location`. + +Это ClickHouse: он подключился к Kafka как читатель и для каждого топика завёл отдельную +группу. У каждой статус **STABLE** и **1 участник** (member) — тот самый единственный +consumer ClickHouse. + +--- + +## 3. Загляни глубже: что значат эти экраны + +Сложим увиденное в понятия — те же, что в видео, но теперь привязанные к экрану. + +| Понятие | Что это | Где увидел в Kafka UI | +|---------|---------|------------------------| +| **Топик** | именованный поток событий одного типа | `browser_events` и три соседних | +| **Партиция** | часть топика, внутри которой строгий порядок | у нас по 1 на топик | +| **Offset** | номер сообщения внутри партиции, по нему адресуют запись | колонка *Offset* в *Messages* | +| **Consumer-группа** | читатели топика; группа помнит, до какого offset'а дочитали | `ch_stg_*` в разделе *Consumers* | +| **Lag** | отставание: сколько в топике уже есть, но ещё не прочитано | колонка пустая — см. врезку ниже | + +Главная мысль: **offset — это адрес сообщения, а не его содержимое**. Пара «партиция + +offset» однозначно показывает на конкретную запись в топике. Именно по offset'у +consumer-группа помнит, где остановилась: дочитала до 19 — в следующий раз начнёт с 20, +а не сначала. Поэтому имя группы — не косметика: смени его, и читатель начнёт топик заново +(это ты потрогаешь руками в уроке 1, там группа задаётся в DDL как `kafka_group_name`). + +**Lag** (отставание) — это, по сути, простая арифметика: сколько сообщений в топике уже +есть, минус сколько группа успела прочитать. Ноль — читатель идёт вровень с потоком; растёт +— читатель не успевает. + +> **Маленькая честность про lag.** У групп `ch_stg_*` колонки offset и lag в Kafka UI +> окажутся **пустыми**, хотя сама группа живая (`STABLE`, 1 участник). Это не баг и не +> поломка: так уж ClickHouse-движок работает с группой — свой прогресс чтения он держит +> **на своей стороне** (его видно в системной таблице `system.kafka_consumers`), и именно +> там стоит смотреть, не отстаёт ли он. Не удивляйся пустой ячейке offset/lag в Kafka UI; +> проверять отставание ClickHouse удобнее с его конца — этим и займёшься в уроке 1. + +--- + +## 4. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| открыть список топиков | Kafka UI → *Topics* | 4 топика событий (`*_events`), у каждого 1 партиция | +| открыть `browser_events` | вкладка *Messages* | сообщения с offset'ами 0, 1, 2, …; в Value — JSON события | +| сравнить два времени | Value (`event_timestamp`) vs Kafka *Timestamp* | время события и время доставки в Kafka различаются | +| открыть consumers | Kafka UI → *Consumers* | 4 группы `ch_stg_*`, статус `STABLE`, по 1 участнику | + +--- + +## 5. Что должно получиться + +После урока у тебя в голове — карта живого кластера: четыре топика событий, в каждом +сообщения с offset'ами, и четыре consumer-группы ClickHouse, которые их читают. Ничего +не меняли — только сопоставили слова из видео с экранами Kafka UI. + +И проверь себя на словах: сможешь объяснить, чем **топик** отличается от **партиции**, что +такое **offset** и зачем consumer-группе его помнить? Короткая зацепка для ответа — что +произойдёт, если читатель забудет свой offset и начнёт топик сначала? Примерно такие вопросы +по теме урока всплывут на еженедельном созвоне. + +--- + +## Мост к уроку 1 + +Мы посмотрели на поток **со стороны Kafka**: события лежат в топиках, ClickHouse подключён +к ним группами и что-то вычитывает. Логичный следующий вопрос: **а куда именно ClickHouse +кладёт прочитанное и как он это делает?** Об этом — урок 1: там ты руками увидишь, как +сообщение из топика превращается в строку таблицы ClickHouse, и заодно посмотришь на чтение +Kafka «с другого конца». From 256ac14c664c5083b408d229b8535ed50f863d22 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 17:08:29 +0300 Subject: [PATCH 06/38] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=202=20(STG?= =?UTF-8?q?=E2=86=92ODS,=20=D1=82=D0=B8=D0=BF=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20DQ-split)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен учебный урок «руки» про типизацию слоя ODS и разделение чистых/битых записей; по пути убрать мусор и неочевидности в эталонном пути, чтобы он читался за один проход. - Что: - добавлен lessons/02_stg_to_ods.md по LESSON_STANDARD (6 секций, режим «руки», эталон голоса — урок 1); регистр смягчён под уровень «обзорно» с расшифровкой терминов (click-контекст, WITH, двойной учёт). - 20_stg_to_ods.sql: поток данных в шапку + блок «DQ-split» (почему строка может попасть и в основную таблицу, и в *_errors). - 20_ods.sql: убран мусорный блок из 8 DROP TABLE mv_*_to_ods; пояснено разное партиционирование (browser — по бизнес-дате, click-контекст — по дате загрузки). - Проверка: - make ddl && make transform — проходят чисто, counts не изменились (browser/location 50, device/geo 26 дедуп, *_errors 0). - правка §4 (toFloat64OrNull→toInt64OrNull для geo_latitude) на стенде даёт geo_by_click_errors 0→50 и NULL-широту с флагом bad_geo_latitude. --- docs/course/lessons/02_stg_to_ods.md | 362 +++++++++++++++++++++++++++ sql/ddl/ods/20_ods.sql | 17 +- sql/ods/20_stg_to_ods.sql | 9 + 3 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 docs/course/lessons/02_stg_to_ods.md diff --git a/docs/course/lessons/02_stg_to_ods.md b/docs/course/lessons/02_stg_to_ods.md new file mode 100644 index 0000000..5c0f39a --- /dev/null +++ b/docs/course/lessons/02_stg_to_ods.md @@ -0,0 +1,362 @@ +# Урок 2. STG → ODS: типизация и DQ-split + +> Статус: черновик. Режим: **руки**. +> Пререквизит: пройден урок 1 (слой STG — сырой JSON строкой уже лежит в `stg.*_raw`, +> рядом метаданные доставки из Kafka). +> Эталонный путь: [`sql/ods/20_stg_to_ods.sql`](../../../sql/ods/20_stg_to_ods.sql) +> и DDL целевых таблиц [`sql/ddl/ods/20_ods.sql`](../../../sql/ddl/ods/20_ods.sql). +> +> Поток данных одной строкой: +> `stg.*_raw → ods.* (валидный ключ) + ods.*_errors (любая ошибка)` +> +> О чём урок простыми словами: берём сырой JSON из STG, разбираем его на поля и приводим +> к типам, а заодно отделяем чистые записи от битых. И смотрим, что бывает, когда тип выбран +> неверно. + +--- + +## 1. Зачем и где в проде + +В прошлом уроке мы сложили сообщение в STG как есть — целым JSON-строкой. Никто его там не +разбирал: задача STG была просто принять поток и ничего не уронить. + +Теперь этот JSON пора разобрать. Каждое поле достаём из строки и приводим к нормальному типу: +`event_id` делаем `UUID`, время события — `DateTime`, координаты — числом. Зачем это нужно? +Пока значение лежит строкой, с ним почти ничего нельзя сделать: по строке не отфильтруешь +события за вчера, не сложишь координаты, не сравнишь числа. Как только поле стало настоящим +типом — с ним уже работают запросы. Слой, где данные впервые типизированы, и называется +**ODS**. + +И ещё одно: именно здесь мы впервые начинаем **отделять чистое от грязного**. Поток никогда +не бывает идеальным — где-то поле пустое, где-то вместо UUID мусор, где-то число записано как +текст. Бросать такие записи нельзя (вдруг пригодятся для разбора), но и держать их вперемешку +с чистыми — мешать себе же. Поэтому на входе в ODS поток раздваивается. + +### Правило, по которому всё раскладывается + +Запомни его на весь урок — дальше всё держится на нём: + +- у каждой таблицы есть **ключ** — поле, которое однозначно опознаёт запись. Для событий это + `event_id`, для контекста клика (устройство, гео) — `click_id`; +- если ключ **разобрался** (получился валидным) — строка едет в **основную таблицу** `ods.*`. + Это «рабочие» данные, с которыми дальше живёт пайплайн; +- а **копия** любой строки, где при разборе случилась **хоть одна ошибка**, едет в отдельную + таблицу ошибок `ods.*_errors`. Туда складываем битое, чтобы потом разобрать, — и не теряем + его, и не мешаем им чистым данным. + +Вот это раздвоение по качеству и называется **DQ-split** (DQ — data quality, качество данных; +split — разделение). + +Осталась одна тонкость, к которой мы вернёмся в секции 3. Ошибка бывает не только в ключе. +Бывает, что ключ-то валидный, а испортилось какое-то **другое** поле. Тогда строка остаётся +в основной таблице (ключ на месте, она рабочая), но рядом, прямо в самой строке, ставится +пометка: вот это поле не разобралось. Пометки складываются в специальный столбец-список +`parse_errors`. И да — из-за этого одна запись может оказаться сразу в двух местах. Это не +ошибка, так задумано; почему — разберём ниже. + +### Почему батч, а не Materialized View + +В уроке 1 остался открытый вопрос: поток в STG перекладывало Materialized View, почти в +реальном времени, — почему дальше так не продолжить? + +Разобрать STG → ODS через MV технически можно: оно бы типизировало каждое сообщение на лету, +по одному. Но мы сознательно идём другим путём — **батчем**. Батч значит вот что: всю таблицу +ODS мы пересобираем целиком, одной задачей Airflow. Сначала очищаем (`TRUNCATE`), потом +заново наполняем (`INSERT` из STG). + +Зачем так, если MV быстрее? Ради двух вещей. + +- **Видно каждый прогон.** Батч — это отдельная задача в Airflow: у неё есть запуск, статус, + лог. Если что-то пошло не так, ты видишь, *какой* прогон сломался. MV же работает молча, + фоном, и поймать момент сложнее. +- **Пересчёт повторяем.** Раз мы каждый раз чистим и наполняем заново, повторный запуск + даёт ровно тот же результат. Захотел пересобрать слой — просто запусти задачу ещё раз. + +На разборе типов и проверках качества это важнее, чем выиграть доли секунды на задержке. Это +и есть «наблюдаемость и управляемость пересчёта» — одна из целей нашего стенда. + +> **В проде иначе.** Чистить и наполнять таблицу целиком каждый раз — это нормально для демо +> и маленького среза. На реальных объёмах так не делают: данные грузят инкрементально — +> добирают только новые, по «водяному знаку» (watermark — отметка, до какого момента уже всё +> загружено). Сама идея слоёв и DQ-split при этом не меняется. + +--- + +## 2. Руки: смотрим базовый прогон + +Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем +трансформацию: + +```bash +make up # поднять инфраструктуру +make ddl # создать базы и таблицы (в т.ч. слой ODS) +LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG +make transform # батч STG → ODS → DDS → DM (нас интересует первый шаг) +``` + +`make transform` прогоняет всю цепочку слоёв сразу, но прямо в консоли печатает то, что нам +нужно сейчас, — блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам +слоя (четыре основных и четыре с ошибками): + +``` +Статистика ODS: + ┌─table──────────────────────┬─rows─┐ + │ ods.browser_event │ 50 │ + │ ods.browser_event_errors │ 0 │ + │ ods.location_event │ 50 │ + │ ods.location_event_errors │ 0 │ + │ ods.device_by_click │ 26 │ + │ ods.device_by_click_errors │ 0 │ + │ ods.geo_by_click │ 26 │ + │ ods.geo_by_click_errors │ 0 │ + └────────────────────────────┴──────┘ +``` + +Прочитаем эту табличку по строчкам — в ней три вещи, которые стоит заметить. + +**Все четыре `*_errors` — по нулям.** Значит, наш срез чистый: ни одна запись не дала ошибки +разбора, столбец `parse_errors` у всех пустой. Это нормально — данные в демо аккуратные. +Ошибки мы увидим в секции 4, когда сами их устроим. + +**`browser` и `location` дали 50 из 50.** Сколько событий пришло — столько и легло, один к +одному. + +**А `device` и `geo` — только 26 из 50.** Вот это уже интересно. Половина куда-то делась? Нет. +И это важно понять, иначе дальше будет казаться, что данные текут. + +Дело в том, что эти две таблицы хранят не события, а **контекст клика**: с какого устройства +был клик и из какой точки на карте. Ключ у них — `click_id`. А в срезе на 50 событий разных +кликов всего 26: на один клик приходится несколько событий, и `click_id` у них повторяется. +Движок таблицы (про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке +на клик. Отсюда и 26. + +Проверь это сам, а не верь на слово. Открой SQL-консоль `http://localhost:9123/play` +(пользователь `default`, пароль `123456`) и посчитай, сколько в срезе *различных* `click_id`: + +```sql +-- Всего строк в STG — 50, но различных click_id среди них — ровно 26 +SELECT count() AS stg_rows, + uniqExact(toUUIDOrNull(JSONExtractString(raw, 'click_id'))) AS distinct_clicks +FROM stg.geo_raw; +``` + +Получишь `stg_rows = 50`, `distinct_clicks = 26` — ровно столько, сколько строк в +`ods.geo_by_click`. Значит, 26 — это схлопнутые повторы, а не пропавшие данные. Ничего не +потерялось молча. + +--- + +## 3. Загляни внутрь + +Слой описан **двумя файлами**. Их полезно держать открытыми рядом — они про разное: + +| Файл | Что задаёт | +|------|------------| +| `sql/ddl/ods/20_ods.sql` | **форму** целевых таблиц: какие колонки, какие типы, какой движок | +| `sql/ods/20_stg_to_ods.sql` | **наполнение**: как из сырого JSON получить эти колонки | + +Дальше — три места, ради которых урок и затевался. Пойдём по ним по порядку. + +### Типизация через `*OrNull` + +Поле достаём из JSON и тут же приводим к нужному типу. Но не «жёстко», а через функции, +у которых на конце стоит `OrNull`: + +```sql +toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id, +parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts, +toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude +``` + +Читается так: `JSONExtractString(raw, 'event_id')` достаёт поле из JSON как строку, а +`toUUIDOrNull(...)` пытается превратить эту строку в `UUID`. + +Весь смысл — в суффиксе `OrNull`. Если значение **не** приводится к нужному типу (вместо UUID +пришёл мусор), функция не падает с ошибкой, а просто возвращает `NULL`. Это ровно то правило +стенда, что и в STG — «грязная запись не валит пайплайн», — только теперь на уровне типов. +Один кривой `event_id` станет `NULL` и будет помечен, а остальные 49 строк спокойно доедут. + +> Кстати, про `AS`: эти строки живут в блоке `WITH` в начале запроса. `WITH` — это просто +> способ заранее посчитать значение и дать ему имя, чтобы ниже по запросу ссылаться на него +> коротко, по имени, а не повторять всю формулу. Имя задаётся через `AS`. + +### Сборка `parse_errors` + +Теперь — как собирается тот самый список пометок. Какие именно поля не разобрались, видно вот +здесь: + +```sql +arrayFilter(x -> x != '', [ + if(event_id IS NULL, 'bad_event_id', ''), + if(event_ts IS NULL, 'bad_event_timestamp', ''), + if(click_id IS NULL, 'bad_click_id', '') +]) AS parse_errors +``` + +Разберём изнутри. Сначала строится список меток: на каждое поле — своя строка. Если поле +вышло `NULL` (не разобралось) — кладём метку вроде `'bad_event_id'`, иначе — пустую строку +`''`. Потом `arrayFilter` выкидывает из списка все пустые строки. Что осталось — и есть список +«что сломалось в этой записи», прямо в самой строке данных. У чистой записи он пустой. + +### Сам split — и почему запись бывает в двух местах + +Теперь главное. Одни и те же строки STG раскладываются по двум `INSERT` — в основную таблицу +и в таблицу ошибок. Отличаются они условием `WHERE`: + +```sql +-- в основную таблицу: берём строки с валидным ключом +... WHERE event_id IS NOT NULL; + +-- в таблицу ошибок: берём строки, где есть хоть одна ошибка разбора +... WHERE length(parse_errors) > 0 + AND (event_id IS NULL OR event_ts IS NULL OR click_id IS NULL); +``` + +Обрати внимание: эти два условия **пересекаются**, и это сделано нарочно. Представь строку, у +которой `event_id` валидный, а вот `event_timestamp` пришёл битый. Что с ней происходит: + +- в основную таблицу она **попадёт** — ключ (`event_id`) на месте, строка рабочая. Рядом в + `parse_errors` будет стоять метка `bad_event_timestamp`; +- и в таблицу ошибок она **тоже попадёт** — ошибка-то в ней есть. + +Одна запись — в двух местах. Это и есть «двойной учёт», и у каждой таблицы тут своя роль. +Основная отвечает на вопрос «что у нас есть для работы» (и честно помечает, где в строке +изъян). Таблица ошибок отвечает на другой вопрос — «что пришло битым и требует разбора». В +самом файле это записано комментарием в шапке, в блоке «DQ-split». + +> **Заметь на будущее.** Логика разбора в файле **продублирована**: каждое поле типизируется +> дважды — один раз в `INSERT` основной таблицы, другой раз в `INSERT` таблицы ошибок (у +> каждого свой `WITH` с теми же формулами). Для учебного файла так нагляднее, но есть цена: +> если поменять разбор только в одном из двух мест, они разойдутся. В секции 4 мы как раз этим +> воспользуемся — и увидим, чем грозит такой рассинхрон. + +### Движок: откуда взялись 26 строк + +И последнее место — строчка про движок основных таблиц: + +```sql +ENGINE = ReplacingMergeTree(src_ingest_ts) +ORDER BY (click_id) +``` + +`ReplacingMergeTree` — это таблица, которая схлопывает строки с одинаковым ключом (ключ берётся +из `ORDER BY`), оставляя самую свежую по `src_ingest_ts` — времени загрузки в ODS. Вот она, +причина «26 из 50» из секции 2: у `device` и `geo` много строк с одинаковым `click_id`, и +движок оставляет по одной на клик. + +--- + +## 4. Управляемая правка: сломай тип — поймай тихую потерю + +Урок про типы — так давай **намеренно ошибёмся типом** и посмотрим, что будет. Это самый +поучительный момент урока. + +Возьмём координату `geo_latitude` — широту. Это дробное число, например `50.82709`. Достаём мы +её через `toFloat64OrNull` — «привести к дробному числу». Заменим тип на целочисленный — +`toInt64OrNull`, «привести к целому». Для строки `"50.82709"` целого числа не получится +(там точка, дробная часть), и функция вернёт `NULL`. То есть широта просто исчезнет. + +Из секции 3 помним: разбор продублирован, поэтому правок будет **две** — в обоих `INSERT` +блока `GEO EVENTS`. Открой `sql/ods/20_stg_to_ods.sql`, найди оба вхождения и в каждом замени +функцию: + +```sql +-- было: +toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude +-- стало: +toInt64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude +``` + +Пересобираем слой: + +```bash +make transform +``` + +И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная: + +``` + │ ods.geo_by_click │ 26 │ + │ ods.geo_by_click_errors │ 50 │ ← было 0 +``` + +А в самой основной таблице широта пропала — но не молча, рядом стоит метка: + +```sql +SELECT click_id, geo_latitude, geo_longitude, parse_errors +FROM ods.geo_by_click +LIMIT 4; +``` + +``` +┌─click_id─────┬─geo_latitude─┬─geo_longitude─┬─parse_errors─────────┐ +│ 58cdfc1e-... │ ᴺᵁᴸᴸ │ -0.2 │ ['bad_geo_latitude'] │ +│ 9ffd819b-... │ ᴺᵁᴸᴸ │ 85.37752 │ ['bad_geo_latitude'] │ +└──────────────┴──────────────┴───────────────┴──────────────────────┘ +``` + +Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. 26 строк +остались в основной таблице (ключ `click_id` цел) с пометкой `bad_geo_latitude`. И те же +записи попали в число 50 строк `geo_by_click_errors`. Долгота на месте, а широты больше нет: +один неверный тип — и целое поле потеряно по всему слою. Заметили это `parse_errors` и таблица +ошибок — для того DQ-split и нужен. + +> **Бывает и хуже — тихо, совсем без метки.** Здесь нас спас суффикс `OrNull`: неверный тип +> дал `NULL`, а `NULL` мы умеем замечать (на него и сработал `parse_errors`). По-настоящему +> опасен другой случай — когда неверный тип **успешно** возвращает *неправильное* значение. +> Ни `NULL`, ни ошибки, ни метки — всё «зелёное», а данные испорчены. Ровно так в уроке 1 и +> нашёлся баг: время `kafka_ts` приводили через `toInt64(...)` от значения типа `DateTime64`, +> это молча срезало миллисекунды, и время по всему стенду уехало в `1970-01-21`. Ничто на это +> не указывало — поймали только прогоном на стенде. Мораль урока: тип выбирают осознанно, даже +> когда функция «не падает». + +**Верни как было.** Откати обе правки — верни `toFloat64OrNull` в оба места. Если запутался, +проще одной командой откатить весь файл к версии из репозитория: + +```bash +git checkout -- sql/ods/20_stg_to_ods.sql +make transform +``` + +После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем «поплыл» — +всегда есть полный сброс: `make clean && make up && make ddl && LIMIT=50 make data && make transform`. + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| `make transform` (базовый прогон) | блок «Статистика ODS» | `browser`/`location` = 50, `device`/`geo` = 26, все `*_errors` = 0 | +| почему 26, а не 50 | запрос `uniqExact(click_id)` по `stg.geo_raw` | 26 различных `click_id` — это схлопывание повторов, а не потеря | +| правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул `0 → 50` | +| правка из секции 4 | `SELECT geo_latitude, parse_errors FROM ods.geo_by_click` | широта `NULL`, в `parse_errors` — `bad_geo_latitude` | + +--- + +## 6. Что должно получиться + +После урока у тебя на руках — видимый результат (одно на выбор): + +- скрин блока «Статистика ODS», где после правки `ods.geo_by_click_errors` ушёл с `0` на `50`; +- либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом. + +И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: + +- чем функции с суффиксом `OrNull` удобнее «жёсткого» приведения типа; +- почему слой ODS мы наполняем батчем, а не Materialized View, как STG; +- почему одна и та же строка может оказаться и в основной таблице, и в `*_errors`. + +Если на последнем вопросе запнёшься — вернись к секции 3 и посмотри на условия `WHERE` у двух +`INSERT`. Ответ там. + +--- + +## Мост к уроку 3 + +Данные теперь типизированы и разложены по качеству. Но `ods.device_by_click` и +`ods.geo_by_click` — это всё ещё **отдельные** кусочки про один клик: устройство в одной +таблице, гео в другой. В уроке 3 (ODS → DDS) мы соберём из них цельную сущность — `dds.click` +(клик сразу с устройством и гео) — и таблицу событий `dds.event`. И там же наткнёмся на первый +вопрос целостности: а что делать с событием, у которого нет своего клика? Такие «сироты» +(orphan) — тема следующего урока. diff --git a/sql/ddl/ods/20_ods.sql b/sql/ddl/ods/20_ods.sql index f9df01c..1493367 100644 --- a/sql/ddl/ods/20_ods.sql +++ b/sql/ddl/ods/20_ods.sql @@ -9,21 +9,9 @@ -- -- Важно: -- - Наполнение ODS выполняется batch-процессом из sql/ods/20_stg_to_ods.sql --- - Materialized View STG → ODS в target-state не используются +-- (а не Materialized View, как в STG): батч ради наблюдаемости пересчёта -- ============================================================================ --- ---------------------------------------------------------------------------- --- Удаляем legacy MV STG → ODS (если ранее были созданы) --- ---------------------------------------------------------------------------- -DROP TABLE IF EXISTS stg.mv_browser_raw_to_ods; -DROP TABLE IF EXISTS stg.mv_browser_raw_to_ods_errors; -DROP TABLE IF EXISTS stg.mv_location_raw_to_ods; -DROP TABLE IF EXISTS stg.mv_location_raw_to_ods_errors; -DROP TABLE IF EXISTS stg.mv_device_raw_to_ods; -DROP TABLE IF EXISTS stg.mv_device_raw_to_ods_errors; -DROP TABLE IF EXISTS stg.mv_geo_raw_to_ods; -DROP TABLE IF EXISTS stg.mv_geo_raw_to_ods_errors; - -- ============================================================================ -- BROWSER EVENTS -- ============================================================================ @@ -46,6 +34,8 @@ CREATE TABLE IF NOT EXISTS ods.browser_event parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть) ) ENGINE = ReplacingMergeTree(src_ingest_ts) +-- Партиционируем по бизнес-дате события: у browser_event есть собственное время (event_ts). +-- У click-контекста ниже (location/device/geo) такого времени нет — там партиция по дате загрузки. PARTITION BY toYYYYMM(event_date) ORDER BY (event_id) SETTINGS allow_nullable_key = 1; @@ -87,6 +77,7 @@ CREATE TABLE IF NOT EXISTS ods.location_event parse_errors Array(LowCardinality(String)) ) ENGINE = ReplacingMergeTree(src_ingest_ts) +-- По дате загрузки: у location нет собственного времени события (только время приёма в STG) PARTITION BY toYYYYMM(toDate(src_ingest_ts)) ORDER BY (event_id) SETTINGS allow_nullable_key = 1; diff --git a/sql/ods/20_stg_to_ods.sql b/sql/ods/20_stg_to_ods.sql index 50b4694..882c174 100644 --- a/sql/ods/20_stg_to_ods.sql +++ b/sql/ods/20_stg_to_ods.sql @@ -1,11 +1,20 @@ -- ============================================================================ -- Batch-трансформация: STG → ODS -- ============================================================================ +-- Поток данных: +-- stg.*_raw → ods.* (валидный ключ) + ods.*_errors (любая ошибка) +-- -- Назначение: -- - Перенос типизации STG → ODS из Materialized View в управляемый batch -- - Полная пересборка ODS для прозрачного мониторинга в Airflow -- - Сохранение DQ-логики: parse_errors + отдельные *_errors таблицы -- +-- DQ-split (почему одна строка может попасть в оба места): +-- - Основная таблица ods.* — строки с валидным КЛЮЧОМ (event_id / click_id). +-- В ней допустимы parse_errors по НЕключевым полям — строка остаётся, но помечена. +-- - Таблица ods.*_errors — копия строк с ЛЮБОЙ ошибкой парсинга (для разбора). +-- Поэтому строка с валидным ключом, но битым неключевым полем, попадёт И туда, И туда. +-- -- Когда запускать: -- - В DAG etl_pipeline перед ODS → DDS -- - После загрузки очередного среза данных в Kafka/STG From f56166181b0d8107f06670926ce415348310d792 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 17:41:21 +0300 Subject: [PATCH 07/38] =?UTF-8?q?docs(course):=20=D1=81=D0=BC=D1=8F=D0=B3?= =?UTF-8?q?=D1=87=D1=91=D0=BD=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=200=E2=80=931=20=D0=B8?= =?UTF-8?q?=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=C2=AB=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - на уроке 2 решили писать разжёванным языком; уроки 0–1 и шапки курса остались в сжатом регистре, а слово «черновик»/«Режим: руки» путало менти. - Что: - урок 1: расшифрованы staging, MergeTree-дедуп, Materialized View и виртуальные колонки; секция «Загляни внутрь» разбита на ###-подзаголовки; плотные абзацы разбиты на пункты; добавлен зачин «О чём урок простыми словами». - урок 0: добавлен зачин «О чём урок простыми словами» (лёгкая полировка). - шапки всех уроков: «Статус: черновик. Режим: руки/наблюдение» заменены на понятное «Формат: практика/наблюдение — …». - PRD/LEARNING_PLAN/LESSON_STANDARD: убрано слово «черновик» из статуса. - Проверка: - grep -rn "черновик" docs/course/ — пусто; - прочитать урок 1 сверху вниз: термины раскрыты на первом употреблении. --- docs/course/LEARNING_PLAN.md | 2 +- docs/course/LESSON_STANDARD.md | 2 +- docs/course/PRD.md | 2 +- docs/course/lessons/00_kafka_intro.md | 5 +- docs/course/lessons/01_kafka_to_clickhouse.md | 72 +++++++++++++------ docs/course/lessons/02_stg_to_ods.md | 2 +- 6 files changed, 58 insertions(+), 27 deletions(-) diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md index db33bd6..3b79f1a 100644 --- a/docs/course/LEARNING_PLAN.md +++ b/docs/course/LEARNING_PLAN.md @@ -1,6 +1,6 @@ # План обучения: курс «Кликстрим на ClickHouse» -> Статус: черновик. Дата: 2026-06-03 (аудит путей выполнен; середина расщеплена — +> Дата: 2026-06-03 (аудит путей выполнен; середина расщеплена — > один паттерн на урок, всего 7 уроков, см. §1–2). > Назначение: высокоуровневый маршрут менти по курсу — карта уроков, порядок, > результаты аудита эталонных путей. Рамка курса (зачем/что/скоуп) — в `PRD.md`; diff --git a/docs/course/LESSON_STANDARD.md b/docs/course/LESSON_STANDARD.md index a4d97a6..395b7af 100644 --- a/docs/course/LESSON_STANDARD.md +++ b/docs/course/LESSON_STANDARD.md @@ -1,6 +1,6 @@ # Стандарт уроков курса «Кликстрим на ClickHouse» -> Статус: черновик. Дата: 2026-06-01. +> Дата: 2026-06-01. > Назначение: рабочий чеклист, по которому пишется **каждый** урок. Открывается при > создании урока. Рамка курса (зачем/что/скоуп) — в `PRD.md`; карта уроков и > маршрут — в `LEARNING_PLAN.md`. diff --git a/docs/course/PRD.md b/docs/course/PRD.md index f661f19..518d2e2 100644 --- a/docs/course/PRD.md +++ b/docs/course/PRD.md @@ -1,6 +1,6 @@ # PRD: продвинутый курс «Кликстрим на ClickHouse» (со звёздочкой) -> Статус: черновик (прообраз PRD). Дата: 2026-06-01. +> Статус: прообраз PRD. Дата: 2026-06-01. > Поправка 2026-06-03 (разморозка по делу): середина пайплайна расщеплена — ODS и DDS > теперь разные уроки (принцип «один паттерн на урок»), витрины DM демотированы в > поверхность потребления. Обязательных уроков стало 0–5, опциональный Superset — урок 6. diff --git a/docs/course/lessons/00_kafka_intro.md b/docs/course/lessons/00_kafka_intro.md index aff8b1e..b24d436 100644 --- a/docs/course/lessons/00_kafka_intro.md +++ b/docs/course/lessons/00_kafka_intro.md @@ -1,6 +1,6 @@ # Урок 0. Вводный по Kafka (Kafka UI, наблюдение) -> Статус: черновик. Режим: **наблюдение** (ничего не меняем, только смотрим). +> Формат: **наблюдение** — ничего не запускаем и не меняем, только смотрим. > Пререквизит: обзорное видео по Kafka из роадмапа — оттуда ты уже знаешь слова > «топик», «партиция», «offset», «consumer-группа», «lag». Этот урок связывает их > с живым стендом, чтобы они перестали быть просто словами. @@ -8,6 +8,9 @@ > > Поток данных одной строкой: > `make data → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает` +> +> О чём урок простыми словами: ходим по Kafka UI и разглядываем поток — где лежат события, +> кто их читает и как Kafka помнит, до какого места уже дочитано. --- diff --git a/docs/course/lessons/01_kafka_to_clickhouse.md b/docs/course/lessons/01_kafka_to_clickhouse.md index 742ce0e..2c12e1a 100644 --- a/docs/course/lessons/01_kafka_to_clickhouse.md +++ b/docs/course/lessons/01_kafka_to_clickhouse.md @@ -1,20 +1,26 @@ # Урок 1. Заземление Kafka → ClickHouse (слой STG) -> Статус: черновик. Режим: **руки**. +> Формат: **практика** — будешь сам запускать команды и менять код, не только читать. > Пререквизит: пройден урок 0 (словарь Kafka — топик, партиция, offset, consumer-группа — > уже знаком и виден в Kafka UI). > Эталонный путь: [`sql/ddl/stg/10_stg.sql`](../../../sql/ddl/stg/10_stg.sql). > > Поток данных одной строкой: > `Kafka → kafka_*_raw (ENGINE=Kafka) → MV → *_raw (MergeTree)` +> +> О чём урок простыми словами: смотрим, как сообщение из Kafka-топика само, без нашего +> участия, превращается в строку таблицы ClickHouse — и почему на этом первом слое мы кладём +> JSON целиком, ничего в нём не разбирая. --- ## 1. Зачем и где в проде -Первое, что делаем с потоком событий — складываем его в таблицу как есть, а рядом пишем -метаданные доставки: из какого топика и партиции пришло сообщение, с каким offset'ом и -временем. Это слой STG — тот самый staging, знакомый тебе по курсовой. +Первое, что делаем с потоком событий — складываем его в таблицу **как есть**, ничего в нём +не меняя. А рядом, в соседних колонках, пишем метаданные доставки: из какого топика и партиции +пришло сообщение, с каким offset'ом и в какое время. Это слой **STG** — тот самый staging, +знакомый тебе по курсовой: первая «посадочная площадка», куда поток приземляется в сыром виде, +до любой обработки. Зачем хранить сырой JSON строкой и не парсить его сразу: @@ -24,10 +30,15 @@ - и, главное, чтобы приём не падал из-за одного кривого поля. Разбор JSON и проверки качества — это уже следующий слой (урок 2), а STG принимает всё подряд. -`kafka_offset` здесь не просто метаданные: пара «партиция + offset» однозначно указывает -на конкретное сообщение в топике — по ней всегда понятно, та же это запись или другая. -Сама таблица повторы при этом не отсеивает (`MergeTree` ничего не дедуплицирует) — если -понадобится, дубли убирают уже на следующих слоях. +Отдельно про `kafka_offset` — это не просто справочная метка. Помнишь из урока 0: пара +«партиция + offset» однозначно указывает на конкретное сообщение в топике. По ней всегда +видно, та же это запись или другая, — пригодится, когда дальше начнём сверять данные между +слоями. + +При этом **повторы STG не отсеивает**. Движок этих таблиц — `MergeTree`, и он не +дедуплицирует, то есть не убирает строки-дубли: что пришло, то и легло, даже если две записи +окажутся одинаковыми. Если дубли потом помешают — их убирают уже на следующих слоях, а STG +держит всё подряд. > **В проде иначе.** На потоке в десятки тысяч сообщений в секунду читателей будет > несколько, и Kafka сама делит работу между ними. В этом уроке — один читатель @@ -73,6 +84,8 @@ LIMIT 5; ## 3. Загляни внутрь (`sql/ddl/stg/10_stg.sql`) +### Три кирпича слоя + Весь слой STG собран из **трёх кирпичей**, и каждый топик повторяет одну и ту же тройку: | Кирпич | Объект | Движок | Что делает | @@ -81,13 +94,21 @@ LIMIT 5; | 2 | `stg.kafka_browser_raw` | `ENGINE = Kafka` | **читает** топик, ничего не хранит | | 3 | `stg.mv_kafka_browser_to_stg` | `MATERIALIZED VIEW` | **перекладывает** из (2) в (1) на лету | -Главное: таблица с `ENGINE = Kafka` — это не хранилище, а «кран» к топику. Сама по себе -она данные не копит; данные забирает Materialized View и складывает их в обычную -`MergeTree`-таблицу. Сообщение появилось в топике → MV тут же положило его в `*_raw`. +Связка работает так. Таблица с `ENGINE = Kafka` (кирпич 2) — это не хранилище, а «кран» к +топику: через неё ClickHouse читает сообщения, но **сами данные она не копит**. Забирает их +третий кирпич — **Materialized View** (MV). -Стоит задержаться на двух местах файла. +И тут стоит остановиться на самом слове. Обычное представление (view) — это сохранённый +запрос: данные оно считает только тогда, когда его спросишь. «Materialized» (материализованное) +значит другое: оно срабатывает **само** на каждую новую порцию из источника и сразу +складывает результат в постоянную таблицу. Получается цепочка: сообщение появилось в топике → +MV тут же подхватило его и положило в `MergeTree`-таблицу `*_raw` (кирпич 1), где оно и лежит. -**Таблица-источник Kafka (`kafka_*_raw`)** — здесь живёт вся настройка чтения: +Дальше задержимся на двух местах файла. + +### Таблица-источник Kafka (`kafka_*_raw`) + +Здесь живёт вся настройка чтения топика: ```sql ENGINE = Kafka @@ -100,12 +121,19 @@ SETTINGS kafka_handle_error_mode = 'stream'; -- кривое сообщение не рвёт чтение топика ``` -`JSONAsString` — почему мы и можем класть `raw` строкой: ClickHouse не пытается разобрать -JSON на этом этапе. `kafka_handle_error_mode = 'stream'` — ровно то «STG принимает всё» -из секции 1: битое сообщение не уронит консьюмера. +Две настройки тут — самые важные для всего урока: -**Materialized View** — здесь сообщение превращается в строку таблицы. Метаданные берутся -из виртуальных колонок Kafka-движка (`_topic`, `_partition`, `_offset`, `_timestamp_ms`): +- `kafka_format = 'JSONAsString'` — вот почему мы и можем класть `raw` одной строкой: + ClickHouse берёт тело сообщения как текст и **не пытается разобрать** JSON на этом этапе; +- `kafka_handle_error_mode = 'stream'` — это ровно то «STG принимает всё» из секции 1: одно + битое сообщение не уронит консьюмера, чтение топика продолжится. + +### Materialized View: как сообщение становится строкой + +Здесь сообщение из топика превращается в строку таблицы. Откуда MV берёт метаданные доставки? +Из **виртуальных колонок** Kafka-движка — это служебные поля (`_topic`, `_partition`, +`_offset`, `_timestamp_ms`), которые движок подставляет к каждому сообщению сам, хотя в теле +JSON их нет: ```sql SELECT @@ -118,10 +146,10 @@ SELECT FROM stg.kafka_browser_raw; ``` -Тут без фокусов: каждая виртуальная колонка ложится в свою. `_timestamp_ms` — это уже -готовый `DateTime64(3)` (время сообщения с точностью до миллисекунд), поэтому идёт в -`kafka_ts` как есть, без преобразований. В секции 4 ты положишь рядом ещё одно время из -Kafka и увидишь, чем они отличаются. +Тут без фокусов: каждая виртуальная колонка ложится в свою. Одно место стоит запомнить — +`_timestamp_ms`: это уже готовый `DateTime64(3)` (время сообщения с точностью до миллисекунд), +поэтому оно идёт в `kafka_ts` как есть, без всякого преобразования. В секции 4 ты положишь +рядом ещё одно время из Kafka и увидишь, чем они отличаются. --- diff --git a/docs/course/lessons/02_stg_to_ods.md b/docs/course/lessons/02_stg_to_ods.md index 5c0f39a..88a7d92 100644 --- a/docs/course/lessons/02_stg_to_ods.md +++ b/docs/course/lessons/02_stg_to_ods.md @@ -1,6 +1,6 @@ # Урок 2. STG → ODS: типизация и DQ-split -> Статус: черновик. Режим: **руки**. +> Формат: **практика** — будешь сам запускать команды и менять код, не только читать. > Пререквизит: пройден урок 1 (слой STG — сырой JSON строкой уже лежит в `stg.*_raw`, > рядом метаданные доставки из Kafka). > Эталонный путь: [`sql/ods/20_stg_to_ods.sql`](../../../sql/ods/20_stg_to_ods.sql) From 79f7103b283fcc97c3984bcf45fd230b20a0d361 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 17:53:57 +0300 Subject: [PATCH 08/38] =?UTF-8?q?docs(course):=20=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=80=D1=82=20=D1=83=D1=80=D0=BE=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D1=83=D0=B5=D1=82=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80,=20=D1=88=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D1=83=20=D0=B8=20=D0=B3=D1=80=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - наработанный по урокам 0–2 мягкий регистр жил только в памяти и хендоффах; стандарт его не требовал — следующий урок мог уехать обратно в сжатый стиль и повторить уже пройденные ошибки. - Что: - добавлен §2 «Регистр и голос»: расшифровка терминов на первом употреблении, ###-подзаголовки, разбивка «стен», человеческий тон, house style, ai-text-lint; - в §1 описана шапка урока (Формат / «О чём урок простыми словами»), старые «Статус: черновик» и «Режим: руки» помечены как не возвращать; - добавлен §5 «Грабли»: проверять на стенде, сверять имена с DDL, один паттерн на урок, спорные API ClickHouse — через MCP Context7; - перенумерованы разделы (качество кода → §3, самопроверка → §4) и ссылки на них. - Проверка: - прочитать LESSON_STANDARD.md сверху вниз: §1–§5 идут по порядку, ссылки «(раздел 4)» указывают на «Самопроверку». --- docs/course/LESSON_STANDARD.md | 73 +++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/course/LESSON_STANDARD.md b/docs/course/LESSON_STANDARD.md index 395b7af..04f0971 100644 --- a/docs/course/LESSON_STANDARD.md +++ b/docs/course/LESSON_STANDARD.md @@ -1,6 +1,7 @@ # Стандарт уроков курса «Кликстрим на ClickHouse» -> Дата: 2026-06-01. +> Дата: 2026-06-01. Поправка: 2026-06-05 (по итогам уроков 0–2 — добавлены §2 «Регистр +> и голос», описание шапки урока и §5 «Грабли, на которые мы уже наступили»). > Назначение: рабочий чеклист, по которому пишется **каждый** урок. Открывается при > создании урока. Рамка курса (зачем/что/скоуп) — в `PRD.md`; карта уроков и > маршрут — в `LEARNING_PLAN.md`. @@ -23,15 +24,60 @@ состоянию (откатить изменение либо `make clean/up/ddl/data/transform`), чтобы самостоятельный менти не застрял со сломанным стендом без ментора. (Урок 0 — без этого шага, только наблюдение.) -5. **Проверь себя** — самопроверка (раздел 3). +5. **Проверь себя** — самопроверка (раздел 4). 6. **Что должно получиться** — **конкретный видимый результат**, который менти проверяет сам: скрин эффекта правки (например, красный DAG или `+1` в таблице ошибок) плюс один абзац «своими словами» про паттерн урока или запрос, который пришлось написать. Это и - есть самопроверка (раздел 3); те же вопросы «своими словами» менти разбирает с ментором + есть самопроверка (раздел 4); те же вопросы «своими словами» менти разбирает с ментором на еженедельном созвоне. (Урок 0 — без правки, поэтому результат здесь — что менти увидел при наблюдении.) -## 2. Стандарт качества эталонного кода +### Шапка урока + +Кроме шести секций, каждый урок открывается единым блоком-цитатой (`>`) сверху — он задаёт +рамку до того, как менти дойдёт до первой секции: + +- **Формат:** одной фразой, что менти делает, — `**практика** — будешь сам запускать…` + или `**наблюдение** — ничего не запускаем и не меняем…`. Старые пометки «Статус: черновик» + и «Режим: руки» не возвращаем: менти читал их как «материал не готов» и «непонятно, как + понимать»; +- **Пререквизит:** какой урок пройден и что менти уже умеет; +- **Эталонный путь:** ссылка(и) на разбираемый файл; +- **Поток данных одной строкой:** та же стрелочная схема, что стоит в начале эталонного файла; +- **О чём урок простыми словами:** 1–2 фразы совсем простым языком, без терминов, — чтобы + менти с первой строки понял, о чём речь. + +В конце урока — короткий **мост к следующему**: какой честный вопрос остаётся открытым и +как его закроет следующий урок. + +## 2. Регистр и голос + +Этот раздел — про то, *как* написан урок. Он появился по итогам уроков 0–2: мы начали со +сжатого регистра и переписывали — дешевле сразу писать мягко. + +**Кто читатель.** Менти прошёл курсовую, уровень — «обзорно» (понять идею и потрогать, а не +стать экспертом). Поддержка — еженедельный созвон с ментором, а не «сессия»; на нём менти +разбирает вопросы «своими словами». + +**Правила регистра:** +- **термин расшифровываем на первом употреблении** — коротко, своими словами (как `ODS`, + `DQ-split`, `Materialized View`, `*OrNull` в уроках 1–2). Не оставляем слово голым; +- **человеческий тон, без жаргона ради жаргона** — короткие предложения, прямое обращение + к менти («открой», «посмотри», «помнишь из урока 0»); +- **`###`-подзаголовки внутри длинных секций** — особенно в «Загляни внутрь»: каждый + смысловой кусок под своим заголовком, а не сплошной стеной; +- **плотные абзацы-«стены» бьём на пункты** с конкретикой; +- **разжёвывать, а не сжимать.** Урок может выйти заметно длиннее первого черновика — для + «обзорно» это норма, а не повод резать. + +**House style (оставляем осознанно, `ai-text-lint` на это не правим):** тире «—», буква «ё», +жирные зачины абзацев, двоеточие перед списком. + +**Перед финалом** — прогнать текст урока через `/ai-text-lint` (контекст `article`) и убрать +настоящие AI-маркеры (например, слова вроде «ключевой», «является»), но house style выше +не снимать. + +## 3. Стандарт качества эталонного кода Эталонные пути полируем до **учебного качества**; остальной код стенда остаётся под капотом — его не трогаем. @@ -60,7 +106,7 @@ Вердикты аудита по конкретным путям — в `LEARNING_PLAN.md`. -## 3. Самопроверка (по-простому) +## 4. Самопроверка (по-простому) Отдельный тест-фреймворк не строим. Опираемся на то, что в стенде уже есть: - штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`; @@ -71,3 +117,20 @@ В каждом уроке — маленькая табличка самопроверки в формате **действие → где смотреть → что ожидать**, а для управляемой правки — какой видимый результат должен появиться. + +## 5. Грабли, на которые мы уже наступили + +Короткий список ошибок из уроков 0–2 — чтобы следующий урок их не повторил. + +- **Проверяй на стенде, а не «на глаз».** Любой категоричный claim про числа или целостность + подтверждай командой на стенде. Так нашёлся баг с `kafka_ts` в уроке 1 (значение типа + `DateTime64` молча резалось через `toInt64`, и время по всему стенду уехало в `1970`) и + подтвердилось, что «26 из 50» в уроке 2 — это схлопывание повторов по `click_id` (проверено + `uniqExact`), а не потеря данных. +- **Имена таблиц и колонок в тексте сверяй с реальным DDL.** То, что написано в уроке, должно + совпадать с `sql/ddl/...`, иначе менти запутается, когда выполнит запрос на стенде. +- **Один паттерн на урок; будущие темы только анонсируй.** Не раскрывай то, что относится к + следующим урокам (пример: жёсткий гейт на «сирот» — это урок 4, в уроке 3 мы их только + вводим как понятие). +- **Спорные API ClickHouse сверяй через MCP Context7** (зачем и в каком порядке — в + `AGENTS.md`), не полагайся на память: именно типы и поведение функций уже один раз подвели. From 4f1e58752c42738f8fecb1c1ba792b1cb81a1a3b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 18:33:36 +0300 Subject: [PATCH 09/38] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=203=20(ODS?= =?UTF-8?q?=E2=86=92DDS,=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82=D0=B5=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - собрать разрозненные кусочки ODS в цельные сущности DDS и ввести понятие целостности связей (сироты), пока без жёсткого гейта — он в уроке 4 - Что: - добавлен docs/course/lessons/03_ods_to_dds.md: сущности dds.click/dds.event, UNION-универсум кликов, дедуп через argMax, LEFT JOIN, понятие сироты, управляемая правка (вставка сироты), recap STG→ODS→DDS, заметка про DM - §3.1: «поток данных» в шапку sql/dds/30_ods_to_dds.sql - демоут DM: убран закомментированный пример материализации в sql/dm/40_dds_to_dm.sql, добавлены «поток данных» и заметка «VIEW сейчас, материализуем если затормозит» со ссылкой на docs/ARCHITECTURE.md - sql/ddl/dm/40_dm.sql: пояснён seed 1919 в groupArraySample, поправлен неверный комментарий «последние» (groupArraySample берёт случайную выборку) - README курса: индекс обновлён до «уроки 0–3» - Проверка: - LIMIT=50 make transform: dds.click=26, dds.event=50, orphan_events=0 - §4 на стенде: вставка события-сироты → orphan 0→1; LEFT JOIN в dm.v_events_enriched даёт NULL по полям клика; make transform откатывает к 0 - /ai-text-lint (article): house style сохранён, AI-маркеры не найдены --- docs/course/README.md | 2 +- docs/course/lessons/03_ods_to_dds.md | 379 +++++++++++++++++++++++++++ sql/ddl/dm/40_dm.sql | 4 +- sql/dds/30_ods_to_dds.sql | 4 + sql/dm/40_dds_to_dm.sql | 33 +-- 5 files changed, 395 insertions(+), 27 deletions(-) create mode 100644 docs/course/lessons/03_ods_to_dds.md diff --git a/docs/course/README.md b/docs/course/README.md index e416c77..8560972 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -18,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0 и 1) | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–3) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md new file mode 100644 index 0000000..d06d13e --- /dev/null +++ b/docs/course/lessons/03_ods_to_dds.md @@ -0,0 +1,379 @@ +# Урок 3. ODS → DDS: сборка сущностей + +> Формат: **практика** — будешь сам запускать команды и менять код, не только читать. +> Пререквизит: пройден урок 2 (слой ODS — данные типизированы и разложены по качеству: +> чистые строки в `ods.*`, битые — в `ods.*_errors`). +> Эталонный путь: [`sql/dds/30_ods_to_dds.sql`](../../../sql/dds/30_ods_to_dds.sql) +> и DDL целевых таблиц [`sql/ddl/dds/30_dds.sql`](../../../sql/ddl/dds/30_dds.sql). +> +> Поток данных одной строкой: +> `ods.device_by_click + ods.geo_by_click → dds.click`, `ods.browser_event + ods.location_event → dds.event` +> +> О чём урок простыми словами: в ODS один клик размазан по двум таблицам (отдельно устройство, +> отдельно гео), а событие — по двум другим. Здесь мы склеиваем эти кусочки в цельные карточки: +> карточку клика и карточку события. И встречаем первую проблему стыковки — событие, у которого +> потерялся свой клик. + +--- + +## 1. Зачем и где в проде + +В ODS у нас аккуратные, типизированные данные — но они всё ещё лежат **по кусочкам**. Про один +и тот же клик стенд знает две отдельные вещи и хранит их в двух разных таблицах: с какого +устройства был клик (`ods.device_by_click`) и из какой точки на карте (`ods.geo_by_click`). Про +событие — то же самое: что за событие и в каком браузере (`ods.browser_event`) и на какой +странице с какими UTM-метками (`ods.location_event`). + +Для аналитики так работать неудобно. Стоит задать простой вопрос — «сколько кликов с мобильных +устройств пришло из России» — и аналитику приходится каждый раз вручную сшивать две-четыре +таблицы. Это долго писать и легко ошибиться. + +Поэтому появляется следующий слой — **DDS**. Это сокращение от Detailed Data Store («подробное +хранилище»): слой, где разрозненные кусочки собраны в цельные карточки, готовые к анализу. Такую +цельную карточку мы дальше называем **сущностью** — это просто запись, которая описывает один +объект целиком, со всеми его признаками сразу. В нашем стенде две сущности: + +- **`dds.click`** — карточка клика: один клик и сразу всё про него — устройство, операционная + система, страна, координаты, IP. Один клик — одна строка; +- **`dds.event`** — карточка события: одно событие и всё про него — тип, время, браузер, + страница, UTM-метки. Одно событие — одна строка. Внутри карточки лежит `click_id` — ссылка на + тот клик, в рамках которого событие произошло. + +Идея слоя: собрать один раз — пользоваться много раз. После сборки аналитику не нужно ничего +сшивать вручную: он берёт готовую `dds.click` или `dds.event` и сразу считает. + +### Откуда берётся проблема целостности + +Как только мы начинаем **склеивать** таблицы, появляется вопрос, которого на прошлых слоях не +было: а что, если стыкуемые кусочки не сходятся? Событие говорит «я случилось в рамках клика +`X`», мы идём искать клик `X` в `dds.click` — а его там нет. Ни устройства, ни гео по этому +клику стенд не получил. + +Такое событие — **без своего клика** — называют **сиротой** (по-английски orphan, «осиротевшее»). +В этом уроке мы вводим само понятие и учимся сирот **считать**. А ловить их жёстко — останавливать +пайплайн, если сироты появились, — будем в уроке 4. Сейчас задача проще: понять, откуда они +берутся и как их увидеть. + +> **Откуда сироты берутся в проде.** Чаще всего — из-за того, что данные приходят **не +> одновременно**. Событие в браузере произошло и улетело в Kafka сразу, а контекст клика +> (устройство, гео) досчитался и доехал на секунды позже. Если собрать `dds.click` именно в этот +> момент — клика ещё нет, и событие на мгновение осиротело. Бывает и совсем потеря: контекст +> клика не доехал вообще. Поэтому целостность между событием и кликом — то, за чем на этом слое +> следят отдельно. + +--- + +## 2. Руки: смотрим базовый прогон + +Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем +трансформацию: + +```bash +make up # поднять инфраструктуру +make ddl # создать базы и таблицы (в т.ч. слой DDS) +LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG +make transform # батч STG → ODS → DDS → DM +``` + +`make transform` прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика +DDS»** — счётчики строк по двум нашим сущностям: + +``` +Статистика DDS: + ┌─table─────┬─rows─┐ + │ dds.click │ 26 │ + │ dds.event │ 50 │ + └───────────┴──────┘ +``` + +Прочитаем эти две строки. + +**`dds.event` — 50.** Сколько событий пришло, столько карточек и собралось: одно событие — одна +строка. Ровно как `ods.browser_event` из прошлого урока. + +**`dds.click` — 26, а не 50.** И это та же история, что мы уже разбирали в уроке 2. Карточка +клика — одна на клик, а в срезе на 50 событий разных кликов всего 26 (на один клик приходится +несколько событий). Поэтому 50 событий ссылаются на 26 кликов — это нормально, так и должно быть. + +Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»** +(это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку +про сирот: + +``` + ┌─check_date─┬─layer─┬─table_name──────────┬─check_name────┬─check_value─┐ + │ 2026-06-05 │ dds │ event_without_click │ orphan_events │ 0 │ + └────────────┴───────┴─────────────────────┴───────────────┴─────────────┘ +``` + +`orphan_events = 0` — ни одной сироты. Каждое из 50 событий нашло свой клик в `dds.click`. На +чистом демо-срезе так и должно быть: данные аккуратные, ничего не потерялось. В секции 4 мы +сироту устроим сами — и эта строка оживёт. + +Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play` +(пользователь `default`, пароль `123456`) и посчитай сирот напрямую: + +```sql +-- Сирота = событие, у которого click_id есть, но в dds.click такого клика нет +SELECT count() AS orphans +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +`NOT IN (SELECT ...)` читается прямо по словам: «click_id события **не входит** в список всех +click_id из `dds.click`». То есть событие ссылается на клик, которого в карточках кликов нет. +Сейчас таких ноль — запомни этот запрос, в секции 4 он покажет другое число. + +--- + +## 3. Загляни внутрь + +Слой, как и ODS, описан **двумя файлами** — держи оба открытыми, они про разное: + +| Файл | Что задаёт | +|------|------------| +| `sql/ddl/dds/30_dds.sql` | **форму** сущностей: какие колонки, какие типы, какой движок | +| `sql/dds/30_ods_to_dds.sql` | **сборку**: как из таблиц ODS склеить эти сущности | + +Дальше — четыре места, ради которых урок и затевался. Пойдём по сборке `dds.click` сверху вниз: +сначала собираем список всех кликов, потом приклеиваем к каждому устройство и гео. + +### Универсум кликов: собрать все `click_id` + +Сборка начинается с вопроса «а какие клики у нас вообще есть?». Источников два — `device` и +`geo`, и клик может быть в любом из них (а то и в обоих). Нам нужен полный список без повторов: + +```sql +SELECT click_id FROM ods.device_by_click ... +UNION DISTINCT +SELECT click_id FROM ods.geo_by_click ... +``` + +`UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор +уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный +список всех клиентов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы +строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал +в `device`. + +> На нашем срезе `device` и `geo` содержат один и тот же набор из 26 кликов, так что универсум +> тоже 26. Но код написан так, чтобы пережить случай, когда наборы **разойдутся**, — и это +> правильно: в проде они расходятся постоянно. + +### `argMax`: одна строка на клик, самая свежая + +Собрав список кликов, к каждому надо приклеить данные об устройстве. Тут есть тонкость из урока 2: +`ods.device_by_click` — таблица на движке `ReplacingMergeTree`, и повторы по `click_id` она +схлопывает **в фоне**, не мгновенно. Значит, прямо сейчас в ней может лежать несколько строк про +один клик. Какую брать? + +Берём самую свежую — и делаем это явно, через `argMax`: + +```sql +argMax(device_type, src_ingest_ts) AS device_type, +argMax(os_name, src_ingest_ts) AS os_name +... +GROUP BY click_id +``` + +`argMax(A, B)` читается так: «верни значение `A` из той строки, где `B` максимально». Здесь +`B` — это `src_ingest_ts`, время загрузки в ODS. То есть для каждого `click_id` берём `device_type` +из самой поздней загрузки. А `GROUP BY click_id` гарантирует, что на выходе **ровно одна строка +на клик** — прямо сейчас, не дожидаясь, пока `ReplacingMergeTree` схлопнет повторы у себя в фоне. + +> **Зачем так строго.** `ReplacingMergeTree` обещает оставить одну строку на ключ, но не обещает, +> *когда* (фоновое схлопывание может ещё не случиться). Если бы мы просто прочитали таблицу, то +> могли бы поймать дубль. `argMax` + `GROUP BY` убирают эту неопределённость на чтении: одна +> свежая строка на клик, всегда. + +### `LEFT JOIN`: приклеиваем устройство и гео + +Теперь главная операция сборки — соединить список кликов с данными об устройстве и гео. Это +делает **`JOIN`** — операция «состыкуй строки двух таблиц по общему ключу». Ключ у нас `click_id`: +для каждого клика из универсума ищем строку с тем же `click_id` среди устройств и среди гео. + +```sql +FROM ( ...универсум кликов... ) AS c +LEFT JOIN ( ...снапшот device... ) AS d ON d.click_id = c.click_id +LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id +``` + +Важно, что это именно `LEFT JOIN`, а не обычный `JOIN`. Разница — в том, что делать, когда пары +**не нашлось**: + +- обычный (`INNER`) `JOIN` выкинул бы клик, у которого нет, скажем, гео, — нет пары, нет строки; +- **`LEFT JOIN`** оставляет **все** строки левой таблицы (нашего универсума кликов) в любом + случае. Если для клика не нашлось гео — клик всё равно в результате, просто гео-поля у него + останутся пустыми (`NULL`). + +Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять +нельзя**. Не доехало гео — ладно, сохраним клик с пустым гео и пометкой, что гео нет. Эта пометка +тут же и ставится: рядом со сборкой стоит `if(g.click_id IS NULL, ['geo_not_found'], [])` — если +гео не подтянулось, в список ошибок карточки добавится метка `geo_not_found`. Тот же принцип +«не теряем и помечаем», что и `parse_errors` в ODS, только теперь про пропавшие связи. + +> Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location` +> связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу +> и UTM. Если `location` не доехал — событие остаётся, а поля страницы пустые с меткой +> `location_not_found`. Разбирать этот блок построчно не будем — он повторяет ту же логику. + +### Сироты: событие без клика + +Мы собрали `dds.click` (26 карточек кликов) и `dds.event` (50 карточек событий). Внутри каждого +события лежит `click_id` — ссылка на клик. И вот тут возникает вопрос целостности из секции 1: +**а на каждую ли ссылку есть карточка клика?** + +Событие, чей `click_id` не находит себе клика в `dds.click`, — это и есть **сирота**. Считается +он ровно тем запросом, что ты уже видел в секции 2: + +```sql +SELECT count() FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +Заметь разницу с предыдущим пунктом. `geo_not_found` — это когда у **клика** нет гео (внутренний +пропуск в карточке). А сирота — это когда у **события** нет вообще никакого клика (порвана связь +между сущностями). Это разные дырки, и следят за ними по отдельности. На чистом срезе сирот ноль — +сейчас мы это изменим. + +--- + +## 4. Управляемая правка: заведём сироту + +Сирота на чистом срезе не появится сама — данные слишком аккуратные. Поэтому **создадим её +руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого в `dds.click` +нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`. + +Открой SQL-консоль `http://localhost:9123/play` и вставь придуманное событие: + +```sql +-- Событие со ссылкой на несуществующий клик dddd...-dddd (такого в dds.click нет) +INSERT INTO dds.event (event_id, event_ts, event_type, click_id, browser_name, dds_update_ts, ods_parse_errors) +VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- event_id нашего «события-сироты» + now64(6), 'pageview', + 'dddddddd-dddd-dddd-dddd-dddddddddddd', -- click_id, которого нет ни в одной карточке клика + 'DemoBrowser', now64(3), [] +); +``` + +Теперь посчитай сирот тем же запросом, что в секции 2: + +```sql +SELECT count() AS orphans +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +Было `0` — стало `1`. Появилась первая сирота: событие `aaaa…` ссылается на клик `dddd…`, +которого в `dds.click` нет. + +Теперь посмотри, что с этим событием делает `LEFT JOIN`. В стенде есть витрина `dm.v_events_enriched` — +это `VIEW` (готовый запрос под именем), который как раз приклеивает к каждому событию его клик +через `LEFT JOIN`. Посмотрим на нашу сироту через неё: + +```sql +SELECT event_id, click_id, device_type, geo_country +FROM dm.v_events_enriched +WHERE event_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +``` + +``` +┌─event_id─────┬─click_id─────┬─device_type─┬─geo_country─┐ +│ aaaaaaaa-... │ dddddddd-... │ ᴺᵁᴸᴸ │ ᴺᵁᴸᴸ │ +└──────────────┴──────────────┴─────────────┴─────────────┘ +``` + +Вот он, `LEFT JOIN` вживую. Событие осталось в результате (его не выкинуло), но клика-то нет — +и все поля из клика (`device_type`, `geo_country` и остальные) пришли пустыми. Будь это `INNER +JOIN`, событие просто исчезло бы из витрины, и мы бы даже не заметили, что потеряли его. `LEFT +JOIN` его сохранил — поэтому сироту вообще можно увидеть и посчитать. + +> **Почему мы её только считаем, а не блокируем.** Логично было бы сказать: раз сирота — это +> разрыв целостности, давай не пустим её дальше, уроним прогон. Так и сделаем — но в уроке 4. +> Там запрос-счётчик из этого урока станет **жёстким гейтом** в Airflow: DAG покраснеет, если +> `orphan_events > 0`. Сейчас мы только научились сирот видеть; превратить взгляд в стоп-кран — +> следующий шаг. + +**Верни как было.** Наша сирота лежит прямо в `dds.event`, мимо ODS. Достаточно пересобрать слой +DDS — `make transform` чистит `dds.event` (`TRUNCATE`) и наполняет заново из ODS, где никакой +сироты нет: + +```bash +make transform +``` + +После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд совсем «поплыл» — +полный сброс: `make clean && make up && make ddl && LIMIT=50 make data && make transform`. + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| `make transform` (базовый прогон) | блок «Статистика DDS» | `dds.click` = 26, `dds.event` = 50 | +| `make transform` (базовый прогон) | блок «Сводка по качеству», строка `orphan_events` | `0` | +| почему `click` = 26, а `event` = 50 | запрос `count()` по `dds.click` и `dds.event` | 50 событий ссылаются на 26 кликов — норма | +| правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` | +| та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` | + +--- + +## 6. Что должно получиться + +После урока у тебя на руках — видимый результат (одно на выбор): + +- скрин запроса со счётчиком сирот: было `0`, после вставки стало `1`; +- либо выборка из `dm.v_events_enriched` по событию-сироте, где `device_type` и `geo_country` + пусты, — `LEFT JOIN` сохранил событие без клика. + +И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: + +- что такое сущность DDS и зачем собирать `dds.click` и `dds.event`, если данные уже есть в ODS; +- почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео; +- что такое сирота и чем разрыв «событие без клика» отличается от пропуска `geo_not_found` внутри + карточки клика. + +Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый +`click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку. + +--- + +## Вся цепочка разом: STG → ODS → DDS + +Мы прошли три слоя по отдельности — стоит собрать их в одну картину, чтобы они не остались тремя +не связанными кусками. Один и тот же клик прошёл весь путь: + +- **STG** (урок 1) — приняли поток как есть: сырой JSON строкой плюс метаданные доставки из + Kafka. Ничего не разбираем, ничего не теряем; +- **ODS** (урок 2) — разобрали JSON на поля и типизировали; чистое поехало в `ods.*`, битое — в + `ods.*_errors` (это и есть DQ-split). Здесь же один клик честно лёг в две таблицы: устройство + отдельно, гео отдельно; +- **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые + спросили про целостность связей между ними (сироты). + +Заметь общий принцип всех трёх слоёв — **«не теряем, а помечаем»**. На STG не роняем приём из-за +кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в +`*_errors`. На DDS не выкидываем клик без гео и событие без клика, а помечаем (`geo_not_found`) +и считаем (`orphan_events`). Один и тот же подход к качеству, проведённый через весь пайплайн. + +> **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые +> `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они +> сделаны как **`VIEW`** — это просто сохранённый под именем запрос поверх DDS, без копии данных: +> логику меняешь — данные не перегружаешь. Если тяжёлая агрегация однажды начнёт тормозить, её +> можно **материализовать** — превратить `VIEW` в обычную таблицу (как это делается — в +> `docs/ARCHITECTURE.md`, раздел «Материализация витрин»). Сами витрины в деле разберём в уроках +> 5–6, где их потребляют мониторинг и Superset. + +--- + +## Мост к уроку 4 + +Сущности собраны, целостность мы умеем **видеть** — но пока только глазами: запустили запрос, +посмотрели на число сирот. В проде так не следят: проверка должна срабатывать сама на каждом +прогоне и **останавливать** пайплайн, если целостность нарушена. В уроке 4 (оркестрация в Airflow) +мы соберём всю цепочку STG → ODS → DDS → DM в один DAG с зависимостями между шагами — и превратим +наш запрос-счётчик сирот в честный **гейт**: если `orphan_events > 0`, DAG падает и дальше данные +не идут. Там же снова заведём сироту — и увидим, как на неё краснеет конкретная задача в Airflow. diff --git a/sql/ddl/dm/40_dm.sql b/sql/ddl/dm/40_dm.sql index 0bd89a5..98db334 100644 --- a/sql/ddl/dm/40_dm.sql +++ b/sql/ddl/dm/40_dm.sql @@ -139,7 +139,9 @@ SELECT arrayDistinct(groupArray(page_url_path)) AS pages_visited, -- Уникальные страницы arrayDistinct(groupArray(geo_country)) AS countries, -- Страны (если менялась) arrayDistinct(groupArray(device_type)) AS devices, -- Устройства (если менялось) - -- Берём последние UTM-метки сессии (для атрибуции) + -- Берём одну UTM-метку сессии (для атрибуции). + -- groupArraySample(1, 1919) выбирает 1 случайный элемент; 1919 — фиксированный seed, + -- чтобы выборка была воспроизводимой между прогонами (одинаковый seed → один результат). groupArraySample(1, 1919)(utm_source)[1] AS utm_source_last, groupArraySample(1, 1919)(utm_medium)[1] AS utm_medium_last FROM dm.v_events_enriched diff --git a/sql/dds/30_ods_to_dds.sql b/sql/dds/30_ods_to_dds.sql index 20921d4..851939d 100644 --- a/sql/dds/30_ods_to_dds.sql +++ b/sql/dds/30_ods_to_dds.sql @@ -1,6 +1,10 @@ -- ============================================================================ -- Batch-трансформация: ODS → DDS -- ============================================================================ +-- Поток данных: +-- ods.device_by_click + ods.geo_by_click → dds.click (контекст клика) +-- ods.browser_event + ods.location_event → dds.event (контекст события) +-- -- Что делает: -- Собирает "чистые" сущности из типизированных данных ODS для аналитики. -- Использует argMax() для получения последней версии строк по ключу. diff --git a/sql/dm/40_dds_to_dm.sql b/sql/dm/40_dds_to_dm.sql index 58d20d7..f8f22a2 100644 --- a/sql/dm/40_dds_to_dm.sql +++ b/sql/dm/40_dds_to_dm.sql @@ -1,6 +1,10 @@ -- ============================================================================ -- Batch-трансформация: DDS → DM (Data Quality summary) -- ============================================================================ +-- Поток данных: +-- dds.click + dds.event + ods.* + stg.* → dm.dq_summary (сводка по слоям) +-- Сами витрины (dm.v_*) — это VIEW поверх DDS, создаются в sql/ddl/dm/40_dm.sql. +-- -- Что делает: -- Собирает статистику по всем слоям (stg/ods/dds) для мониторинга качества данных. -- Позволяет быстро проверить, сколько данных прошло через каждый слой @@ -9,33 +13,12 @@ -- Важно: -- Таблица dq_summary пересоздаётся при каждом запуске (TRUNCATE + INSERT), -- чтобы не накапливать дубликаты при повторных прогонах. +-- +-- Витрины DM сейчас — это VIEW (логика без копии данных). Если тяжёлая агрегация +-- начнёт тормозить, её материализуют в таблицу; пример — в docs/ARCHITECTURE.md, +-- раздел «Материализация витрин». -- ============================================================================ --- ---------------------------------------------------------------------------- --- Пример материализации тяжёлой витрины (закомментировано) --- ---------------------------------------------------------------------------- --- Если VIEW dm.v_daily_traffic работает медленно, можно создать таблицу: --- --- CREATE TABLE IF NOT EXISTS dm.daily_traffic_mart --- ( --- event_date Date, --- geo_country LowCardinality(Nullable(String)), --- device_type LowCardinality(Nullable(String)), --- browser_name LowCardinality(Nullable(String)), --- utm_source LowCardinality(Nullable(String)), --- utm_medium LowCardinality(Nullable(String)), --- events UInt64, --- uniq_clicks UInt64, --- uniq_users UInt64 --- ) --- ENGINE = ReplacingMergeTree(event_date) --- PARTITION BY toYYYYMM(event_date) --- ORDER BY (event_date, geo_country, device_type, browser_name, utm_source, utm_medium); --- --- TRUNCATE TABLE dm.daily_traffic_mart; --- INSERT INTO dm.daily_traffic_mart SELECT * FROM dm.v_daily_traffic; --- ---------------------------------------------------------------------------- - -- ---------------------------------------------------------------------------- -- Таблица для сводки по качеству данных (DQ summary) -- ---------------------------------------------------------------------------- From 707da9f80e618683a22c9ffc214766ab990f5340 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 19:13:22 +0300 Subject: [PATCH 10/38] =?UTF-8?q?feat(airflow):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B3=D0=B5=D0=B9=D1=82=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BB=D0=BE=D1=81=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?DDS=20=D0=B4=D0=BB=D1=8F=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - урок 4 должен показывать не только измерение сирот в DDS, но и остановку Airflow DAG при нарушении связи dds.event -> dds.click. - Что: - добавлен assert_dds_integrity в etl_pipeline и документация управляемого красного сценария. - вынесены общие helper'ы для SQL-split и boolean-параметров Airflow. - добавлен урок 4 и обновлены навигация курса, план обучения и operations notes. - Проверка: - python3 -m py_compile airflow/dags/etl_pipeline_dag.py airflow/dags/ddl_init_dag.py airflow/dags/kafka_load_dag.py airflow/dags/utils/airflow_params.py airflow/dags/utils/sql_helpers.py. - docker compose exec -T airflow-webserver airflow dags test etl_pipeline 2026-06-05T18:00:00 -c '{"full_refresh": true}'. --- airflow/dags/ddl_init_dag.py | 26 +- airflow/dags/etl_pipeline_dag.py | 61 ++- airflow/dags/kafka_load_dag.py | 6 +- airflow/dags/utils/airflow_params.py | 33 ++ airflow/dags/utils/sql_helpers.py | 153 +++++++ docs/OPERATIONS.md | 5 + docs/course/LEARNING_PLAN.md | 6 +- docs/course/README.md | 2 +- .../lessons/04_airflow_orchestration.md | 374 ++++++++++++++++++ sql/dm/40_dds_to_dm.sql | 2 +- 10 files changed, 623 insertions(+), 45 deletions(-) create mode 100644 airflow/dags/utils/airflow_params.py create mode 100644 airflow/dags/utils/sql_helpers.py create mode 100644 docs/course/lessons/04_airflow_orchestration.md diff --git a/airflow/dags/ddl_init_dag.py b/airflow/dags/ddl_init_dag.py index ff34b0a..f3767c1 100644 --- a/airflow/dags/ddl_init_dag.py +++ b/airflow/dags/ddl_init_dag.py @@ -9,7 +9,6 @@ DAG инициализации DDL в ClickHouse. from __future__ import annotations -import re from datetime import datetime, timedelta from pathlib import Path @@ -20,6 +19,8 @@ from airflow.operators.empty import EmptyOperator from airflow.operators.python import BranchPythonOperator, PythonOperator from airflow.utils.trigger_rule import TriggerRule from airflow_clickhouse_plugin.operators.clickhouse import ClickHouseOperator +from utils.airflow_params import parse_bool_param +from utils.sql_helpers import load_sql_statements as load_sql_file_statements # ----------------------------------------------------------------------------- @@ -55,23 +56,7 @@ SQL_ROOT = resolve_sql_root() def load_sql_statements(relative_path: str) -> tuple[str, ...]: """Читает SQL-файл и делит его на отдельные команды по ';'.""" - file_path = SQL_ROOT / relative_path - if not file_path.is_file(): - raise AirflowException(f"SQL-файл не найден: {file_path}") - - sql_text = file_path.read_text(encoding="utf-8") - statements: list[str] = [] - for segment in sql_text.split(";"): - # Убираем блочные и строковые комментарии, чтобы не отправлять "пустые" запросы. - no_block_comments = re.sub(r"/\*.*?\*/", "", segment, flags=re.S) - lines = [line for line in no_block_comments.splitlines() if not line.strip().startswith("--")] - cleaned = "\n".join(lines).strip() - if cleaned: - statements.append(cleaned) - - if not statements: - raise AirflowException(f"SQL-файл пустой: {file_path}") - return tuple(statements) + return load_sql_file_statements(SQL_ROOT, relative_path) # ----------------------------------------------------------------------------- @@ -96,7 +81,10 @@ def choose_ddl_mode(**context) -> str: """Выбирает ветку выполнения: full DDL или только verify.""" dag_run = context.get("dag_run") conf = dag_run.conf if dag_run else {} - verify_only = bool(conf.get("verify_only", context["params"]["verify_only"])) + verify_only = parse_bool_param( + conf.get("verify_only", context["params"]["verify_only"]), + "verify_only", + ) return "skip_ddl" if verify_only else "ddl_00_databases" diff --git a/airflow/dags/etl_pipeline_dag.py b/airflow/dags/etl_pipeline_dag.py index ce43d2b..923fb5a 100644 --- a/airflow/dags/etl_pipeline_dag.py +++ b/airflow/dags/etl_pipeline_dag.py @@ -1,6 +1,9 @@ """ DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного проекта. +Поток задач: + precheck -> transform: wait -> ods -> dq -> branch -> dds -> integrity -> dm -> validate + Принципы реализации: - SQL выполняется явными task на ClickHouseOperator; - SQL-файлы вызываются по фиксированным путям; @@ -9,7 +12,6 @@ DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного про from __future__ import annotations -import re import time from datetime import datetime, timedelta from pathlib import Path @@ -23,6 +25,8 @@ from airflow.utils.task_group import TaskGroup from airflow.utils.trigger_rule import TriggerRule from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook from airflow_clickhouse_plugin.operators.clickhouse import ClickHouseOperator +from utils.airflow_params import parse_bool_param +from utils.sql_helpers import load_sql_statements as load_sql_file_statements # ----------------------------------------------------------------------------- @@ -58,23 +62,7 @@ SQL_ROOT = resolve_sql_root() def load_sql_statements(relative_path: str) -> tuple[str, ...]: """Читает SQL-файл и делит его на отдельные команды по ';'.""" - file_path = SQL_ROOT / relative_path - if not file_path.is_file(): - raise AirflowException(f"SQL-файл не найден: {file_path}") - - sql_text = file_path.read_text(encoding="utf-8") - statements: list[str] = [] - for segment in sql_text.split(";"): - # Убираем блочные и строковые комментарии, чтобы не отправлять "пустые" запросы. - no_block_comments = re.sub(r"/\*.*?\*/", "", segment, flags=re.S) - lines = [line for line in no_block_comments.splitlines() if not line.strip().startswith("--")] - cleaned = "\n".join(lines).strip() - if cleaned: - statements.append(cleaned) - - if not statements: - raise AirflowException(f"SQL-файл пустой: {file_path}") - return tuple(statements) + return load_sql_file_statements(SQL_ROOT, relative_path) # ----------------------------------------------------------------------------- @@ -228,7 +216,10 @@ def choose_full_refresh(**context) -> str: """Ветвление: делать TRUNCATE DDS или пропустить.""" dag_run = context.get("dag_run") conf = dag_run.conf if dag_run else {} - full_refresh = bool(conf.get("full_refresh", context["params"]["full_refresh"])) + full_refresh = parse_bool_param( + conf.get("full_refresh", context["params"]["full_refresh"]), + "full_refresh", + ) return "transform.truncate_dds_click" if full_refresh else "transform.skip_truncate" @@ -245,6 +236,22 @@ def assert_dm_summary_not_empty(**context) -> None: raise AirflowException("dm.dq_summary пуста после load_dm_summary.") +def assert_dds_integrity(**context) -> None: + """Падает, если события ссылаются на отсутствующие клики.""" + ti = context["ti"] + result = ti.xcom_pull(task_ids="transform.check_dds_integrity") + + if not result or not result[0] or len(result[0]) != 1: + raise AirflowException(f"Некорректный результат check_dds_integrity: {result}") + + orphan_events = int(result[0][0]) + if orphan_events > 0: + raise AirflowException( + f"DDS integrity check failed: orphan_events={orphan_events}. " + "Есть события, чей click_id отсутствует в dds.click." + ) + + with DAG( dag_id="etl_pipeline", description="ETL STG -> ODS -> DDS -> DM для demo-проекта", @@ -342,6 +349,12 @@ with DAG( database="default", ) + assert_dds_integrity_task = PythonOperator( + task_id="assert_dds_integrity", + python_callable=assert_dds_integrity, + retries=0, + ) + load_dm_summary = ClickHouseOperator( task_id="load_dm_summary", sql=load_sql_statements("dm/40_dds_to_dm.sql"), @@ -364,6 +377,14 @@ with DAG( wait_for_stg_data_task >> load_ods >> check_ods_quality >> choose_refresh_mode choose_refresh_mode >> truncate_dds_click >> truncate_dds_event >> truncate_complete choose_refresh_mode >> skip_truncate >> truncate_complete - truncate_complete >> load_dds >> check_dds_integrity >> load_dm_summary >> validate_dm_summary_sql >> validate_dm_summary + ( + truncate_complete + >> load_dds + >> check_dds_integrity + >> assert_dds_integrity_task + >> load_dm_summary + >> validate_dm_summary_sql + >> validate_dm_summary + ) precheck >> transform diff --git a/airflow/dags/kafka_load_dag.py b/airflow/dags/kafka_load_dag.py index 484f8c8..f3f2783 100644 --- a/airflow/dags/kafka_load_dag.py +++ b/airflow/dags/kafka_load_dag.py @@ -24,6 +24,7 @@ from airflow.operators.python import PythonOperator from airflow.utils.task_group import TaskGroup # Импортируем helper-функции +from utils.airflow_params import parse_bool_param from utils.kafka_helpers import ( check_input_files, check_kafka_ready, @@ -69,7 +70,10 @@ def _validate_params(**context) -> None: def _prepare_topics(**context) -> None: """Подготовка топиков Kafka (создание/сброс).""" conf = context.get("dag_run", {}).conf or {} - reset_topics = bool(conf.get("reset_topics", context["params"]["reset_topics"])) + reset_topics = parse_bool_param( + conf.get("reset_topics", context["params"]["reset_topics"]), + "reset_topics", + ) prepare_topics(reset=reset_topics) diff --git a/airflow/dags/utils/airflow_params.py b/airflow/dags/utils/airflow_params.py new file mode 100644 index 0000000..ce232bc --- /dev/null +++ b/airflow/dags/utils/airflow_params.py @@ -0,0 +1,33 @@ +"""Общие helper-функции для параметров Airflow DAG.""" + +from __future__ import annotations + +TRUE_VALUES = {"1", "true", "t", "yes", "y", "on"} +FALSE_VALUES = {"0", "false", "f", "no", "n", "off"} + + +def _param_error(message: str) -> Exception: + try: + from airflow.exceptions import AirflowException + + return AirflowException(message) + except ModuleNotFoundError: + return ValueError(message) + + +def parse_bool_param(value: object, name: str) -> bool: + """Преобразует bool-параметр из dag_run.conf/params в явный boolean.""" + if isinstance(value, bool): + return value + + if isinstance(value, int) and value in (0, 1): + return bool(value) + + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + + raise _param_error(f"Параметр {name} должен быть boolean, получено: {value!r}") diff --git a/airflow/dags/utils/sql_helpers.py b/airflow/dags/utils/sql_helpers.py new file mode 100644 index 0000000..1b2b42f --- /dev/null +++ b/airflow/dags/utils/sql_helpers.py @@ -0,0 +1,153 @@ +"""Общие helper-функции для чтения SQL-файлов из Airflow DAG.""" + +from __future__ import annotations + +from pathlib import Path + + +def _sql_error(message: str) -> Exception: + try: + from airflow.exceptions import AirflowException + + return AirflowException(message) + except ModuleNotFoundError: + return ValueError(message) + + +def strip_sql_comments(sql_text: str) -> str: + """Удаляет SQL-комментарии, не трогая строки в кавычках.""" + result: list[str] = [] + i = 0 + in_single_quote = False + in_double_quote = False + + while i < len(sql_text): + char = sql_text[i] + next_char = sql_text[i + 1] if i + 1 < len(sql_text) else "" + + if in_single_quote: + result.append(char) + if char == "'" and next_char == "'": + result.append(next_char) + i += 2 + continue + if char == "'" and (i == 0 or sql_text[i - 1] != "\\"): + in_single_quote = False + i += 1 + continue + + if in_double_quote: + result.append(char) + if char == '"' and (i == 0 or sql_text[i - 1] != "\\"): + in_double_quote = False + i += 1 + continue + + if char == "'": + in_single_quote = True + result.append(char) + i += 1 + continue + + if char == '"': + in_double_quote = True + result.append(char) + i += 1 + continue + + if char == "-" and next_char == "-": + i += 2 + while i < len(sql_text) and sql_text[i] not in "\r\n": + i += 1 + continue + + if char == "/" and next_char == "*": + i += 2 + while ( + i < len(sql_text) - 1 + and not (sql_text[i] == "*" and sql_text[i + 1] == "/") + ): + if sql_text[i] in "\r\n": + result.append(sql_text[i]) + i += 1 + i += 2 + continue + + result.append(char) + i += 1 + + return "".join(result) + + +def split_sql_statements(sql_text: str) -> tuple[str, ...]: + """Делит SQL на команды по ';' вне строковых литералов.""" + statements: list[str] = [] + current: list[str] = [] + cleaned_sql = strip_sql_comments(sql_text) + in_single_quote = False + in_double_quote = False + i = 0 + + while i < len(cleaned_sql): + char = cleaned_sql[i] + next_char = cleaned_sql[i + 1] if i + 1 < len(cleaned_sql) else "" + + if in_single_quote: + current.append(char) + if char == "'" and next_char == "'": + current.append(next_char) + i += 2 + continue + if char == "'" and (i == 0 or cleaned_sql[i - 1] != "\\"): + in_single_quote = False + i += 1 + continue + + if in_double_quote: + current.append(char) + if char == '"' and (i == 0 or cleaned_sql[i - 1] != "\\"): + in_double_quote = False + i += 1 + continue + + if char == "'": + in_single_quote = True + current.append(char) + i += 1 + continue + + if char == '"': + in_double_quote = True + current.append(char) + i += 1 + continue + + if char == ";": + statement = "".join(current).strip() + if statement: + statements.append(statement) + current = [] + i += 1 + continue + + current.append(char) + i += 1 + + statement = "".join(current).strip() + if statement: + statements.append(statement) + + return tuple(statements) + + +def load_sql_statements(sql_root: Path, relative_path: str) -> tuple[str, ...]: + """Читает SQL-файл и возвращает отдельные команды.""" + file_path = sql_root / relative_path + if not file_path.is_file(): + raise _sql_error(f"SQL-файл не найден: {file_path}") + + statements = split_sql_statements(file_path.read_text(encoding="utf-8")) + if not statements: + raise _sql_error(f"SQL-файл пустой: {file_path}") + + return statements diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index ffb992e..e605eb3 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -59,6 +59,11 @@ - Запуск: ручной (`Trigger DAG with config`) - Параметр: `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой - Зависимость: требует наличия данных в STG (от `kafka_load` или `make data`) +- Гейт целостности DDS: `check_dds_integrity` считает события без клика, а + `assert_dds_integrity` роняет DAG при `orphan_events > 0`. Проверка идёт после + `load_dds` и до `load_dm_summary`, чтобы DM не собирался поверх нарушенной связи + `dds.event -> dds.click`. Для `assert_dds_integrity` задано `retries=0`: повтор не + чинит уже собранную сироту и только задерживает явный failed-статус. ## Рекомендуемый сценарий (фаза 2) diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md index 3b79f1a..fd460a9 100644 --- a/docs/course/LEARNING_PLAN.md +++ b/docs/course/LEARNING_PLAN.md @@ -113,15 +113,15 @@ Kafka из роадмапа — оно даёт словарь терминов. - Сами витрины DM показываем в деле в уроках 5–6, отдельного разбора не делаем. **Урок 4 — Airflow DAG (`airflow/dags/etl_pipeline_dag.py`):** -- [ ] **Главная правка (код↔доки):** сделать `check_dds_integrity` честным гейтом — +- [x] **Главная правка (код↔доки):** сделать `check_dds_integrity` честным гейтом — добавить `assert_dds_integrity` (PythonOperator), роняющий DAG при `orphan_events > 0`. Сейчас «проверка» только считает сирот в xcom и пишет их в `dq_summary`, но DAG остаётся зелёным, хотя `LESSON_STANDARD` §3 и `PRD` §2 (цель 3) обещают остановку при нарушении целостности. После правки доки и код сходятся. -- [ ] **Управляемая правка урока 4** строится на этом гейте: менти намеренно ломает +- [x] **Управляемая правка урока 4** строится на этом гейте: менти намеренно ломает целостность (вставляет «осиротевшее» событие) и видит, как DAG краснеет на `assert_dds_integrity`. Опирается на понятие сирот из урока 3. -- [ ] Добавить ASCII-поток задач в docstring DAG +- [x] Добавить ASCII-поток задач в docstring DAG (`precheck → transform: wait → ods → dq → branch → dds → integrity → dm → validate`) — для теста одного прохода. - Замечание: `check_ods_quality` тоже measure-only (метрики в xcom, без гейта) — это diff --git a/docs/course/README.md b/docs/course/README.md index 8560972..9abd56d 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -18,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–3) | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–4) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/04_airflow_orchestration.md b/docs/course/lessons/04_airflow_orchestration.md new file mode 100644 index 0000000..c328202 --- /dev/null +++ b/docs/course/lessons/04_airflow_orchestration.md @@ -0,0 +1,374 @@ +# Урок 4. Оркестрация в Airflow + +> Формат: **практика** — будешь сам запускать команды и менять код, не только читать. +> Пререквизит: пройден урок 3 (слой DDS собран, ты знаешь, что такое событие-сирота и как +> посчитать `orphan_events`). +> Эталонный путь: [`airflow/dags/etl_pipeline_dag.py`](../../../airflow/dags/etl_pipeline_dag.py). +> +> Поток данных одной строкой: +> `precheck → transform: wait → ods → dq → branch → dds → integrity → dm → validate` +> +> О чём урок простыми словами: собираем все шаги STG → ODS → DDS → DM в один управляемый +> сценарий Airflow. И превращаем счётчик сирот из прошлого урока в стоп-кран: если событие +> ссылается на несуществующий клик, пайплайн краснеет и не идёт дальше. + +--- + +## 1. Зачем и где в проде + +В прошлых уроках мы смотрели на слои по отдельности: STG принял поток, ODS разобрал JSON, DDS +собрал сущности. Но в проде эти шаги не запускают «по памяти» руками. Иначе легко ошибиться: +сначала собрать DDS до ODS, забыть проверку, не заметить пустую витрину или пропустить сироту. + +Поэтому появляется **оркестрация** — управление порядком работ. В нашем стенде этим занимается +Airflow. Главная единица Airflow — **DAG** (Directed Acyclic Graph, направленный ациклический +граф). Проще: это схема задач без петли назад. В ней видно: + +- какие шаги есть в пайплайне; +- какой шаг ждёт какой; +- где пайплайн должен остановиться, если данные плохие; +- какой именно шаг покраснел, когда что-то сломалось. + +Отдельный шаг в Airflow называется **task** («задача»). Например, `load_ods` — одна task: +выполнить SQL для слоя ODS. `check_dds_integrity` — другая task: посчитать события-сироты. +А весь `etl_pipeline` — DAG, который связывает эти задачи в правильном порядке. + +### Проверка и гейт — не одно и то же + +Важная мысль урока: не каждая проверка должна ронять пайплайн. + +Есть проверки, которые **измеряют**. Например, `check_ods_quality` считает, сколько строк с +`parse_errors` появилось в ODS. Это полезная метрика: её можно отправить в лог, XCom или мониторинг. +Но сам факт ошибки парсинга в нашем учебном стенде не блокирует весь прогон. Грязные записи уже +отложены в `ods.*_errors`, чистые продолжают ехать дальше. + +А есть проверки, которые **гейтят**. **Гейт** (gate, «ворота») — это проверка, через которую +данные должны пройти, иначе следующие шаги не запускаются. Сироты в DDS — как раз такой случай. +Если `dds.event.click_id` ссылается на клик, которого нет в `dds.click`, целостность связей +сломана. В уроке 3 мы это только считали. Теперь DAG сам скажет: `orphan_events > 0`, дальше +не идём. + +> **В проде иначе.** Правил-гейтов обычно больше: свежесть данных, объём относительно вчера, +> доля ошибок, обязательные справочники. Но принцип тот же: одни проверки только измеряют, другие +> останавливают выпуск данных дальше. + +--- + +## 2. Руки: запускаем DAG и смотрим зелёный прогон + +Подними стенд, создай схему и залей малый срез: + +```bash +make up +make ddl +LIMIT=50 make data +``` + +Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`). Найди DAG +`etl_pipeline` и запусти его через **Trigger DAG with config**: + +```json +{"full_refresh": true} +``` + +`full_refresh=true` значит: перед сборкой DDS Airflow очистит `dds.click` и `dds.event`, а потом +заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый, +старые эксперименты не мешают. + +Когда DAG завершится, открой его граф. На чистом срезе все задачи должны быть зелёными. Найди +внутри группы `transform` две задачи подряд: + +- `check_dds_integrity` — SQL-задача, которая считает сирот; +- `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше. + +На чистом срезе `assert_dds_integrity` зелёная: сирот нет, пайплайн прошёл в DM. + +Проверь то же число в ClickHouse play-консоли `http://localhost:9123/play`: + +```sql +SELECT check_date, layer, table_name, check_name, check_value +FROM dm.dq_summary +WHERE layer = 'dds' + AND table_name = 'event_without_click' + AND check_name = 'orphan_events'; +``` + +Ожидаем `check_value = 0`. Это тот же смысл, что в уроке 3, только теперь число появилось внутри +управляемого прогона Airflow. + +--- + +## 3. Загляни внутрь + +Открой [`airflow/dags/etl_pipeline_dag.py`](../../../airflow/dags/etl_pipeline_dag.py). Сначала +прочитай верхний docstring. Там есть короткая карта задач: + +```text +precheck -> transform: wait -> ods -> dq -> branch -> dds -> integrity -> dm -> validate +``` + +Это не SQL-слои, а именно задачи DAG: + +- **`precheck`** — проверить, что ClickHouse доступен и схема уже создана; +- **`wait`** — дождаться строк в STG, чтобы не пересчитывать пустоту; +- **`ods`** — выполнить батч STG → ODS; +- **`dq`** — измерить качество ODS; +- **`branch`** — выбрать, чистить ли DDS перед загрузкой; +- **`dds`** — собрать `dds.click` и `dds.event`; +- **`integrity`** — проверить целостность событий и кликов; +- **`dm`** — собрать сводку качества `dm.dq_summary`; +- **`validate`** — убедиться, что сводка не пустая. + +Дальше разберём пять мест в файле. + +### `ClickHouseOperator`: SQL как отдельная задача + +Большая часть DAG — это задачи на `ClickHouseOperator`. Они выполняют SQL в ClickHouse: + +```python +load_ods = ClickHouseOperator( + task_id="load_ods", + sql=load_sql_statements("ods/20_stg_to_ods.sql"), + clickhouse_conn_id="clickhouse_default", + database="default", +) +``` + +Это читается почти как команда: задача `load_ods` берёт SQL-файл `ods/20_stg_to_ods.sql` и +выполняет его в ClickHouse. Так же устроены `load_dds`, `load_dm_summary` и SQL-проверки. + +### `PythonOperator`: управляющая логика на Python + +**`PythonOperator`** — оператор Airflow, который запускает обычную Python-функцию как task. +В этом DAG Python нужен не для трансформации данных, а для управляющих решений: + +- `wait_for_stg_data` ждёт, пока в STG появятся строки; +- `assert_schema_ready` падает, если DDL не применён; +- `assert_dds_integrity` падает, если есть сироты; +- `assert_dm_summary_not_empty` падает, если финальная сводка пустая. + +То есть данные мы меняем SQL-ем, а Python оставляем для «можно ли продолжать». + +### `XCom`: маленькая передача результата между task + +Airflow хранит маленькие результаты задач в **XCom** (cross-communication, «передача между +задачами»). Это не место для данных пайплайна: туда не кладут таблицы и большие JSON. Но туда +нормально положить маленький результат проверки. + +Так работает пара `check_dds_integrity` → `assert_dds_integrity`: + +```python +check_dds_integrity = ClickHouseOperator( + task_id="check_dds_integrity", + sql=SQL_CHECK_DDS_INTEGRITY, + ... +) + +assert_dds_integrity_task = PythonOperator( + task_id="assert_dds_integrity", + python_callable=assert_dds_integrity, + retries=0, +) +``` + +SQL-задача возвращает одну строку с одним числом — `orphan_events`. Python-задача достаёт это +число из XCom: + +```python +result = ti.xcom_pull(task_ids="transform.check_dds_integrity") +``` + +Если число больше нуля, функция бросает `AirflowException`. Для Airflow это значит: task упала. +А у следующих задач по умолчанию правило «запускайся только если upstream успешен», поэтому +`load_dm_summary` дальше не пойдёт. + +У этой задачи отдельно стоит `retries=0`: если целостность уже нарушена, повтор через две минуты +ничего не исправит. Для учебного гейта честнее сразу показать красную задачу. + +> **Что проверили по API.** Перед правкой мы сверили Airflow через MCP Context7: `PythonOperator` +> подходит для Python-проверки, результат upstream можно читать через `ti.xcom_pull`, зависимости +> задаются оператором `>>`, а `AirflowException` переводит task в ошибку. Для ветвления после +> `BranchPythonOperator` оставлен `TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS`, чтобы join не +> пропускался из-за skipped-ветки. + +### `BranchPythonOperator`: развилка full refresh + +**Branching** («ветвление») — это выбор одной из нескольких веток DAG. У нас развилка простая: +чистить DDS перед загрузкой или не чистить. + +```python +choose_refresh_mode = BranchPythonOperator( + task_id="choose_refresh_mode", + python_callable=choose_full_refresh, +) +``` + +Если `full_refresh=true`, функция выбирает `transform.truncate_dds_click`: DAG очищает +`dds.click`, потом `dds.event`, и только после этого грузит DDS заново. Если `full_refresh=false`, +Airflow идёт через `skip_truncate` и сохраняет текущие строки DDS. + +После развилки ветки снова сходятся в `truncate_complete`. У этой задачи стоит специальное +правило `NONE_FAILED_MIN_ONE_SUCCESS`: «ни одна выбранная ветка не упала, и хотя бы одна успешно +прошла». Без него Airflow мог бы считать skipped-ветку проблемой. + +### Цепочка `>>`: порядок выполнения + +В самом низу файла порядок задач задан стрелками `>>`: + +```python +truncate_complete >> load_dds >> check_dds_integrity >> assert_dds_integrity_task +``` + +Читается слева направо: + +1. дождаться завершения развилки; +2. собрать DDS; +3. посчитать сирот; +4. проверить число и, если надо, уронить DAG. + +И только после этого идут `load_dm_summary`, `validate_dm_summary_sql` и `validate_dm_summary`. +Так гейт стоит именно там, где нужен: после сборки DDS, но до выпуска DM. + +--- + +## 4. Управляемая правка: заведём сироту и уроним DAG + +Сейчас воспроизведём обещание из урока 3: заведём событие-сироту и увидим, как Airflow красит +конкретную задачу. + +### Шаг 1. Вставь сироту в DDS + +Открой ClickHouse play-консоль `http://localhost:9123/play` и выполни: + +```sql +-- Событие со ссылкой на несуществующий клик dddd...-dddd (такого в dds.click нет) +INSERT INTO dds.event (event_id, event_ts, event_type, click_id, browser_name, dds_update_ts, ods_parse_errors) +VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + now64(6), 'pageview', + 'dddddddd-dddd-dddd-dddd-dddddddddddd', + 'DemoBrowser', now64(3), [] +); +``` + +Проверь, что сирота правда появилась: + +```sql +SELECT count() AS orphans +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +Ожидаем `orphans = 1`. + +### Шаг 2. Запусти DAG без очистки DDS + +Теперь вернись в Airflow и запусти `etl_pipeline` через **Trigger DAG with config**: + +```json +{"full_refresh": false} +``` + +Здесь важен именно `false`. Если поставить `true`, DAG сначала очистит `dds.event`, и наша +ручная сирота исчезнет ещё до проверки. А с `full_refresh=false` мы специально сохраняем текущий +DDS и даём гейту поймать битую связь. + +В графе Airflow ожидаем такую картину: + +- `check_dds_integrity` зелёная — SQL успешно посчитал `orphan_events`; +- `assert_dds_integrity` красная — Python-проверка увидела `orphan_events = 1` и бросила ошибку; +- `load_dm_summary` и последующие задачи дальше не пошли. + +Открой лог `assert_dds_integrity`. В нём должно быть сообщение примерно такого смысла: + +```text +DDS integrity check failed: orphan_events=1. Есть события, чей click_id отсутствует в dds.click. +``` + +Вот теперь сирота не просто «видна запросом». Она стала настоящим гейтом пайплайна. + +### Верни как было + +Чтобы вернуть стенд в чистое состояние, запусти `etl_pipeline` ещё раз, но уже с очисткой DDS: + +```json +{"full_refresh": true} +``` + +После зелёного прогона проверь: + +```sql +SELECT count() AS orphans +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +Снова должно быть `0`. Если стенд после экспериментов совсем запутался, полный сброс остаётся +тем же: + +```bash +make clean && make up && make ddl && LIMIT=50 make data +``` + +После этого запусти `etl_pipeline` с `{"full_refresh": true}`. + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| `etl_pipeline` с `{"full_refresh": true}` | Airflow graph | все задачи зелёные | +| чистый прогон | `dm.dq_summary`, строка `orphan_events` | `0` | +| вставка события-сироты | прямой SQL-счётчик сирот | `0 → 1` | +| `etl_pipeline` с `{"full_refresh": false}` после вставки | task `transform.assert_dds_integrity` | task красная, DAG failed | +| откат через `{"full_refresh": true}` | прямой SQL-счётчик сирот | снова `0` | + +--- + +## 6. Что должно получиться + +После урока у тебя на руках — видимый результат: + +- скрин Airflow graph, где `transform.assert_dds_integrity` красная после вставки сироты; +- и рядом короткое объяснение своими словами: почему `check_dds_integrity` зелёная, а + `assert_dds_integrity` красная. + +Проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: + +- что такое DAG и task в Airflow; +- чем проверка-метрика отличается от проверки-гейта; +- почему `check_ods_quality` только измеряет, а `assert_dds_integrity` останавливает пайплайн; +- зачем в управляемой правке нужен `full_refresh=false`; +- почему гейт стоит после `load_dds`, но до `load_dm_summary`. + +Если ответ на последний вопрос получается мутным, вернись к цепочке внизу DAG: DM должна +собираться только из DDS, который уже прошёл проверку целостности. + +--- + +## Вся цепочка разом: STG → ODS → DDS → DM + +Теперь у нас есть не только отдельные слои, но и порядок их жизни: + +- **STG** принимает поток и хранит сырой JSON; +- **ODS** типизирует и разделяет чистое/битое; +- **DDS** собирает сущности и проверяет связи между ними; +- **DM** даёт готовые витрины и сводку качества; +- **Airflow** связывает всё это в DAG, где виден порядок, статус и место падения. + +Главное изменение этого урока — сироты перестали быть ручным запросом «когда-нибудь посмотреть». +Теперь это автоматический гейт: если связь `dds.event → dds.click` порвалась, DAG показывает +красную задачу и не выпускает следующие шаги. + +--- + +## Мост к уроку 5 + +Airflow хорошо показывает судьбу конкретного прогона: зелёный он или красный, на какой задаче +упал, что написано в логе. Но есть другой вопрос: как увидеть состояние всего стенда со стороны — +живы ли сервисы, есть ли лаг в Kafka, не пропали ли метрики качества? В уроке 5 перейдём к +мониторингу: Prometheus и Grafana покажут пайплайн не как один DAG-run, а как систему, за которой +можно наблюдать постоянно. diff --git a/sql/dm/40_dds_to_dm.sql b/sql/dm/40_dds_to_dm.sql index f8f22a2..8b70592 100644 --- a/sql/dm/40_dds_to_dm.sql +++ b/sql/dm/40_dds_to_dm.sql @@ -15,7 +15,7 @@ -- чтобы не накапливать дубликаты при повторных прогонах. -- -- Витрины DM сейчас — это VIEW (логика без копии данных). Если тяжёлая агрегация --- начнёт тормозить, её материализуют в таблицу; пример — в docs/ARCHITECTURE.md, +-- начнёт тормозить, её материализуют в таблицу — пример в docs/ARCHITECTURE.md, -- раздел «Материализация витрин». -- ============================================================================ From d55f183fc9aa9a724d9bd45189f9aea709a6f022 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 20:20:42 +0300 Subject: [PATCH 11/38] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D1=83=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен завершённый урок 5, который объясняет мониторинг стенда без предположения, что менти уже знаком с Grafana. - Что: - добавлен урок про Prometheus targets, Grafana dashboards, exporters и alert rules. - описан управляемый сбой через остановку airflow-scheduler и восстановление стенда. - обновлены навигация курса, план урока и названия панелей мониторинга в operations runbook. - Проверка: - git diff --cached --check. - сверка названий dashboard/panel/alert rules с provisioning-файлами Grafana. --- docs/OPERATIONS.md | 12 +- docs/course/LEARNING_PLAN.md | 4 +- docs/course/README.md | 2 +- docs/course/lessons/05_monitoring.md | 371 +++++++++++++++++++++++++++ 4 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 docs/course/lessons/05_monitoring.md diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index e605eb3..885ad83 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -188,8 +188,8 @@ URL: `http://localhost:3000/d/clickhouse-overview/clickhouse-overview` | Раздел | Метрики | |--------|---------| | System Health | CPU Usage, Memory Resident, Memory Code | -| Query Performance | Queries/sec, Active Queries, Failed Queries, Total Queries, Inserted Rows/sec | -| MergeTree Storage | Total Parts, Parts by State, Total Merges, Merges/sec | +| Query Performance | Queries per Second, Active Queries, Failed Queries (total), Total Queries, Inserted Rows/sec | +| MergeTree Storage | Total Parts, Parts by State, Total Merges, Merges per Second | Принятое решение по метрикам: сверили naming через Context7 (`/clickhouse/clickhouse-docs`, раздел Prometheus interface) и заменили недоступные в `25.1` серии на фактически экспортируемые (`ClickHouseProfileEvents_InsertedRows`, `ClickHouseAsyncMetrics_TotalPartsOfMergeTreeTables`, `ClickHouseMetrics_Parts*`). @@ -199,10 +199,10 @@ URL: `http://localhost:3000/d/kafka-overview/kafka-overview` | Раздел | Метрики | |--------|---------| -| Cluster Health | Brokers Up, Topics Count, Total Partitions, Consumer Groups | -| Throughput | Messages In/sec by Topic | -| Consumers | Consumer Lag by Group, Consumer Lag Table | -| Partitions | Partition Offsets (Current), Oldest vs Current Offset Gap | +| Cluster Health | Brokers Up, Topics, Total Partitions, Consumer Groups | +| Throughput | Messages In / sec by Topic | +| Consumers | Consumer Lag by Group | +| Partitions | Partition Offsets (Current) | **Источник метрик:** `kafka-exporter` (danielqsj/kafka-exporter), формат конфигурации подтверждён через Context7 (`/danielqsj/kafka_exporter`, `/prometheus/docs`). diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md index fd460a9..e31c4f0 100644 --- a/docs/course/LEARNING_PLAN.md +++ b/docs/course/LEARNING_PLAN.md @@ -132,9 +132,9 @@ Kafka из роадмапа — оно даёт словарь терминов. - Конфиги (`prometheus.yml`, экспортёры, provisioning Grafana) полировать под «учебное качество кода» не нужно: дашборды/JSON не читаются построчно. Вся учебная нагрузка ложится на **текст урока** (маршрут по дашбордам + смысл панелей). -- [ ] При написании проверить, что имена дашбордов/панелей/метрик в тексте совпадают +- [x] При написании проверить, что имена дашбордов/панелей/метрик в тексте совпадают с реальными (`configs/grafana/provisioning/dashboards/*.json`). -- [ ] Кандидат на мини-правку (зеркало урока 4): погасить сервис → увидеть, как панель/ +- [x] Кандидат на мини-правку (зеркало урока 4): погасить сервис → увидеть, как панель/ алерт в Grafana краснеет. Решает открытый вопрос PRD про глубину урока (даёт «сломал-увидел» вместо чистого наблюдения) — обсудить при написании. diff --git a/docs/course/README.md b/docs/course/README.md index 9abd56d..87c2436 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -18,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–4) | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–5) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/05_monitoring.md b/docs/course/lessons/05_monitoring.md new file mode 100644 index 0000000..a7dacfa --- /dev/null +++ b/docs/course/lessons/05_monitoring.md @@ -0,0 +1,371 @@ +# Урок 5. Мониторинг в Prometheus и Grafana + +> Формат: **наблюдение с управляемым сбоем** — будешь смотреть метрики и алерты, а в конце +> ненадолго остановишь один сервис и вернёшь его обратно. +> Пререквизит: пройден урок 4 (ты запускал `etl_pipeline`, видел зелёный и красный DAG-run +> и понимаешь, где Airflow показывает судьбу одного прогона). +> Эталонные пути: +> [`configs/prometheus.yml`](../../../configs/prometheus.yml), +> [`configs/statsd_mapping.yml`](../../../configs/statsd_mapping.yml), +> [`configs/grafana/provisioning/`](../../../configs/grafana/provisioning/). +> +> Поток метрик одной строкой: +> `ClickHouse / Kafka / Airflow → exporters → Prometheus → Grafana dashboards → Grafana alerts` +> +> О чём урок простыми словами: Airflow показывает конкретный запуск пайплайна, а мониторинг +> показывает состояние всего стенда. Мы посмотрим, живы ли сервисы, есть ли лаг в Kafka, +> не застрял ли Airflow, и как Grafana подсвечивает поломку. + +--- + +## 1. Зачем и где в проде + +После урока 4 у нас есть управляемый DAG: если в DDS появляются сироты, Airflow красит задачу +и не выпускает DM дальше. Это важно, но это взгляд изнутри одного запуска. + +В проде нужен ещё один слой: **мониторинг**. Он отвечает на другие вопросы: + +- жив ли ClickHouse и отдаёт ли метрики; +- видит ли Kafka брокер, топики и consumer lag; +- работает ли scheduler Airflow; +- есть ли очередь задач; +- появились ли failed queries или failed tasks; +- что изменилось не в одном DAG-run, а во времени. + +**Prometheus** — база временных рядов для метрик. Он регулярно ходит в endpoints сервисов и +забирает числа: память, счётчики запросов, lag, heartbeat scheduler-а. Такой регулярный опрос +называется **scrape** («сбор метрик»). + +**Exporter** — маленький мост между сервисом и Prometheus. Если сервис не отдаёт метрики в +удобном для Prometheus виде, exporter переводит их. В нашем стенде: + +- ClickHouse сам отдаёт `/metrics` на порту `9126`; +- Kafka читается через `kafka-exporter` на порту `9308`; +- Airflow отправляет StatsD-метрики в `statsd-exporter`, а тот отдаёт Prometheus endpoint на + порту `9102`. + +**Grafana** — витрина поверх метрик. В ней мы смотрим дашборды и алерты. Дашборд отвечает +«что сейчас происходит?», а алерт отвечает «какое условие уже достаточно плохое, чтобы привлечь +внимание?». + +> **В проде иначе.** Метрики отправляют в общий мониторинг компании, алерты уходят в Slack, +> PagerDuty или другой on-call канал, а пороги подбирают по SLO и истории нагрузки. Но учебный +> паттерн тот же: сервис отдаёт метрики, Prometheus их собирает, Grafana помогает увидеть +> состояние и поломку. + +--- + +## 2. Руки: открываем дашборды и targets + +Подними стенд и прогони маленький срез, если он ещё не поднят: + +```bash +make up +make ddl +LIMIT=50 make data +``` + +После этого запусти `etl_pipeline` в Airflow с конфигом: + +```json +{"full_refresh": true} +``` + +Нам нужны не идеальные объёмы, а живой стенд, в котором есть Kafka-топики, строки в ClickHouse +и хотя бы один прогон Airflow. + +### Проверяем Prometheus targets + +Открой Prometheus: `http://localhost:9090`. В меню зайди в **Status → Targets**. + +Ожидаем три job: + +| Job | Target внутри Docker | Что это значит | +|-----|----------------------|----------------| +| `clickhouse` | `clickhouse:9126` | ClickHouse отдаёт встроенный Prometheus endpoint | +| `kafka` | `kafka-exporter:9308` | `kafka-exporter` подключился к Kafka и отдаёт метрики | +| `airflow` | `statsd-exporter:9102` | `statsd-exporter` отдаёт метрики Airflow в формате Prometheus | + +У всех трёх состояние должно быть `UP`. Если один target `DOWN`, Grafana дальше будет показывать +`No data` или старые значения. + +То же можно проверить из терминала: + +```bash +curl -s http://localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' +curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"' +``` + +### Открываем Grafana + +Открой Grafana: `http://localhost:3000` (логин `admin`, пароль `admin`). + +Если Grafana просит сменить пароль после первого входа, для учебного стенда нажми **Skip**. +Так все инструкции в курсе останутся одинаковыми: локальный пользователь `admin`, пароль +`admin`. Если ты уже сменил пароль раньше, используй свой новый пароль. + +После входа ты попадаешь на домашний экран Grafana. Минимальная навигация на этот урок: + +- **Dashboards** — раздел с готовыми дашбордами. Обычно он доступен из левого меню или через + поиск по слову `Dashboards`; +- **Alerting → Alert rules** — список правил алертов. Он понадобится в управляемой правке; +- строка поиска сверху помогает быстро найти `ClickHouse Overview`, `Kafka Overview` или + `Airflow Overview`, если меню выглядит иначе. + +В **Dashboards** должны быть три дашборда: + +- `ClickHouse Overview`; +- `Kafka Overview`; +- `Airflow Overview`. + +Открой каждый и смотри не на красоту графиков, а на смысл: какой слой стенда он показывает и +какой вопрос помогает задать. + +### ClickHouse Overview + +URL: `http://localhost:3000/d/clickhouse-overview/clickhouse-overview` + +Главные панели: + +- **System Health** — общий блок про ресурсное состояние; +- **CPU Usage**, **Memory Resident**, **Memory Code** — насколько ClickHouse нагружен и сколько + памяти держит; +- **Query Performance** — блок про запросы; +- **Queries per Second**, **Active Queries**, **Failed Queries (total)**, **Total Queries** — + есть ли запросная активность и ошибки; +- **Inserted Rows/sec** — идут ли вставки; +- **MergeTree Storage**, **Total Parts**, **Parts by State**, **Total Merges**, + **Merges per Second** — что происходит с MergeTree-частями. + +Для учебного стенда здесь обычно не будет большой нагрузки. Это нормально. Главное — увидеть, +что метрики не пустые и меняются после запросов или загрузки данных. + +### Kafka Overview + +URL: `http://localhost:3000/d/kafka-overview/kafka-overview` + +Главные панели: + +- **Cluster Health** — жив ли Kafka-брокер и видны ли топики; +- **Brokers Up**, **Topics**, **Total Partitions**, **Consumer Groups** — базовый снимок + кластера; +- **Throughput** и **Messages In / sec by Topic** — идут ли новые сообщения в топики; +- **Consumer Lag by Group** — насколько consumer group отстаёт от конца топика; +- **Partitions** и **Partition Offsets (Current)** — текущие offset-ы по партициям. + +**Consumer lag** — это разница между тем, что уже лежит в топике, и тем, что consumer group +успела прочитать. В нашем стенде lag обычно быстро возвращается к нулю: данных мало, ClickHouse +читает быстро. Если lag растёт и не снижается, downstream не успевает за Kafka. + +### Airflow Overview + +URL: `http://localhost:3000/d/airflow-overview/airflow-overview` + +Главные панели: + +- **Scheduler Health** — жив ли scheduler; +- **DAG Bag Size** — сколько DAG-ов Airflow видит; +- **Parse Time** — сколько времени занимает разбор DAG-файлов; +- **Scheduler Heartbeat Rate** — продолжает ли scheduler отправлять heartbeat; +- **Executor Status**, **Open Slots**, **Queued Tasks**, **Running Tasks**, + **Executor Tasks Over Time** — хватает ли executor-у места для задач; +- **Task Performance**, **Task Duration (avg)**, **Task Failures vs Success Rate** — как + ведут себя задачи во времени. + +После урока 4 тебе знаком красный `etl_pipeline` в UI Airflow. На этом дашборде та же проблема +видна шире: не «какая task упала в одном run», а «есть ли failed tasks как метрика во времени». + +--- + +## 3. Загляни внутрь + +Открой [`configs/prometheus.yml`](../../../configs/prometheus.yml). Это короткая карта того, +откуда Prometheus забирает метрики. + +### `scrape_configs`: кого опрашивает Prometheus + +В файле три блока: + +```yaml +scrape_configs: + - job_name: "clickhouse" + metrics_path: "/metrics" + static_configs: + - targets: ["clickhouse:9126"] +``` + +`job_name` — имя источника метрик. Его ты видел в Prometheus Targets. `metrics_path` говорит, +куда ходить за метриками. `targets` использует внутренние имена Docker Compose, а не +`localhost`: Prometheus живёт внутри compose-сети и ходит к соседним контейнерам по их service +name. + +Kafka и Airflow устроены так же, но с exporter-ами: + +- `kafka` → `kafka-exporter:9308`; +- `airflow` → `statsd-exporter:9102`. + +### Почему Airflow идёт через StatsD + +Открой [`configs/statsd_mapping.yml`](../../../configs/statsd_mapping.yml). Airflow отправляет +метрики в StatsD-формате, например `airflow.scheduler_heartbeat`. Prometheus так напрямую не +читает, поэтому между ними стоит `statsd-exporter`. + +Mapping переводит имена в Prometheus-стиль: + +| StatsD-метрика Airflow | Prometheus-метрика | Где видна | +|------------------------|--------------------|-----------| +| `airflow.scheduler_heartbeat` | `airflow_scheduler_heartbeat_total` | `Scheduler Heartbeat Rate` | +| `airflow.executor.queued_tasks` | `airflow_executor_queued_tasks` | `Queued Tasks` | +| `airflow.ti.finish.*.*.failed` | `airflow_task_failures_total` | `Task Failures vs Success Rate` | +| `airflow.dag.*.*.duration` | `airflow_task_duration_seconds` | `Task Duration (avg)` | + +Звёздочки в mapping — это части имени, которые становятся label-ами. Например, у task duration +появляются `dag_id` и `task_id`, чтобы в Grafana можно было отличить один DAG и одну task от +других. + +### Grafana provisioning: дашборды и алерты как файлы + +Открой папку [`configs/grafana/provisioning/`](../../../configs/grafana/provisioning/). + +В ней три вида настройки: + +- `datasources/prometheus.yml` — говорит Grafana, где находится Prometheus; +- `dashboards/*.json` — описывает панели дашбордов; +- `alerting/*-alert-rules.yml` — описывает правила алертов. + +Это называется **provisioning**: Grafana получает дашборды и алерты из файлов при старте, а не +только через ручные клики в UI. Для стенда это удобно: поднял compose — получил одинаковую +Grafana. + +### Какие алерты уже есть + +Открой **Alerting → Alert rules** в Grafana. Там должны быть правила: + +| Группа | Правило | Условие простыми словами | +|--------|---------|--------------------------| +| ClickHouse Alerts | `ClickHouse Failed Queries Rate` | появились failed queries | +| ClickHouse Alerts | `ClickHouse Memory Resident High` | ClickHouse занял больше 85% памяти | +| ClickHouse Alerts | `ClickHouse Parts Active High` | активных MergeTree-частей больше 500 | +| Kafka Alerts | `Kafka Broker Down` | Prometheus видит меньше одного Kafka-брокера | +| Kafka Alerts | `Kafka Consumer Lag High` | lag больше 10000 | +| Kafka Alerts | `Kafka No Messages Produced` | в топики долго почти не идут новые сообщения | +| Airflow Alerts | `Airflow Scheduler Down` | heartbeat scheduler-а почти исчез | +| Airflow Alerts | `Airflow Queue Backlog` | в очереди больше 50 задач | +| Airflow Alerts | `High Task Failure Rate` | растёт rate failed tasks | +| Airflow Alerts | `High DAG Parse Time` | DAG-файлы долго парсятся | + +Не все эти правила обязаны стрелять в учебном стенде. Часть порогов специально похожа на +продовые: они показывают, как формулируется условие, но не создают шум на каждом маленьком +прогоне. + +--- + +## 4. Управляемая правка: остановим scheduler и увидим алерт + +Теперь сделаем маленькую поломку, которую легко вернуть назад: остановим только +`airflow-scheduler`. Web UI Airflow останется доступен, ClickHouse и Kafka не трогаем. + +### Шаг 1. Убедись, что сейчас всё живо + +Открой Grafana → **Airflow Overview** и найди панель **Scheduler Heartbeat Rate**. + +Потом открой **Alerting → Alert rules** и найди правило `Airflow Scheduler Down`. Перед +экспериментом оно должно быть не в `Alerting`. + +Можно проверить и командой: + +```bash +docker compose ps airflow-scheduler +``` + +### Шаг 2. Останови scheduler + +```bash +docker compose stop airflow-scheduler +``` + +Подожди 2-3 минуты. У правила `Airflow Scheduler Down` стоит окно `for: 2m`, поэтому алерт не +обязан покраснеть мгновенно. Это нормальное поведение: мониторинг защищается от коротких +миганий. + +Что ожидаем: + +- в **Airflow Overview** панель **Scheduler Heartbeat Rate** падает к нулю или перестаёт + обновляться; +- в **Alerting → Alert rules** правило `Airflow Scheduler Down` переходит в `Alerting`; +- новые DAG-runs не должны нормально планироваться, потому что scheduler остановлен. + +Это и есть отличие мониторинга от ручной проверки: тебе не нужно помнить команду для heartbeat. +Условие уже записано в alert rule. + +### Верни как было + +Сразу верни scheduler: + +```bash +docker compose up -d airflow-scheduler +``` + +Подожди ещё 2-3 минуты и проверь: + +- `docker compose ps airflow-scheduler` показывает running/up; +- **Scheduler Heartbeat Rate** снова растёт; +- `Airflow Scheduler Down` выходит из `Alerting`. + +Если Grafana или Prometheus после экспериментов показывают `No data`, восстанови мониторинг +штатной командой: + +```bash +make reload-monitoring +``` + +Если это не помогло и видишь `out of bounds` или залипшие старые значения: + +```bash +make recover-monitoring +``` + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| открыть Prometheus targets | `http://localhost:9090` → Status → Targets | `clickhouse`, `kafka`, `airflow` в состоянии `UP` | +| открыть `ClickHouse Overview` | Grafana dashboards | панели `Queries per Second`, `Failed Queries (total)`, `Inserted Rows/sec` не пустые | +| открыть `Kafka Overview` | Grafana dashboards | видны `Brokers Up`, `Topics`, `Consumer Lag by Group` | +| открыть `Airflow Overview` | Grafana dashboards | видны `Scheduler Heartbeat Rate`, `Queued Tasks`, `Task Failures vs Success Rate` | +| остановить `airflow-scheduler` | Grafana alert rules | `Airflow Scheduler Down` переходит в `Alerting` после окна ожидания | +| вернуть `airflow-scheduler` | Grafana alert rules и `docker compose ps` | сервис снова running/up, алерт возвращается в норму | + +--- + +## 6. Что должно получиться + +После урока у тебя на руках — видимый результат: + +- скрин Prometheus Targets, где `clickhouse`, `kafka` и `airflow` находятся в `UP`; +- скрин Grafana с правилом `Airflow Scheduler Down` в `Alerting` после остановки scheduler-а; +- короткое объяснение своими словами: почему Airflow UI и Grafana отвечают на разные вопросы. + +Проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: + +- зачем Prometheus нужен отдельно от Grafana; +- чем exporter отличается от самого сервиса; +- почему Airflow-метрики проходят через `statsd-exporter`; +- что такое scrape target; +- что показывает consumer lag; +- почему алерт `Airflow Scheduler Down` не краснеет в ту же секунду, когда ты остановил сервис; +- почему после эксперимента нужно явно вернуть `airflow-scheduler`. + +Если ответ про Airflow UI и Grafana получается одним и тем же, вернись к началу урока. Airflow UI +удобен для разбора конкретного DAG-run. Grafana удобна для состояния системы во времени. + +--- + +## Мост к следующему шагу + +Теперь стенд закрывает полный учебный маршрут: Kafka принимает поток, ClickHouse раскладывает +слои, Airflow управляет порядком, а Prometheus и Grafana показывают состояние системы. Дальше +этот же стенд можно использовать не как разовый набор уроков, а как тренажёр: менять данные, +ломать отдельные места, смотреть, где появляется сигнал, и объяснять по метрикам, что произошло. From 5f5312c70cef46b0b2c5d2b319b2003732182736 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 21:58:44 +0300 Subject: [PATCH 12/38] =?UTF-8?q?fix(dds):=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=8B=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20*?= =?UTF-8?q?=5Fnot=5Ffound=20+=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=BA=D0=B0=203=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - перечитка урока 3 свежим взглядом нашла баг в его эталонном пути: проверки device_not_found/geo_not_found/location_not_found в DDS никогда не срабатывали, а текст урока ошибочно утверждал, что метки ставятся - Что: - sql/dds/30_ods_to_dds.sql: добавлен SETTINGS join_use_nulls=1 в оба INSERT...SELECT. Без него LEFT JOIN на несовпадении клал в assumeNotNull(click_id) нулевой UUID (не NULL), и if(...IS NULL, ['*_not_found'], []) молча давал [] (мёртвый код). Тот же класс бага про типы/NULL, что kafka_ts в уроке 1 - docs/course/lessons/03_ods_to_dds.md: убраны ложные claim'ы про geo_not_found/ location_not_found, формулировки приведены к реальному поведению (клик без гео остаётся с пустыми полями NULL; целостность событий — через orphan_events); поправлена опечатка «список всех клиентов» → «всех кликов» - Проверка: - синтетический тест join_use_nulls=1: клик в device без geo → в ods_parse_errors появляются geo_not_found и geo_country_missing (до фикса — пусто) - LIMIT=50 make transform после фикса: dds.click=26, dds.event=50, orphan_events=0, ни одной строки с непустым ods_parse_errors (вывод не изменился — на чистом срезе несовпадений нет) --- docs/course/lessons/03_ods_to_dds.md | 36 +++++++++++++++------------- sql/dds/30_ods_to_dds.sql | 11 +++++++-- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md index d06d13e..fe1c8d9 100644 --- a/docs/course/lessons/03_ods_to_dds.md +++ b/docs/course/lessons/03_ods_to_dds.md @@ -151,7 +151,7 @@ SELECT click_id FROM ods.geo_by_click ... `UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный -список всех клиентов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы +список всех кликов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал в `device`. @@ -206,15 +206,15 @@ LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id останутся пустыми (`NULL`). Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять -нельзя**. Не доехало гео — ладно, сохраним клик с пустым гео и пометкой, что гео нет. Эта пометка -тут же и ставится: рядом со сборкой стоит `if(g.click_id IS NULL, ['geo_not_found'], [])` — если -гео не подтянулось, в список ошибок карточки добавится метка `geo_not_found`. Тот же принцип -«не теряем и помечаем», что и `parse_errors` в ODS, только теперь про пропавшие связи. +нельзя**. Не доехало гео — ладно, сохраним клик, а гео-поля (страна, координаты, IP) останутся +пустыми (`NULL`). И эта пустота — уже видимый сигнал: по ней сразу понятно, что контекст по клику +не подтянулся. Тот же принцип, что и в ODS: **не теряем, а оставляем видимый след**, — только +теперь не про кривое поле, а про пропавшую связь между таблицами. > Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location` > связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу -> и UTM. Если `location` не доехал — событие остаётся, а поля страницы пустые с меткой -> `location_not_found`. Разбирать этот блок построчно не будем — он повторяет ту же логику. +> и UTM. Если `location` не доехал — событие остаётся, а поля страницы остаются пустыми. Разбирать +> этот блок построчно не будем — он повторяет ту же логику. ### Сироты: событие без клика @@ -231,10 +231,11 @@ WHERE click_id IS NOT NULL AND click_id NOT IN (SELECT click_id FROM dds.click); ``` -Заметь разницу с предыдущим пунктом. `geo_not_found` — это когда у **клика** нет гео (внутренний -пропуск в карточке). А сирота — это когда у **события** нет вообще никакого клика (порвана связь -между сущностями). Это разные дырки, и следят за ними по отдельности. На чистом срезе сирот ноль — -сейчас мы это изменим. +Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой +контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события** +нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по +пустым полям внутри карточки, вторую — отдельным счётчиком. На чистом срезе сирот ноль — сейчас +мы это изменим. --- @@ -333,8 +334,8 @@ make transform - что такое сущность DDS и зачем собирать `dds.click` и `dds.event`, если данные уже есть в ODS; - почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео; -- что такое сирота и чем разрыв «событие без клика» отличается от пропуска `geo_not_found` внутри - карточки клика. +- что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки + клика. Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый `click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку. @@ -354,10 +355,11 @@ make transform - **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые спросили про целостность связей между ними (сироты). -Заметь общий принцип всех трёх слоёв — **«не теряем, а помечаем»**. На STG не роняем приём из-за -кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в -`*_errors`. На DDS не выкидываем клик без гео и событие без клика, а помечаем (`geo_not_found`) -и считаем (`orphan_events`). Один и тот же подход к качеству, проведённый через весь пайплайн. +Заметь общий принцип всех трёх слоёв — **«не теряем, а оставляем след»**. На STG не роняем приём +из-за кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в +`*_errors`. На DDS не выкидываем клик без гео (оставляем его с пустыми полями) и событие без клика +(считаем такие сироты через `orphan_events`). Один и тот же подход к качеству, проведённый через +весь пайплайн. > **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые > `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они diff --git a/sql/dds/30_ods_to_dds.sql b/sql/dds/30_ods_to_dds.sql index 851939d..074eba6 100644 --- a/sql/dds/30_ods_to_dds.sql +++ b/sql/dds/30_ods_to_dds.sql @@ -101,7 +101,11 @@ LEFT JOIN ( FROM ods.geo_by_click WHERE click_id IS NOT NULL GROUP BY click_id -) AS g ON g.click_id = c.click_id; +) AS g ON g.click_id = c.click_id +-- join_use_nulls=1: при несовпадении LEFT JOIN кладёт в правые колонки NULL, а не дефолт. +-- Без этого assumeNotNull(click_id) на несовпавшей строке стал бы нулевым UUID (не NULL), +-- и проверки if(d.click_id IS NULL, ...) / if(g.click_id IS NULL, ...) молча не сработали бы. +SETTINGS join_use_nulls = 1; -- ---------------------------------------------------------------------------- -- Сущность: dds.event (объединяет browser + location) @@ -165,4 +169,7 @@ LEFT JOIN ( FROM ods.location_event WHERE event_id IS NOT NULL GROUP BY event_id -) AS l ON l.event_id = b.event_id; +) AS l ON l.event_id = b.event_id +-- join_use_nulls=1: чтобы при отсутствии location поле l.event_id было NULL, а не нулевым UUID, +-- и проверка if(l.event_id IS NULL, ['location_not_found'], []) реально срабатывала. +SETTINGS join_use_nulls = 1; From 9f69a33c311eba61d10fb9bebde8c8d91f44055d Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 22:30:19 +0300 Subject: [PATCH 13/38] =?UTF-8?q?fix(superset):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20BI-=D0=B4=D0=B0=D1=88=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B4=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - Superset dashboard открывался с ошибками datasources и неудобным layout, а курс не содержал готового урока по BI-витрине. - Что: - добавлен урок 6 про Superset поверх ClickHouse DM-витрин. - исправлена раскладка dashboard и дефолтный фильтр даты для исторических демо-данных. - добавлено восстановление metadata колонок датасетов при обновлении dashboard. - Проверка: - make superset-dashboard. - /api/v1/dashboard/1/datasets и /superset/explore_json для chart 10 возвращают 200. - python3 -m py_compile superset/create_dashboard.py; git diff --cached --check. --- README.md | 5 +- docs/SUPERSET_DASHBOARD.md | 6 +- docs/course/README.md | 4 +- docs/course/lessons/06_superset_bi.md | 400 ++++++++++++++++++++++++++ superset/create_dashboard.py | 47 ++- 5 files changed, 444 insertions(+), 18 deletions(-) create mode 100644 docs/course/lessons/06_superset_bi.md diff --git a/README.md b/README.md index 18b30fc..8cb4a34 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ flowchart LR | `make ddl` | Применить DDL в ClickHouse (вне Airflow) | | `make transform` | Запустить batch-процесс `STG -> ODS -> DDS -> DM` (вне Airflow) | | `make superset-init` | Подключение к ClickHouse + импорт датасетов | -| `make superset-dashboard` | Создание дашборда с чартами | +| `make superset-dashboard` | Создание дашборда с чартами, обновление layout/metadata | | `make superset-ui` | Показать URL Superset | | `make superset-restart` | Перезапуск Superset | @@ -276,6 +276,9 @@ make superset-dashboard | **Маркетинг** | UTM Effectiveness Table, Top Pages | `v_utm_effectiveness`, `v_top_pages_daily` | | **Quality** | Data Quality Summary | `dq_summary` | +Фильтр `Date Range` по умолчанию открыт как `No filter`, потому что демо-данные лежат в +историческом диапазоне (`2022-11-28`). + ### Архитектура Superset ``` diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index 2fe6a04..ff61627 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -28,7 +28,7 @@ make transform # Автоматическая инициализация (создание подключения и датасетов) make superset-init -# Создание дашборда с чартами +# Создание дашборда с чартами; при необходимости обновляет metadata колонок датасетов make superset-dashboard ``` @@ -62,6 +62,8 @@ make superset-dashboard - **🎯 Unique Sessions** — уникальные сессии (click_id) - **📈 Avg Events/Session** — среднее количество событий на сессию +KPI разложены в одну строку по 12-колоночной сетке Superset: четыре блока по 3 колонки. + #### Динамика трафика - **📅 Events by Hour** — линейный график событий по часам - **📱 Traffic by Device** — pie chart распределения по устройствам @@ -80,7 +82,7 @@ make superset-dashboard | Фильтр | Поле | Тип | Применение | |--------|------|-----|------------| -| 📅 Date Range | `event_date` | Time Range | Все чарты | +| 📅 Date Range | `event_date` | Time Range | Все чарты; по умолчанию `No filter`, чтобы демо-данные 2022 года не скрывались | | 🌍 Country | `geo_country` | Multi-select | Все чарты | | 📱 Device Type | `device_type` | Multi-select | Все чарты | | 🌐 Browser | `browser_name` | Multi-select | Все чарты | diff --git a/docs/course/README.md b/docs/course/README.md index 87c2436..5c9c325 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -1,7 +1,7 @@ # Курс «Кликстрим на ClickHouse» (со звёздочкой) Продвинутый курс для менти, уже прошедших базовую программу: основные паттерны -инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг. Менти проходит +инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг + BI. Менти проходит материал самостоятельно, а на еженедельном созвоне с ментором разбирает затыки и отвечает на вопросы по теме — чтобы проверить глубину понимания. @@ -18,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–5) | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–6) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md new file mode 100644 index 0000000..047a13b --- /dev/null +++ b/docs/course/lessons/06_superset_bi.md @@ -0,0 +1,400 @@ +# Урок 6. BI-витрина в Superset + +> Формат: **практика** — будешь запускать Superset поверх готовых витрин ClickHouse, +> смотреть дашборд и делать маленькую обратимую правку в конфигурации чарта. +> Пререквизит: пройдены уроки 4–5 (ты уже запускал `etl_pipeline`, видел DM-слой в конце +> пайплайна и понимаешь разницу между мониторингом стенда и данными для анализа). +> Эталонные пути: +> [`sql/ddl/dm/40_dm.sql`](../../../sql/ddl/dm/40_dm.sql), +> [`sql/dm/40_dds_to_dm.sql`](../../../sql/dm/40_dds_to_dm.sql), +> [`superset/init_superset.py`](../../../superset/init_superset.py), +> [`superset/create_dashboard.py`](../../../superset/create_dashboard.py). +> +> Поток данных одной строкой: +> `DDS → DM views / dm.dq_summary → Superset datasets → charts → dashboard → native filters` +> +> О чём урок простыми словами: ClickHouse уже подготовил таблицы и представления для +> потребления. Superset превращает их в экран для аналитика: датасеты, графики, фильтры +> и один общий дашборд. + +--- + +## 1. Зачем и где в проде + +После уроков 1–4 у нас есть данные: поток приземлился в STG, разобрался в ODS, собрался в DDS, +а в конце появился DM-слой. После урока 5 у нас есть мониторинг: он отвечает, жив ли стенд и +не сломался ли пайплайн. + +Теперь нужен другой взгляд — **BI** (Business Intelligence, «аналитический интерфейс для +бизнес-вопросов»). BI отвечает не «жив ли ClickHouse», а: + +- сколько событий пришло; +- какие устройства чаще встречаются; +- из каких стран пришёл трафик; +- какие UTM-каналы дают больше кликов; +- какие страницы самые популярные; +- есть ли видимые проблемы качества данных. + +**Superset** — BI-инструмент. Он не заменяет ClickHouse, Airflow или Grafana. Он сидит поверх +готовых данных и даёт интерфейс для просмотра, фильтрации и сборки графиков. + +В нашем стенде роли такие: + +| Слой | Что делает | +|------|------------| +| `dds.click`, `dds.event` | хранит собранные сущности после ODS | +| `dm.v_*`, `dm.dq_summary` | готовит поверхность потребления для аналитики | +| Superset dataset | регистрирует таблицу или VIEW из ClickHouse в Superset | +| Superset chart | сохраняет один график или KPI на базе dataset | +| Superset dashboard | собирает charts в один экран | +| Native filters | фильтруют dashboard по дате, стране, устройству, браузеру | + +Граница урока: **витрина DM и BI-экран — не одно и то же**. + +DM-витрина — это SQL-объект в ClickHouse. Она задаёт форму данных: какие поля есть, на какой +гранулярности лежит агрегат, какие joins уже сделаны. BI-экран — это способ показать эту +витрину человеку: график, таблица, фильтр, порядок блоков на странице. + +> **В проде иначе.** Superset обычно подключают к нескольким хранилищам, заводят роли и права, +> разделяют черновые и опубликованные дашборды, а тяжёлые витрины материализуют. Но базовая +> схема та же: хранилище готовит данные, BI даёт удобную точку потребления. + +--- + +## 2. Руки: запускаем Superset и смотрим дашборд + +Подними стенд и прогони маленький срез: + +```bash +make up +make ddl +LIMIT=50 make data +make transform +``` + +`make transform` прогоняет цепочку STG → ODS → DDS → DM вне Airflow. Для этого урока так +быстрее: нам нужен готовый DM-слой, а не разбор DAG. + +Теперь инициализируй Superset: + +```bash +make superset-init +``` + +Эта команда создаёт или обновляет: + +- подключение `clickhouse_dwh`; +- 6 datasets поверх `dm.*`; +- 10 charts; +- dashboard `E-commerce Analytics Dashboard`. + +Открой Superset: `http://localhost:8088` (логин `admin`, пароль `admin`). + +Если Superset предлагает сменить пароль после первого входа, для учебного стенда можно нажать +**Skip**. В проде так не делают, но локальный курс держит одинаковые инструкции для всех. + +### Открываем dashboard + +Открой готовый dashboard: + +```text +http://localhost:8088/superset/dashboard/1/ +``` + +Если URL не открылся, зайди через меню **Dashboards** и найди `E-commerce Analytics Dashboard`. + +На экране должны быть блоки: + +- KPI сверху: `Total Events`, `Unique Users`, `Unique Sessions`, `Avg Events/Session`; +- динамика: `Events by Hour`, `Traffic by Device`; +- география: `Geography Map`; +- маркетинг: `UTM Effectiveness Table`, `Top Pages`; +- качество данных: `Data Quality Summary`. + +### Фильтр даты + +В демо-данных события датированы `2022-11-28`. В текущей конфигурации dashboard фильтр даты +открывается как `No filter`. Если у тебя осталась старая metadata Superset и native filter +**Date Range** стоит в значении `Last week`, часть графиков может быть пустой, хотя данные есть. + +Для этого урока поставь в фильтре даты одно из двух: + +- `No filter`; +- или ручной диапазон вокруг `2022-11-28`. + +После этого нажми **Apply filters**. Теперь смотри на dashboard как аналитик: какие графики +отвечают на бизнес-вопросы, а какие только показывают техническое качество данных. + +### Проверяем данные напрямую в ClickHouse + +Открой ClickHouse play-консоль: `http://localhost:9123/play`. + +Проверь, что основной dataset Superset не пустой: + +```sql +SELECT count() AS events +FROM dm.v_events_enriched; +``` + +И посмотри, откуда берётся график `Top Pages`: + +```sql +SELECT page_url_path, sum(pageviews) AS pageviews +FROM dm.v_top_pages_daily +GROUP BY page_url_path +ORDER BY pageviews DESC +LIMIT 20; +``` + +Запомни эту связку: Superset показывает график, но данные и логика агрегации живут в ClickHouse. + +--- + +## 3. Загляни внутрь + +Разберём три места: DM-витрины в ClickHouse, регистрацию datasets в Superset и сборку charts / +dashboard. + +### DM: поверхность потребления + +Открой [`sql/ddl/dm/40_dm.sql`](../../../sql/ddl/dm/40_dm.sql). + +В начале файла написано, почему DM сейчас сделан через `VIEW`: + +- логику можно менять без пересоздания таблиц; +- нет копии данных поверх DDS; +- для демо производительности достаточно. + +Основная витрина для dashboard — `dm.v_events_enriched`. Она соединяет `dds.event` и `dds.click` +через `click_id`: + +```sql +FROM dds.event AS e +LEFT JOIN dds.click AS c ON c.click_id = e.click_id; +``` + +`LEFT JOIN` здесь осознанный: событие может существовать без части контекста из клика. Для BI это +значит: график событий не исчезает только потому, что у части строк нет устройства или географии. + +Другие VIEW дают более узкие поверхности: + +| VIEW / таблица | Для чего нужна в Superset | +|----------------|---------------------------| +| `dm.v_events_enriched` | KPI, динамика, устройства, география, фильтры | +| `dm.v_daily_traffic` | готовая дневная агрегация трафика | +| `dm.v_utm_effectiveness` | таблица по UTM-каналам | +| `dm.v_top_pages_daily` | популярные страницы | +| `dm.v_session_overview` | обзор сессий | +| `dm.dq_summary` | сводка качества данных по слоям | + +Открой [`sql/dm/40_dds_to_dm.sql`](../../../sql/dm/40_dds_to_dm.sql). Этот файл не пересчитывает +все `dm.v_*`: VIEW создаются в DDL. Здесь batch-часть наполняет `dm.dq_summary`, чтобы dashboard +мог показать качество данных после каждого прогона. + +### `init_superset.py`: подключение и datasets + +Открой [`superset/init_superset.py`](../../../superset/init_superset.py). + +Сначала скрипт собирает URI ClickHouse: + +```python +return f"clickhousedb://{user}:{password}@{host}:{port}/{database}" +``` + +Внутри Docker-сети Superset ходит в ClickHouse по HTTP-порту `8123`, поэтому URI использует +`clickhousedb://...@clickhouse:8123/default`. + +Потом скрипт создаёт подключение `clickhouse_dwh` и регистрирует datasets: + +```python +datasets = [ + { + "table_name": "v_events_enriched", + "schema": "dm", + "database_name": "clickhouse_dwh", + "description": "Полная обогащённая витрина событий (event + click)" + }, + ... +] +``` + +**Dataset** в Superset — это не копия данных. Это запись в metadata Superset: какая таблица или +VIEW есть в ClickHouse, какие у неё колонки и как её можно использовать в графиках. + +Metadata Superset живёт в PostgreSQL, а сами данные остаются в ClickHouse. Поэтому после полного +сброса volumes нужно заново создать metadata Superset, а после пересчёта данных — сами charts +обычно остаются теми же. + +### `create_dashboard.py`: charts, dashboard, filters + +Открой [`superset/create_dashboard.py`](../../../superset/create_dashboard.py). + +В `CHARTS_CONFIG` лежит список charts. Один элемент списка — один график: + +```python +{ + "slice_name": "📄 Top Pages", + "viz_type": "dist_bar", + "dataset_name": "v_top_pages_daily", + "params": { + "groupby": ["page_url_path"], + "metrics": [ + {"expressionType": "SQL", "sqlExpression": "SUM(pageviews)", "label": "Pageviews"} + ], + "row_limit": 20, + "time_range": "No filter", + "orientation": "vertical", + "show_legend": False + } +} +``` + +Здесь видно четыре идеи: + +- `slice_name` — имя chart в Superset; +- `viz_type` — тип визуализации; +- `dataset_name` — на каком dataset строится chart; +- `params` — настройка запроса и отображения. + +Ниже `DASHBOARD_CONFIG` задаёт сам dashboard: + +```python +DASHBOARD_CONFIG = { + "dashboard_title": "🛒 E-commerce Analytics Dashboard", + "description": "...", + "published": True, + "slug": "ecommerce-analytics", +} +``` + +Dashboard находится по `slug`, а charts добавляются в layout. Если dashboard уже существует, +скрипт обновляет metadata, layout и список charts. На этом держится управляемая правка: можно +поменять параметр chart, запустить `make superset-dashboard` и увидеть результат в UI. + +Native filters создаются в `build_dashboard_metadata`. Там есть фильтры: + +- `Date Range` по `event_date`; +- `Country` по `geo_country`; +- `Device Type` по `device_type`; +- `Browser` по `browser_name`. + +> **Что проверили по API.** Перед уроком Superset сверили через MCP Context7 (`/apache/superset`): +> в Superset есть отдельные сущности charts и dashboards, metadata хранится отдельно от +> подключаемых источников данных, а row limit — часть настройки запросов и конфигурации. +> Поэтому в уроке не лезем в REST API Superset, а работаем через уже существующий скрипт стенда. + +--- + +## 4. Управляемая правка: уменьшаем Top Pages + +Сейчас chart `Top Pages` показывает до 20 страниц: + +```python +"row_limit": 20, +``` + +Сделай маленькую видимую правку: временно покажи только топ-3 страницы. + +Открой [`superset/create_dashboard.py`](../../../superset/create_dashboard.py), найди chart +`Top Pages` и поменяй: + +```python +"row_limit": 20, +``` + +на: + +```python +"row_limit": 3, +``` + +Запусти обновление dashboard: + +```bash +make superset-dashboard +``` + +Вернись в Superset и обнови страницу dashboard. В chart `Top Pages` должно остаться не больше +трёх страниц. Если фильтр даты снова скрыл данные, поставь **Date Range → No filter** и нажми +**Apply filters**. + +Почему это хорошая маленькая правка: + +- мы не меняем SQL-витрину в ClickHouse; +- не создаём новый dataset; +- не трогаем подключение к ClickHouse; +- меняем только BI-представление уже готовых данных. + +### Верни как было + +Верни в [`superset/create_dashboard.py`](../../../superset/create_dashboard.py): + +```python +"row_limit": 20, +``` + +И снова запусти: + +```bash +make superset-dashboard +``` + +После обновления страницы chart `Top Pages` снова может показывать до 20 страниц. + +Если после экспериментов Superset выглядит странно, самый простой учебный возврат dashboard +metadata к конфигурации из репозитория: + +```bash +make superset-init +``` + +Данные в ClickHouse эта команда не удаляет. Она повторно применяет подключение, datasets и +dashboard metadata Superset. + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| `make transform` | ClickHouse `dm.v_events_enriched` | `count() > 0` | +| `make superset-init` | Superset → **Settings → Database Connections** | есть подключение `clickhouse_dwh` | +| открыть **Datasets** | Superset UI | есть datasets `v_events_enriched`, `v_top_pages_daily`, `dq_summary` | +| открыть dashboard | Superset UI | видны KPI, маркетинг, география и качество данных | +| поставить **Date Range → No filter** | dashboard filters | графики не скрываются из-за даты `2022-11-28` | +| поменять `row_limit` у `Top Pages` на `3` и запустить `make superset-dashboard` | chart `Top Pages` | не больше трёх страниц | +| вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Top Pages` | ограничение снова до 20 страниц | + +Вопросы для созвона: + +- чем DM-витрина отличается от Superset dataset; +- почему Superset не должен ходить напрямую в сырые STG-таблицы; +- зачем dashboard нужен `Date Range`, если SQL-витрина уже агрегирована; +- почему изменение `row_limit` — это BI-правка, а не изменение модели данных; +- где хранятся данные, а где metadata Superset. + +--- + +## 6. Что должно получиться + +К концу урока у тебя должен быть открытый dashboard `E-commerce Analytics Dashboard` в Superset. +Сделай скриншот после временной правки `Top Pages`: на нём должно быть видно, что chart показывает +не больше трёх страниц. + +Второй артефакт — короткий абзац своими словами: + +> DM-слой в ClickHouse готовит данные для потребления, dataset в Superset регистрирует эту +> витрину, chart задаёт один график, dashboard собирает charts в экран, а native filters дают +> аналитику быстрый способ менять срез данных. + +После этого обязательно верни `row_limit` на `20`, чтобы следующий урок или следующий прогон +стенда начинался с исходной конфигурации. + +--- + +## Мост после курса + +Теперь у тебя есть сквозная цепочка: Kafka → ClickHouse STG → ODS → DDS → DM → мониторинг → +Superset. Следующий честный вопрос уже не про этот стенд, а про продакшен: какие витрины стоит +материализовать, какие права дать BI-пользователям и как не превратить dashboard в единственный +источник правды вместо версионированного SQL в репозитории. diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 7d9e0ed..b768999 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -114,7 +114,7 @@ CHARTS_CONFIG = [ } ], "groupby": [], - "time_range": "Last week", + "time_range": "No filter", "adhoc_filters": [], "row_limit": 10000 } @@ -231,6 +231,20 @@ DASHBOARD_CONFIG = { } +CHART_LAYOUT_BY_TITLE = { + "📊 Total Events": {"width": 3, "height": 32, "x": 0, "y": 0}, + "👤 Unique Users": {"width": 3, "height": 32, "x": 3, "y": 0}, + "🎯 Unique Sessions": {"width": 3, "height": 32, "x": 6, "y": 0}, + "📈 Avg Events/Session": {"width": 3, "height": 32, "x": 9, "y": 0}, + "📅 Events by Hour": {"width": 8, "height": 60, "x": 0, "y": 32}, + "📱 Traffic by Device": {"width": 4, "height": 60, "x": 8, "y": 32}, + "🌍 Geography Map": {"width": 6, "height": 60, "x": 0, "y": 92}, + "🔗 UTM Effectiveness Table": {"width": 6, "height": 60, "x": 6, "y": 92}, + "📄 Top Pages": {"width": 6, "height": 60, "x": 0, "y": 152}, + "🔍 Data Quality Summary": {"width": 6, "height": 60, "x": 6, "y": 152}, +} + + def sync_query_context(chart, params: dict, dataset_id: int) -> None: """ Синхронизирует сохраненный query_context с обновленными params чарта. @@ -302,7 +316,7 @@ def build_dashboard_metadata(filter_dataset_id: int | None) -> str: "name": "📅 Date Range", "filterType": "filter_time", "targets": [{"datasetId": filter_dataset_id, "column": {"name": "event_date"}}], - "defaultValue": "Last week", + "defaultValue": "No filter", "scope": {"rootPath": ["ROOT_ID"], "excluded": []}, "cascadeParentIds": [], "isInstant": True @@ -377,6 +391,14 @@ def main() -> bool: if not dataset: logger.warning(f"Dataset '{chart_config['dataset_name']}' not found, skipping chart") continue + + if not dataset.columns: + # Если dataset создали до DDL/DM, в metadata Superset нет колонок. + # Обновляем их здесь, чтобы dashboard восстанавливался через make superset-dashboard. + dataset.fetch_metadata() + db.session.commit() + logger.info("Refreshed dataset metadata: %s", dataset.table_name) + datasets_by_name[chart_config["dataset_name"]] = dataset.id try: @@ -452,13 +474,15 @@ def main() -> bool: }, } - # Добавляем чарты в layout (grid: 12 columns) - y_position = 0 - chart_index = 0 - + # Раскладываем dashboard вручную по 12-колоночной сетке: + # KPI в одну строку, затем аналитические блоки парами. for chart in created_charts: if chart: chart_component_id = f"CHART-{chart['id']}" + layout = CHART_LAYOUT_BY_TITLE.get( + chart["title"], + {"width": 6, "height": 50, "x": 0, "y": 212}, + ) positions[chart_component_id] = { "id": chart_component_id, "type": "CHART", @@ -467,16 +491,13 @@ def main() -> bool: "meta": { "chartId": chart['id'], "sliceName": chart['title'], - "height": 50, - "width": 4 if chart_index < 4 else 6, - "x": (chart_index % 3) * 4 if chart_index < 4 else (chart_index % 2) * 6, - "y": y_position, + "height": layout["height"], + "width": layout["width"], + "x": layout["x"], + "y": layout["y"], }, } positions["GRID_ID"]["children"].append(chart_component_id) - chart_index += 1 - if chart_index % 4 == 0: - y_position += 50 # Создаём дашборд if created_charts: From f1fc31fa3f860e75161beaa21340db65c7177741 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 22:56:53 +0300 Subject: [PATCH 14/38] =?UTF-8?q?fix(superset):=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B0=20ROW-=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B8=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20KPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - дашборд рендерился одной колонкой во всю ширину, а KPI показывали значение последнего часового бакета (1/1/1) вместо итогов по срезу. - Что: - чарты разложены по строкам-контейнерам ROW (Superset layout v2): KPI-полоса 4-в-ряд + аналитические блоки парами вместо плоского списка CHART под GRID с игнорируемыми x/y. - KPI переведены с big_number на big_number_total без granularity_sqla; sync_query_context чистит granularity/time_grain для тотала. - Проверка: - make superset-dashboard; GET /api/v1/dashboard/1/datasets → 200; position_json содержит 4 ROW; KPI показывают 50/26/26/1.92. --- superset/create_dashboard.py | 141 ++++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 53 deletions(-) diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index b768999..5c76750 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -30,7 +30,9 @@ CHARTS_CONFIG = [ # KPI блок { "slice_name": "📊 Total Events", - "viz_type": "big_number", + # big_number_total = итог по всему срезу (без granularity), а не последний + # временной бакет, как у big_number. + "viz_type": "big_number_total", "dataset_name": "v_events_enriched", "params": { "metric": { @@ -41,15 +43,13 @@ CHARTS_CONFIG = [ "label": "Total Events", "optionName": "metric_1" }, - "granularity_sqla": "event_ts", "y_axis_format": ",d", - "show_trend_line": False, "time_range": "No filter" } }, { "slice_name": "👤 Unique Users", - "viz_type": "big_number", + "viz_type": "big_number_total", "dataset_name": "v_events_enriched", "params": { "metric": { @@ -58,15 +58,13 @@ CHARTS_CONFIG = [ "label": "Unique Users", "optionName": "metric_2" }, - "granularity_sqla": "event_ts", "y_axis_format": ",d", - "show_trend_line": False, "time_range": "No filter" } }, { "slice_name": "🎯 Unique Sessions", - "viz_type": "big_number", + "viz_type": "big_number_total", "dataset_name": "v_events_enriched", "params": { "metric": { @@ -75,15 +73,13 @@ CHARTS_CONFIG = [ "label": "Unique Sessions", "optionName": "metric_3" }, - "granularity_sqla": "event_ts", "y_axis_format": ",d", - "show_trend_line": False, "time_range": "No filter" } }, { "slice_name": "📈 Avg Events/Session", - "viz_type": "big_number", + "viz_type": "big_number_total", "dataset_name": "v_events_enriched", "params": { "metric": { @@ -92,9 +88,7 @@ CHARTS_CONFIG = [ "label": "Avg Events/Session", "optionName": "metric_4" }, - "granularity_sqla": "event_ts", "y_axis_format": ".2f", - "show_trend_line": False, "time_range": "No filter" } }, @@ -231,18 +225,26 @@ DASHBOARD_CONFIG = { } -CHART_LAYOUT_BY_TITLE = { - "📊 Total Events": {"width": 3, "height": 32, "x": 0, "y": 0}, - "👤 Unique Users": {"width": 3, "height": 32, "x": 3, "y": 0}, - "🎯 Unique Sessions": {"width": 3, "height": 32, "x": 6, "y": 0}, - "📈 Avg Events/Session": {"width": 3, "height": 32, "x": 9, "y": 0}, - "📅 Events by Hour": {"width": 8, "height": 60, "x": 0, "y": 32}, - "📱 Traffic by Device": {"width": 4, "height": 60, "x": 8, "y": 32}, - "🌍 Geography Map": {"width": 6, "height": 60, "x": 0, "y": 92}, - "🔗 UTM Effectiveness Table": {"width": 6, "height": 60, "x": 6, "y": 92}, - "📄 Top Pages": {"width": 6, "height": 60, "x": 0, "y": 152}, - "🔍 Data Quality Summary": {"width": 6, "height": 60, "x": 6, "y": 152}, -} +# Раскладка дашборда по строкам (Superset layout v2 — флекс-модель ROW/CHART). +# Superset НЕ использует абсолютные координаты x/y: ширина чарта (1..12) имеет +# смысл только внутри строки-контейнера ROW. Без ROW каждый чарт занимает всю +# ширину и валится в одну колонку. Поэтому раскладываем именно строками. +# Каждая строка — список (slice_name, width); сумма width в строке должна быть ≤ 12. +DASHBOARD_ROWS = [ + # KPI-полоса: 4 числа в один ряд + [("📊 Total Events", 3), ("👤 Unique Users", 3), + ("🎯 Unique Sessions", 3), ("📈 Avg Events/Session", 3)], + # Динамика во времени + разрез по устройствам + [("📅 Events by Hour", 8), ("📱 Traffic by Device", 4)], + # География + эффективность маркетинговых каналов + [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], + # Популярные страницы + качество данных + [("📄 Top Pages", 6), ("🔍 Data Quality Summary", 6)], +] + +# Высота строки в grid-units Superset (одинаковая для всех чартов строки — +# чтобы они выравнивались по нижней границе). +ROW_HEIGHTS = [30, 60, 60, 60] def sync_query_context(chart, params: dict, dataset_id: int) -> None: @@ -274,18 +276,18 @@ def sync_query_context(chart, params: dict, dataset_id: int) -> None: chart.query_context = json.dumps(query_context) return - if chart.viz_type == "big_number": + if chart.viz_type == "big_number_total": + # Итоговое число по всему срезу: не time-series, без granularity и + # time-grain — иначе запрос группируется по времени и число «прыгает» + # на значение последнего бакета. query = queries[0] - granularity = params.get("granularity_sqla") - if granularity: - query["granularity"] = granularity - query["is_timeseries"] = True + query["is_timeseries"] = False + query.pop("granularity", None) query["time_range"] = params.get("time_range", query.get("time_range")) if "metric" in params: query["metrics"] = [params["metric"]] extras = query.get("extras") if isinstance(query.get("extras"), dict) else {} - if "time_grain_sqla" in params: - extras["time_grain_sqla"] = params.get("time_grain_sqla") + extras.pop("time_grain_sqla", None) query["extras"] = extras elif params.get("x_axis") or params.get("groupby"): # Для категориальных графиков синхронизируем колонки измерений. @@ -474,30 +476,63 @@ def main() -> bool: }, } - # Раскладываем dashboard вручную по 12-колоночной сетке: - # KPI в одну строку, затем аналитические блоки парами. - for chart in created_charts: - if chart: - chart_component_id = f"CHART-{chart['id']}" - layout = CHART_LAYOUT_BY_TITLE.get( - chart["title"], - {"width": 6, "height": 50, "x": 0, "y": 212}, - ) - positions[chart_component_id] = { - "id": chart_component_id, - "type": "CHART", - "children": [], + # Раскладываем dashboard строками (ROW): KPI в один ряд, затем + # аналитические блоки парами. Каждый CHART обязан лежать внутри ROW, + # иначе Superset игнорирует width и ставит чарты в одну колонку. + charts_by_title = {c["title"]: c for c in created_charts if c} + + def add_chart(component_id_owner_row: str, chart: dict, width: int, height: int) -> str: + """Регистрирует CHART-компонент и возвращает его id.""" + cid = f"CHART-{chart['id']}" + positions[cid] = { + "id": cid, + "type": "CHART", + "children": [], + "parents": ["ROOT_ID", "GRID_ID", component_id_owner_row], + "meta": { + "chartId": chart["id"], + "sliceName": chart["title"], + "width": width, + "height": height, + }, + } + return cid + + placed_titles = set() + for row_idx, row in enumerate(DASHBOARD_ROWS): + row_id = f"ROW-{row_idx}" + height = ROW_HEIGHTS[row_idx] if row_idx < len(ROW_HEIGHTS) else 50 + row_children = [] + for title, width in row: + chart = charts_by_title.get(title) + if not chart: + continue + row_children.append(add_chart(row_id, chart, width, height)) + placed_titles.add(title) + if row_children: + positions[row_id] = { + "id": row_id, + "type": "ROW", + "children": row_children, "parents": ["ROOT_ID", "GRID_ID"], - "meta": { - "chartId": chart['id'], - "sliceName": chart['title'], - "height": layout["height"], - "width": layout["width"], - "x": layout["x"], - "y": layout["y"], - }, + "meta": {"background": "BACKGROUND_TRANSPARENT"}, } - positions["GRID_ID"]["children"].append(chart_component_id) + positions["GRID_ID"]["children"].append(row_id) + + # Чарты, не описанные в DASHBOARD_ROWS, кладём отдельной строкой во всю + # ширину — чтобы новый чарт не «потерялся» из дашборда. + leftovers = [c for c in created_charts if c and c["title"] not in placed_titles] + if leftovers: + row_id = "ROW-extra" + row_children = [add_chart(row_id, c, 12, 50) for c in leftovers] + positions[row_id] = { + "id": row_id, + "type": "ROW", + "children": row_children, + "parents": ["ROOT_ID", "GRID_ID"], + "meta": {"background": "BACKGROUND_TRANSPARENT"}, + } + positions["GRID_ID"]["children"].append(row_id) # Создаём дашборд if created_charts: From 0dd88183b7f5b11634dfdd79f11557fa32ddaa9a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 16:12:10 +0300 Subject: [PATCH 15/38] =?UTF-8?q?chore(git):=20=D0=B8=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D1=8B=20playwright-cli/mcp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - артефакты браузерной автоматизации не должны попадать в индекс. - Что: - добавлены .playwright-cli и .playwright-mcp в .gitignore. - Проверка: - git status не показывает .playwright-cli/ после прогона playwright-cli. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ef34e0b..6ce5b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ $RECYCLE.BIN/ *.msp *.lnk node_modules + +.playwright-cli +.playwright-mcp \ No newline at end of file From 18a55dd6409ed566766633aed9d36c720c6fff43 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 16:12:10 +0300 Subject: [PATCH 16/38] =?UTF-8?q?docs(adr):=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20docs/specs,=20handoff'=D1=8B=20=D0=B2=20.scra?= =?UTF-8?q?tch/handoffs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - воркфлоу эволюционировал: design-спекам нужен durable-дом, а handoff'ам — стабильное место вместо эфемерного /tmp. - Что: - ADR-0002: design-спеки переезжают в docs/specs/YYYY-MM-DD-*.md (superseding ADR-0001). - ADR-0003: handoff'ы — одноразовые леса́ в .scratch/handoffs/, коммитятся, уборка best-effort. - ADR-0001 помечен как частично заменённый; AGENTS.md описывает место handoff'ов. - Проверка: - docs/adr/000{1,2,3} согласованы, перекрёстные ссылки рабочие. --- AGENTS.md | 1 + docs/adr/0001-spec-adr-issue-layout.md | 3 ++ docs/adr/0002-specs-as-durable-design-docs.md | 48 +++++++++++++++++++ docs/adr/0003-handoffs-in-scratch.md | 33 +++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 docs/adr/0002-specs-as-durable-design-docs.md create mode 100644 docs/adr/0003-handoffs-in-scratch.md diff --git a/AGENTS.md b/AGENTS.md index ca3d65f..6cc9fc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ Single-context: `CONTEXT.md` и `docs/adr/` в корне репозитория - [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов. - [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md). - [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины). +- `.scratch/handoffs/YYYY-MM-DD-.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md). ## Ограничения по структуре diff --git a/docs/adr/0001-spec-adr-issue-layout.md b/docs/adr/0001-spec-adr-issue-layout.md index 3376196..9dd540a 100644 --- a/docs/adr/0001-spec-adr-issue-layout.md +++ b/docs/adr/0001-spec-adr-issue-layout.md @@ -1,6 +1,9 @@ # ADR-0001: Раскладка спецификаций, решений и задач Принято: 2026-06-01 +Статус: частично заменён [ADR-0002](./0002-specs-as-durable-design-docs.md) — +маршрут design-спек переехал из `.scratch/` в `docs/specs/`. Остальные решения +(ADR, `CONTEXT.md`, issue-tracker) в силе. ## Решение diff --git a/docs/adr/0002-specs-as-durable-design-docs.md b/docs/adr/0002-specs-as-durable-design-docs.md new file mode 100644 index 0000000..8ccb31e --- /dev/null +++ b/docs/adr/0002-specs-as-durable-design-docs.md @@ -0,0 +1,48 @@ +# ADR-0002: Спеки как durable design-доки в `docs/specs/` + +Принято: 2026-06-06 +Статус: accepted; заменяет маршрут спек из [ADR-0001](./0001-spec-adr-issue-layout.md) +(остальные решения ADR-0001 — ADR, `CONTEXT.md`, issue-tracker — в силе). + +## Решение + +Design-спеки (инженерная аргументация «что строим и почему», без реализации) +живут в `docs/specs/YYYY-MM-DD-.md` — датированные, с статус-заголовком +(`Draft | Accepted | Implemented | Superseded`). `.scratch/` остаётся для +по-настоящему эфемерного (todo, черновые заметки), а не для курируемых спек. +ADR — для кратких durable-решений; `CONTEXT.md` — словарь. + +## Контекст + +ADR-0001 (2026-06-01) маршрутизировал все спеки в `.scratch/` как транзиентные +снимки «не для перечитывания» — таким был грилл-поток Мэтта Покока, который тогда +осваивался. С тех пор воркфлоу эволюционировал: появился гибридный скилл +`brainstorm-with-docs` (Покок + obra/superpowers), и выделился **второй жанр** — +спека как durable-аргументация архитектуры, которую перечитывают. ADR-0001 этот +жанр не предусматривал. Репозиторий учебный (и для студентов, и для собственного +исследовательского трека владельца), поэтому design-доки сами по себе — +учебный материал, а следы принятия решений ценны: отсюда отдельный +superseding-ADR, а не правка ADR-0001 на месте. + +## Рассмотренные варианты + +- **Оставить `.scratch/` для всех спек (status quo ADR-0001).** Отклонено: + durable-аргументацию перечитывают, а каталог «не для перечитывания» её прячет — + ровно та боль, что привела к пересмотру. +- **`docs/specs/` для durable-спек (принято).** Реверс отклонения из ADR-0001. + Тогда спека считалась только транзиентной, и `docs/specs/` отклонили как + «засорение курируемых доков». Страх clutter снимается датой в имени (снимок на + момент) + статус-заголовком + правилом «кристаллизовавшееся решение выносим + короткой ADR». +- **Два уровня (`.scratch/` транзиентные + `docs/specs/` durable, по каждой + спеке).** Отклонено: требует суждения «durable?» на каждую спеку и двух домов; + для репо такого размера overhead не окупается, граница плывёт. + +## Последствия + +- `docs/specs/` создаётся лениво; первая спека — редизайн Superset-дашборда. +- Дисциплина: статус-заголовок в каждой спеке; краткое решение дублируется + короткой ADR, если переживёт спеку. +- ADR-0001 получает forward-pointer на этот ADR (история не стирается). +- `to-prd`/`.scratch/PRD.md` остаётся для продуктовых требований; инженерные + design-решения идут спекой, не PRD. diff --git a/docs/adr/0003-handoffs-in-scratch.md b/docs/adr/0003-handoffs-in-scratch.md new file mode 100644 index 0000000..63186c7 --- /dev/null +++ b/docs/adr/0003-handoffs-in-scratch.md @@ -0,0 +1,33 @@ +# ADR-0003: Handoff'ы — одноразовые леса́ в `.scratch/handoffs/` + +Принято: 2026-06-06 +Статус: accepted + +## Решение + +Handoff (документ «где остановился и как продолжить» для следующей сессии/агента) +кладём в `.scratch/handoffs/YYYY-MM-DD-.md` и **коммитим**. Это одноразовые +леса́, а не след решения: durable-рассуждение живёт в спеке (`docs/specs/`), ADR и +истории коммитов, поэтому handoff можно удалять без потери трассы. Уборка — +**best-effort** (`git rm`, когда вспомнил или перед важным мержем), без +обязательной дисциплины и пост-процессов. + +## Контекст + +Раньше handoff жил в `/tmp` — слишком эфемерно (не переживает ребут/смену машины). +Рассматривали гитигнор `.scratch/handoffs/`, но он отпал по двум причинам +владельца: разработка **на нескольких машинах** (гитигнор не синхронизируется) и +планируемые **git worktrees** (untracked-файл виден только в одном worktree). +Оба требования требуют коммита. «Закоммичено на ветке, но автоматически не в +master» git без машинерии (CI-гейт) не даёт; засорение `.scratch/` считаем мягким, +т.к. это некурируемый «ящик» (читатель туда не ходит — см. ADR-0001). + +## Последствия + +- Carve-out из ADR-0001 «`.scratch/` коммитим целиком»: гитигнор не вводим, но и не + обязаны вычищать handoff'ы — они одноразовые. Курируемый скретч (если будет) + по-прежнему коммитится. +- Расположение зафиксировано в `AGENTS.md`, чтобы скилл `handoff` писал туда. +- Решение проекто-зависимое: для строгих/шаренных проектов без мультимашинности + допустим opt-in на гитигнор или CI-гейт, блокирующий `.scratch/handoffs/` в PR в + master. Здесь — коммит + best-effort. From 79481a93514b2f53db2153682dff2334017926e6 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 16:12:10 +0300 Subject: [PATCH 17/38] =?UTF-8?q?docs(context):=20=D0=B3=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D0=B0=D1=80=D0=B8=D0=B9=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BB=D0=B8=D0=BA=D1=81=D1=82=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - зафиксировать доменный язык, чтобы метрики дашборда и урок 6 опирались на единые термины. - Что: - добавлен CONTEXT.md: пользователь/визит-сессия/событие, иерархия, почему на демо Users == Sessions. - Проверка: - термины сверены с sql/ddl/dds/30_dds.sql и sql/ddl/dm/40_dm.sql. --- CONTEXT.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1f5b847 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,68 @@ +# CONTEXT.md — глоссарий домена + +Доменный язык стенда: как мы называем сущности кликстрима и что под ними +понимаем. Один термин — одно имя. Ведётся вместе с `docs/adr/` (см. +[ADR-0001](./docs/adr/0001-spec-adr-issue-layout.md)). + +## Сущности + +### Пользователь (user) + +`user_domain_id` (`UUID`) — постоянный идентификатор пользователя (cookie-уровень), +переживает отдельные визиты. Источник — `device_events`, привязан к `click_id`. +В витринах: `uniqExact(user_domain_id)`. + +### Визит / сессия (visit / session) + +`click_id` (`UUID`) — **визит**: envelope из одного и более событий с общим +контекстом device/geo. В DDL `dds.click` описана как «контекст сессии +пользователя» (см. [`sql/ddl/dds/30_dds.sql`](./sql/ddl/dds/30_dds.sql)). + +Важно: в источнике **нет отдельного session-таймаута** (как Snowplow +`domain_sessionid`). Поэтому в этой модели **сессия ≡ визит ≡ `click_id`** — +группа событий под одним идентификатором, а не окно активности по тайм-ауту. +Если в будущем понадобится «настоящая» сессия по 30-минутному окну неактивности — +это отдельная производная над `user_domain_id` + `event_ts`; на текущих данных +она дала бы те же группы (разброс времени внутри `click_id` ≤ 1 мин). + +«Сессию» и «визит» используем как синонимы; в UI предпочитаем «визит», когда важно +подчеркнуть, что это не сессия-по-тайм-ауту. + +### Событие (event) + +`event_id` (`UUID`) — отдельное действие пользователя в рамках визита: `pageview`, +`click`, `purchase`, `add_to_cart` и т.п. (поле `event_type`). Источник — +`browser_events` + `location_events`. В витринах: `count()`. + +## Иерархия + +``` +user_domain_id (пользователь, постоянный) + └── click_id (визит/сессия — 1..N событий с общим device/geo) + └── event_id (событие: pageview / click / purchase ...) +``` + +Здоровые данные дают пирамиду `users ≤ sessions ≤ events`. + +## Почему на демо `Unique Users == Unique Sessions` + +В демо-датасете (`data/*.jsonl`) каждый пользователь имеет **ровно один** +`click_id` — связь user↔визит строго 1:1 (проверено: 99 пользователей = +99 `click_id` в полном файле). Поэтому `uniqExact(user_domain_id)` и +`uniqExact(click_id)` дают одинаковое число. + +Это **свойство конкретного датасета, а не закон модели и не баг расчёта.** В +реальных данных пользователь возвращается несколькими визитами, и числа +расходятся. Поэтому на дашборде мы **не** показываем две одинаковые KPI-плитки +(это читалось бы как ошибка) — вместо дубля идут производные метрики +(события на визит, доля визитов с одним событием), а различие «пользователь vs +сессия» объясняется текстом урока. + +Синтетический генератор (ветка `feature/data-generator`) проблему не решает, а +усугубляет: он штампует свежий `click_id` на каждое событие, и `click_id` +вырождается в «событие». См. `generator/KNOWN_ISSUES.md` на той ветке. + +## Слои данных + +`STG → ODS → DDS → DM` — см. [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). +Витрины (`dm.*`) и дашборд Superset строятся на статическом сиде, не на потоке. From 7c7e9e5a7cff632ecba0cf2395b68842981edbb2 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 16:12:10 +0300 Subject: [PATCH 18/38] =?UTF-8?q?docs(superset):=20=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B8=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - дашборд-эталон показывал две одинаковые KPI-плитки; нужен честный состав метрик на полных данных, зафиксированный до реализации. - Что: - docs/specs/2026-06-06-...: KPI Events/Users/Avg per Visit/Conversion, Top Pages → Funnel, триаж чартов. - .scratch/handoffs/2026-06-06-...: handoff для реализации в новой сессии. - Проверка: - числа спеки сверены с прямым запросом в ClickHouse на полном датасете. --- .../2026-06-06-superset-dashboard-redesign.md | 61 +++++++ .../2026-06-06-superset-dashboard-redesign.md | 152 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 .scratch/handoffs/2026-06-06-superset-dashboard-redesign.md create mode 100644 docs/specs/2026-06-06-superset-dashboard-redesign.md diff --git a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md new file mode 100644 index 0000000..2db995a --- /dev/null +++ b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md @@ -0,0 +1,61 @@ +# Handoff: реализация редизайна Superset-дашборда + +Дата: 2026-06-06 · Язык сессии: русский · Понять-режим был включён (можно не продолжать) + +## Источник истины + +**Сначала прочитать спеку:** [`docs/specs/2026-06-06-superset-dashboard-redesign.md`](../../docs/specs/2026-06-06-superset-dashboard-redesign.md) +— там весь дизайн (проблема, числа данных, триаж чартов, состав KPI, решения, риски, +критерии проверки). Этот handoff — только «как возобновить», не дублирует дизайн. +Доменные термины — [`CONTEXT.md`](../../CONTEXT.md). + +## Решение одной строкой + +KPI-полоса = `Total Events · Unique Users · Avg Events/Visit · Conversion to /confirmation` +(дубль «Unique Sessions» убрать); `Top Pages → Funnel`-чарт по страницам; различие +user/session — текстом, не двумя одинаковыми цифрами; мёртвые колонки purchases — выкинуть. + +## Сделать ПЕРВЫМ делом + +1. **Снять open questions из спеки** (без них реализация буксует): + - точная формула Conversion (доля визитов с ≥1 pageview `/confirmation`?); + - поддерживает ли Superset **4.1.2** `viz_type` воронки (иначе — упорядоченный bar); + - судьба `Events by Hour` (проверить, не пустой ли на полных данных). +2. **Перегрузить стенд на ПОЛНЫЕ данные** — сейчас в ClickHouse отладочный срез + (50 событий). Нужно: `make data` (без `LIMIT`) + `make transform`. На полных + данных: 1000 событий, 99 визитов, 99 пользователей. + +## Где править и как прогонять + +- Единственный файл реализации: `superset/create_dashboard.py` + (`CHARTS_CONFIG` / `DASHBOARD_ROWS` / `ROW_HEIGHTS`). +- Прогон: `make superset-dashboard` — идемпотентно (чарты по `slice_name`, дашборд + по `slug`, обновляются на месте). При переименовании чартов следить, чтобы не + плодились дубли. + +## Проверка + +- `GET /api/v1/dashboard//datasets` → 200; DQ Summary без `Columns missing in datasource`. +- Визуально: `playwright-cli` — логин формой `admin`/`admin` на `http://localhost:8088/login/`, + затем `goto .../superset/dashboard/ecommerce-analytics/`, `screenshot --filename=/tmp/x.png` (читать через Read). + Скриншоты — в `/tmp`. `.playwright-cli/` НЕ коммитить. +- Сверка чисел с ClickHouse: креды в `configs/default_user.xml` (default / `123456`), + `docker exec clickstream-ch-kafka-superset-demo-clickhouse-1 clickhouse-client --password 123456 -q "..."`. + (Через `docker exec printenv` креды НЕ дёргать — классификатор блокирует.) + +## Синхронизировать доки ПРИ реализации + +- `docs/SUPERSET_DASHBOARD.md` — раздел «Структура дашборда» (новый состав чартов/KPI). +- `docs/course/lessons/06_superset_bi.md` — убрать `LIMIT=50 make data`, синхронизировать состав/скриншоты. + +## Git-гигиена + +- Ветка `docs/advanced-clickstream-course`, на ней **параллельно пишет Codex** — + git строго **аддитивно**, не amend/rebase/reset чужих коммитов. `git add` только своих файлов. +- Артефакты этой сессии (если ещё не закоммичены): `CONTEXT.md`, `docs/adr/0001` (правка), + `docs/adr/0002`, `docs/adr/0003`, `docs/specs/2026-06-06-...`, `AGENTS.md` (правка), этот handoff. + +## Suggested skills + +`conventional-commits` (любой коммит) · `playwright-cli` (визуальная проверка) · +`diagnose` (если чарт/датасет отвалится). diff --git a/docs/specs/2026-06-06-superset-dashboard-redesign.md b/docs/specs/2026-06-06-superset-dashboard-redesign.md new file mode 100644 index 0000000..461008c --- /dev/null +++ b/docs/specs/2026-06-06-superset-dashboard-redesign.md @@ -0,0 +1,152 @@ +# Редизайн KPI-полосы и состава чартов дашборда «E-commerce Analytics» + +Дата: 2026-06-06 +Статус: Accepted (не реализовано — код `superset/create_dashboard.py` правится +отдельным заходом на реализацию) +Связано: [`CONTEXT.md`](../../CONTEXT.md), [ADR-0002](../adr/0002-specs-as-durable-design-docs.md), +урок [`docs/course/lessons/06_superset_bi.md`](../course/lessons/06_superset_bi.md) + +## Problem + +Дашборд-эталон показывает две KPI-плитки — `Unique Users` и `Unique Sessions` — с +одинаковым числом (26 на срезе, 99 на полных данных), потому что в датасете +`user_domain_id ↔ click_id` строго 1:1. Две одинаковые цифры читаются как баг +расчёта и ничему не учат менти, который копирует дашборд как образец. Нужен +честный, копируемый состав KPI и чартов на полных данных. + +## Context + +- Дашборд — учебный эталон для менти. Требований обратной совместимости нет: + состав можно менять свободно, критерий — учебная ценность. +- Доменные термины зафиксированы в `CONTEXT.md`: **пользователь** = + `user_domain_id`, **визит/сессия** = `click_id`, **событие** = `event_id`. +- Источник данных — статический сид `data/*.jsonl`. Синтетический генератор + (ветка `feature/data-generator`) источником **не** является и семантику ломает + (см. `generator/KNOWN_ISSUES.md` на той ветке). + +## Goals + +- KPI-полоса из 4 различающихся честных метрик (без дубля одинаковых чисел). +- Превратить слабые места данных в учебные объекты, а не маскировать их. +- Консистентность с `CONTEXT.md` и уроком 6. + +## Non-goals + +- Не модернизируем **типы** виджетов (`pie`/`world_map`/`dist_bar` → ECharts) — + отдельный косметический заход. +- Не трогаем эмодзи в тайтлах, секции-заголовки, языковой винегрет. +- Не трогаем генератор. +- Не правим код `create_dashboard.py` в рамках этой спеки — это реализация. + +## Current state + +Полный датасет (`make data` без `LIMIT`): + +``` +события: 1000 | визиты (click_id): 99 | пользователи (user_domain_id): 99 (1:1) +avg событий на визит: 10.10 | bounce (визиты с 1 событием): 4/99 = 4.0% +event_type: 100% pageview (других типов НЕТ) +page_url_path — воронка: /home 426 → /product_a 236 + /product_b 157 → + /cart 93 → /payment 53 → /confirmation 35 (~8% в конец) +geo_country: 40 | device_type: 2 (Mobile/Computer) | utm_source: 5 | utm_medium: 4 +``` + +Текущие чарты и триаж по учебной ценности: + +| Чарт | Тип | Вердикт | +|---|---|---| +| Total Events, Unique Users | big_number_total | оставить (KPI) | +| Unique Sessions | big_number_total | **убрать** (дубль Users) | +| Avg Events/Session | big_number_total | оставить, переименовать `/Visit` | +| Top Pages | dist_bar | **апгрейд в Funnel** (центральный учебный объект) | +| UTM Effectiveness | table | оставить; **выкинуть колонки purchases/add_to_cart** (всегда 0) | +| Geography Map | world_map | оставить (40 стран; тип модернизировать отдельно) | +| Traffic by Device | pie | оставить | +| Events by Hour | line | **проверить на пустоту**, иначе дропнуть | +| Data Quality Summary | dist_bar | оставить | + +## Options considered + +- **A — честная воронка вовлечённости (стандартный KPI-strip).** 4 плитки + объём→охват→глубина→качество; различие user/session уходит в текст. +- **B — оставить обе плитки + Markdown-панель**, объясняющая равенство 1:1. +- **C — strip как форма модели:** воронка `Events → Visits → Users` вместо + бизнес-KPI; вырожденное 1:1 становится наглядным. + +## Recommended approach + +**A для KPI-полосы + идея C отдельным чартом-воронкой.** KPI-полоса — +стандартная и чистая (копируемый паттерн); иерархия модели подаётся отдельным +чартом, а `Top Pages` превращается в **Funnel** по страницам. + +KPI-полоса: + +``` +📊 Total Events 1000 · 👤 Unique Users 99 · 📈 Avg Events/Visit 10.1 · 🎯 Conversion to /confirmation ~8% +``` + +## Rationale + +- Конверсия через `page_url_path` честнее блёклого bounce (4%) и превращает + «всё pageview» из слабости в сильный урок: воронка реально затухает. +- Различие user/session учим текстом (`CONTEXT.md` + урок 6), а не двумя + одинаковыми числами — это убирает сигнал «баг». +- Стандартный KPI-strip ценнее для копирования, чем нестандартный вариант C; + но идею C сохраняем как отдельный наглядный чарт. + +## Decisions and rejected alternatives + +- **Данные: полный датасет** и для дашборда, и для урока 6. Срез `LIMIT=50` + отклонён: числа эталона разойдутся с тем, что менти получит у себя. Урок 6 — + убрать `LIMIT=50 make data`. (Срез `LIMIT` оставить в доке как явный приём + отладки, не как основной прогон. Замечание: ограничение `head -n` исходно + возникло, чтобы агентский контекст не давился полным jsonl — это ограничение + для агента, не для пайплайна.) +- **Убрать `Unique Sessions` из KPI** — дубль `Unique Users` при 1:1. +- **4-я KPI = Conversion**, не bounce. Bounce 4% отклонён как блёклый. +- **Top Pages → Funnel.** Воронка по страницам — главный учебный объект. +- **UTM-таблица: убрать колонки purchases/add_to_cart** — всегда 0, т.к. + `event_type` только `pageview`. +- **Гео/device/DQ не удаляем** — данные оправдывают ценность; модернизация их + типов виджетов — отдельно (non-goal здесь). +- **«Снести всё legacy»** на практике = убрать дубль-KPI + мёртвые колонки + + проверить Events by Hour. Инсайты не вырезаем — у большинства есть данные. + +## Risks and mitigations + +- **Funnel в Superset 4.1.2** — проверить доступность `viz_type` воронки до + реализации; при отсутствии — упорядоченный bar. +- **Формула Conversion** — определить точно и валидировать SQL (см. Open + questions). +- **Идемпотентность `create_dashboard.py`** при смене состава (чарты по + `slice_name`, дашборд по `slug`) — переименование `Unique Sessions`/`Top Pages` + не должно плодить дубли; проверить апдейт на месте. + +## Validation + +- KPI-полоса: 4 разных числа, дубля нет. +- `GET /api/v1/dashboard//datasets` → 200; DQ Summary без + `Columns missing in datasource`. +- Визуальная проверка через playwright-cli (скриншот в `/tmp`). +- Числа на дашборде сходятся с прямым запросом в ClickHouse. + +## Documentation impact + +- `docs/SUPERSET_DASHBOARD.md` — обновить раздел «Структура дашборда» + (состав чартов/KPI/фильтров) **в момент реализации**, чтобы пользовательская + дока не описывала несуществующие чарты. +- `docs/course/lessons/06_superset_bi.md` — убрать `LIMIT=50`, синхронизировать + состав чартов и скриншоты. +- `CONTEXT.md` — уже отражает user/visit/event; правок не требует. + +## Open questions + +- **Точная формула Conversion:** доля визитов с ≥1 pageview `/confirmation`, или + просмотры `/confirmation` / просмотры `/home`? Влияет на число (~8%) и на то, + что именно учим (визит-конверсия vs page-to-page). +- **Events by Hour** — оставляем ли (зависит от проверки на пустоту на полных + данных: разброс `event_ts` по часам). +- **Требования к будущему генератору**, вытекающие из упёртостей этих данных + (разнообразие `event_type`, возвраты пользователей → sessions>users, + реалистичная воронка) — при возврате к генератору перенести в его + `KNOWN_ISSUES.md`. From 0fd8a08667bc8a2daa969fa110b2367bd55911e3 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 16:17:34 +0300 Subject: [PATCH 19/38] =?UTF-8?q?docs(superset):=20=D0=B2=20handoff=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D0=B5=D0=B7=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BB=D1=8F=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - Codex силён в реализации, но визуальную приёмку дашборда не вытянет — нужно явно оставить screenshot sign-off человеку/vision-агенту. - Что: - в handoff добавлен блок «Если задачу берёт Codex»: не визуальные само-проверки его, визуальный sign-off — отдельно. - Проверка: - .scratch/handoffs/2026-06-06-...md содержит блок и не закрывает done по визуалу. --- .../2026-06-06-superset-dashboard-redesign.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md index 2db995a..ae99cab 100644 --- a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md +++ b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md @@ -43,6 +43,18 @@ user/session — текстом, не двумя одинаковыми цифр `docker exec clickstream-ch-kafka-superset-demo-clickhouse-1 clickhouse-client --password 123456 -q "..."`. (Через `docker exec printenv` креды НЕ дёргать — классификатор блокирует.) +## Если задачу берёт Codex (разрез по приёмке) + +Codex силён в реализации/рассуждении, **слаб в визуальной оценке** — а приёмка +дашборда визуальна (обе прошлые сессии были про поломки раскладки: ROW-оверлапы, +«прыгающие» KPI). Поэтому: + +- Codex делает реализацию + **не**визуальные само-проверки: API `/datasets → 200`, + числа сходятся с прямым запросом в ClickHouse, скриншоты складывает в `/tmp`. +- Codex **НЕ объявляет done по визуалу.** Самый рискованный пункт (отрисовалась ли + воронка вообще, нет ли наездов плиток) — чисто визуальный. Остановиться и передать + скриншоты на визуальный sign-off человеку или vision-агенту. + ## Синхронизировать доки ПРИ реализации - `docs/SUPERSET_DASHBOARD.md` — раздел «Структура дашборда» (новый состав чартов/KPI). From 1bec6bb8ad6bcc2d36f630fe40720d022940df26 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 16:50:14 +0300 Subject: [PATCH 20/38] =?UTF-8?q?feat(superset):=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=81=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=20KPI=20=D0=B8=20=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно убрать дублирующий KPI Unique Sessions и сделать эталонный dashboard честнее для учебного анализа. - Что: - обновлены KPI, добавлена Conversion to /confirmation и Page Funnel. - добавлена идемпотентная миграция старых chart names без дублей. - синхронизированы документация, урок 6 и спека редизайна. - добавлен handoff для продолжения работы в новой сессии. - Проверка: - python3 -m py_compile superset/create_dashboard.py. - make superset-dashboard. - Superset metadata: dashboard_charts=10, obsolete_unique_sessions=0, page_funnel_type=funnel. --- ...superset-dashboard-redesign-implemented.md | 96 ++++++++++++++++++ docs/SUPERSET_DASHBOARD.md | 18 +++- docs/course/lessons/06_superset_bi.md | 74 +++++++++----- .../2026-06-06-superset-dashboard-redesign.md | 19 ++-- superset/create_dashboard.py | 98 +++++++++++++------ 5 files changed, 237 insertions(+), 68 deletions(-) create mode 100644 .scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md diff --git a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md new file mode 100644 index 0000000..52c9e45 --- /dev/null +++ b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md @@ -0,0 +1,96 @@ +# Handoff: Superset dashboard redesign implemented + +Дата: 2026-06-06 · Язык сессии: русский + +## Статус + +Редизайн Superset-дашборда реализован и закоммичен. + +Коммит: + +```text +04995af832743ed984c25af08a185f7abe0485f1 +feat(superset): обновлен состав KPI и воронки дашборда +``` + +Рабочее дерево было чистым перед созданием этого handoff; сам handoff создан после коммита. + +## Источники истины + +- Спека: [`docs/specs/2026-06-06-superset-dashboard-redesign.md`](../../docs/specs/2026-06-06-superset-dashboard-redesign.md) +- Реализация: [`superset/create_dashboard.py`](../../superset/create_dashboard.py) +- Пользовательская документация: [`docs/SUPERSET_DASHBOARD.md`](../../docs/SUPERSET_DASHBOARD.md) +- Урок 6: [`docs/course/lessons/06_superset_bi.md`](../../docs/course/lessons/06_superset_bi.md) +- Предыдущий handoff: [`2026-06-06-superset-dashboard-redesign.md`](./2026-06-06-superset-dashboard-redesign.md) + +## Что сделано + +- KPI-полоса теперь: + `Total Events · Unique Users · Avg Events/Visit · Conversion to /confirmation`. +- `Unique Sessions` удалён из KPI и из Superset metadata как obsolete chart. +- `Avg Events/Session` переименован в `Avg Events/Visit` идемпотентно через `previous_slice_names`. +- `Top Pages` переименован в `Page Funnel` и переведён на `viz_type: funnel`. +- Conversion считается как page-funnel metric: + `countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home')`. +- UTM-таблица оставлена без мёртвых колонок `purchases` / `add_to_cart`. +- `Events by Hour` оставлен: на полном датасете есть два часовых бакета. +- Документация и урок 6 синхронизированы с полным датасетом и новым dashboard flow. + +## Проверки + +Выполнено: + +```bash +make data +make transform +python3 -m py_compile superset/create_dashboard.py +make superset-dashboard +make superset-dashboard +``` + +Результаты: + +- полный датасет в DM: `1000` events, `99` visits, `99` users; +- `Avg Events/Visit = 10.1`; +- `Conversion to /confirmation = 35 / 426 = 8.2%`; +- Superset dashboard metadata: + - `dashboard_charts = 10`; + - `obsolete_unique_sessions = 0`; + - `page_funnel_type = funnel`; +- Superset API `/api/v1/dashboard/1/datasets` вернул `200`; +- browser chart-data для `Page Funnel` вернул `status: success`, 6 строк; +- browser chart-data для `Data Quality Summary` вернул `errors: []`, `status: success`. + +## Визуальная проверка + +Пользователь визуально подтвердил: «Визуально - выглядит норм». + +Скриншоты были сохранены в `/tmp` во время сессии: + +- `/tmp/ecommerce-analytics-dashboard-desktop.png` +- `/tmp/ecommerce-analytics-dashboard-lower.png` + +Они могут исчезнуть после очистки `/tmp`; при необходимости переснять через `playwright-cli`. + +## Важные детали реализации + +- Для Superset 4.1.2 `funnel` проверялся через MCP Context7 по `/apache/superset`; Context7 не дал точную строку `viz_type`. +- Решение подтверждено по установленному Superset 4.1.2: bundled example `Featured Charts/Funnel.yaml` использует `viz_type: funnel`. +- `create_dashboard.py` ищет старые имена через `previous_slice_names`, чтобы не плодить дубли при rename. +- Cleanup obsolete charts удаляет все найденные `🎯 Unique Sessions`, если metadata уже была загрязнена дублями. + +## Что осталось + +Обязательных незакрытых задач по редизайну нет. + +Возможные следующие шаги, если продолжать dashboard-направление: + +- экспортировать обновлённый dashboard JSON через `make superset-export`, если экспортный артефакт должен соответствовать новой metadata; +- отдельно модернизировать legacy chart types (`pie`, `world_map`, `dist_bar`) на ECharts, если это станет целью следующего захода; +- при развитии генератора перенести требования из спеки в его `KNOWN_ISSUES.md`. + +## Suggested skills + +- `playwright-cli` — если нужно переснять визуальную проверку dashboard. +- `conventional-commits` — если нужно коммитить этот handoff или дальнейшие правки. +- `diagnose` — если Superset chart-data или layout начнут падать после сброса volumes. diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index ff61627..b6552d1 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -59,10 +59,12 @@ make superset-dashboard #### KPI-блок (верх дашборда) - **📊 Total Events** — общее количество событий - **👤 Unique Users** — уникальные пользователи -- **🎯 Unique Sessions** — уникальные сессии (click_id) -- **📈 Avg Events/Session** — среднее количество событий на сессию +- **📈 Avg Events/Visit** — среднее количество событий на визит (`click_id`) +- **🎯 Conversion to /confirmation** — доля просмотров `/confirmation` от просмотров `/home` KPI разложены в одну строку по 12-колоночной сетке Superset: четыре блока по 3 колонки. +`Unique Sessions` не вынесен отдельной KPI-плиткой, потому что в демо-данных +`user_domain_id` и `click_id` идут 1:1 и дают то же число, что `Unique Users`. #### Динамика трафика - **📅 Events by Hour** — линейный график событий по часам @@ -73,7 +75,13 @@ KPI разложены в одну строку по 12-колоночной с #### Маркетинг - **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток -- **📄 Top Pages** — bar chart топ-20 страниц +- **🪜 Page Funnel** — funnel chart по просмотрам страниц, от `/home` к `/confirmation` + +> **Что проверили по Superset 4.1.2.** Через MCP Context7 проверили официальную +> библиотеку `/apache/superset`; документация не дала точной строки `viz_type`. +> В установленном Superset 4.1.2 дополнительно проверили bundled example +> `Featured Charts/Funnel.yaml` и frontend assets: для воронки используется +> `viz_type: funnel`, поэтому dashboard создаёт именно funnel chart. #### Качество данных - **🔍 Data Quality Summary** — статистика по слоям STG/ODS/DDS @@ -219,7 +227,11 @@ make superset-restart # Полная переинициализация docker compose down -v docker compose up -d +make ddl +make data +make transform make superset-init +make superset-dashboard ``` ### Нет данных в чартах diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index 047a13b..3d60cb5 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -63,17 +63,19 @@ DM-витрина — это SQL-объект в ClickHouse. Она задаёт ## 2. Руки: запускаем Superset и смотрим дашборд -Подними стенд и прогони маленький срез: +Подними стенд и прогони полный демо-датасет: ```bash make up make ddl -LIMIT=50 make data +make data make transform ``` `make transform` прогоняет цепочку STG → ODS → DDS → DM вне Airflow. Для этого урока так -быстрее: нам нужен готовый DM-слой, а не разбор DAG. +быстрее: нам нужен готовый DM-слой, а не разбор DAG. Отладочный срез через +`LIMIT=50 make data` можно использовать для быстрых экспериментов, но эталонный dashboard +и числа урока рассчитаны на полном наборе данных. Теперь инициализируй Superset: @@ -84,7 +86,16 @@ make superset-init Эта команда создаёт или обновляет: - подключение `clickhouse_dwh`; -- 6 datasets поверх `dm.*`; +- 6 datasets поверх `dm.*`. + +После этого создай или обнови charts и сам dashboard: + +```bash +make superset-dashboard +``` + +Эта команда создаёт или обновляет: + - 10 charts; - dashboard `E-commerce Analytics Dashboard`. @@ -105,12 +116,17 @@ http://localhost:8088/superset/dashboard/1/ На экране должны быть блоки: -- KPI сверху: `Total Events`, `Unique Users`, `Unique Sessions`, `Avg Events/Session`; +- KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`, + `Conversion to /confirmation`; - динамика: `Events by Hour`, `Traffic by Device`; - география: `Geography Map`; -- маркетинг: `UTM Effectiveness Table`, `Top Pages`; +- маркетинг: `UTM Effectiveness Table`, `Page Funnel`; - качество данных: `Data Quality Summary`. +`Conversion to /confirmation` считается как просмотры `/confirmation` / просмотры `/home`. +Это page-funnel метрика, а не доля визитов: она совпадает с тем, как ниже устроен chart +`Page Funnel`. + ### Фильтр даты В демо-данных события датированы `2022-11-28`. В текущей конфигурации dashboard фильтр даты @@ -136,7 +152,7 @@ SELECT count() AS events FROM dm.v_events_enriched; ``` -И посмотри, откуда берётся график `Top Pages`: +И посмотри, откуда берётся график `Page Funnel`: ```sql SELECT page_url_path, sum(pageviews) AS pageviews @@ -233,18 +249,20 @@ Metadata Superset живёт в PostgreSQL, а сами данные остаю ```python { - "slice_name": "📄 Top Pages", - "viz_type": "dist_bar", + "slice_name": "🪜 Page Funnel", + "viz_type": "funnel", "dataset_name": "v_top_pages_daily", "params": { "groupby": ["page_url_path"], - "metrics": [ - {"expressionType": "SQL", "sqlExpression": "SUM(pageviews)", "label": "Pageviews"} - ], + "metric": { + "expressionType": "SQL", + "sqlExpression": "SUM(pageviews)", + "label": "Pageviews" + }, "row_limit": 20, "time_range": "No filter", - "orientation": "vertical", - "show_legend": False + "sort_by_metric": True, + "percent_calculation_type": "first_step" } } ``` @@ -280,14 +298,16 @@ Native filters создаются в `build_dashboard_metadata`. Там есть > **Что проверили по API.** Перед уроком Superset сверили через MCP Context7 (`/apache/superset`): > в Superset есть отдельные сущности charts и dashboards, metadata хранится отдельно от -> подключаемых источников данных, а row limit — часть настройки запросов и конфигурации. -> Поэтому в уроке не лезем в REST API Superset, а работаем через уже существующий скрипт стенда. +> подключаемых источников данных. Для `funnel` Context7 не дал точную строку `viz_type`, поэтому +> дополнительно проверили установленный Superset 4.1.2: bundled example +> `Featured Charts/Funnel.yaml` использует `viz_type: funnel`. Поэтому в уроке не лезем в REST API +> Superset, а работаем через уже существующий скрипт стенда. --- -## 4. Управляемая правка: уменьшаем Top Pages +## 4. Управляемая правка: уменьшаем Page Funnel -Сейчас chart `Top Pages` показывает до 20 страниц: +Сейчас chart `Page Funnel` показывает до 20 страниц: ```python "row_limit": 20, @@ -296,7 +316,7 @@ Native filters создаются в `build_dashboard_metadata`. Там есть Сделай маленькую видимую правку: временно покажи только топ-3 страницы. Открой [`superset/create_dashboard.py`](../../../superset/create_dashboard.py), найди chart -`Top Pages` и поменяй: +`Page Funnel` и поменяй: ```python "row_limit": 20, @@ -314,7 +334,7 @@ Native filters создаются в `build_dashboard_metadata`. Там есть make superset-dashboard ``` -Вернись в Superset и обнови страницу dashboard. В chart `Top Pages` должно остаться не больше +Вернись в Superset и обнови страницу dashboard. В chart `Page Funnel` должно остаться не больше трёх страниц. Если фильтр даты снова скрыл данные, поставь **Date Range → No filter** и нажми **Apply filters**. @@ -339,17 +359,17 @@ make superset-dashboard make superset-dashboard ``` -После обновления страницы chart `Top Pages` снова может показывать до 20 страниц. +После обновления страницы chart `Page Funnel` снова может показывать до 20 страниц. Если после экспериментов Superset выглядит странно, самый простой учебный возврат dashboard metadata к конфигурации из репозитория: ```bash -make superset-init +make superset-dashboard ``` -Данные в ClickHouse эта команда не удаляет. Она повторно применяет подключение, datasets и -dashboard metadata Superset. +Данные в ClickHouse эта команда не удаляет. Она повторно применяет charts и dashboard +metadata Superset. --- @@ -362,8 +382,8 @@ dashboard metadata Superset. | открыть **Datasets** | Superset UI | есть datasets `v_events_enriched`, `v_top_pages_daily`, `dq_summary` | | открыть dashboard | Superset UI | видны KPI, маркетинг, география и качество данных | | поставить **Date Range → No filter** | dashboard filters | графики не скрываются из-за даты `2022-11-28` | -| поменять `row_limit` у `Top Pages` на `3` и запустить `make superset-dashboard` | chart `Top Pages` | не больше трёх страниц | -| вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Top Pages` | ограничение снова до 20 страниц | +| поменять `row_limit` у `Page Funnel` на `3` и запустить `make superset-dashboard` | chart `Page Funnel` | не больше трёх страниц | +| вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Page Funnel` | ограничение снова до 20 страниц | Вопросы для созвона: @@ -378,7 +398,7 @@ dashboard metadata Superset. ## 6. Что должно получиться К концу урока у тебя должен быть открытый dashboard `E-commerce Analytics Dashboard` в Superset. -Сделай скриншот после временной правки `Top Pages`: на нём должно быть видно, что chart показывает +Сделай скриншот после временной правки `Page Funnel`: на нём должно быть видно, что chart показывает не больше трёх страниц. Второй артефакт — короткий абзац своими словами: diff --git a/docs/specs/2026-06-06-superset-dashboard-redesign.md b/docs/specs/2026-06-06-superset-dashboard-redesign.md index 461008c..9351792 100644 --- a/docs/specs/2026-06-06-superset-dashboard-redesign.md +++ b/docs/specs/2026-06-06-superset-dashboard-redesign.md @@ -1,8 +1,8 @@ # Редизайн KPI-полосы и состава чартов дашборда «E-commerce Analytics» Дата: 2026-06-06 -Статус: Accepted (не реализовано — код `superset/create_dashboard.py` правится -отдельным заходом на реализацию) +Статус: Implemented (`superset/create_dashboard.py`, `docs/SUPERSET_DASHBOARD.md`, +`docs/course/lessons/06_superset_bi.md`) Связано: [`CONTEXT.md`](../../CONTEXT.md), [ADR-0002](../adr/0002-specs-as-durable-design-docs.md), урок [`docs/course/lessons/06_superset_bi.md`](../course/lessons/06_superset_bi.md) @@ -141,11 +141,16 @@ KPI-полоса: ## Open questions -- **Точная формула Conversion:** доля визитов с ≥1 pageview `/confirmation`, или - просмотры `/confirmation` / просмотры `/home`? Влияет на число (~8%) и на то, - что именно учим (визит-конверсия vs page-to-page). -- **Events by Hour** — оставляем ли (зависит от проверки на пустоту на полных - данных: разброс `event_ts` по часам). +- **Resolved: точная формула Conversion.** Используем page-funnel conversion: + `countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home')`. + На полном датасете это `35 / 426 = 8.2%`. Визитовая формула даёт 25.3% и + отклонена, потому что KPI должен совпадать с логикой chart `Page Funnel`. +- **Resolved: Events by Hour.** Оставляем: на полном датасете есть два часовых + бакета (`20 → 256`, `21 → 744`), график не пустой. +- **Resolved: Funnel в Superset 4.1.2.** MCP Context7 по `/apache/superset` не + дал точной строки `viz_type`; установленный Superset 4.1.2 проверен по bundled + example `Featured Charts/Funnel.yaml` и frontend assets. Используем + `viz_type: funnel`. - **Требования к будущему генератору**, вытекающие из упёртостей этих данных (разнообразие `event_type`, возвраты пользователей → sessions>users, реалистичная воронка) — при возврате к генератору перенести в его diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 5c76750..5bdbd88 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -25,6 +25,11 @@ logger = logging.getLogger(__name__) sys.path.insert(0, '/app') +OBSOLETE_CHART_NAMES = { + "🎯 Unique Sessions", +} + + # Конфигурация чартов CHARTS_CONFIG = [ # KPI блок @@ -63,32 +68,36 @@ CHARTS_CONFIG = [ } }, { - "slice_name": "🎯 Unique Sessions", - "viz_type": "big_number_total", - "dataset_name": "v_events_enriched", - "params": { - "metric": { - "expressionType": "SQL", - "sqlExpression": "COUNT(DISTINCT click_id)", - "label": "Unique Sessions", - "optionName": "metric_3" - }, - "y_axis_format": ",d", - "time_range": "No filter" - } - }, - { - "slice_name": "📈 Avg Events/Session", + "slice_name": "📈 Avg Events/Visit", + "previous_slice_names": ["📈 Avg Events/Session"], "viz_type": "big_number_total", "dataset_name": "v_events_enriched", "params": { "metric": { "expressionType": "SQL", "sqlExpression": "COUNT(*) / COUNT(DISTINCT click_id)", - "label": "Avg Events/Session", + "label": "Avg Events/Visit", "optionName": "metric_4" }, - "y_axis_format": ".2f", + "y_axis_format": ".1f", + "time_range": "No filter" + } + }, + { + "slice_name": "🎯 Conversion to /confirmation", + "viz_type": "big_number_total", + "dataset_name": "v_events_enriched", + "params": { + "metric": { + "expressionType": "SQL", + "sqlExpression": ( + "if(countIf(page_url_path = '/home') = 0, 0, " + "countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home'))" + ), + "label": "Conversion to /confirmation", + "optionName": "metric_5" + }, + "y_axis_format": ".1%", "time_range": "No filter" } }, @@ -175,18 +184,32 @@ CHARTS_CONFIG = [ } }, { - "slice_name": "📄 Top Pages", - "viz_type": "dist_bar", + "slice_name": "🪜 Page Funnel", + "previous_slice_names": ["📄 Top Pages"], + # В Superset 4.1.2 `funnel` есть в frontend-плагинах и примерах + # поставки, хотя legacy registry `superset.viz.viz_types` его не + # показывает. Параметры взяты из bundled Featured Charts/Funnel.yaml. + "viz_type": "funnel", "dataset_name": "v_top_pages_daily", "params": { "groupby": ["page_url_path"], - "metrics": [ - {"expressionType": "SQL", "sqlExpression": "SUM(pageviews)", "label": "Pageviews"} - ], + "metric": { + "expressionType": "SQL", + "sqlExpression": "SUM(pageviews)", + "label": "Pageviews" + }, "row_limit": 20, "time_range": "No filter", - "orientation": "vertical", - "show_legend": False + "sort_by_metric": True, + "percent_calculation_type": "first_step", + "color_scheme": "supersetColors", + "show_legend": True, + "legendOrientation": "top", + "legendMargin": 50, + "tooltip_label_type": 5, + "number_format": "SMART_NUMBER", + "show_labels": True, + "show_tooltip_labels": True } }, # Качество данных @@ -233,13 +256,13 @@ DASHBOARD_CONFIG = { DASHBOARD_ROWS = [ # KPI-полоса: 4 числа в один ряд [("📊 Total Events", 3), ("👤 Unique Users", 3), - ("🎯 Unique Sessions", 3), ("📈 Avg Events/Session", 3)], + ("📈 Avg Events/Visit", 3), ("🎯 Conversion to /confirmation", 3)], # Динамика во времени + разрез по устройствам [("📅 Events by Hour", 8), ("📱 Traffic by Device", 4)], # География + эффективность маркетинговых каналов [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], # Популярные страницы + качество данных - [("📄 Top Pages", 6), ("🔍 Data Quality Summary", 6)], + [("🪜 Page Funnel", 6), ("🔍 Data Quality Summary", 6)], ] # Высота строки в grid-units Superset (одинаковая для всех чартов строки — @@ -410,13 +433,17 @@ def main() -> bool: params["viz_type"] = chart_config["viz_type"] serialized_params = json.dumps(params) - # Проверяем, существует ли уже чарт - existing = db.session.query(Slice).filter_by( - slice_name=chart_config["slice_name"] - ).first() + # Проверяем, существует ли уже чарт. Старые имена нужны для + # идемпотентного rename без дублей в списке Charts. + chart_names = [chart_config["slice_name"]] + chart_names.extend(chart_config.get("previous_slice_names", [])) + existing = db.session.query(Slice).filter( + Slice.slice_name.in_(chart_names) + ).order_by(Slice.id.asc()).first() if existing: # Синхронизируем параметры существующего чарта с конфигом. + existing.slice_name = chart_config["slice_name"] existing.viz_type = chart_config["viz_type"] existing.datasource_id = dataset.id existing.datasource_type = "table" @@ -456,6 +483,15 @@ def main() -> bool: db.session.rollback() logger.info(f"Created/Found {len(created_charts)} charts") + + current_chart_names = {chart_config["slice_name"] for chart_config in CHARTS_CONFIG} + for obsolete_name in sorted(OBSOLETE_CHART_NAMES - current_chart_names): + obsolete_charts = db.session.query(Slice).filter_by(slice_name=obsolete_name).all() + for obsolete in obsolete_charts: + db.session.delete(obsolete) + logger.info("Deleted obsolete chart: %s (ID: %s)", obsolete_name, obsolete.id) + db.session.flush() + metadata_json = build_dashboard_metadata(datasets_by_name.get("v_events_enriched")) # Создаём позиции чартов для layout. From 58df1d55c4f7c219df1c03132420d9fa64f2ff8c Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 17:04:53 +0300 Subject: [PATCH 21/38] =?UTF-8?q?docs(superset):=20=D0=B8=D1=81=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9=20handoff=20=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B5=D0=BD=20=D0=BA=D0=B0=D0=BA=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - редизайн реализован и принят; старый handoff «иди реализуй» вводил в заблуждение. - Что: - в .scratch/handoffs/2026-06-06-superset-dashboard-redesign.md добавлен баннер СТАТУС: ВЫПОЛНЕНО со ссылкой на -implemented.md. - Проверка: - файл открывается баннером, ниже историческая постановка. --- .scratch/handoffs/2026-06-06-superset-dashboard-redesign.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md index ae99cab..fd367be 100644 --- a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md +++ b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md @@ -2,6 +2,12 @@ Дата: 2026-06-06 · Язык сессии: русский · Понять-режим был включён (можно не продолжать) +> **СТАТУС: ВЫПОЛНЕНО (2026-06-06).** Редизайн реализован (коммит `04995af`/amend +> `1bec6bb`) и визуально принят. Этот файл — исходная постановка «иди реализуй», +> оставлен как след. Актуальная сводка сделанного и возможных следующих шагов → +> [`2026-06-06-superset-dashboard-redesign-implemented.md`](./2026-06-06-superset-dashboard-redesign-implemented.md). +> Ниже — **историческая** постановка, НЕ список текущих дел. + ## Источник истины **Сначала прочитать спеку:** [`docs/specs/2026-06-06-superset-dashboard-redesign.md`](../../docs/specs/2026-06-06-superset-dashboard-redesign.md) From 281d9d25c9a063e1f57d9cb1884c731476181ef3 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 17:48:43 +0300 Subject: [PATCH 22/38] =?UTF-8?q?feat(superset):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20row-lineage=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D1=8F=D0=BC=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE=D0=B9=20DQ-=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - чарт «Data Quality Summary» суммировал total_rows по всем таблицам слоя, складывал таблицы разного зерна (события 1000 + визиты 99 + error-таблицы 0) и рисовал убывающую «воронку потерь» (stg≈4250→ods≈2198→dds≈1099), которой в данных нет. На учебном стенде это активно вводит в заблуждение. - Что: - чарт переделан в row-lineage одного event-зерна и переименован в «🧱 Rows by Layer (event)»; rename идемпотентный через previous_slice_names. - чарт берёт по одной канонической таблице на слой (browser_raw→browser_event→event→v_events_enriched), порядок слоёв задан числовым префиксом в groupby + order_bars. - в dm.dq_summary добавлена строка total_rows для слоя dm, чтобы цепочка замыкалась до витрины. - описание дашборда обновлено под новый смысл. - Проверка: - python3 -m py_compile superset/create_dashboard.py. - make transform / прогон sql/dm/40_dds_to_dm.sql; в dq_summary есть строка dm. - make superset-dashboard (идемпотентно, 10 чартов, дублей нет). - визуально через playwright-cli: 4 столбца 1·stg→2·ods→3·dds→4·dm, видимый шаг дедупликации 1050→1000, консоль без ошибок. --- sql/dm/40_dds_to_dm.sql | 19 +++++++++++------ superset/create_dashboard.py | 41 +++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/sql/dm/40_dds_to_dm.sql b/sql/dm/40_dds_to_dm.sql index 8b70592..d1e9c87 100644 --- a/sql/dm/40_dds_to_dm.sql +++ b/sql/dm/40_dds_to_dm.sql @@ -2,7 +2,7 @@ -- Batch-трансформация: DDS → DM (Data Quality summary) -- ============================================================================ -- Поток данных: --- dds.click + dds.event + ods.* + stg.* → dm.dq_summary (сводка по слоям) +-- stg.* + ods.* + dds.* + dm.v_events_enriched → dm.dq_summary (сводка по слоям) -- Сами витрины (dm.v_*) — это VIEW поверх DDS, создаются в sql/ddl/dm/40_dm.sql. -- -- Что делает: @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS dm.dq_summary ( check_date Date, -- Дата проверки - layer LowCardinality(String), -- Слой: stg, ods, dds + layer LowCardinality(String), -- Слой: stg, ods, dds, dm table_name LowCardinality(String), -- Имя таблицы check_name LowCardinality(String), -- Тип проверки: total_rows, rows_with_errors и т.д. check_value UInt64 -- Значение метрики @@ -102,7 +102,14 @@ UNION ALL SELECT today(), 'dds', 'click', 'total_rows', count() FROM dds.click UNION ALL -- Считаем "осиротевшие" события (есть click_id, но нет такого click в dds.click) -SELECT today(), 'dds', 'event_without_click', 'orphan_events', count() -FROM dds.event -WHERE click_id IS NOT NULL - AND click_id NOT IN (SELECT click_id FROM dds.click); +SELECT today(), 'dds', 'event_without_click', 'orphan_events', count() +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click) + +UNION ALL +-- DM-слой: финальная витрина событий (VIEW поверх dds.event). +-- Нужна, чтобы lineage-чарт замыкал цепочку stg→ods→dds→dm на одном (event) зерне. +-- VIEW создаётся в DDL (sql/ddl/dm/40_dm.sql) до трансформаций, а этот шаг идёт +-- после наполнения dds.event — поэтому count() здесь корректен. +SELECT today(), 'dm', 'v_events_enriched', 'total_rows', count() FROM dm.v_events_enriched; diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 5bdbd88..2517b5b 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -212,28 +212,53 @@ CHARTS_CONFIG = [ "show_tooltip_labels": True } }, - # Качество данных + # Прохождение строк по слоям (lineage одного event-зерна) { - "slice_name": "🔍 Data Quality Summary", + "slice_name": "🧱 Rows by Layer (event)", + "previous_slice_names": ["🔍 Data Quality Summary"], + # Честный row-lineage одного event-зерна через слои stg→ods→dds→dm. + # Берём ПО ОДНОЙ канонической таблице на слой (browser_raw → browser_event + # → event → v_events_enriched). Прежний вариант суммировал total_rows по + # ВСЕМ таблицам слоя — таблицы разного зерна (события 1000 + визиты 99 + + # error-таблицы 0) складывались в один столбец и рисовали ложную «воронку + # потерь», которой нет. На одном зерне убывание становится настоящим: + # видимый шаг 1050→1000 — это дедупликация at-least-once потока по + # event_id в ODS (ReplacingMergeTree), а не потеря данных. + # Префикс "N · " в groupby задаёт порядок слоёв (order_bars сортирует по + # подписи), иначе бары встают по убыванию значения, а не по конвейеру. "viz_type": "dist_bar", "dataset_name": "dq_summary", "params": { - "groupby": ["layer"], + "groupby": [ + { + "expressionType": "SQL", + "sqlExpression": ( + "multiIf(layer = 'stg', '1 · stg', layer = 'ods', '2 · ods', " + "layer = 'dds', '3 · dds', '4 · dm')" + ), + "label": "Layer" + } + ], "metrics": [ - {"expressionType": "SQL", "sqlExpression": "SUM(check_value)", "label": "Row Count"} + {"expressionType": "SQL", "sqlExpression": "SUM(check_value)", "label": "Rows"} ], "adhoc_filters": [ { "clause": "WHERE", "expressionType": "SQL", - "sqlExpression": "check_name = 'total_rows'", + "sqlExpression": ( + "check_name = 'total_rows' AND table_name IN " + "('browser_raw', 'browser_event', 'event', 'v_events_enriched')" + ), "subject": None, "operator": None, "comparator": None } ], + "order_bars": True, "row_limit": 100, "time_range": "No filter", + "y_axis_format": ",d", "show_legend": False } } @@ -242,7 +267,7 @@ CHARTS_CONFIG = [ # Конфигурация дашборда DASHBOARD_CONFIG = { "dashboard_title": "🛒 E-commerce Analytics Dashboard", - "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и качество данных.", + "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и прохождение строк по слоям.", "published": True, "slug": "ecommerce-analytics", } @@ -261,8 +286,8 @@ DASHBOARD_ROWS = [ [("📅 Events by Hour", 8), ("📱 Traffic by Device", 4)], # География + эффективность маркетинговых каналов [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], - # Популярные страницы + качество данных - [("🪜 Page Funnel", 6), ("🔍 Data Quality Summary", 6)], + # Популярные страницы + прохождение строк по слоям + [("🪜 Page Funnel", 6), ("🧱 Rows by Layer (event)", 6)], ] # Высота строки в grid-units Superset (одинаковая для всех чартов строки — From 2563c79082ccebd46ee277ec7bc59ca15fd539d4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 17:49:01 +0300 Subject: [PATCH 23/38] =?UTF-8?q?docs(superset):=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=206=20=D0=BF=D0=BE=D0=B4=20row-lineage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - после смены чарта на row-lineage пользовательская дока, README и урок 6 описывали несуществующий «Data Quality Summary» и старый состав KPI; термин «зерно (grain)» использовался без пояснения. - Что: - SUPERSET_DASHBOARD.md, README, урок 6 описывают «Rows by Layer (event)», выровнен состав KPI/чартов; термин «зерно» поясняется в уроке простыми словами с якорем из данных (события 1000 / визиты 99). - термин выровнен на «визит» по CONTEXT.md (click_id = визит/сессия). - в спеку редизайна добавлена секция «Пересмотр после приёмки» как след решения. - Проверка: - grep по «Data Quality Summary»/«Row Count» вне handoffs пуст. - визуальная вычитка изменённых разделов. --- README.md | 10 +++--- docs/SUPERSET_DASHBOARD.md | 24 +++++++++++-- docs/course/lessons/06_superset_bi.md | 36 +++++++++++++++---- .../2026-06-06-superset-dashboard-redesign.md | 18 +++++++++- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8cb4a34..3177034 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ curl http://localhost:8088/health # должно вернуть 200 Что создаётся автоматически: - **Подключение к ClickHouse**: `clickhouse_dwh` (URI: `clickhousedb://default:123456@clickhouse:8123/default`) - **Датасеты** (6 шт.): `v_events_enriched`, `v_daily_traffic`, `v_utm_effectiveness`, `v_top_pages_daily`, `v_session_overview`, `dq_summary` -- **Чарты** (10 шт.): KPI метрики, графики трафика, география, UTM-эффективность, качество данных +- **Чарты** (10 шт.): KPI метрики, графики трафика, география, UTM-эффективность, прохождение строк по слоям - **Дашборд**: "🛒 E-commerce Analytics Dashboard" ### Ручная инициализация (если автоматика не сработала) @@ -270,11 +270,11 @@ make superset-dashboard | Блок | Чарты | Датасет | |------|-------|---------| -| **KPI** | Total Events, Unique Users, Unique Sessions, Avg Events/Session | `v_events_enriched` | +| **KPI** | Total Events, Unique Users, Avg Events/Visit, Conversion to /confirmation | `v_events_enriched` | | **Динамика** | Events by Hour (timeline), Traffic by Device (pie) | `v_events_enriched` | -| **География** | World Map по странам | `v_events_enriched` | -| **Маркетинг** | UTM Effectiveness Table, Top Pages | `v_utm_effectiveness`, `v_top_pages_daily` | -| **Quality** | Data Quality Summary | `dq_summary` | +| **География** | Geography Map по странам | `v_events_enriched` | +| **Маркетинг** | UTM Effectiveness Table, Page Funnel | `v_utm_effectiveness`, `v_top_pages_daily` | +| **Слои** | Rows by Layer (event) — прохождение строк STG→ODS→DDS→DM | `dq_summary` | Фильтр `Date Range` по умолчанию открыт как `No filter`, потому что демо-данные лежат в историческом диапазоне (`2022-11-28`). diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index b6552d1..aa802f2 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -52,7 +52,7 @@ make superset-dashboard | **UTM Effectiveness** | `dm.v_utm_effectiveness` | Эффективность маркетинговых каналов | | **Top Pages** | `dm.v_top_pages_daily` | Популярность страниц | | **Session Overview** | `dm.v_session_overview` | Анализ сессий | -| **DQ Summary** | `dm.dq_summary` | Качество данных | +| **DQ Summary** | `dm.dq_summary` | Метрики по слоям (строки, ошибки, сироты) | ### Чарты (Charts) @@ -83,8 +83,26 @@ KPI разложены в одну строку по 12-колоночной с > `Featured Charts/Funnel.yaml` и frontend assets: для воронки используется > `viz_type: funnel`, поэтому dashboard создаёт именно funnel chart. -#### Качество данных -- **🔍 Data Quality Summary** — статистика по слоям STG/ODS/DDS +#### Прохождение строк по слоям +- **🧱 Rows by Layer (event)** — `dist_bar` по `dm.dq_summary`: сколько строк + одного **event-зерна** в каждом слое конвейера `STG → ODS → DDS → DM`. + +> **Почему именно одно зерно, а не сумма по слою.** Чарт берёт по одной +> канонической таблице на слой (`browser_raw → browser_event → event → +> v_events_enriched`). Если суммировать `total_rows` по всем таблицам слоя, +> в один столбец складываются таблицы разного зерна (события `1000` + визиты `99` +> + пустые error-таблицы) и получается **ложная «воронка потерь»**, которой нет. +> На одном зерне убывание становится настоящим: видимый шаг **1050 → 1000** — +> это дедупликация at-least-once потока по `event_id` в ODS +> (`ReplacingMergeTree`), а дальше число стабильно до витрины. +> +> Настоящие сигналы качества (`rows_with_errors` в ODS, `orphan_events` в DDS) +> на чистых демо-данных равны нулю и живут в `dm.dq_summary` отдельными +> `check_name` — их разбирают уроки 3–4, а не этот чарт. + +> **Порядок столбцов.** В groupby подпись слоя получает числовой префикс +> (`1 · stg`, `2 · ods`, …), а `order_bars` сортирует бары по подписи — иначе +> `dist_bar` ставит их по убыванию значения, а не по порядку конвейера. ### Фильтры (Native Filters) diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index 3d60cb5..6f0c759 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -121,12 +121,35 @@ http://localhost:8088/superset/dashboard/1/ - динамика: `Events by Hour`, `Traffic by Device`; - география: `Geography Map`; - маркетинг: `UTM Effectiveness Table`, `Page Funnel`; -- качество данных: `Data Quality Summary`. +- прохождение строк по слоям: `Rows by Layer (event)`. `Conversion to /confirmation` считается как просмотры `/confirmation` / просмотры `/home`. Это page-funnel метрика, а не доля визитов: она совпадает с тем, как ниже устроен chart `Page Funnel`. +`Rows by Layer (event)` показывает, сколько строк доходит до каждого слоя конвейера +`STG → ODS → DDS → DM`. Прежде чем читать чарт, договоримся про одно слово. + +> **Зерно (grain), он же уровень гранулярности — это что считается одной строкой +> таблицы.** У события зерно +> «одно событие = одна строка» (ключ `event_id`), у визита — «один визит = одна +> строка» (ключ `click_id`). Это разные зёрна: событий 1000, а визитов 99, потому +> что в одном визите много событий. Складывать строки разного зерна в одно число +> бессмысленно — это всё равно что сложить «штуки яблок» и «корзины яблок». + +Поэтому чарт держит **одно зерно — event**: берёт по одной канонической таблице +событий на слой (`browser_raw → browser_event → event → v_events_enriched`), а не сумму +по слою. Если просуммировать все таблицы слоя, в один столбец попадут таблицы разного +зерна (события 1000 + визиты 99 + пустые error-таблицы) и получится «воронка потерь», +которой на самом деле нет. + +Шаг **1050 → 1000** на первом переходе — это не потеря данных, а дедупликация +at-least-once потока по `event_id` в ODS (`ReplacingMergeTree`): в STG приехало 1050 строк, +но уникальных `event_id` среди них — 1000 (часть событий Kafka доставила повторно). Дальше +число стабильно. Настоящие проблемы качества (ошибки парсинга, осиротевшие события) на +чистых демо-данных равны нулю и лежат в `dm.dq_summary` отдельными `check_name` — их +разбирали уроки 3–4. + ### Фильтр даты В демо-данных события датированы `2022-11-28`. В текущей конфигурации dashboard фильтр даты @@ -139,7 +162,7 @@ http://localhost:8088/superset/dashboard/1/ - или ручной диапазон вокруг `2022-11-28`. После этого нажми **Apply filters**. Теперь смотри на dashboard как аналитик: какие графики -отвечают на бизнес-вопросы, а какие только показывают техническое качество данных. +отвечают на бизнес-вопросы, а какие только показывают техническое устройство конвейера. ### Проверяем данные напрямую в ClickHouse @@ -201,11 +224,12 @@ LEFT JOIN dds.click AS c ON c.click_id = e.click_id; | `dm.v_utm_effectiveness` | таблица по UTM-каналам | | `dm.v_top_pages_daily` | популярные страницы | | `dm.v_session_overview` | обзор сессий | -| `dm.dq_summary` | сводка качества данных по слоям | +| `dm.dq_summary` | метрики по слоям: строки (`total_rows`), ошибки, сироты | Открой [`sql/dm/40_dds_to_dm.sql`](../../../sql/dm/40_dds_to_dm.sql). Этот файл не пересчитывает -все `dm.v_*`: VIEW создаются в DDL. Здесь batch-часть наполняет `dm.dq_summary`, чтобы dashboard -мог показать качество данных после каждого прогона. +все `dm.v_*`: VIEW создаются в DDL. Здесь batch-часть наполняет `dm.dq_summary` метриками по +слоям (включая строку для слоя `dm`), чтобы dashboard мог показать прохождение строк по +конвейеру после каждого прогона. ### `init_superset.py`: подключение и datasets @@ -380,7 +404,7 @@ metadata Superset. | `make transform` | ClickHouse `dm.v_events_enriched` | `count() > 0` | | `make superset-init` | Superset → **Settings → Database Connections** | есть подключение `clickhouse_dwh` | | открыть **Datasets** | Superset UI | есть datasets `v_events_enriched`, `v_top_pages_daily`, `dq_summary` | -| открыть dashboard | Superset UI | видны KPI, маркетинг, география и качество данных | +| открыть dashboard | Superset UI | видны KPI, маркетинг, география и прохождение строк по слоям | | поставить **Date Range → No filter** | dashboard filters | графики не скрываются из-за даты `2022-11-28` | | поменять `row_limit` у `Page Funnel` на `3` и запустить `make superset-dashboard` | chart `Page Funnel` | не больше трёх страниц | | вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Page Funnel` | ограничение снова до 20 страниц | diff --git a/docs/specs/2026-06-06-superset-dashboard-redesign.md b/docs/specs/2026-06-06-superset-dashboard-redesign.md index 9351792..ce13449 100644 --- a/docs/specs/2026-06-06-superset-dashboard-redesign.md +++ b/docs/specs/2026-06-06-superset-dashboard-redesign.md @@ -63,7 +63,7 @@ geo_country: 40 | device_type: 2 (Mobile/Computer) | utm_source: 5 | utm_medium: | Geography Map | world_map | оставить (40 стран; тип модернизировать отдельно) | | Traffic by Device | pie | оставить | | Events by Hour | line | **проверить на пустоту**, иначе дропнуть | -| Data Quality Summary | dist_bar | оставить | +| Data Quality Summary | dist_bar | оставить (⚠️ позже пересмотрено — см. ниже) | ## Options considered @@ -155,3 +155,19 @@ KPI-полоса: (разнообразие `event_type`, возвраты пользователей → sessions>users, реалистичная воронка) — при возврате к генератору перенести в его `KNOWN_ISSUES.md`. + +## Пересмотр после приёмки (2026-06-06) + +Решение «Data Quality Summary | dist_bar | оставить» **пересмотрено** при визуальной +проверке дашборда. Чарт суммировал `total_rows` по всем таблицам слоя, складывая +таблицы разного зерна (события 1000 + визиты 99 + пустые error-таблицы) в один столбец, +и рисовал убывающую «воронку потерь» (stg≈4250 → ods≈2198 → dds≈1099), которой в данных +нет. Для учебного стенда это активно вводит в заблуждение. + +Чарт переделан в честный row-lineage **одного event-зерна** +(`browser_raw → browser_event → event → v_events_enriched`) и переименован в +`🧱 Rows by Layer (event)`. Теперь убывание настоящее: шаг `1050 → 1000` — это +дедупликация at-least-once потока по `event_id` в ODS (`ReplacingMergeTree`). +В `dm.dq_summary` добавлена строка для слоя `dm` (`sql/dm/40_dds_to_dm.sql`), чтобы +цепочка замыкалась до витрины. Настоящие сигналы качества (`rows_with_errors`, +`orphan_events`) на чистых демо-данных = 0 и остаются отдельными `check_name`. From c9300e7d5d20f302121607ce9176b4f0849d5b24 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 17:58:50 +0300 Subject: [PATCH 24/38] =?UTF-8?q?fix(superset):=205-=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B1=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D1=8B=D1=85=20=D0=B2=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B5=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - все события стенда укладываются в ~50 минут (20:51–21:41 28.11.2022), поэтому часовая гранулярность давала всего 2 точки и прямую диагональ, которая читалась как ошибка расчёта и ничему не учила менти. - Что: - time_grain_sqla переведён с PT1H на PT5M (~10 точек, реальная форма трафика). - чарт переименован «Events by Hour» → «Events over Time» (идемпотентно через previous_slice_names), т.к. «by Hour» противоречит 5-минутным бакетам. - синхронизированы README, SUPERSET_DASHBOARD.md и урок 6. - Проверка: - python3 -m py_compile superset/create_dashboard.py. - make superset-dashboard (идемпотентно, чарт ID 5 переименован, 10 чартов). - визуально через playwright-cli: кривая ~11 точек 20:50–21:40, консоль чистая. --- README.md | 2 +- docs/SUPERSET_DASHBOARD.md | 4 +++- docs/course/lessons/06_superset_bi.md | 2 +- superset/create_dashboard.py | 11 ++++++++--- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3177034..7dc3fa0 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ make superset-dashboard | Блок | Чарты | Датасет | |------|-------|---------| | **KPI** | Total Events, Unique Users, Avg Events/Visit, Conversion to /confirmation | `v_events_enriched` | -| **Динамика** | Events by Hour (timeline), Traffic by Device (pie) | `v_events_enriched` | +| **Динамика** | Events over Time (timeline), Traffic by Device (pie) | `v_events_enriched` | | **География** | Geography Map по странам | `v_events_enriched` | | **Маркетинг** | UTM Effectiveness Table, Page Funnel | `v_utm_effectiveness`, `v_top_pages_daily` | | **Слои** | Rows by Layer (event) — прохождение строк STG→ODS→DDS→DM | `dq_summary` | diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index aa802f2..def91bb 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -67,7 +67,9 @@ KPI разложены в одну строку по 12-колоночной с `user_domain_id` и `click_id` идут 1:1 и дают то же число, что `Unique Users`. #### Динамика трафика -- **📅 Events by Hour** — линейный график событий по часам +- **📅 Events over Time** — линейный график событий с 5-минутными бакетами + (все события стенда укладываются в ~50 минут, поэтому часовая гранулярность + давала бы всего 2 точки и прямую линию) - **📱 Traffic by Device** — pie chart распределения по устройствам #### География diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index 6f0c759..54220b5 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -118,7 +118,7 @@ http://localhost:8088/superset/dashboard/1/ - KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`, `Conversion to /confirmation`; -- динамика: `Events by Hour`, `Traffic by Device`; +- динамика: `Events over Time`, `Traffic by Device`; - география: `Geography Map`; - маркетинг: `UTM Effectiveness Table`, `Page Funnel`; - прохождение строк по слоям: `Rows by Layer (event)`. diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 2517b5b..43bef21 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -103,12 +103,17 @@ CHARTS_CONFIG = [ }, # Динамика { - "slice_name": "📅 Events by Hour", + "slice_name": "📅 Events over Time", + "previous_slice_names": ["📅 Events by Hour"], + # Все события стенда укладываются в ~50 минут (20:51–21:41 28.11.2022), + # поэтому часовая гранулярность давала всего 2 точки и прямую линию, + # читавшуюся как ошибка. 5-минутные бакеты дают ~10 точек — реальную + # форму трафика. Поэтому и название не «by Hour», а «over Time». "viz_type": "echarts_timeseries_line", "dataset_name": "v_events_enriched", "params": { "granularity_sqla": "event_ts", - "time_grain_sqla": "PT1H", + "time_grain_sqla": "PT5M", "metrics": [ { "expressionType": "SQL", @@ -283,7 +288,7 @@ DASHBOARD_ROWS = [ [("📊 Total Events", 3), ("👤 Unique Users", 3), ("📈 Avg Events/Visit", 3), ("🎯 Conversion to /confirmation", 3)], # Динамика во времени + разрез по устройствам - [("📅 Events by Hour", 8), ("📱 Traffic by Device", 4)], + [("📅 Events over Time", 8), ("📱 Traffic by Device", 4)], # География + эффективность маркетинговых каналов [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], # Популярные страницы + прохождение строк по слоям From bf940cc249aef8ea5860f3f430939c35a9a3eab5 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:01:34 +0300 Subject: [PATCH 25/38] =?UTF-8?q?docs(course):=20=D0=BF=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=82=20=D0=B8=D0=BD=D1=82=D1=80=D0=BE=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=206=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=BE=D0=B4=20row-lineage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - после замены DQ-чарта на row-lineage в списке «на какие вопросы отвечает BI» остался повисший пункт «есть ли видимые проблемы качества данных» — чарта, который на него отвечал, больше нет. - Что: - пункт переформулирован под актуальный чарт Rows by Layer («доходят ли строки до витрины без потерь по слоям конвейера»). - Проверка: - сквозная вычитка урока 6: состав чартов, имена и числа согласованы. --- docs/course/lessons/06_superset_bi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index 54220b5..f02917a 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -33,7 +33,7 @@ - из каких стран пришёл трафик; - какие UTM-каналы дают больше кликов; - какие страницы самые популярные; -- есть ли видимые проблемы качества данных. +- доходят ли строки до витрины без потерь по слоям конвейера. **Superset** — BI-инструмент. Он не заменяет ClickHouse, Airflow или Grafana. Он сидит поверх готовых данных и даёт интерфейс для просмотра, фильтрации и сборки графиков. From 2bd4087e4a5eb43035efc21dc5a73e6eb049cdd0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:06:16 +0300 Subject: [PATCH 26/38] =?UTF-8?q?docs(handoff):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4-=D1=81=D0=BD=D0=B8=D0=BF=D0=BF=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - дашборд за сессию менялся трижды; остался зазор — в §3 урока 6 сниппет Page Funnel показывает урезанный params без многоточия. Правку выносим в отдельную сессию, чтобы не раздувать контекст текущей. - Что: - добавлен handoff с задачей (сниппет Page Funnel + сквозная сверка сниппетов §3), контекстом запушенных коммитов и git-гигиеной. - Проверка: - следующая сессия сверяет сниппеты урока с superset/create_dashboard.py. --- .../2026-06-06-lesson6-consistency.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .scratch/handoffs/2026-06-06-lesson6-consistency.md diff --git a/.scratch/handoffs/2026-06-06-lesson6-consistency.md b/.scratch/handoffs/2026-06-06-lesson6-consistency.md new file mode 100644 index 0000000..ac089b7 --- /dev/null +++ b/.scratch/handoffs/2026-06-06-lesson6-consistency.md @@ -0,0 +1,76 @@ +# Handoff: консистентность урока 6 (Superset BI) + +Дата: 2026-06-06 · Язык сессии: русский + +## Задача следующей сессии + +Урок 6 — **наш** (не Codex), и он должен быть консистентен с реальным кодом +стенда. Дашборд за эту сессию менялся трижды, текст урока подтянули, но остался +**один известный зазор** + стоит сделать сквозную проверку код-сниппетов. + +**Главное (известный зазор):** в §3 урока +[`docs/course/lessons/06_superset_bi.md`](../../docs/course/lessons/06_superset_bi.md) +код-сниппет чарта `🪜 Page Funnel` (примерно строки 274–291) показывает +**урезанный** набор `params` **без многоточия** — выглядит как полный конфиг, но +реальный в [`superset/create_dashboard.py`](../../superset/create_dashboard.py) +богаче (`color_scheme`, `show_legend`, `legendOrientation`, `tooltip_label_type`, +`number_format`, `show_labels` и др.). Привести к честному виду: либо добавить +`...`/комментарий «здесь только ключевые поля», либо показать поля, которые +реально объясняются в тексте. Для задания §4 (менять `row_limit`) сниппет не +мешает, но как учебный эталон он вводит в заблуждение «это весь конфиг». + +**Заодно (сквозная проверка):** пройтись по остальным код-сниппетам урока 6 +(§3 `DASHBOARD_CONFIG` стр. ~304–309, `init_superset.py` datasets стр. ~250–258, +`v_events_enriched` JOIN стр. ~210–213) и сверить, что они не разошлись с +актуальным кодом. Метод тот же: открыть реальный файл и сравнить. + +## Контекст: что уже сделано и запушено (НЕ переделывать) + +Состав/числа дашборда уже выверены и синхронизированы с уроком. Ветка +`docs/advanced-clickstream-course`, всё запушено. Коммиты этой сессии: + +- `281d9d2` feat — честный row-lineage по слоям вместо ложной DQ-воронки + (`create_dashboard.py` + `sql/dm/40_dds_to_dm.sql`). +- `2563c79` docs — синхронизация доков и термин «зерно». +- `c9300e7` fix — 5-мин бакеты в `Events over Time` (был `Events by Hour`). +- `bf940cc` docs — пункт интро §1 урока 6 выровнен под row-lineage. + +Актуальный состав дашборда (10 чартов): KPI `Total Events / Unique Users / +Avg Events/Visit / Conversion to /confirmation`; `Events over Time` (PT5M); +`Traffic by Device`; `Geography Map`; `UTM Effectiveness Table`; `Page Funnel`; +`🧱 Rows by Layer (event)`. Имена и эти числа в уроке уже верны — менять не нужно. + +## Источники истины + +- Реализация чартов: [`superset/create_dashboard.py`](../../superset/create_dashboard.py) + (`CHARTS_CONFIG` / `DASHBOARD_CONFIG` / `DASHBOARD_ROWS`). +- Урок: [`docs/course/lessons/06_superset_bi.md`](../../docs/course/lessons/06_superset_bi.md). +- Доменные термины: [`CONTEXT.md`](../../CONTEXT.md) (визит/сессия = `click_id` — в UI + предпочитаем «визит», не «клик»). +- Стандарт уроков: `docs/course/LESSON_STANDARD.md` (регистр/голос/шапка/грабли) — + читать перед правкой текста. +- Прошлые handoff'ы по дашборду: `2026-06-06-superset-dashboard-redesign*.md` + (исторические, задача там закрыта). + +## Проверка + +- Текст не требует прогона стенда; правка чисто в `.md`. Достаточно открыть + реальный код и сверить сниппеты глазами. +- Если захочется перепроверить дашборд: стенд поднят (`docker ps`), логин + Superset формой `admin`/`admin` на `http://localhost:8088/login/` (заполнять + поля через refs снапшота, не по name-селекторам — последние давали Access + Denied), дашборд `…/superset/dashboard/ecommerce-analytics/?standalone=1`. + +## Git-гигиена + +- Ветка `docs/advanced-clickstream-course`, на ней **параллельно пишет Codex**. + Git строго **аддитивно**: не amend/rebase/reset чужих коммитов, `git add` + только своих файлов. Перед push — `git fetch` и проверить, что не разошлись. +- Коммиты — через скилл `conventional-commits` (русский, тело Зачем/Что/Проверка). + +## Suggested skills + +- `conventional-commits` — для коммита правки. +- `ai-text-lint` — прогнать изменённый фрагмент урока на AI-маркеры перед коммитом + (урок учебный, голос важен). +- `playwright-cli` — только если понадобится переснять дашборд для сверки. From 24f538e6f498e85f1b6356fc64f379cbe7575655 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:12:06 +0300 Subject: [PATCH 27/38] =?UTF-8?q?docs(superset):=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=206=20=D1=81=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - урок 6 должен совпадать с текущей конфигурацией Superset и не вводить в заблуждение по metadata store. - Что: - URL дашборда в уроке переведен на стабильный slug ecommerce-analytics. - сниппет Page Funnel помечен как фрагмент с ключевыми полями, а не полный params. - устаревший комментарий про SQLite заменен на PostgreSQL metadata store. - Проверка: - python3 -m py_compile superset/init_superset.py superset/create_dashboard.py. - git diff --check. --- docs/course/lessons/06_superset_bi.md | 9 ++++++--- superset/init_superset.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index f02917a..3cc4fba 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -109,7 +109,7 @@ make superset-dashboard Открой готовый dashboard: ```text -http://localhost:8088/superset/dashboard/1/ +http://localhost:8088/superset/dashboard/ecommerce-analytics/ ``` Если URL не открылся, зайди через меню **Dashboards** и найди `E-commerce Analytics Dashboard`. @@ -269,7 +269,9 @@ Metadata Superset живёт в PostgreSQL, а сами данные остаю Открой [`superset/create_dashboard.py`](../../../superset/create_dashboard.py). -В `CHARTS_CONFIG` лежит список charts. Один элемент списка — один график: +В `CHARTS_CONFIG` лежит список charts. Один элемент списка — один график. +Ниже показаны ключевые поля `Page Funnel`; в реальном файле после них есть ещё +визуальные параметры funnel — схема цветов, легенда, подписи и формат чисел: ```python { @@ -286,7 +288,8 @@ Metadata Superset живёт в PostgreSQL, а сами данные остаю "row_limit": 20, "time_range": "No filter", "sort_by_metric": True, - "percent_calculation_type": "first_step" + "percent_calculation_type": "first_step", + # Дальше в файле идут визуальные параметры funnel. } } ``` diff --git a/superset/init_superset.py b/superset/init_superset.py index 378ec9e..a98fdb1 100644 --- a/superset/init_superset.py +++ b/superset/init_superset.py @@ -12,7 +12,7 @@ python /app/superset_init/init_superset.py Важно: - Superset использует SQLite по умолчанию (не PostgreSQL). + Superset использует PostgreSQL metadata store через configs/superset_config.py. Текущий подход: 1. CLI для создания подключения к БД From bbd5784e2afc9ae436390df82c186dca6030398e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:14:07 +0300 Subject: [PATCH 28/38] =?UTF-8?q?docs(course):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=20LEARNING=5FPLAN=20=D1=81=20=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=20=C2=A73.1=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - все 11 чекбоксов §3.1 по урокам 2–3 были `[ ]`, хотя правки давно в коде/уроках; план вводил в заблуждение «работа не сделана». - Что: - проставлены `[x]` по урокам 2 и 3 после сверки с реальным кодом (шапки «поток данных», DQ-split «зачем», чистка legacy-MV, демоут DM, seed 1919, war-story kafka_ts, recap цепочки). - в §4 добавлена строка статуса: уроки 0–6 написаны, контент собран. - Проверка: - grep '\[ \]' docs/course/LEARNING_PLAN.md # незакрытых пунктов §3.1 нет --- docs/course/LEARNING_PLAN.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md index e31c4f0..2d582aa 100644 --- a/docs/course/LEARNING_PLAN.md +++ b/docs/course/LEARNING_PLAN.md @@ -83,32 +83,32 @@ Kafka из роадмапа — оно даёт словарь терминов. - В финале урока — мост к уроку 2: вопрос «почему дальше не MV?». **Урок 2 — STG→ODS, типизация + DQ-split (`sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql`):** -- [ ] **Кандидат на war-story по типам:** баг `kafka_ts` из урока 1 (`toInt64(DateTime64)` +- [x] **Кандидат на war-story по типам:** баг `kafka_ts` из урока 1 (`toInt64(DateTime64)` молча срезал миллисекунды → 1970). Это идеальная иллюстрация темы урока — «тихая - потеря данных на неверном типе». Решить при написании, давать ли как пример. -- [ ] Добавить однострочный «поток данных» в шапку `20_stg_to_ods.sql` + потеря данных на неверном типе». Решено: дан как пример в уроке 2 (war-story в финале). +- [x] Добавить однострочный «поток данных» в шапку `20_stg_to_ods.sql` (`stg.*_raw → ods.* + ods.*_errors`) — п.3 чеклиста. -- [ ] Добавить строку «зачем»: основная таблица = валидный ключ (но может иметь +- [x] Добавить строку «зачем»: основная таблица = валидный ключ (но может иметь `parse_errors` по неключевым полям), а `*_errors` = любая ошибка. Без этого непонятно, почему одна строка попадает в оба места (двойной учёт) — проваливается тест одного прохода. -- [ ] Убрать мусор: в `sql/ddl/ods/20_ods.sql` (стр. ~15–25) — восемь +- [x] Убрать мусор: в `sql/ddl/ods/20_ods.sql` (стр. ~15–25) — восемь `DROP TABLE IF EXISTS stg.mv_*_to_ods` (чистка legacy-MV). Шум прошлой архитектуры, мента читает его раньше сути. Вынести из учебного файла. -- [ ] Минор: в `20_ods.sql` разнобой партиционирования (`browser` по `event_date`, +- [x] Минор: в `20_ods.sql` разнобой партиционирования (`browser` по `event_date`, остальные по `src_ingest_ts`) — одна строка «почему» либо унифицировать. - В «Зачем» урока — ответ на мост из урока 1: батч ради наблюдаемости/управляемости. **Урок 3 — ODS→DDS, сборка сущностей (`sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql`):** -- [ ] Добавить однострочный «поток данных» в шапку `30_ods_to_dds.sql` +- [x] Добавить однострочный «поток данных» в шапку `30_ods_to_dds.sql` (`ods.* → dds.click + dds.event`) — п.3 чеклиста. -- [ ] В финале урока — **recap всей цепочки** STG→ODS→DDS: защита от фрагментации после +- [x] В финале урока — **recap всей цепочки** STG→ODS→DDS: защита от фрагментации после расщепления середины (менти должен собрать сквозную модель, а не три изолированных слоя). -- [ ] **Демоут DM** оформляем здесь: убрать мусор в `sql/dm/40_dds_to_dm.sql` +- [x] **Демоут DM** оформляем здесь: убрать мусор в `sql/dm/40_dds_to_dm.sql` (стр. ~14–37, закомментированный «пример материализации витрины») и превратить его в короткую заметку «VIEW сейчас, материализуем если затормозит, см. `docs/ARCHITECTURE.md`». Добавить «поток данных» в шапку `40_dds_to_dm.sql`. -- [ ] Минор: `sql/ddl/dm/40_dm.sql` — магическое `1919` в `groupArraySample` +- [x] Минор: `sql/ddl/dm/40_dm.sql` — магическое `1919` в `groupArraySample` (строка «зачем» или упростить). - Сами витрины DM показываем в деле в уроках 5–6, отдельного разбора не делаем. @@ -140,6 +140,10 @@ Kafka из роадмапа — оно даёт словарь терминов. ## 4. Что дальше -Пишем уроки по одному, по шаблону из `LESSON_STANDARD.md`, начиная с урока 1 -(Kafka→CH). Урок 0 (разминка на Kafka UI) можно готовить параллельно — он не зависит -от полировки кода. Правки кода из §3.1 применяем в составе соответствующего урока. +Все уроки 0–6 написаны (`lessons/`), правки кода §3.1 применены в составе уроков — +чекбоксы выше сверены с реальным кодом 2026-06-06. Базовый контент курса собран. + +Исходный план фазы написания (оставлен как контекст): пишем уроки по одному, по +шаблону из `LESSON_STANDARD.md`, начиная с урока 1 (Kafka→CH); урок 0 (разминка на +Kafka UI) готовим параллельно — он не зависит от полировки кода; правки кода из §3.1 +применяем в составе соответствующего урока. From 589c556b17096f144dce2a41781938388ccb17af Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:33:14 +0300 Subject: [PATCH 29/38] =?UTF-8?q?docs(course):=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D1=83=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=202/3?= =?UTF-8?q?/5=20=D0=B8=20OPERATIONS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - сквозной ревью курса нашёл расхождения учебного текста с реальным выводом стенда и один баг в операторских доках — менти увидел бы не то, что в уроке. - Что: - урок 2: порядок строк «Статистики ODS» выровнен под фактический вывод run_batch.sh (4 основных таблицы, затем 4 *_errors); снято «по строчкам». - урок 3: добавлено пояснение, что check_date — это today() из витрины (у менти будет своя дата, не как в примере). - урок 5: «должно быть не в Alerting» → «в состоянии Normal (не Alerting)». - OPERATIONS.md: несуществующий FULL=1 заменён на реальный knob LIMIT=50 (по умолчанию полный объём — подтверждено load_kafka_data.sh:27,128). - Проверка: - git diff показывает 4 файла, +9/-6; grep 'FULL=' по docs/ пуст. - порядок таблицы сверен с run_batch.sh:111-118; today() — sql/dm/40_dds_to_dm.sql:105. --- docs/OPERATIONS.md | 2 +- docs/course/lessons/02_stg_to_ods.md | 8 ++++---- docs/course/lessons/03_ods_to_dds.md | 3 +++ docs/course/lessons/05_monitoring.md | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 885ad83..9470e80 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -10,7 +10,7 @@ - `make down` (остановить и удалить контейнеры/сети проекта) - `make clean` (полная очистка: `down -v --remove-orphans`) - `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse) -- `make data` (пересоздаёт топики и заливает небольшой срез данных в Kafka; полный режим — `FULL=1 make data`) +- `make data` (пересоздаёт топики и заливает данные в Kafka; по умолчанию полный объём, срез — `LIMIT=50 make data`) - `make transform` (запускает batch-процесс ODS -> DDS -> DM) - `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд) - `docker compose ps` diff --git a/docs/course/lessons/02_stg_to_ods.md b/docs/course/lessons/02_stg_to_ods.md index 88a7d92..8265017 100644 --- a/docs/course/lessons/02_stg_to_ods.md +++ b/docs/course/lessons/02_stg_to_ods.md @@ -102,17 +102,17 @@ make transform # батч STG → ODS → DDS → DM (нас инте Статистика ODS: ┌─table──────────────────────┬─rows─┐ │ ods.browser_event │ 50 │ - │ ods.browser_event_errors │ 0 │ │ ods.location_event │ 50 │ - │ ods.location_event_errors │ 0 │ │ ods.device_by_click │ 26 │ - │ ods.device_by_click_errors │ 0 │ │ ods.geo_by_click │ 26 │ + │ ods.browser_event_errors │ 0 │ + │ ods.location_event_errors │ 0 │ + │ ods.device_by_click_errors │ 0 │ │ ods.geo_by_click_errors │ 0 │ └────────────────────────────┴──────┘ ``` -Прочитаем эту табличку по строчкам — в ней три вещи, которые стоит заметить. +Прочитаем эту табличку — в ней три вещи, которые стоит заметить. **Все четыре `*_errors` — по нулям.** Значит, наш срез чистый: ни одна запись не дала ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные в демо аккуратные. diff --git a/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md index fe1c8d9..86ff12b 100644 --- a/docs/course/lessons/03_ods_to_dds.md +++ b/docs/course/lessons/03_ods_to_dds.md @@ -105,6 +105,9 @@ DDS»** — счётчики строк по двум нашим сущност └────────────┴───────┴─────────────────────┴───────────────┴─────────────┘ ``` +В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя +дата — не пугайся, если она не совпадёт с примером. + `orphan_events = 0` — ни одной сироты. Каждое из 50 событий нашло свой клик в `dds.click`. На чистом демо-срезе так и должно быть: данные аккуратные, ничего не потерялось. В секции 4 мы сироту устроим сами — и эта строка оживёт. diff --git a/docs/course/lessons/05_monitoring.md b/docs/course/lessons/05_monitoring.md index a7dacfa..db96b03 100644 --- a/docs/course/lessons/05_monitoring.md +++ b/docs/course/lessons/05_monitoring.md @@ -270,7 +270,7 @@ Grafana. Открой Grafana → **Airflow Overview** и найди панель **Scheduler Heartbeat Rate**. Потом открой **Alerting → Alert rules** и найди правило `Airflow Scheduler Down`. Перед -экспериментом оно должно быть не в `Alerting`. +экспериментом оно должно быть в состоянии `Normal` (не `Alerting`). Можно проверить и командой: From d98ae9c48ad80807e6df056ce977026a2f71eeaf Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:41:15 +0300 Subject: [PATCH 30/38] =?UTF-8?q?docs(course):=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20ai-text-lint,=20=D1=81=D0=B8?= =?UTF-8?q?=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20PRD=20=C2=A77=20=D0=B8=20=D1=83=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20handoff'=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - финализация подготовки уроков: пройти обязательный QA-шаг (ai-text-lint по LESSON_STANDARD §2), привести PRD к факту и убрать отработанные handoff'ы. - Что: - урок 4: убран AI-маркер S01 («не только… но и» → «и… и») по итогам прогона ai-text-lint; остальные 6 уроков чисты от маркеров. - PRD §7: закрыты устаревшие открытые вопросы (глубина урока 5, Superset), оставлен только реальный пункт — ретроспектива после первого прогона. - удалены 3 отработанных handoff'а в .scratch/handoffs/. - Проверка: - git show --stat HEAD; визуальная сверка PRD §7 и урока 4. --- .../2026-06-06-lesson6-consistency.md | 76 --------------- ...superset-dashboard-redesign-implemented.md | 96 ------------------- .../2026-06-06-superset-dashboard-redesign.md | 79 --------------- docs/course/PRD.md | 21 ++-- .../lessons/04_airflow_orchestration.md | 2 +- 5 files changed, 16 insertions(+), 258 deletions(-) delete mode 100644 .scratch/handoffs/2026-06-06-lesson6-consistency.md delete mode 100644 .scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md delete mode 100644 .scratch/handoffs/2026-06-06-superset-dashboard-redesign.md diff --git a/.scratch/handoffs/2026-06-06-lesson6-consistency.md b/.scratch/handoffs/2026-06-06-lesson6-consistency.md deleted file mode 100644 index ac089b7..0000000 --- a/.scratch/handoffs/2026-06-06-lesson6-consistency.md +++ /dev/null @@ -1,76 +0,0 @@ -# Handoff: консистентность урока 6 (Superset BI) - -Дата: 2026-06-06 · Язык сессии: русский - -## Задача следующей сессии - -Урок 6 — **наш** (не Codex), и он должен быть консистентен с реальным кодом -стенда. Дашборд за эту сессию менялся трижды, текст урока подтянули, но остался -**один известный зазор** + стоит сделать сквозную проверку код-сниппетов. - -**Главное (известный зазор):** в §3 урока -[`docs/course/lessons/06_superset_bi.md`](../../docs/course/lessons/06_superset_bi.md) -код-сниппет чарта `🪜 Page Funnel` (примерно строки 274–291) показывает -**урезанный** набор `params` **без многоточия** — выглядит как полный конфиг, но -реальный в [`superset/create_dashboard.py`](../../superset/create_dashboard.py) -богаче (`color_scheme`, `show_legend`, `legendOrientation`, `tooltip_label_type`, -`number_format`, `show_labels` и др.). Привести к честному виду: либо добавить -`...`/комментарий «здесь только ключевые поля», либо показать поля, которые -реально объясняются в тексте. Для задания §4 (менять `row_limit`) сниппет не -мешает, но как учебный эталон он вводит в заблуждение «это весь конфиг». - -**Заодно (сквозная проверка):** пройтись по остальным код-сниппетам урока 6 -(§3 `DASHBOARD_CONFIG` стр. ~304–309, `init_superset.py` datasets стр. ~250–258, -`v_events_enriched` JOIN стр. ~210–213) и сверить, что они не разошлись с -актуальным кодом. Метод тот же: открыть реальный файл и сравнить. - -## Контекст: что уже сделано и запушено (НЕ переделывать) - -Состав/числа дашборда уже выверены и синхронизированы с уроком. Ветка -`docs/advanced-clickstream-course`, всё запушено. Коммиты этой сессии: - -- `281d9d2` feat — честный row-lineage по слоям вместо ложной DQ-воронки - (`create_dashboard.py` + `sql/dm/40_dds_to_dm.sql`). -- `2563c79` docs — синхронизация доков и термин «зерно». -- `c9300e7` fix — 5-мин бакеты в `Events over Time` (был `Events by Hour`). -- `bf940cc` docs — пункт интро §1 урока 6 выровнен под row-lineage. - -Актуальный состав дашборда (10 чартов): KPI `Total Events / Unique Users / -Avg Events/Visit / Conversion to /confirmation`; `Events over Time` (PT5M); -`Traffic by Device`; `Geography Map`; `UTM Effectiveness Table`; `Page Funnel`; -`🧱 Rows by Layer (event)`. Имена и эти числа в уроке уже верны — менять не нужно. - -## Источники истины - -- Реализация чартов: [`superset/create_dashboard.py`](../../superset/create_dashboard.py) - (`CHARTS_CONFIG` / `DASHBOARD_CONFIG` / `DASHBOARD_ROWS`). -- Урок: [`docs/course/lessons/06_superset_bi.md`](../../docs/course/lessons/06_superset_bi.md). -- Доменные термины: [`CONTEXT.md`](../../CONTEXT.md) (визит/сессия = `click_id` — в UI - предпочитаем «визит», не «клик»). -- Стандарт уроков: `docs/course/LESSON_STANDARD.md` (регистр/голос/шапка/грабли) — - читать перед правкой текста. -- Прошлые handoff'ы по дашборду: `2026-06-06-superset-dashboard-redesign*.md` - (исторические, задача там закрыта). - -## Проверка - -- Текст не требует прогона стенда; правка чисто в `.md`. Достаточно открыть - реальный код и сверить сниппеты глазами. -- Если захочется перепроверить дашборд: стенд поднят (`docker ps`), логин - Superset формой `admin`/`admin` на `http://localhost:8088/login/` (заполнять - поля через refs снапшота, не по name-селекторам — последние давали Access - Denied), дашборд `…/superset/dashboard/ecommerce-analytics/?standalone=1`. - -## Git-гигиена - -- Ветка `docs/advanced-clickstream-course`, на ней **параллельно пишет Codex**. - Git строго **аддитивно**: не amend/rebase/reset чужих коммитов, `git add` - только своих файлов. Перед push — `git fetch` и проверить, что не разошлись. -- Коммиты — через скилл `conventional-commits` (русский, тело Зачем/Что/Проверка). - -## Suggested skills - -- `conventional-commits` — для коммита правки. -- `ai-text-lint` — прогнать изменённый фрагмент урока на AI-маркеры перед коммитом - (урок учебный, голос важен). -- `playwright-cli` — только если понадобится переснять дашборд для сверки. diff --git a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md deleted file mode 100644 index 52c9e45..0000000 --- a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign-implemented.md +++ /dev/null @@ -1,96 +0,0 @@ -# Handoff: Superset dashboard redesign implemented - -Дата: 2026-06-06 · Язык сессии: русский - -## Статус - -Редизайн Superset-дашборда реализован и закоммичен. - -Коммит: - -```text -04995af832743ed984c25af08a185f7abe0485f1 -feat(superset): обновлен состав KPI и воронки дашборда -``` - -Рабочее дерево было чистым перед созданием этого handoff; сам handoff создан после коммита. - -## Источники истины - -- Спека: [`docs/specs/2026-06-06-superset-dashboard-redesign.md`](../../docs/specs/2026-06-06-superset-dashboard-redesign.md) -- Реализация: [`superset/create_dashboard.py`](../../superset/create_dashboard.py) -- Пользовательская документация: [`docs/SUPERSET_DASHBOARD.md`](../../docs/SUPERSET_DASHBOARD.md) -- Урок 6: [`docs/course/lessons/06_superset_bi.md`](../../docs/course/lessons/06_superset_bi.md) -- Предыдущий handoff: [`2026-06-06-superset-dashboard-redesign.md`](./2026-06-06-superset-dashboard-redesign.md) - -## Что сделано - -- KPI-полоса теперь: - `Total Events · Unique Users · Avg Events/Visit · Conversion to /confirmation`. -- `Unique Sessions` удалён из KPI и из Superset metadata как obsolete chart. -- `Avg Events/Session` переименован в `Avg Events/Visit` идемпотентно через `previous_slice_names`. -- `Top Pages` переименован в `Page Funnel` и переведён на `viz_type: funnel`. -- Conversion считается как page-funnel metric: - `countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home')`. -- UTM-таблица оставлена без мёртвых колонок `purchases` / `add_to_cart`. -- `Events by Hour` оставлен: на полном датасете есть два часовых бакета. -- Документация и урок 6 синхронизированы с полным датасетом и новым dashboard flow. - -## Проверки - -Выполнено: - -```bash -make data -make transform -python3 -m py_compile superset/create_dashboard.py -make superset-dashboard -make superset-dashboard -``` - -Результаты: - -- полный датасет в DM: `1000` events, `99` visits, `99` users; -- `Avg Events/Visit = 10.1`; -- `Conversion to /confirmation = 35 / 426 = 8.2%`; -- Superset dashboard metadata: - - `dashboard_charts = 10`; - - `obsolete_unique_sessions = 0`; - - `page_funnel_type = funnel`; -- Superset API `/api/v1/dashboard/1/datasets` вернул `200`; -- browser chart-data для `Page Funnel` вернул `status: success`, 6 строк; -- browser chart-data для `Data Quality Summary` вернул `errors: []`, `status: success`. - -## Визуальная проверка - -Пользователь визуально подтвердил: «Визуально - выглядит норм». - -Скриншоты были сохранены в `/tmp` во время сессии: - -- `/tmp/ecommerce-analytics-dashboard-desktop.png` -- `/tmp/ecommerce-analytics-dashboard-lower.png` - -Они могут исчезнуть после очистки `/tmp`; при необходимости переснять через `playwright-cli`. - -## Важные детали реализации - -- Для Superset 4.1.2 `funnel` проверялся через MCP Context7 по `/apache/superset`; Context7 не дал точную строку `viz_type`. -- Решение подтверждено по установленному Superset 4.1.2: bundled example `Featured Charts/Funnel.yaml` использует `viz_type: funnel`. -- `create_dashboard.py` ищет старые имена через `previous_slice_names`, чтобы не плодить дубли при rename. -- Cleanup obsolete charts удаляет все найденные `🎯 Unique Sessions`, если metadata уже была загрязнена дублями. - -## Что осталось - -Обязательных незакрытых задач по редизайну нет. - -Возможные следующие шаги, если продолжать dashboard-направление: - -- экспортировать обновлённый dashboard JSON через `make superset-export`, если экспортный артефакт должен соответствовать новой metadata; -- отдельно модернизировать legacy chart types (`pie`, `world_map`, `dist_bar`) на ECharts, если это станет целью следующего захода; -- при развитии генератора перенести требования из спеки в его `KNOWN_ISSUES.md`. - -## Suggested skills - -- `playwright-cli` — если нужно переснять визуальную проверку dashboard. -- `conventional-commits` — если нужно коммитить этот handoff или дальнейшие правки. -- `diagnose` — если Superset chart-data или layout начнут падать после сброса volumes. diff --git a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md b/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md deleted file mode 100644 index fd367be..0000000 --- a/.scratch/handoffs/2026-06-06-superset-dashboard-redesign.md +++ /dev/null @@ -1,79 +0,0 @@ -# Handoff: реализация редизайна Superset-дашборда - -Дата: 2026-06-06 · Язык сессии: русский · Понять-режим был включён (можно не продолжать) - -> **СТАТУС: ВЫПОЛНЕНО (2026-06-06).** Редизайн реализован (коммит `04995af`/amend -> `1bec6bb`) и визуально принят. Этот файл — исходная постановка «иди реализуй», -> оставлен как след. Актуальная сводка сделанного и возможных следующих шагов → -> [`2026-06-06-superset-dashboard-redesign-implemented.md`](./2026-06-06-superset-dashboard-redesign-implemented.md). -> Ниже — **историческая** постановка, НЕ список текущих дел. - -## Источник истины - -**Сначала прочитать спеку:** [`docs/specs/2026-06-06-superset-dashboard-redesign.md`](../../docs/specs/2026-06-06-superset-dashboard-redesign.md) -— там весь дизайн (проблема, числа данных, триаж чартов, состав KPI, решения, риски, -критерии проверки). Этот handoff — только «как возобновить», не дублирует дизайн. -Доменные термины — [`CONTEXT.md`](../../CONTEXT.md). - -## Решение одной строкой - -KPI-полоса = `Total Events · Unique Users · Avg Events/Visit · Conversion to /confirmation` -(дубль «Unique Sessions» убрать); `Top Pages → Funnel`-чарт по страницам; различие -user/session — текстом, не двумя одинаковыми цифрами; мёртвые колонки purchases — выкинуть. - -## Сделать ПЕРВЫМ делом - -1. **Снять open questions из спеки** (без них реализация буксует): - - точная формула Conversion (доля визитов с ≥1 pageview `/confirmation`?); - - поддерживает ли Superset **4.1.2** `viz_type` воронки (иначе — упорядоченный bar); - - судьба `Events by Hour` (проверить, не пустой ли на полных данных). -2. **Перегрузить стенд на ПОЛНЫЕ данные** — сейчас в ClickHouse отладочный срез - (50 событий). Нужно: `make data` (без `LIMIT`) + `make transform`. На полных - данных: 1000 событий, 99 визитов, 99 пользователей. - -## Где править и как прогонять - -- Единственный файл реализации: `superset/create_dashboard.py` - (`CHARTS_CONFIG` / `DASHBOARD_ROWS` / `ROW_HEIGHTS`). -- Прогон: `make superset-dashboard` — идемпотентно (чарты по `slice_name`, дашборд - по `slug`, обновляются на месте). При переименовании чартов следить, чтобы не - плодились дубли. - -## Проверка - -- `GET /api/v1/dashboard//datasets` → 200; DQ Summary без `Columns missing in datasource`. -- Визуально: `playwright-cli` — логин формой `admin`/`admin` на `http://localhost:8088/login/`, - затем `goto .../superset/dashboard/ecommerce-analytics/`, `screenshot --filename=/tmp/x.png` (читать через Read). - Скриншоты — в `/tmp`. `.playwright-cli/` НЕ коммитить. -- Сверка чисел с ClickHouse: креды в `configs/default_user.xml` (default / `123456`), - `docker exec clickstream-ch-kafka-superset-demo-clickhouse-1 clickhouse-client --password 123456 -q "..."`. - (Через `docker exec printenv` креды НЕ дёргать — классификатор блокирует.) - -## Если задачу берёт Codex (разрез по приёмке) - -Codex силён в реализации/рассуждении, **слаб в визуальной оценке** — а приёмка -дашборда визуальна (обе прошлые сессии были про поломки раскладки: ROW-оверлапы, -«прыгающие» KPI). Поэтому: - -- Codex делает реализацию + **не**визуальные само-проверки: API `/datasets → 200`, - числа сходятся с прямым запросом в ClickHouse, скриншоты складывает в `/tmp`. -- Codex **НЕ объявляет done по визуалу.** Самый рискованный пункт (отрисовалась ли - воронка вообще, нет ли наездов плиток) — чисто визуальный. Остановиться и передать - скриншоты на визуальный sign-off человеку или vision-агенту. - -## Синхронизировать доки ПРИ реализации - -- `docs/SUPERSET_DASHBOARD.md` — раздел «Структура дашборда» (новый состав чартов/KPI). -- `docs/course/lessons/06_superset_bi.md` — убрать `LIMIT=50 make data`, синхронизировать состав/скриншоты. - -## Git-гигиена - -- Ветка `docs/advanced-clickstream-course`, на ней **параллельно пишет Codex** — - git строго **аддитивно**, не amend/rebase/reset чужих коммитов. `git add` только своих файлов. -- Артефакты этой сессии (если ещё не закоммичены): `CONTEXT.md`, `docs/adr/0001` (правка), - `docs/adr/0002`, `docs/adr/0003`, `docs/specs/2026-06-06-...`, `AGENTS.md` (правка), этот handoff. - -## Suggested skills - -`conventional-commits` (любой коммит) · `playwright-cli` (визуальная проверка) · -`diagnose` (если чарт/датасет отвалится). diff --git a/docs/course/PRD.md b/docs/course/PRD.md index 518d2e2..81b5c9f 100644 --- a/docs/course/PRD.md +++ b/docs/course/PRD.md @@ -129,9 +129,18 @@ Kafka → ClickHouse → BI). ## 7. Открытые вопросы / на будущее -- Глубина урока 5 (мониторинг): сколько внутреннего устройства показывать против - «просто наблюдай дашборд». Кандидат — мини-правка «погаси сервис → алерт краснеет» - (зеркало урока 4), см. `LEARNING_PLAN.md` §3.1. -- Нужна ли BI-витрина (Superset) уже в первой версии или переносим на следующую. -- Переиспользование: после первого прогона — ретроспектива и обобщение материала - под других менти. +Закрыто при написании уроков (2026-06-06): + +- ~~Глубина урока 5 (мониторинг): сколько устройства показывать против «просто + наблюдай дашборд».~~ **Решено:** взяли мини-правку «погаси сервис → алерт краснеет» + (зеркало урока 4) — урок 5 даёт «сломал-увидел», а не чистое наблюдение. См. + `LEARNING_PLAN.md` §3.1. +- ~~Нужна ли BI-витрина (Superset) уже в первой версии.~~ **Решено:** урок 6 написан и + синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не + блокирует). + +Остаётся на будущее: + +- Переиспользование: после **первого реального прогона менти** — ретроспектива и + обобщение материала под других менти. Это валидация уже собранного курса, а не + часть его подготовки. diff --git a/docs/course/lessons/04_airflow_orchestration.md b/docs/course/lessons/04_airflow_orchestration.md index c328202..2b3ec5c 100644 --- a/docs/course/lessons/04_airflow_orchestration.md +++ b/docs/course/lessons/04_airflow_orchestration.md @@ -351,7 +351,7 @@ make clean && make up && make ddl && LIMIT=50 make data ## Вся цепочка разом: STG → ODS → DDS → DM -Теперь у нас есть не только отдельные слои, но и порядок их жизни: +Теперь у нас есть и сами слои, и порядок их жизни: - **STG** принимает поток и хранит сырой JSON; - **ODS** типизирует и разделяет чистое/битое; From bae1e02240075f5b1ded7ca774ece8a0eafd38a3 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 18:50:50 +0300 Subject: [PATCH 31/38] =?UTF-8?q?docs(superset):=20=D1=83=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно снять двусмысленность между native filters и click-to-filter в Superset dashboard. - Что: - описана фильтрация через левую панель Superset. - уточнена область действия фильтров по совместимым charts и DM-витринам. - зафиксировано, что click-to-filter между виджетами не включен. - Проверка: - git diff --check -- README.md docs/SUPERSET_DASHBOARD.md docs/course/lessons/06_superset_bi.md. --- README.md | 5 +++++ docs/SUPERSET_DASHBOARD.md | 16 ++++++++++++---- docs/course/lessons/06_superset_bi.md | 7 +++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7dc3fa0..6a86310 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,11 @@ make superset-dashboard Фильтр `Date Range` по умолчанию открыт как `No filter`, потому что демо-данные лежат в историческом диапазоне (`2022-11-28`). +Фильтры Superset задаются в левой панели dashboard. Click-to-filter по виджетам не включен: +клики по pie chart, карте, таблице или funnel не меняют другие charts. `Country`, +`Device Type` и `Browser` применяются к charts на `dm.v_events_enriched`; `Date Range` +работает с charts, где есть `event_date`. + ### Архитектура Superset ``` diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index def91bb..d778227 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -110,10 +110,18 @@ KPI разложены в одну строку по 12-колоночной с | Фильтр | Поле | Тип | Применение | |--------|------|-----|------------| -| 📅 Date Range | `event_date` | Time Range | Все чарты; по умолчанию `No filter`, чтобы демо-данные 2022 года не скрывались | -| 🌍 Country | `geo_country` | Multi-select | Все чарты | -| 📱 Device Type | `device_type` | Multi-select | Все чарты | -| 🌐 Browser | `browser_name` | Multi-select | Все чарты | +| 📅 Date Range | `event_date` | Time Range | Charts с `event_date`; по умолчанию `No filter`, чтобы демо-данные 2022 года не скрывались | +| 🌍 Country | `geo_country` | Multi-select | Charts на `dm.v_events_enriched` | +| 📱 Device Type | `device_type` | Multi-select | Charts на `dm.v_events_enriched` | +| 🌐 Browser | `browser_name` | Multi-select | Charts на `dm.v_events_enriched` | + +Фильтры работают через левую панель Superset. Click-to-filter между виджетами не включен: +клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts. + +Фильтр применяется только к charts, где есть нужное поле. Агрегированные витрины +`dm.v_utm_effectiveness` и `dm.v_top_pages_daily` содержат `event_date`, но не содержат +`geo_country`, `device_type` и `browser_name`. `dm.dq_summary` использует `check_date`; +бизнес-фильтры на него не рассчитаны. --- diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index 3cc4fba..df5b0ba 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -323,6 +323,13 @@ Native filters создаются в `build_dashboard_metadata`. Там есть - `Device Type` по `device_type`; - `Browser` по `browser_name`. +Эти фильтры задаются в левой панели dashboard. Click-to-filter между виджетами не включен: +клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts. + +Фильтр применяется только к charts, где есть нужное поле. `Country`, `Device Type` и +`Browser` работают с charts на `dm.v_events_enriched`; агрегированные витрины для UTM, +page funnel и DQ не содержат все эти измерения. + > **Что проверили по API.** Перед уроком Superset сверили через MCP Context7 (`/apache/superset`): > в Superset есть отдельные сущности charts и dashboards, metadata хранится отдельно от > подключаемых источников данных. Для `funnel` Context7 не дал точную строку `viz_type`, поэтому From 2f905e4c73329647d6b5d902b980130e1d4d543a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 19:06:16 +0300 Subject: [PATCH 32/38] =?UTF-8?q?docs(course):=20README=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BE=D1=82=20=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=20?= =?UTF-8?q?+=20=D0=BA=D1=80=D1=8E=D1=87=D0=BE=D0=BA=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - прежний README вёл читателя в авторские доки (PRD/LEARNING_PLAN/ LESSON_STANDARD), а менти нужна точка входа: с чего начать, как ходить по урокам и как поднять стенд. Цель PRD — самодостаточный материал. - Что: - mentee-first структура: «что нужно до старта» (make up → ddl → LIMIT=50 make data, совпадает с уроками и правилом малого среза), индекс уроков 0–6 со ссылками и режимом, «как проходить». - блок-крючок «сам / с ментором» в тон роадмапа + CTA в Telegram. - авторские доки убраны в секцию «Под капотом курса». - Проверка: - все 11 внутренних ссылок резолвятся; ai-text-lint чист (house style сохранён). --- docs/course/README.md | 89 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/docs/course/README.md b/docs/course/README.md index 5c9c325..bfaa579 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -1,32 +1,85 @@ # Курс «Кликстрим на ClickHouse» (со звёздочкой) -Продвинутый курс для менти, уже прошедших базовую программу: основные паттерны -инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг + BI. Менти проходит -материал самостоятельно, а на еженедельном созвоне с ментором разбирает затыки и отвечает -на вопросы по теме — чтобы проверить глубину понимания. +Это продвинутый курс «со звёздочкой» для тех, кто уже прошёл базовую программу. Ты +поднимаешь у себя живой стенд (Kafka + ClickHouse + Airflow + мониторинг + BI) и +проходишь по нему основные паттерны инженерии данных. Курс самодостаточный — он +рассчитан на то, что ты идёшь по нему сам: каждый урок объясняет, зачем нужен паттерн, +даёт потрогать его руками и проверить себя в конце. -Сам курс — это блок «со звёздочкой» открытого роадмапа Data Engineer +Курс — блок «со звёздочкой» открытого роадмапа Data Engineer ([de.dementev.space](https://de.dementev.space/), он же -[на GitHub](https://github.com/dementev-dev/de-roadmap)). Роадмап открыт и устроен так, -что идти по нему можно в одиночку; с ментором — ощутимо короче дорога: добавляются -персональный план, разбор домашек и код-ревью. +[на GitHub](https://github.com/dementev-dev/de-roadmap)). -## Что где искать +> **Пройти можно самому — а можно с ментором.** В одиночку реально — курс для того и +> сделан. С ментором — быстрее, понятнее и чаще с лучшим результатом: +> персональный план под твою ситуацию, разбор затыков на еженедельном созвоне, код-ревью +> твоих правок и проверка, что ты понял тему вглубь, а не просто прокликал. Хочешь так — +> напиши в Telegram [@dementev_dev](https://t.me/dementev_dev). -| Файл | Что это | Когда открывать | -|------|---------|-----------------| -| [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | -| [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | -| [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–6) | Прохождение курса менти | +## Что нужно до старта -## Порядок чтения +- **База пройдена:** SQL, моделирование данных, Python, Git, Docker, Airflow. ClickHouse + знать заранее не нужно — это и есть тема курса. +- **Обзорное видео по Kafka из роадмапа** — посмотри перед уроком 0. Оттуда ты возьмёшь + словарь («топик», «партиция», «offset», «consumer-группа», «lag»), а урок 0 свяжет эти + слова с живым стендом. +- **Железо:** стек тяжёлый — Kafka, ClickHouse, Airflow, Superset, Prometheus и Grafana + поднимаются одновременно. Нужна машина, которая это потянет. +- **Подними стенд и залей малый срез** (из корня репозитория) — этого хватит, чтобы + начать, и прогон быстрый: -1. Новому участнику (или будущей сессии): начать с `PRD.md`, затем `LEARNING_PLAN.md`. -2. Перед написанием урока: держать открытым `LESSON_STANDARD.md`. + ```bash + make up # поднять контейнеры + make ddl # создать схему в ClickHouse (базы, таблицы, VIEW) + LIMIT=50 make data # залить по 50 строк на топик в Kafka + ``` + + Дальше каждый урок в секции «Руки» сам напоминает, что перезапустить и с каким срезом. + Точные шаги, параметры и troubleshooting — в [`docs/OPERATIONS.md`](../OPERATIONS.md). + +## Уроки + +Проходи по порядку — уроки 0→4 повторяют сам пайплайн (Kafka → STG → ODS → DDS → +оркестрация), а 5 и 6 надстраиваются поверх готовых данных. В колонке «режим»: +**наблюдение** — только смотрим, **руки** — запускаешь и меняешь сам. + +| # | Урок | Режим | О чём | +|---|------|-------|-------| +| 0 | [Вводный по Kafka](./lessons/00_kafka_intro.md) | наблюдение | Ходим по Kafka UI: где лежат события, кто их читает, как Kafka помнит, докуда дочитано | +| 1 | [Kafka → ClickHouse (STG)](./lessons/01_kafka_to_clickhouse.md) | руки | Как сообщение из топика становится строкой ClickHouse через Kafka engine и Materialized View | +| 2 | [STG → ODS: типизация и DQ-split](./lessons/02_stg_to_ods.md) | руки | Приводим сырьё к типам и разводим чистое и битое по разным таблицам | +| 3 | [ODS → DDS: сборка сущностей](./lessons/03_ods_to_dds.md) | руки | Собираем `click` и `event` из кусочков (argMax, JOIN) и встречаем «сирот» | +| 4 | [Оркестрация в Airflow](./lessons/04_airflow_orchestration.md) | руки | Всю цепочку — в один DAG с зависимостями и честным гейтом целостности | +| 5 | [Мониторинг: Prometheus и Grafana](./lessons/05_monitoring.md) | наблюдение + мини-правка | Смотрим систему со стороны; гасим сервис — видим, как краснеет алерт | +| 6 | [BI-витрина в Superset](./lessons/06_superset_bi.md) | руки (опционально) | Дашборд поверх ClickHouse: KPI, динамика, воронка | + +## Как проходить + +- **По одному уроку за раз** — ожидаемый темп около дня на урок (уроки короткие и + односоставные: один паттерн на урок). +- **Каждый урок устроен одинаково:** зачем паттерн нужен → что запустить и понаблюдать → + чтение эталонного кода → одна управляемая правка с видимым результатом → самопроверка. + Любая правка завершается шагом **«верни как было»**, чтобы стенд не остался сломанным. +- **Идёшь сам** — опорой служат секции «Проверь себя» в конце каждого урока: по ним + видно, понял ты тему или только пробежал глазами. +- **Идёшь с ментором** — еженедельный созвон покрывает несколько уроков сразу: туда + несёшь затыки и ответы «своими словами» из тех же секций «Проверь себя». ## Границы Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к собесам) в этот репозиторий **не кладём** — репозиторий публичный, приватное живёт в менторской базе. + +--- + +## Под капотом курса (для автора и любопытных) + +Эти документы менти для прохождения не нужны — они объясняют, как курс устроен и почему +именно так. Открывай, если ведёшь курс или хочешь заглянуть в его «исходники». + +| Файл | Что это | +|------|---------| +| [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха (замороженный документ) | +| [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | +| [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон, качество кода, самопроверка — рабочий чеклист при написании | From d4e6d845244797e3b4cd863237fe23bbce526a3c Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 19:06:29 +0300 Subject: [PATCH 33/38] =?UTF-8?q?docs(handoff):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - следующая сессия продолжает работу над документацией с фокусом на корневой README; нужен контекст (модель курса, голос, подходы), а не чек-лист задач. - Что: - добавлен .scratch/handoffs/2026-06-06-root-readme-polish.md: модель курса, линза mentee-first, рабочие подходы, состояние git и рабочего дерева, направление по корневому README (без предписаний). - Проверка: - чистый .md, прогон стенда не нужен. --- .../handoffs/2026-06-06-root-readme-polish.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .scratch/handoffs/2026-06-06-root-readme-polish.md diff --git a/.scratch/handoffs/2026-06-06-root-readme-polish.md b/.scratch/handoffs/2026-06-06-root-readme-polish.md new file mode 100644 index 0000000..292a69d --- /dev/null +++ b/.scratch/handoffs/2026-06-06-root-readme-polish.md @@ -0,0 +1,68 @@ +# Handoff: контекст для работы над документацией курса + +Дата: 2026-06-06 · Язык сессии: русский + +Следующая сессия продолжает работу над документацией. Ближайший фокус — **корневой +[`README.md`](../../README.md)**: ощущение, что он слишком инженерно-демовый и требует +более серьёзной переработки, чем косметика. Что именно с ним делать — решаем в новой +сессии. Здесь — не план задач, а контекст и понимание, чтобы войти быстро. + +## Модель курса (держать в голове при любой правке доков) + +- **Аудитория и режим.** Продвинутый менти, прошёл базу; идёт сам, ментор — на + еженедельном **созвоне** (не «сессии»). Отсюда железное требование PRD: материал + **самодостаточен**. Любой документ курса проверяю вопросом «менти поймёт это один, без + ментора рядом?». +- **Голос.** Мягкий, уровень «обзорно», термин расшифровываем на первом употреблении, + обращаемся на «ты». Правила — `docs/course/LESSON_STANDARD.md` §2/§5. +- **House style — осознанный, линтером НЕ правим:** тире «—», буква «ё», жирные зачины + абзацев, двоеточие перед списком. `ai-text-lint` гоняем только на настоящие маркеры + (канцелярит, зачины, хеджинг, negative parallelism). +- **Принципы стенда:** один паттерн на урок; данные — **малый срез** (`LIMIT=50`) ради + быстрого повторяемого прогона (`AGENTS.md`). Это не мелочь — на этом я поймал баг (см. + ниже). +- **Крючок менторства.** Тон роадмапа: «пройти можно самому — а можно с ментором, так + быстрее/понятнее/лучше», CTA в Telegram `@dementev_dev`. Эталон тона — + `~/sources/de-roadmap/README.md`. Уже зеркально внесён в `docs/course/README.md`. + +## Линза, которую я применял (и которой не хватает корневому README) + +**Mentee-first vs author-first.** `docs/course/README.md` был написан от автора — вёл +читателя в `PRD → LEARNING_PLAN → LESSON_STANDARD` (мета-доки «зачем курс»), а не в +уроки. Я переписал его **от менти**: сначала «что нужно до старта» и индекс уроков со +ссылками, авторские доки убраны в секцию «Под капотом». Корневой README сейчас — про +демо-стенд и DE-задание, **курс в нём не упомянут вообще**; он явный кандидат на ту же +линзу, но, возможно, глубже (разделить «демо-стенд» и «вход в учебный курс»). + +## Подходы, которые сработали — стоит продолжить + +- **Проверять claim на стенде/в коде, а не на глаз.** Так нашёлся баг: я сперва написал в + course/README полную загрузку `make data`, хотя все уроки и правило среза требуют + `LIMIT=50 make data`. Сверка с реальными уроками вскрыла расхождение. +- **Свежий проход после написания** ловит фактические нестыковки, не только стиль — + именно он дал ту правку. +- **Grep-линт на AI-маркеры** с поправкой на house style быстрее сплошного чтения. +- **Читать глазами целевого читателя**: где точка входа, как ходить между частями, как + стартовать с нуля. + +## Состояние (факты, которые понадобятся) + +- **Рабочее дерево:** `docs/course/README.md` изменён, **не закоммичен** — это + mentee-first редизайн + крючок менторства + правки свежего взгляда. Пользователь хочет + закоммитить его (и правку корневого README, если будет) — **после** того как + разберёмся с корневым. +- **Git:** ветка `docs/advanced-clickstream-course`, на ней **параллельно пишет Codex** + (его `bae1e02` уже лёг поверх моего `d98ae9c`). Строго аддитивно: не трогать чужие + коммиты, `git add` только своих файлов, `git fetch` перед push. **2 локальных коммита + не запушены.** +- **Сделано в этой сессии:** `d98ae9c` (финальный `ai-text-lint` по урокам 0–6, синхрон + `PRD.md` §7, уборка отработанных handoff'ов); `docs/course/README.md` переписан от + менти. Уроки 0–6 как тексты готовы и единообразны. +- **Мелочь на заметку:** корневой README (стр. 78, 238) даёт Superset-дашборд как + `/superset/dashboard/1/`, а урок 6 — по slug `ecommerce-analytics`. Стоит сверить при + правке. + +## Suggested skills + +- `ai-text-lint` — прогон правок доков на AI-маркеры (context `article`, house style не снимать). +- `conventional-commits` — для коммита. From ce7f03be28c92dda29e4e40be483452b07b59ff4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 20:11:38 +0300 Subject: [PATCH 34/38] =?UTF-8?q?docs(readme):=20=D0=BA=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B9=20README=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=20=D0=BF=D0=BE=D0=B4=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B8=20=D0=B8=20=D0=BF=D0=B5=D1=81=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D1=86=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - стенд теперь учебный (для менти и для экспериментов), рекрутерская рамка DE-задания неактуальна и сбивала читателя; README дублировал профильные доки и расходился с ними. - Что: - README сделан тонким указателем на три двери: курс, быстрый старт, устройство стенда; объём сокращён с 361 до 96 строк. - быстрый старт переведён на основной Airflow-путь (ddl_init → kafka_load → etl_pipeline) вместо legacy make-пути. - срезаны дубли (DBeaver, структура дашборда, мониторинг, troubleshooting, Makefile, дерево проекта, «Статус/В планах») с уводом в OPERATIONS, ARCHITECTURE, REPO_MAP, SUPERSET_DASHBOARD. - исправлен URL дашборда Superset на slug ecommerce-analytics; убран фейковый бейдж лицензии. - Проверка: - открыть README.md, пройти быстрый старт, проверить рендер mermaid и рабочие ссылки на профильные доки. --- README.md | 359 ++++++++++-------------------------------------------- 1 file changed, 62 insertions(+), 297 deletions(-) diff --git a/README.md b/README.md index 6a86310..63e2f85 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,85 @@ -# ClickHouse Mini DWH для кликстрима +# Учебный стенд DWH кликстрима [![Stack](https://img.shields.io/badge/stack-Kafka%20%7C%20ClickHouse%20%7C%20Airflow%20%7C%20Superset%20%7C%20Prometheus%2FGrafana-blue)](./docker-compose.yml) [![Layers](https://img.shields.io/badge/layers-STG%20→%20ODS%20→%20DDS%20→%20DM-green)](./docs/ARCHITECTURE.md) -[![License](https://img.shields.io/badge/license-Educational-orange)]() -Мини-демо для решения задания [DE-task.md](./docs/DE-task.md): развернуть инфраструктуру на своей машине, прогнать кликстрим через Kafka в ClickHouse, сделать регулярный расчёт в Airflow и подготовить витрины под дашборд. +Живой стек для работы с кликстримом: Kafka, ClickHouse, Airflow, Superset и мониторинг +(Prometheus с Grafana) поднимаются в Docker одной командой. На этом стенде можно учиться +по курсу или просто поднять его у себя и поэкспериментировать с потоковой загрузкой и +витринами. -Фокус проекта: быстро показать работающий end-to-end сценарий и понятным языком объяснить, как устроены слои и почему пайплайн не падает на "грязных" данных. +Поток данных коротко: +`data/*.jsonl → Airflow (kafka_load) → Kafka → ClickHouse (слой STG) → Airflow +(etl_pipeline: STG → ODS → DDS → DM) → Superset`. -Коротко про поток: -`data/*.jsonl` -> Airflow DAG `kafka_load` -> Kafka (1 строка = 1 сообщение) -> ClickHouse `stg` (сырые JSON) -> Airflow DAG `etl_pipeline` (`stg -> ods -> dds -> dm`) -> Superset. +## Куда дальше ---- +- **Хочешь учиться** — открой [курс «Кликстрим на ClickHouse»](./docs/course/README.md). + Это продвинутый курс «со звёздочкой»: основные приёмы инженерии данных проходишь прямо + на этом стенде. +- **Хочешь поднять и попробовать** — следуй быстрому старту ниже. +- **Хочешь разобраться в устройстве** — смотри [архитектуру слоёв](./docs/ARCHITECTURE.md), + [запуск и эксплуатацию](./docs/OPERATIONS.md) и [карту репозитория](./docs/REPO_MAP.md). -## Быстрый старт (демо-сценарий) +## Быстрый старт + +Стенд управляется через Airflow — это основной рабочий способ. Отдельные shell-скрипты в +`scripts/` оставлены как запасной вариант для локальных прогонов (см. +[OPERATIONS](./docs/OPERATIONS.md)). ```bash -# 1) Поднять инфраструктуру +# 1. Поднять весь стек make up - -# Проверить статусы контейнеров -docker compose ps +docker compose ps # убедиться, что контейнеры запустились ``` -Дальше основной путь идёт через Airflow (как в задании). +Дальше — три шага в Airflow (веб-интерфейс `http://localhost:8080`, логин и пароль +`admin`/`admin`). Сними каждый DAG с паузы (кнопка Unpause) и запусти по очереди: -1. Открыть Airflow UI: `http://localhost:8080` (admin/admin) -2. Включить (unpause) и запустить `ddl_init` (создаёт базы/таблицы/VIEW в ClickHouse) +1. `ddl_init` — создаёт базы, таблицы и представления в ClickHouse. +2. `kafka_load` — заливает события из `data/*.jsonl` в Kafka. +3. `etl_pipeline` — прогоняет цепочку STG → ODS → DDS → DM. + +Те же шаги можно запускать из командной строки — это удобно для скриптов: -Опционально можно триггернуть DAG из CLI (удобно для CI/скрипта): ```bash docker compose exec -T airflow-webserver airflow dags trigger ddl_init -``` -Загрузка данных в Kafka через Airflow DAG: -```bash -# Полная загрузка (по умолчанию limit=0) -docker compose exec -T airflow-webserver airflow dags trigger kafka_load \ - --conf '{"reset_topics": true}' - -# Ограниченная загрузка — первые 100 строк +# Загрузить первые 100 строк каждого файла (limit=0 — загрузить всё) docker compose exec -T airflow-webserver airflow dags trigger kafka_load \ --conf '{"limit": 100, "reset_topics": true}' -``` -Запуск batch-трансформации (STG -> ODS -> DDS -> DM) в Airflow (если DAG выключен, сначала unpause): -```bash docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \ --conf '{"full_refresh": true}' ``` -Smoke-check результата в ClickHouse: +Проверить, что данные дошли до витрин: + ```bash -docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \ - "SELECT 'ods.browser_event' AS t, count() AS rows FROM ods.browser_event" -docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \ - "SELECT 'dds.click' AS t, count() AS rows FROM dds.click" -docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \ - "SELECT 'dds.event' AS t, count() AS rows FROM dds.event" -docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \ - "SELECT 'dm.dq_summary' AS t, count() AS rows FROM dm.dq_summary" +docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \ + --query "SELECT count() FROM dm.v_events_enriched" ``` ---- +Подробный сценарий запуска, параметры DAG-ов и разбор частых проблем — в +[OPERATIONS](./docs/OPERATIONS.md). -## Доступные сервисы +## Сервисы и доступы -| Сервис | URL | Назначение | Логин/Пароль | -|--------|-----|------------|--------------| -| ClickHouse HTTP | http://localhost:9123/play | SQL-запросы | default/123456 | -| Kafka UI | http://localhost:8082 | Просмотр топиков | — | -| Airflow | http://localhost:8080 | Оркестрация ETL | admin/admin | -| Superset | http://localhost:8088 | BI-дашборды | admin/admin | -| Prometheus | http://localhost:9090 | Метрики | — | -| Grafana | http://localhost:3000 | Визуализация метрик | admin/admin | +| Сервис | Адрес | Назначение | Логин/пароль | +|--------|-------|------------|--------------| +| Airflow | `http://localhost:8080` | оркестрация ETL | admin/admin | +| ClickHouse | `http://localhost:9123/play` | SQL-запросы | default/123456 | +| Kafka UI | `http://localhost:8082` | просмотр топиков | — | +| Superset | `http://localhost:8088` | дашборды | admin/admin | +| Prometheus | `http://localhost:9090` | метрики | — | +| Grafana | `http://localhost:3000` | графики метрик | admin/admin | -Superset: UI доступен после `make up` по адресу http://localhost:8088, дашборд — http://localhost:8088/superset/dashboard/1/ +Готовый дашборд в Superset: +`http://localhost:8088/superset/dashboard/ecommerce-analytics/` — он создаётся +автоматически через минуту-две после `make up`. Состав и настройка дашборда описаны в +[SUPERSET_DASHBOARD](./docs/SUPERSET_DASHBOARD.md). ---- - -## Подключение DBeaver (кратко) - -После прогона `ddl_init -> kafka_load -> etl_pipeline` можно быстро проверить витрины в DBeaver (удобно для демо бизнесу). - -1. `Database -> New Database Connection -> ClickHouse` -2. Параметры: - - `Host`: `localhost` - - `Port`: `9123` (HTTP) - - `Database`: `default` - - `Username`: `default` - - `Password`: `123456` -3. Нажать `Test Connection` -> `Finish` - -Если ваш драйвер просит native-протокол, используйте порт `8002`. - -Полезные быстрые запросы для первичного анализа: -```sql -SELECT count() AS rows FROM dm.v_events_enriched; -SELECT * FROM dm.v_daily_traffic ORDER BY event_date DESC LIMIT 20; -SELECT * FROM dm.v_utm_effectiveness ORDER BY clicks DESC LIMIT 20; -``` - ---- - -## Архитектура (в двух словах) +## Как устроен поток данных ```mermaid flowchart LR @@ -131,230 +107,19 @@ flowchart LR D3 -.->|batch| ODS & DDS ``` -Особенность задания про "грязные данные": парсинг не валит pipeline, ошибки фиксируются в `ods.*_errors` и в поле `parse_errors`. +«Грязные» записи не роняют пайплайн: ошибки разбора складываются в `ods.*_errors` и в +поле `parse_errors`, а обработка продолжается. -[Подробное описание архитектуры →](./docs/ARCHITECTURE.md) - ---- - -## Структура проекта - -``` -. -├── sql/ -│ ├── ddl/ # DDL по слоям -│ │ ├── 00_databases.sql -│ │ ├── stg/10_stg.sql -│ │ ├── ods/20_ods.sql -│ │ ├── dds/30_dds.sql -│ │ └── dm/40_dm.sql -│ ├── ods/ # Batch SQL: STG -> ODS -│ ├── dds/ # Batch SQL: ODS -> DDS -│ └── dm/ # Batch SQL: DDS -> DM -├── configs/ # Конфигурации сервисов -│ ├── superset_config.py # Конфиг Superset (PostgreSQL metadata) -│ ├── prometheus/ # Prometheus конфигурация -│ └── grafana/ # Grafana dashboards & datasources -├── scripts/ # Служебные shell-скрипты (legacy fallback, не основной путь) -├── airflow/ # Конфигурация Airflow -│ ├── dags/ # Airflow DAGs для оркестрации -│ └── requirements.txt -├── superset/ # Скрипты инициализации Superset -│ ├── init_superset.py # Подключение к ClickHouse + датасеты -│ └── create_dashboard.py # Создание дашборда с чартами -├── docs/ # Документация -│ └── ARCHITECTURE.md # Подробное описание слоёв -├── data/ # Исходные JSONL файлы -├── docker-compose.yml -└── Makefile # Команды: up, ddl, transform, superset-* -``` - ---- - -## Команды Makefile - -| Команда | Описание | -|---------|----------| -| `make up` | Поднять инфраструктуру | -| `make ddl` | Применить DDL в ClickHouse (вне Airflow) | -| `make transform` | Запустить batch-процесс `STG -> ODS -> DDS -> DM` (вне Airflow) | -| `make superset-init` | Подключение к ClickHouse + импорт датасетов | -| `make superset-dashboard` | Создание дашборда с чартами, обновление layout/metadata | -| `make superset-ui` | Показать URL Superset | -| `make superset-restart` | Перезапуск Superset | - -Примечания про сохранность данных: -- Данные ClickHouse сохраняются в Docker volume `clickhouse-data`. -- Данные Kafka сохраняются в Docker volume `kafka-data`. -- `docker compose down` сохраняет named volumes, `docker compose down -v` удаляет их (и данные пропадут). - ---- - -## Ключи данных (как джойним) - -```mermaid -flowchart LR - subgraph Sources["Источники"] - BE["browser_events (event_id, click_id)"] - LE["location_events (event_id)"] - DE["device_events (click_id)"] - GE["geo_events (click_id)"] - end - - subgraph DDS["DDS"] - EV["event (event_id PK)"] - CL["click (click_id PK)"] - end - - subgraph DM["DM"] - V1[v_events_enriched] - V2[v_daily_traffic] - V3[v_utm_effectiveness] - end - - BE -->|event_id| EV - LE -->|event_id| EV - BE -->|click_id| CL - DE -->|click_id| CL - GE -->|click_id| CL - - EV -->|LEFT JOIN click_id| V1 - CL --> V1 - EV --> V2 & V3 - CL --> V2 & V3 -``` - ---- - -## Дашборд в Superset (опционально, но полезно) - -Superset развёрнут с автоматической инициализацией: подключение к ClickHouse, датасеты и дашборд создаются автоматически при первом запуске. - -### Быстрый доступ - -| URL | Назначение | Логин/Пароль | -|-----|------------|--------------| -| http://localhost:8088 | Superset UI | admin/admin | -| http://localhost:8088/superset/dashboard/1/ | Готовый дашборд | — | - -### Автоматическая инициализация (рекомендуется) - -```bash -# При первом запуске инфраструктуры -make up - -# Дашборд создаётся автоматически через 30-60 секунд -# Проверить готовность: -curl http://localhost:8088/health # должно вернуть 200 -``` - -Что создаётся автоматически: -- **Подключение к ClickHouse**: `clickhouse_dwh` (URI: `clickhousedb://default:123456@clickhouse:8123/default`) -- **Датасеты** (6 шт.): `v_events_enriched`, `v_daily_traffic`, `v_utm_effectiveness`, `v_top_pages_daily`, `v_session_overview`, `dq_summary` -- **Чарты** (10 шт.): KPI метрики, графики трафика, география, UTM-эффективность, прохождение строк по слоям -- **Дашборд**: "🛒 E-commerce Analytics Dashboard" - -### Ручная инициализация (если автоматика не сработала) - -```bash -# Подключение к ClickHouse + датасеты -make superset-init - -# Создание дашборда с чартами -make superset-dashboard -``` - -### Структура дашборда - -Дашборд "E-commerce Analytics Dashboard" включает: - -| Блок | Чарты | Датасет | -|------|-------|---------| -| **KPI** | Total Events, Unique Users, Avg Events/Visit, Conversion to /confirmation | `v_events_enriched` | -| **Динамика** | Events over Time (timeline), Traffic by Device (pie) | `v_events_enriched` | -| **География** | Geography Map по странам | `v_events_enriched` | -| **Маркетинг** | UTM Effectiveness Table, Page Funnel | `v_utm_effectiveness`, `v_top_pages_daily` | -| **Слои** | Rows by Layer (event) — прохождение строк STG→ODS→DDS→DM | `dq_summary` | - -Фильтр `Date Range` по умолчанию открыт как `No filter`, потому что демо-данные лежат в -историческом диапазоне (`2022-11-28`). - -Фильтры Superset задаются в левой панели dashboard. Click-to-filter по виджетам не включен: -клики по pie chart, карте, таблице или funnel не меняют другие charts. `Country`, -`Device Type` и `Browser` применяются к charts на `dm.v_events_enriched`; `Date Range` -работает с charts, где есть `event_date`. - -### Архитектура Superset - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Superset UI │────▶│ PostgreSQL │───▶│ ClickHouse │ -│ (localhost) │ │ (metadata) │ │ (данные) │ -│ :8088 │ │ dashboards, │ │ dm.v_* VIEW │ -└─────────────────┘ │ datasets, charts│ └─────────────────┘ - └──────────────────┘ -``` - -Особенности конфигурации: -- **Metadata**: PostgreSQL (shared с Airflow) — данные сохраняются при перезапуске -- **Data**: ClickHouse через `clickhouse-connect` (HTTP порт 8123) -- **Config**: `configs/superset_config.py` (PostgreSQL URI, секретный ключ) - ---- - -## Мониторинг инфраструктуры (Grafana + Prometheus) - -Для оценки состояния ClickHouse доступен дашборд мониторинга: - -1. Открыть Grafana: `http://localhost:3000` (admin/admin) -2. Дашборд "ClickHouse Overview" загружается автоматически -3. Проверить метрики Prometheus: `http://localhost:9090` → Status → Targets - -Что отслеживается: -- System Health: CPU, Memory (Resident/Code) -- Query Performance: queries/sec, active queries, failed queries -- MergeTree Storage: parts count, merge rate - -Что алертится: -- Failed queries rate (`rate(ClickHouseProfileEvents_FailedQuery[5m]) > 0`) -- Memory Resident > 85% от `OSMemoryTotal` -- Active parts > 500 - -Конфигурация provisioning находится в [`configs/grafana/provisioning/`](configs/grafana/provisioning/). Подробнее в [`docs/OPERATIONS.md`](docs/OPERATIONS.md#мониторинг). - ---- - -## Частые проблемы - -- `etl_pipeline` падает с сообщением про схему: сначала запустите `ddl_init`. -- После `docker compose down -v` схема и данные исчезнут: нужно заново запустить `ddl_init`, затем `kafka_load`, затем `etl_pipeline`. -- Подключения используют разные протоколы: - - Airflow (ClickHouseOperator) ходит в ClickHouse по native TCP (порт `9000` внутри сети Docker). - - Superset (clickhouse-connect) ходит по HTTP (порт `8123` внутри сети Docker). -- **Superset**: дашборд не появился сразу — подождите 30-60 секунд после `make up`, затем проверьте `curl http://localhost:8088/health`. -- **Superset**: после `make clean` витрины `dm.*` ещё не созданы, поэтому чарты могут быть пустыми до запуска `ddl_init -> kafka_load -> etl_pipeline`; после этого выполните `make superset-init`. -- **Superset**: при полном сбросе (`docker compose down -v`) метаданные Superset пропадут т.к. используется общая PostgreSQL. Для чистого перезапуска Superset удалите только БД `superset` в PostgreSQL и перезапустите контейнеры. - ---- - -## Статус проекта - -Реализовано: -- **Инфраструктура**: Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana -- **Ingest**: DAG `ddl_init` (DDL + проверка схемы), DAG `kafka_load` (параметры `limit`, `reset_topics`) -- **Трансформации**: DAG `etl_pipeline` (pre-check, batch STG→ODS→DDS→DM, валидация) -- **Витрины**: VIEW в DM для бизнес-дашбордов (трафик, UTM, качество данных) -- **Superset**: Автоматическая инициализация (подключение ClickHouse, 6 датасетов, 10 чартов, дашборд), метаданные в PostgreSQL -- **Надёжность**: ошибки парсинга сохраняются в ODS, пайплайн не падает на "грязных" данных -- **Мониторинг**: Prometheus скрейпит ClickHouse метрики, Grafana дашборд и alert rules - -В планах (не требуется для MVP задания): -- Инкрементальный batch (watermark вместо `full_refresh`) -- DQ мониторинг по расписанию - ---- +Подробное описание слоёв STG/ODS/DDS/DM, диаграммы и обоснование решений — +в [ARCHITECTURE](./docs/ARCHITECTURE.md). ## Документация -- [Архитектура и слои](./docs/ARCHITECTURE.md) — подробное описание STG/ODS/DDS/DM, ER-диаграммы, обоснование решений -- [DE-task.md](./docs/DE-task.md) — исходное задание +- [Архитектура и слои](./docs/ARCHITECTURE.md) — устройство STG/ODS/DDS/DM, диаграммы, + обоснование решений. +- [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов, + мониторинг, частые проблемы. +- [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять. +- [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом + стенде. +- [DE-task.md](./docs/DE-task.md) — задание, из которого вырос стенд. From 5bd215d19d5d95dfd8a1d94ec2142cf0fb9aa1df Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 20:11:51 +0300 Subject: [PATCH 35/38] =?UTF-8?q?docs(audit):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - при переработке README выяснилось, что профильные доки местами отстали от кода; находки нужно сохранить как отдельную задачу, чтобы не потерять и чинить отдельным проходом. - Что: - добавлен .scratch/docs-accuracy-audit/findings.md со сверенными с кодом расхождениями ARCHITECTURE, OPERATIONS, REPO_MAP, SUPERSET_DASHBOARD (с привязкой к файлам и строкам). - находки разнесены по серьёзности и снабжены порядком починки. - Проверка: - открыть .scratch/docs-accuracy-audit/findings.md; сверить 🔴-пункты с указанными строками кода. --- .scratch/docs-accuracy-audit/findings.md | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .scratch/docs-accuracy-audit/findings.md diff --git a/.scratch/docs-accuracy-audit/findings.md b/.scratch/docs-accuracy-audit/findings.md new file mode 100644 index 0000000..3f0c893 --- /dev/null +++ b/.scratch/docs-accuracy-audit/findings.md @@ -0,0 +1,84 @@ +# Аудит точности технической документации против кода + +Дата: 2026-06-06 · Статус: открыто (починка не начата) + +Контекст: при переработке корневого `README.md` (mentee-first) встал вопрос, можно ли +смело отправлять читателя в профильные доки — не устарели ли они сами. Прогнали сверку +каждого профильного дока против кода (источник истины — SQL/DDL, batch-SQL, DAG-и, +`docker-compose.yml`, `Makefile`, скрипты Superset). Ниже — подтверждённые расхождения. + +README по итогам признан самостоятельно корректным (он ссылается на доки, а не повторяет +их детали) и закоммичен отдельно. Починку доков решили делать отдельным проходом, с +оглядкой на параллельную работу Codex (особенно по ARCHITECTURE). + +## 🔴 Вводит в заблуждение / сломано + +1. **ARCHITECTURE: схема `ods.*_errors` описана неверно.** Док подаёт таблицы ошибок как + копию типизированной таблицы события (с `event_id`, `parse_errors`). Реально это + метаданные Kafka + `raw` + `error_reason` — другая схема. + `docs/ARCHITECTURE.md:149,529-532` против `sql/ddl/ods/20_ods.sql:46-58`. + +2. **ARCHITECTURE: DQ-split подан как взаимоисключающий.** Док: «валидные → основная + таблица, ошибки → errors». Реально строка с валидным `event_id`, но битым + `event_ts`/`click_id` попадает **в обе** таблицы одновременно. + `docs/ARCHITECTURE.md:160-162,520-521` против `sql/ods/20_stg_to_ods.sql:67,91-96`. + +3. **ARCHITECTURE: `dds.event` — browser-driven LEFT JOIN, а не симметричный JOIN 1:1.** + event_id, которые есть только в `location_event` (без browser), в `dds.event` не + попадают. Маркер `location_not_found` в доке не упомянут. + `docs/ARCHITECTURE.md:205,446-460,398` против `sql/dds/30_ods_to_dds.sql:139,141-172`. + +4. **SUPERSET_DASHBOARD: `make superset-export` задокументирован как рабочий, но сломан.** + `superset/export_dashboard.py:22-23` импортирует несуществующие модули Superset + (`superset.dashboards.data_access_layer`, `superset.charts.data_access_layer`) и падает + в `except ImportError → sys.exit(1)`. Плюс реальный файл экспорта — + `superset/dashboards/ecommerce_analytics.zip.json`, а док обещает `ecommerce_analytics.json`. + `docs/SUPERSET_DASHBOARD.md:81-83,202-207`. + +5. **REPO_MAP: пропущен `sql/ods/20_stg_to_ods.sql`** — первый шаг ETL (STG→ODS) вообще + отсутствует в карте исполняемых артефактов. `docs/REPO_MAP.md:21-23`. + +## 🟡 Неполно / неточно + +6. **ARCHITECTURE: `v_session_overview` считает только авторизованных** + (`WHERE user_domain_id IS NOT NULL`) — в доке не сказано. + `docs/ARCHITECTURE.md:304` против `sql/ddl/dm/40_dm.sql:148`. + +7. **ARCHITECTURE: `dq_summary` шире, чем сказано.** Док: «слои stg/ods/dds». Реально есть + ещё слой `dm` (`v_events_enriched`) и метрика `orphan_events`. + `docs/ARCHITECTURE.md:320-331` против `sql/dm/40_dds_to_dm.sql:99-115`. + +8. **ARCHITECTURE: перечень DQ-маркеров неполон.** Не упомянуты `geo_country_missing` + (DDS click), `bad_geo_latitude`/`bad_geo_longitude`/`bad_user_domain_id` (ODS), + `os_timezone` в ER-диаграмме DEVICE_BY_CLICK. + `sql/dds/30_ods_to_dds.sql:50`, `sql/ods/20_stg_to_ods.sql:172,227-228`. + +9. **OPERATIONS: у `etl_pipeline` не задокументирован параметр `wait_stg_timeout_sec`** + (default 600). `docs/OPERATIONS.md:57-66` против `airflow/dags/etl_pipeline_dag.py:265-268`. + +10. **REPO_MAP: пропущены артефакты.** Скрипты `superset/{init_superset,create_dashboard,export_dashboard}.py`, + утилиты `airflow/dags/utils/{sql_helpers,airflow_params}.py`. Описание + `sql/dm/40_dds_to_dm.sql` занижено («обновление dq_summary» вместо полного + TRUNCATE+INSERT DDS→DM). `docs/REPO_MAP.md:9-13,21-23`. + +## 🟢 Мелочи + +11. **OPERATIONS:** Superset (`8088`) и креды ClickHouse (`default/123456`) не вынесены в + каноническую секцию портов/доступа (фигурируют только ниже по тексту). +12. **REPO_MAP:** список документации неполон (нет `SUPERSET_DASHBOARD.md`, `docs/course/`, + `docs/adr/`, демо-скриптов и т. п.). + +## Вывод для владельца + +Концептуальные расхождения ARCHITECTURE (DQ-split, сборка DDS с «сиротами») — это ровно +то, что подробнее и точнее разбирают уроки 2–3 курса. ARCHITECTURE.md отстал от +реализации, а свежие уроки догнали код. Де-факто каноном по этим темам стали уроки, а не +ARCHITECTURE — это стоит учесть при починке (возможно, ARCHITECTURE проще подтянуть к +формулировкам уроков, чем переписывать с нуля). + +## Предлагаемый порядок починки + +1. Сначала 🔴 (искажают модель данных и ломают команду). +2. Перед правкой ARCHITECTURE — свериться, не редактирует ли её Codex. +3. `make superset-export`: решить — чинить импорты `export_dashboard.py` или убрать команду + из документации, если экспорт больше не используется. From 90f3ac5ea163f6a27e10be3a2712eefb52bd8c0b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 20:17:44 +0300 Subject: [PATCH 36/38] =?UTF-8?q?docs(handoff):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=20=D0=B4=D0=BB=D1=8F=20=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - следующая сессия чинит профильные доки, отставшие от кода; нужен быстрый вход с готовым рабочим списком, а отработанный handoff про корневой README больше не актуален. - Что: - добавлен handoff 2026-06-06-docs-accuracy-fixes.md со ссылкой на аудит, порядком починки и ключевым контекстом. - удалён отработанный 2026-06-06-root-readme-polish.md. - Проверка: - открыть .scratch/handoffs/2026-06-06-docs-accuracy-fixes.md и .scratch/docs-accuracy-audit/findings.md. --- .../2026-06-06-docs-accuracy-fixes.md | 56 +++++++++++++++ .../handoffs/2026-06-06-root-readme-polish.md | 68 ------------------- 2 files changed, 56 insertions(+), 68 deletions(-) create mode 100644 .scratch/handoffs/2026-06-06-docs-accuracy-fixes.md delete mode 100644 .scratch/handoffs/2026-06-06-root-readme-polish.md diff --git a/.scratch/handoffs/2026-06-06-docs-accuracy-fixes.md b/.scratch/handoffs/2026-06-06-docs-accuracy-fixes.md new file mode 100644 index 0000000..b676ab1 --- /dev/null +++ b/.scratch/handoffs/2026-06-06-docs-accuracy-fixes.md @@ -0,0 +1,56 @@ +# Handoff: уточнение документации по архитектуре + +Дата: 2026-06-06 · Язык сессии: русский + +Следующая сессия чинит профильную документацию, которая отстала от кода. Рабочий список +уже собран и сверён с кодом — **не переоткрывай его заново**, бери готовый: +[`.scratch/docs-accuracy-audit/findings.md`](../docs-accuracy-audit/findings.md) +(12 находок, разнесены по серьёзности, с привязкой файл:строка). + +## Что делать + +Чинить доки по списку аудита, **начиная с 🔴**: +- `ARCHITECTURE.md` — три концептуальных искажения: схема `ods.*_errors`, DQ-split подан + как взаимоисключающий (а строка попадает в обе таблицы), сборка `dds.event` + (browser-driven LEFT JOIN, не симметричный JOIN; «сироты» из location отбрасываются). +- `SUPERSET_DASHBOARD.md` — `make superset-export` задокументирован как рабочий, но + `export_dashboard.py` падает на устаревших импортах. **Решение за владельцем:** чинить + импорты или убрать команду из доки, если экспорт больше не нужен. +- `REPO_MAP.md` — пропущен первый шаг ETL `sql/ods/20_stg_to_ods.sql` и ряд артефактов. + +Потом 🟡 (неполнота параметров и DQ-маркеров) и 🟢 (мелочи). + +## Контекст, который сэкономит время + +- **Источник истины — код**, не доки: `sql/ddl/*`, `sql/ods|dds|dm/*`, `airflow/dags/*`, + `superset/*.py`, `docker-compose.yml`, `Makefile`. Аудит уже сослался на нужные строки. +- **Ключевой инсайт:** концептуальные темы ARCHITECTURE (DQ-split, сборка DDS с сиротами) + точнее и подробнее разобраны в **уроках 2–3 курса** (`docs/course/lessons/`). Де-факто + каноном стали уроки, а не ARCHITECTURE. Скорее всего проще подтянуть ARCHITECTURE к + формулировкам уроков, чем переписывать с нуля — и заодно проверить, что урок и код + совпадают (уроки свежие, но тоже стоит сверить). +- **Codex сейчас стоит** — параллельной записи в ветку нет, можно править ARCHITECTURE + спокойно. Но привычку держать аддитивно не теряй: `git add` только своих файлов. +- **Стиль/голос доков:** чистый литературный русский без лишних англицизмов (см. память + `plain-literary-russian`); house style осознанный (тире «—», «ё», жирное начало абзаца, + двоеточие перед списком) — линтером не правим. `ai-text-lint` гонять только на настоящие + маркеры, context `article`. +- **Проверять claim на коде, а не на глаз** — этот подход в этой сессии и вскрыл все + расхождения. + +## Состояние + +- **Ветка** `docs/advanced-clickstream-course`. Эта сессия добавила два коммита: + `ce7f03b` (корневой README переписан под менти+песочницу) и `5bd215d` (аудит в .scratch). + Корневой README трогать больше не нужно — он самостоятельно корректен (только ссылается + на доки). +- **Рабочее дерево чистое** (после коммита/пуша этой сессии). +- 🅿️ **Future, не в скоупе:** лицензия стенда (роадмап = CC BY 4.0; для кода обычно MIT — + кандидат на dual MIT + CC BY); фейк-бейдж лицензии в README уже убран. + +## Suggested skills + +- `grill-with-docs` — выверить формулировки ARCHITECTURE против доменной модели и кода, + обновляя док по ходу решений (ровно под эту задачу). +- `ai-text-lint` — прогон правок (context `article`, house style не снимать). +- `conventional-commits` — для коммитов. diff --git a/.scratch/handoffs/2026-06-06-root-readme-polish.md b/.scratch/handoffs/2026-06-06-root-readme-polish.md deleted file mode 100644 index 292a69d..0000000 --- a/.scratch/handoffs/2026-06-06-root-readme-polish.md +++ /dev/null @@ -1,68 +0,0 @@ -# Handoff: контекст для работы над документацией курса - -Дата: 2026-06-06 · Язык сессии: русский - -Следующая сессия продолжает работу над документацией. Ближайший фокус — **корневой -[`README.md`](../../README.md)**: ощущение, что он слишком инженерно-демовый и требует -более серьёзной переработки, чем косметика. Что именно с ним делать — решаем в новой -сессии. Здесь — не план задач, а контекст и понимание, чтобы войти быстро. - -## Модель курса (держать в голове при любой правке доков) - -- **Аудитория и режим.** Продвинутый менти, прошёл базу; идёт сам, ментор — на - еженедельном **созвоне** (не «сессии»). Отсюда железное требование PRD: материал - **самодостаточен**. Любой документ курса проверяю вопросом «менти поймёт это один, без - ментора рядом?». -- **Голос.** Мягкий, уровень «обзорно», термин расшифровываем на первом употреблении, - обращаемся на «ты». Правила — `docs/course/LESSON_STANDARD.md` §2/§5. -- **House style — осознанный, линтером НЕ правим:** тире «—», буква «ё», жирные зачины - абзацев, двоеточие перед списком. `ai-text-lint` гоняем только на настоящие маркеры - (канцелярит, зачины, хеджинг, negative parallelism). -- **Принципы стенда:** один паттерн на урок; данные — **малый срез** (`LIMIT=50`) ради - быстрого повторяемого прогона (`AGENTS.md`). Это не мелочь — на этом я поймал баг (см. - ниже). -- **Крючок менторства.** Тон роадмапа: «пройти можно самому — а можно с ментором, так - быстрее/понятнее/лучше», CTA в Telegram `@dementev_dev`. Эталон тона — - `~/sources/de-roadmap/README.md`. Уже зеркально внесён в `docs/course/README.md`. - -## Линза, которую я применял (и которой не хватает корневому README) - -**Mentee-first vs author-first.** `docs/course/README.md` был написан от автора — вёл -читателя в `PRD → LEARNING_PLAN → LESSON_STANDARD` (мета-доки «зачем курс»), а не в -уроки. Я переписал его **от менти**: сначала «что нужно до старта» и индекс уроков со -ссылками, авторские доки убраны в секцию «Под капотом». Корневой README сейчас — про -демо-стенд и DE-задание, **курс в нём не упомянут вообще**; он явный кандидат на ту же -линзу, но, возможно, глубже (разделить «демо-стенд» и «вход в учебный курс»). - -## Подходы, которые сработали — стоит продолжить - -- **Проверять claim на стенде/в коде, а не на глаз.** Так нашёлся баг: я сперва написал в - course/README полную загрузку `make data`, хотя все уроки и правило среза требуют - `LIMIT=50 make data`. Сверка с реальными уроками вскрыла расхождение. -- **Свежий проход после написания** ловит фактические нестыковки, не только стиль — - именно он дал ту правку. -- **Grep-линт на AI-маркеры** с поправкой на house style быстрее сплошного чтения. -- **Читать глазами целевого читателя**: где точка входа, как ходить между частями, как - стартовать с нуля. - -## Состояние (факты, которые понадобятся) - -- **Рабочее дерево:** `docs/course/README.md` изменён, **не закоммичен** — это - mentee-first редизайн + крючок менторства + правки свежего взгляда. Пользователь хочет - закоммитить его (и правку корневого README, если будет) — **после** того как - разберёмся с корневым. -- **Git:** ветка `docs/advanced-clickstream-course`, на ней **параллельно пишет Codex** - (его `bae1e02` уже лёг поверх моего `d98ae9c`). Строго аддитивно: не трогать чужие - коммиты, `git add` только своих файлов, `git fetch` перед push. **2 локальных коммита - не запушены.** -- **Сделано в этой сессии:** `d98ae9c` (финальный `ai-text-lint` по урокам 0–6, синхрон - `PRD.md` §7, уборка отработанных handoff'ов); `docs/course/README.md` переписан от - менти. Уроки 0–6 как тексты готовы и единообразны. -- **Мелочь на заметку:** корневой README (стр. 78, 238) даёт Superset-дашборд как - `/superset/dashboard/1/`, а урок 6 — по slug `ecommerce-analytics`. Стоит сверить при - правке. - -## Suggested skills - -- `ai-text-lint` — прогон правок доков на AI-маркеры (context `article`, house style не снимать). -- `conventional-commits` — для коммита. From 92e9c4b45a2519c9ced3d9d8fb64eeb837bec13f Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 21:43:00 +0300 Subject: [PATCH 37/38] =?UTF-8?q?docs(accuracy):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - профильные доки отстали от реализации: схема ods.*_errors, DQ-split и сборка dds.event описывались неверно, ряд артефактов и параметров отсутствовал. Канон по этим темам де-факто задают свежие уроки 2–3 курса. - Что: - ARCHITECTURE: ods.*_errors как копия с Kafka-метаданными+raw+error_reason; DQ-split подан как пересекающийся; dds.event — browser-driven LEFT JOIN с маркером location_not_found; добавлен перечень DQ-маркеров; уточнены dq_summary (слой dm, orphan_events) и v_session_overview. - REPO_MAP: добавлен sql/ods/20_stg_to_ods.sql, утилиты DAG, скрипты Superset; scripts/make помечены как запасной путь, Airflow — основной. - OPERATIONS: параметр wait_stg_timeout_sec; порт Superset и креды ClickHouse. - SUPERSET_DASHBOARD: исправлен пароль ClickHouse (123456); убран сломанный make superset-export, удалён superset/export_dashboard.py и target в Makefile. - Проверка: - git diff проверен против sql/*, airflow/dags/*, configs/default_user.xml; ER-диаграмма провалидирована mermaid-валидатором. --- .scratch/docs-accuracy-audit/findings.md | 11 ++- Makefile | 6 +- docs/ARCHITECTURE.md | 82 +++++++++++------ docs/OPERATIONS.md | 11 ++- docs/REPO_MAP.md | 29 ++++-- docs/SUPERSET_DASHBOARD.md | 21 ++--- superset/export_dashboard.py | 107 ----------------------- 7 files changed, 106 insertions(+), 161 deletions(-) delete mode 100644 superset/export_dashboard.py diff --git a/.scratch/docs-accuracy-audit/findings.md b/.scratch/docs-accuracy-audit/findings.md index 3f0c893..b1782fa 100644 --- a/.scratch/docs-accuracy-audit/findings.md +++ b/.scratch/docs-accuracy-audit/findings.md @@ -1,6 +1,15 @@ # Аудит точности технической документации против кода -Дата: 2026-06-06 · Статус: открыто (починка не начата) +Дата: 2026-06-06 · Статус: **закрыто** (все 12 находок починены 2026-06-06) + +> Резолюция. 🔴 #1–#3 правлены в `ARCHITECTURE.md` (схема `*_errors`, DQ-split как +> пересекающийся, `dds.event` как browser-driven LEFT JOIN + маркер `location_not_found`) — +> формулировки подтянуты к урокам 2–3. #4: владелец решил **убрать** `make superset-export` +> (раздел доки + target в Makefile + битый `superset/export_dashboard.py` удалены). +> 🟡 #6–#10 и 🟢 #11–#12 закрыты в `ARCHITECTURE.md`/`OPERATIONS.md`/`REPO_MAP.md` +> (добавлен перечень DQ-маркеров, параметр `wait_stg_timeout_sec`, порты Superset/креды CH, +> пропущенные артефакты). Заодно зафиксирована рамка: основной путь — Airflow, +> `scripts/`/`make` — запасной. Контекст: при переработке корневого `README.md` (mentee-first) встал вопрос, можно ли смело отправлять читателя в профильные доки — не устарели ли они сами. Прогнали сверку diff --git a/Makefile b/Makefile index 2470dec..3c7b388 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: up down clean ddl data transform logs \ reload-monitoring recover-monitoring \ - superset-init superset-dashboard superset-export superset-ui superset-restart + superset-init superset-dashboard superset-ui superset-restart COMPOSE ?= docker compose @@ -77,10 +77,6 @@ superset-init: superset-dashboard: $(COMPOSE) exec -T superset bash -c "python /app/superset_init/create_dashboard.py" -# Экспорт дашборда в JSON -superset-export: - $(COMPOSE) exec -T superset bash -c "python /app/superset_init/export_dashboard.py" - # Открыть Superset UI superset-ui: @echo "Superset доступен по адресу: http://localhost:8088" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4630e71..c61195c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -146,7 +146,7 @@ CREATE TABLE stg.browser_raw ( | `location_event` | event_id | ReplacingMergeTree(src_ingest_ts) | Данные страниц | | `device_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Устройства | | `geo_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Гео-данные | -| `*_errors` | — | MergeTree | Строки с критичными ошибками парсинга | +| `*_errors` | — | MergeTree | Копия строк с любой ошибкой разбора: Kafka-метаданные + `raw` + `error_reason` (не типизированная копия события) | **Batch шаг наполнения ODS:** @@ -155,11 +155,12 @@ CREATE TABLE stg.browser_raw ( | `load_ods` (`sql/ods/20_stg_to_ods.sql`) | Полная пересборка ODS из STG в рамках DAG `etl_pipeline` | | `TRUNCATE ods.*` | Очистка перед пересборкой для детерминированного результата | | `INSERT ... SELECT` | Типизация валидных строк в основные ODS таблицы | -| `INSERT ... SELECT` в `*_errors` | Сохранение строк с критичными ошибками парсинга | +| `INSERT ... SELECT` в `*_errors` | Сохранение копии строк с любой ошибкой разбора (Kafka-метаданные + `raw` + `error_reason`) | -**Логика разделения:** -- **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`). -- **Таблица ошибок**: строки с критичными ошибками (невалидный key, timestamp/координаты и т.п.) через отдельный `INSERT ... SELECT`. +**Логика разделения (DQ-split):** +- **Основная таблица** `ods.*`: строки с валидным бизнес-ключом (`WHERE key IS NOT NULL`). Ошибка по *неключевому* полю не выкидывает строку — она остаётся, но помечается в массиве `parse_errors`. +- **Таблица ошибок** `ods.*_errors`: **копия** строк, где при разборе случилась *любая* ошибка (отдельный `INSERT ... SELECT`). У неё своя схема — не типизированное событие, а сырьё для разбора. +- Условия пересекаются нарочно: строка с валидным ключом, но битым неключевым полем попадает **и в основную таблицу, и в `*_errors`**. Подробный разбор — в разделе [«Обработка ошибок в ODS»](#обработка-ошибок-в-ods-dq-split) и в уроке 2 курса. **Пример структуры:** ```sql @@ -202,7 +203,7 @@ GROUP BY error; | Таблица | PK | Источники | JOIN-ключ | |---------|-----|-----------|-----------| -| `event` | event_id | browser_event + location_event | click_id → click | +| `event` | event_id | browser_event (ведущая) + location_event (LEFT) | click_id → click | | `click` | click_id | device_by_click + geo_by_click | — | **Структура:** @@ -301,7 +302,7 @@ LEFT JOIN ( | `v_daily_traffic` | Агрегация трафика | День × страна × устройство × браузер × UTM | | `v_top_pages_daily` | Популярность страниц | День × URL path | | `v_utm_effectiveness` | Маркетинговая аналитика | День × UTM source/medium/campaign | -| `v_session_overview` | Сессионная аналитика | День × пользователь × сессия | +| `v_session_overview` | Сессионная аналитика (только идентифицированные пользователи, `user_domain_id IS NOT NULL`) | День × пользователь × сессия | | `v_dq_errors_daily` | Мониторинг качества | День × тип ошибки | **Пример:** @@ -328,7 +329,7 @@ FROM ... ``` - `TRUNCATE` предотвращает накопление дубликатов при повторных запусках -- Хранит статистику по всем слоям (stg/ods/dds) для быстрой проверки +- Хранит статистику по всем слоям (`stg`/`ods`/`dds`/`dm`): `total_rows` по таблицам, `rows_with_errors` в ODS, `orphan_events` в DDS (события без своего клика) и `total_rows` финальной витрины `dm.v_events_enriched` — чтобы lineage замыкался на одном (event) зерне вплоть до DM **Почему VIEW:** - Для демо: достаточно производительности @@ -395,7 +396,7 @@ sequenceDiagram ```mermaid erDiagram - BROWSER_EVENT ||--|| LOCATION_EVENT : "event_id" + BROWSER_EVENT ||--o| LOCATION_EVENT : "event_id (LEFT)" BROWSER_EVENT ||--o| DEVICE_BY_CLICK : "click_id" BROWSER_EVENT ||--o| GEO_BY_CLICK : "click_id" @@ -424,6 +425,7 @@ erDiagram UUID click_id PK String os String os_name + String os_timezone String device_type UInt8 device_is_mobile String user_custom_id @@ -443,11 +445,11 @@ erDiagram ### Сборка DDS-сущностей -**event** (browser + location): +**event** (browser + location), `browser_event` — ведущая таблица: ```mermaid flowchart LR subgraph ODS["ODS"] - B["browser_event"] + B["browser_event
(ведущая)"] L["location_event"] end @@ -455,10 +457,12 @@ flowchart LR EV["event"] end - B -->|JOIN event_id| EV - L -->|JOIN event_id| EV + B -->|все event_id| EV + L -.->|LEFT JOIN event_id| EV ``` +**Важно:** сборка идёт **от browser** через `LEFT JOIN location`. `event_id`, которые есть только в `location_event` (без browser), в `dds.event` **не попадают**. Если у события нет своей location-строки — событие остаётся, поля страницы/UTM пустые (`NULL`), и в `ods_parse_errors` ставится маркер `location_not_found`. Тот же принцип, что и в `dds.click`: не теряем, а оставляем видимый след. Подробнее — урок 3 курса (`docs/course/lessons/03_ods_to_dds.md`). + **click** (device + geo) с поддержкой partial data: ```mermaid flowchart LR @@ -512,24 +516,37 @@ flowchart LR | **MV + JOIN** | Реалтайм | Eventual consistency, дубли при late arrival | | **Batch (выбрано)** | Согласованность, контроль | Задержка до следующего запуска | -### Обработка ошибок в ODS +### Обработка ошибок в ODS (DQ-split) **Проблема:** Грязные данные могут содержать не только невалидные ключи, но и невалидные timestamp/координаты/ID. -**Решение:** -1. **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`) -2. **Таблица ошибок**: строки с критичными ошибками парсинга через отдельные `INSERT ... SELECT` -3. **DQ-метрики**: массив `parse_errors` для аудита +**Решение — DQ-split:** +1. **Основная таблица** `ods.*`: строки с валидным бизнес-ключом (`WHERE key IS NOT NULL`). Ошибки по *неключевым* полям не выкидывают строку — она остаётся, но помечается массивом `parse_errors`. +2. **Таблица ошибок** `ods.*_errors`: **копия** строк, где при разборе случилась *любая* ошибка. Это не типизированная копия события, а сырьё для разбора — метаданные доставки из Kafka, исходный JSON и причина ошибки: + - `ingest_ts`, `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_ts` — координаты сообщения в Kafka; + - `raw` — исходный JSON «как пришёл»; + - `error_reason` — список несработавших полей одной строкой (`arrayStringConcat(parse_errors, ',')`). +3. **DQ-метрики**: массив `parse_errors` в основной таблице для аудита. + +**Одна строка может попасть в оба места — это не баг, а замысел.** Строка с валидным ключом, но битым неключевым полем (например, валидный `event_id`, но `event_ts IS NULL`) и **остаётся** в основной таблице (с меткой в `parse_errors`), и **копируется** в `*_errors`. Основная таблица отвечает на вопрос «что есть для работы», таблица ошибок — «что пришло битым и требует разбора». Подробный разбор — в уроке 2 курса (`docs/course/lessons/02_stg_to_ods.md`). ```sql --- Основная таблица -INSERT INTO ods.browser_event -SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL; +-- event_id, event_ts, click_id, parse_errors — это не колонки stg.browser_raw, +-- а алиасы из блока WITH, где raw (сырой JSON) разбирается через JSONExtract*/*OrNull. +-- Здесь WITH опущен для краткости; полная версия — в sql/ods/20_stg_to_ods.sql. --- Таблица ошибок +-- Основная таблица: валидный ключ; parse_errors помечает битые неключевые поля +INSERT INTO ods.browser_event +SELECT ..., parse_errors FROM stg.browser_raw WHERE event_id IS NOT NULL; + +-- Таблица ошибок: другая схема (Kafka-метаданные + raw + error_reason); +-- сюда едет копия любой строки с хотя бы одной ошибкой разбора INSERT INTO ods.browser_event_errors -SELECT ... FROM stg.browser_raw -WHERE event_id IS NULL OR event_ts IS NULL OR click_id IS NULL; +SELECT ingest_ts, kafka_topic, kafka_partition, kafka_offset, kafka_ts, + raw, arrayStringConcat(parse_errors, ',') AS error_reason +FROM stg.browser_raw +WHERE length(parse_errors) > 0 + AND (event_id IS NULL OR event_ts IS NULL OR click_id IS NULL); ``` ### Partial data в DDS @@ -539,7 +556,22 @@ WHERE event_id IS NULL OR event_ts IS NULL OR click_id IS NULL; **Решение:** 1. **UNION DISTINCT** всех click_id из обоих источников 2. **LEFT JOIN** для получения данных (обрабатываем device-only и geo-only) -3. **DQ-маркеры**: `device_not_found`, `geo_not_found` в `parse_errors` +3. **DQ-маркеры** в `ods_parse_errors`: `device_not_found`, `geo_not_found` (нет соответствующего источника по `click_id`), `geo_country_missing` (гео есть, но страна не определена) + +### Перечень DQ-маркеров + +Маркеры качества копятся в массивах `parse_errors` (ODS) и `ods_parse_errors` (DDS). Полный список: + +| Слой / таблица | Маркеры | Когда ставится | +|----------------|---------|----------------| +| ODS `browser_event` | `bad_event_id`, `bad_event_timestamp`, `bad_click_id` | поле не разобралось в нужный тип | +| ODS `location_event` | `bad_event_id` | не разобрался `event_id` | +| ODS `device_by_click` | `bad_click_id`, `bad_user_domain_id` | не разобрались `click_id` / `user_domain_id` | +| ODS `geo_by_click` | `bad_click_id`, `bad_geo_latitude`, `bad_geo_longitude` | не разобрались ключ или координаты | +| DDS `click` | `device_not_found`, `geo_not_found`, `geo_country_missing` | нет источника по `click_id` либо страна не определена | +| DDS `event` | `location_not_found` | у события нет своей location-строки | + +В DDS наследуются `parse_errors` только **ведущего** источника (device → `dds.click`, browser → `dds.event`); к ним добавляются маркеры стыковки (`*_not_found`, `*_missing`). `parse_errors` из geo/location в DDS не переносятся. --- diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 9470e80..375df1c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -22,13 +22,14 @@ Порты задаются в `docker-compose.yml`: -- ClickHouse native: `localhost:8002` -- ClickHouse HTTP: `localhost:9123` +- ClickHouse native: `localhost:8002` (пользователь `default`, пароль `123456`) +- ClickHouse HTTP / play-консоль: `http://localhost:9123/play` (`default` / `123456`) - Kafka: `localhost:9092` - Kafka UI: `http://localhost:8082` - Airflow: `http://localhost:8080` (`admin/admin`) +- Superset: `http://localhost:8088` (`admin/admin`) - Prometheus: `http://localhost:9090` -- Grafana: `http://localhost:3000` +- Grafana: `http://localhost:3000` (`admin/admin`) ## Airflow DAGs @@ -57,7 +58,9 @@ ### `etl_pipeline` - Запуск: ручной (`Trigger DAG with config`) -- Параметр: `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой +- Параметры: + - `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой + - `wait_stg_timeout_sec` (`int`, default `600`, minimum `30`) — сколько секунд задача `wait_for_stg_data` ждёт появления данных в STG, прежде чем упасть по таймауту - Зависимость: требует наличия данных в STG (от `kafka_load` или `make data`) - Гейт целостности DDS: `check_dds_integrity` считает события без клика, а `assert_dds_integrity` роняет DAG при `orphan_events > 0`. Проверка идёт после diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index d313b2b..05bebb1 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -4,25 +4,40 @@ ## Исполняемые файлы -### Airflow +### Airflow (основной путь запуска) - `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse - `airflow/dags/kafka_load_dag.py` — загрузка в Kafka из JSONL - `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM - `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka +- `airflow/dags/utils/sql_helpers.py` — чтение и подготовка SQL-файлов для DAG +- `airflow/dags/utils/airflow_params.py` — разбор и валидация параметров DAG - `airflow/requirements.txt` — зависимости Airflow/ClickHouse plugin ### SQL +DDL (форма таблиц): + - `sql/ddl/00_databases.sql` — создание БД `stg`/`ods`/`dds`/`dm` - `sql/ddl/stg/10_stg.sql` — STG (Kafka Engine + MV) -- `sql/ddl/ods/20_ods.sql` — ODS (типизация + MV для ошибок) +- `sql/ddl/ods/20_ods.sql` — ODS: типизированные таблицы и `*_errors` (наполняются batch, не MV) - `sql/ddl/dds/30_dds.sql` — DDS (таблицы для batch-загрузки) - `sql/ddl/dm/40_dm.sql` — DM (витрины VIEW) -- `sql/dds/30_ods_to_dds.sql` — ODS -> DDS (argMax + JOIN) -- `sql/dm/40_dds_to_dm.sql` — обновление `dq_summary` -### Скрипты +Трансформации (наполнение, шаги `etl_pipeline`): + +- `sql/ods/20_stg_to_ods.sql` — STG -> ODS: типизация + DQ-split (валидный ключ → `ods.*`, любая ошибка → `ods.*_errors`) +- `sql/dds/30_ods_to_dds.sql` — ODS -> DDS (argMax + LEFT JOIN) +- `sql/dm/40_dds_to_dm.sql` — DDS -> DM: пересборка `dm.dq_summary` (TRUNCATE+INSERT) по всем слоям; сами витрины `dm.v_*` — это VIEW из DDL + +### Superset + +- `superset/init_superset.py` — подключение к ClickHouse + создание датасетов +- `superset/create_dashboard.py` — сборка дашборда с чартами + +### Скрипты (запасной путь, не основной) + +Shell-скрипты `scripts/*` (и обёртки `make ddl`/`make data`/`make transform`) — это локальный fallback в обход Airflow. Основной путь запуска — DAG-и Airflow (см. выше). - `scripts/apply_clickhouse_ddl.sh` — применение DDL - `scripts/load_kafka_data.sh` — загрузка в Kafka @@ -48,8 +63,12 @@ - `README.md` — быстрый старт и обзор проекта - `docs/ARCHITECTURE.md` — техническая архитектура - `docs/OPERATIONS.md` — запуск, проверки, troubleshooting +- `docs/SUPERSET_DASHBOARD.md` — настройка и использование дашборда Superset - `docs/DE-task.md` — исходное задание - `docs/COMMIT_RULES.md` — правила коммитов +- `docs/course/` — продвинутый учебный курс на базе стенда (PRD, план, уроки) +- `docs/adr/` — архитектурные решения (ADR) +- `docs/agents/` — контракты для агентских скиллов (issue-tracker, triage, domain) ## Legacy-планы diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index d778227..78b4541 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -142,7 +142,6 @@ make transform # Запуск batch-процесса # Superset make superset-init # Инициализация (подключение + датасеты) make superset-dashboard # Создание дашборда -make superset-export # Экспорт дашборда в JSON make superset-ui # Показать URL и логин make superset-restart # Перезапуск сервиса ``` @@ -195,22 +194,16 @@ python /app/superset_init/init_superset.py --- -## Экспорт и импорт дашборда +## Импорт дашборда -### Экспорт - -```bash -# Автоматический экспорт в JSON -make superset-export - -# Результат: superset/dashboards/ecommerce_analytics.json -``` - -### Импорт +Основной способ собрать дашборд — `make superset-dashboard` (скрипт `create_dashboard.py`). +Готовый экспорт дашборда лежит в репозитории на случай ручного импорта: +`superset/dashboards/ecommerce_analytics.zip.json` (внутри контейнера — +`/app/superset_init/dashboards/ecommerce_analytics.zip.json`). ```bash # Импорт через CLI -docker compose exec superset superset import-dashboards -p /app/superset_init/dashboards/ecommerce_analytics.json +docker compose exec superset superset import-dashboards -p /app/superset_init/dashboards/ecommerce_analytics.zip.json # Или через UI: Settings → Import Dashboards ``` @@ -289,7 +282,7 @@ docker compose exec superset bash -c "curl clickhouse:8123" | Сервис | URL | Логин/Пароль | |--------|-----|--------------| | Superset | http://localhost:8088 | admin / admin | -| ClickHouse HTTP | http://localhost:9123 | default / (пустой) | +| ClickHouse HTTP | http://localhost:9123 | default / 123456 | | Airflow | http://localhost:8080 | admin / admin | | Grafana | http://localhost:3000 | admin / admin | | Prometheus | http://localhost:9090 | - | diff --git a/superset/export_dashboard.py b/superset/export_dashboard.py deleted file mode 100644 index 92395cc..0000000 --- a/superset/export_dashboard.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -================================================================================ -Экспорт дашборда Superset в JSON-формат -================================================================================ -Назначение: - - Экспорт созданного дашборда в JSON для версионирования - - Формат совместимый с superset import-dashboards - -Запуск: - docker compose exec superset python /app/superset_init/export_dashboard.py -================================================================================ -""" - -import json -import sys -import os -sys.path.insert(0, '/app') - -try: - from superset.app import create_app - from superset.dashboards.data_access_layer import DashboardDAO - from superset.charts.data_access_layer import ChartDAO -except ImportError as e: - print(f"Error importing: {e}") - sys.exit(1) - - -def export_dashboard(slug: str, output_path: str): - """Экспорт дашборда в JSON""" - app = create_app() - - with app.app_context(): - dashboard = DashboardDAO.get_by_slug(slug) - - if not dashboard: - print(f"Dashboard with slug '{slug}' not found") - return False - - # Собираем данные дашборда - dashboard_data = { - "dashboards": [ - { - "__Dashboard__": { - "dashboard_title": dashboard.dashboard_title, - "description": dashboard.description, - "slug": dashboard.slug, - "json_metadata": dashboard.json_metadata, - "position_json": dashboard.position_json, - "published": dashboard.published, - "slices": [] - } - } - ], - "charts": [], - "datasets": [] - } - - # Добавляем чарты - for slice_obj in dashboard.slices: - chart_data = { - "__Slice__": { - "slice_name": slice_obj.slice_name, - "viz_type": slice_obj.viz_type, - "params": slice_obj.params, - "description": slice_obj.description, - "datasource_type": slice_obj.datasource_type, - "datasource_name": slice_obj.datasource.name if slice_obj.datasource else None - } - } - dashboard_data["dashboards"][0]["__Dashboard__"]["slices"].append(slice_obj.id) - dashboard_data["charts"].append(chart_data) - - # Добавляем датасет - if slice_obj.datasource: - ds = slice_obj.datasource - dataset_data = { - "__SqlaTable__": { - "table_name": ds.table_name, - "schema": ds.schema, - "database": ds.database.database_name if ds.database else None, - "description": ds.description, - "columns": [ - { - "column_name": col.column_name, - "type": col.type, - "description": col.description - } - for col in ds.columns - ] - } - } - # Добавляем уникальные датасеты - if dataset_data not in dashboard_data["datasets"]: - dashboard_data["datasets"].append(dataset_data) - - # Сохраняем в файл - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(dashboard_data, f, indent=2, ensure_ascii=False) - - print(f"Dashboard exported to: {output_path}") - return True - - -if __name__ == "__main__": - output_file = "/app/superset_init/dashboards/ecommerce_analytics.json" - export_dashboard("ecommerce-analytics", output_file) From 9e660eaf98f5ea188bfff7a4aa33fbbf2752f919 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 6 Jun 2026 21:45:52 +0300 Subject: [PATCH 38/38] =?UTF-8?q?docs(handoff):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B0=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - следующей сессии нужна точка опоры: что сделано, какой канон у слоёв и какие хвосты остались вне скоупа. - Что: - добавлен .scratch/handoffs/2026-06-06-docs-accuracy-done.md. - зафиксированы рамка путей (Airflow — основной, scripts/make — запасной), канон по урокам 2–3 и открытые хвосты (лицензия, непроверенные уроки 0,1,4–6). - Проверка: - git show --stat HEAD; ссылки на findings.md и коммит 92e9c4b актуальны. --- .../handoffs/2026-06-06-docs-accuracy-done.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .scratch/handoffs/2026-06-06-docs-accuracy-done.md diff --git a/.scratch/handoffs/2026-06-06-docs-accuracy-done.md b/.scratch/handoffs/2026-06-06-docs-accuracy-done.md new file mode 100644 index 0000000..8c1fd35 --- /dev/null +++ b/.scratch/handoffs/2026-06-06-docs-accuracy-done.md @@ -0,0 +1,58 @@ +# Handoff: профильные доки выверены по коду (готово) + +Дата: 2026-06-06 · Язык сессии: русский + +Эта сессия **закрыла** аудит точности документации. Делать по нему больше нечего — handoff +нужен как точка опоры для следующих шагов, а не как список задач. + +## Что сделано + +- Все 12 находок аудита [`/.scratch/docs-accuracy-audit/findings.md`](../docs-accuracy-audit/findings.md) + починены. Файл переведён в статус **закрыто** с резолюцией (см. шапку файла). +- Один коммит: **`92e9c4b`** `docs(accuracy): профильная документация выверена по коду`, + запушен в `docs/advanced-clickstream-course`. Детали правок — в теле коммита и в `git show 92e9c4b`, + здесь не дублирую. +- Бонусом поймана и исправлена ошибка, которую аудит пропустил: в таблице портов + `SUPERSET_DASHBOARD.md` пароль ClickHouse стоял как «(пустой)» вместо `123456`. + +## Ключевой контекст (пригодится дальше) + +- **Источник истины — код, а не доки.** Этот проход сверял каждый claim против + `sql/*`, `airflow/dags/*`, `configs/default_user.xml`, `docker-compose.yml`. Подход себя + оправдал — так и нашлись расхождения. Повторять при любых правках доков. +- **Канон по слоям — уроки 2–3 курса**, а не ARCHITECTURE. В этой сессии ARCHITECTURE + подтянут к их формулировкам (DQ-split как пересекающийся, dds.event как browser-driven + LEFT JOIN). Уроки 2–3 сверены с кодом и точны; уроки 0,1,4,5,6 **не** проверял. +- **Рамка путей запуска (важно, владелец подчёркивал):** основной путь — **Airflow** + (DAG-и `ddl_init`/`kafka_load`/`etl_pipeline`); `scripts/*` и обёртки `make` — **запасной + fallback** в обход Airflow. Старый README до правки прямо помечал `make ddl/transform` + как «(вне Airflow)», а `scripts/` — «legacy fallback». Эта рамка теперь зафиксирована в + REPO_MAP; держать её во всех доках, не возвышать Makefile. +- **Параллельный Codex в ветке.** Codex пишет другие уроки в ту же ветку. Git держать + строго аддитивно: `git add` только своих файлов, не amend/rebase/reset чужих коммитов. + (Свой не-запушенный коммит amend-ить можно — в этой сессии так и поправил оплошность с + индексом.) +- **Стиль:** чистый литературный русский (память `plain-literary-russian`); house style + осознанный (тире «—», «ё», жирное начало абзаца) — линтером не править. `ai-text-lint` + гонять только на настоящие маркеры, context `article`. + +## Открытые хвосты (не в скоупе этой сессии) + +- 🅿️ **Лицензия стенда.** Роадмап: CC BY 4.0 для контента; для кода обычно MIT — + кандидат на dual MIT + CC BY. Фейк-бейдж лицензии из README уже убран ранее. +- **Импорт дашборда не прогонялся вживую.** В `SUPERSET_DASHBOARD.md` команда + `superset import-dashboards -p .../ecommerce_analytics.zip.json` указывает на реальный + файл, но runtime не проверялся (основной способ сборки — `make superset-dashboard`). +- **Уроки 0,1,4,5,6** против кода не сверены — если будут править, сверить так же построчно. + +## Состояние + +- Ветка `docs/advanced-clickstream-course`, рабочее дерево чистое, всё запушено. +- `.scratch/` коммитится (не в `.gitignore`); этот handoff одноразовый, уборка best-effort. + +## Suggested skills + +- `grill-with-docs` — если доки снова разойдутся с кодом: выверять формулировки против + доменной модели и кода, обновляя док по ходу. +- `ai-text-lint` — прогон правок (context `article`, house style не снимать). +- `conventional-commits` — для коммитов.