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 diff --git a/AGENTS.md b/AGENTS.md index f071ffc..6cc9fc1 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 и обзор. @@ -34,7 +50,9 @@ - [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-планы (использовать как исторический контекст, не как источник истины). +- `.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/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/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 строятся на статическом сиде, не на потоке. 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/README.md b/README.md index 18b30fc..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,222 +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` | Создание дашборда с чартами | -| `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, Unique Sessions, Avg Events/Session | `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` | - -### Архитектура 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) — задание, из которого вырос стенд. 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/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 ffb992e..375df1c 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` @@ -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,8 +58,15 @@ ### `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`. Проверка идёт после + `load_dds` и до `load_dm_summary`, чтобы DM не собирался поверх нарушенной связи + `dds.event -> dds.click`. Для `assert_dds_integrity` задано `retries=0`: повтор не + чинит уже собранную сироту и только задерживает явный failed-статус. ## Рекомендуемый сценарий (фаза 2) @@ -183,8 +191,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*`). @@ -194,10 +202,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/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 2fe6a04..78b4541 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -28,7 +28,7 @@ make transform # Автоматическая инициализация (создание подключения и датасетов) make superset-init -# Создание дашборда с чартами +# Создание дашборда с чартами; при необходимости обновляет metadata колонок датасетов make superset-dashboard ``` @@ -52,18 +52,24 @@ 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) #### 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** — линейный график событий по часам +- **📅 Events over Time** — линейный график событий с 5-минутными бакетами + (все события стенда укладываются в ~50 минут, поэтому часовая гранулярность + давала бы всего 2 точки и прямую линию) - **📱 Traffic by Device** — pie chart распределения по устройствам #### География @@ -71,19 +77,51 @@ make superset-dashboard #### Маркетинг - **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток -- **📄 Top Pages** — bar chart топ-20 страниц +- **🪜 Page Funnel** — funnel chart по просмотрам страниц, от `/home` к `/confirmation` -#### Качество данных -- **🔍 Data Quality Summary** — статистика по слоям STG/ODS/DDS +> **Что проверили по 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. + +#### Прохождение строк по слоям +- **🧱 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) | Фильтр | Поле | Тип | Применение | |--------|------|-----|------------| -| 📅 Date Range | `event_date` | Time Range | Все чарты | -| 🌍 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`; +бизнес-фильтры на него не рассчитаны. --- @@ -104,7 +142,6 @@ make transform # Запуск batch-процесса # Superset make superset-init # Инициализация (подключение + датасеты) make superset-dashboard # Создание дашборда -make superset-export # Экспорт дашборда в JSON make superset-ui # Показать URL и логин make superset-restart # Перезапуск сервиса ``` @@ -157,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 ``` @@ -217,7 +248,11 @@ make superset-restart # Полная переинициализация docker compose down -v docker compose up -d +make ddl +make data +make transform make superset-init +make superset-dashboard ``` ### Нет данных в чартах @@ -247,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/docs/adr/0001-spec-adr-issue-layout.md b/docs/adr/0001-spec-adr-issue-layout.md new file mode 100644 index 0000000..9dd540a --- /dev/null +++ b/docs/adr/0001-spec-adr-issue-layout.md @@ -0,0 +1,43 @@ +# ADR-0001: Раскладка спецификаций, решений и задач + +Принято: 2026-06-01 +Статус: частично заменён [ADR-0002](./0002-specs-as-durable-design-docs.md) — +маршрут design-спек переехал из `.scratch/` в `docs/specs/`. Остальные решения +(ADR, `CONTEXT.md`, issue-tracker) в силе. + +## Решение + +Рабочую документацию разносим по времени жизни: + +- **Транзиентные спеки фич** — 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/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. 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` | Не будет сделано | + +Правый столбец можно поменять под свою лексику. Сейчас — дефолт (строка = имя роли). diff --git a/docs/course/LEARNING_PLAN.md b/docs/course/LEARNING_PLAN.md new file mode 100644 index 0000000..2d582aa --- /dev/null +++ b/docs/course/LEARNING_PLAN.md @@ -0,0 +1,149 @@ +# План обучения: курс «Кликстрим на ClickHouse» + +> Дата: 2026-06-03 (аудит путей выполнен; середина расщеплена — +> один паттерн на урок, всего 7 уроков, см. §1–2). +> Назначение: высокоуровневый маршрут менти по курсу — карта уроков, порядок, +> результаты аудита эталонных путей. Рамка курса (зачем/что/скоуп) — в `PRD.md`; +> как устроен отдельный урок — в `LESSON_STANDARD.md`. +> +> Раздел 3 содержит финальные вердикты аудита и конкретный список правок — +> их применяем при написании соответствующих уроков, а не отдельным забегом. + +--- + +## 1. Маршрут + +Порядок линейный — уроки 1→4 повторяют сам пайплайн (STG → ODS → DDS → оркестрация), +а урок 0 — разминка перед ним. Мониторинг (5) и Superset (6) более самостоятельны и +работают как надстройки поверх готовых данных. + +Принцип нарезки — **один прод-паттерн на урок** (`LESSON_STANDARD` §2). Поэтому +середина пайплайна разнесена: типизация+DQ (ODS) и сборка сущностей (DDS) — разные +уроки. Витрины DM **не отдельный урок**: их показываем в деле там, где их потребляют — +в мониторинге (DQ-витрины) и BI (traffic/utm-витрины). + +**Что нужно знать заранее по 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 | 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–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): + +| Урок | Путь | Файлы | Вердикт | +|------|------|-------|---------| +| 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` | **точечно править** | +| 5 | Мониторинг | `configs/*`, экспортёры | **годно как есть** (для режима наблюдения) | + +> Карты путей раздела 2 включают и DDL целевых таблиц (`sql/ddl/{ods,dds}/*`), а не +> только батч-трансформации: без формы целевых таблиц слой читается неполно. + +### 3.1. Список правок (применяем при написании урока) + +Правки точечные, делаем не отдельным забегом, а в составе соответствующего урока. + +**Урок 1 — STG (`sql/ddl/stg/10_stg.sql`):** +- [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`):** +- [x] **Кандидат на war-story по типам:** баг `kafka_ts` из урока 1 (`toInt64(DateTime64)` + молча срезал миллисекунды → 1970). Это идеальная иллюстрация темы урока — «тихая + потеря данных на неверном типе». Решено: дан как пример в уроке 2 (war-story в финале). +- [x] Добавить однострочный «поток данных» в шапку `20_stg_to_ods.sql` + (`stg.*_raw → ods.* + ods.*_errors`) — п.3 чеклиста. +- [x] Добавить строку «зачем»: основная таблица = валидный ключ (но может иметь + `parse_errors` по неключевым полям), а `*_errors` = любая ошибка. Без этого + непонятно, почему одна строка попадает в оба места (двойной учёт) — проваливается + тест одного прохода. +- [x] Убрать мусор: в `sql/ddl/ods/20_ods.sql` (стр. ~15–25) — восемь + `DROP TABLE IF EXISTS stg.mv_*_to_ods` (чистка legacy-MV). Шум прошлой + архитектуры, мента читает его раньше сути. Вынести из учебного файла. +- [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`):** +- [x] Добавить однострочный «поток данных» в шапку `30_ods_to_dds.sql` + (`ods.* → dds.click + dds.event`) — п.3 чеклиста. +- [x] В финале урока — **recap всей цепочки** STG→ODS→DDS: защита от фрагментации после + расщепления середины (менти должен собрать сквозную модель, а не три изолированных слоя). +- [x] **Демоут DM** оформляем здесь: убрать мусор в `sql/dm/40_dds_to_dm.sql` + (стр. ~14–37, закомментированный «пример материализации витрины») и превратить его + в короткую заметку «VIEW сейчас, материализуем если затормозит, см. `docs/ARCHITECTURE.md`». + Добавить «поток данных» в шапку `40_dds_to_dm.sql`. +- [x] Минор: `sql/ddl/dm/40_dm.sql` — магическое `1919` в `groupArraySample` + (строка «зачем» или упростить). +- Сами витрины DM показываем в деле в уроках 5–6, отдельного разбора не делаем. + +**Урок 4 — Airflow DAG (`airflow/dags/etl_pipeline_dag.py`):** +- [x] **Главная правка (код↔доки):** сделать `check_dds_integrity` честным гейтом — + добавить `assert_dds_integrity` (PythonOperator), роняющий DAG при + `orphan_events > 0`. Сейчас «проверка» только считает сирот в xcom и пишет их в + `dq_summary`, но DAG остаётся зелёным, хотя `LESSON_STANDARD` §3 и `PRD` §2 (цель 3) + обещают остановку при нарушении целостности. После правки доки и код сходятся. +- [x] **Управляемая правка урока 4** строится на этом гейте: менти намеренно ломает + целостность (вставляет «осиротевшее» событие) и видит, как DAG краснеет на + `assert_dds_integrity`. Опирается на понятие сирот из урока 3. +- [x] Добавить 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 не читаются построчно. Вся учебная нагрузка + ложится на **текст урока** (маршрут по дашбордам + смысл панелей). +- [x] При написании проверить, что имена дашбордов/панелей/метрик в тексте совпадают + с реальными (`configs/grafana/provisioning/dashboards/*.json`). +- [x] Кандидат на мини-правку (зеркало урока 4): погасить сервис → увидеть, как панель/ + алерт в Grafana краснеет. Решает открытый вопрос PRD про глубину урока (даёт + «сломал-увидел» вместо чистого наблюдения) — обсудить при написании. + +## 4. Что дальше + +Все уроки 0–6 написаны (`lessons/`), правки кода §3.1 применены в составе уроков — +чекбоксы выше сверены с реальным кодом 2026-06-06. Базовый контент курса собран. + +Исходный план фазы написания (оставлен как контекст): пишем уроки по одному, по +шаблону из `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 new file mode 100644 index 0000000..04f0971 --- /dev/null +++ b/docs/course/LESSON_STANDARD.md @@ -0,0 +1,136 @@ +# Стандарт уроков курса «Кликстрим на ClickHouse» + +> Дата: 2026-06-01. Поправка: 2026-06-05 (по итогам уроков 0–2 — добавлены §2 «Регистр +> и голос», описание шапки урока и §5 «Грабли, на которые мы уже наступили»). +> Назначение: рабочий чеклист, по которому пишется **каждый** урок. Открывается при +> создании урока. Рамка курса (зачем/что/скоуп) — в `PRD.md`; карта уроков и +> маршрут — в `LEARNING_PLAN.md`. + +--- + +## 1. Шаблон урока + +Каждый урок строится по единому шаблону: + +1. **Зачем и где в проде** — что за паттерн и где он встречается в обычной + кликстрим-аналитике (без привязки к конкретной компании). +2. **Руки** — что запустить и что понаблюдать (Kafka UI / ClickHouse / Grafana). +3. **Загляни внутрь** — чтение отполированного эталонного кода с пояснениями. +4. **Управляемая правка** — одно-два маленьких изменения с **видимым результатом**. + Например: добавить поле в Materialized View и увидеть его в `*_raw`; «сломать» + запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name` + и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет. + **Верни как было** — каждая правка завершается явным шагом отката к чистому + состоянию (откатить изменение либо `make clean/up/ddl/data/transform`), чтобы + самостоятельный менти не застрял со сломанным стендом без ментора. + (Урок 0 — без этого шага, только наблюдение.) +5. **Проверь себя** — самопроверка (раздел 4). +6. **Что должно получиться** — **конкретный видимый результат**, который менти проверяет + сам: скрин эффекта правки (например, красный DAG или `+1` в таблице ошибок) плюс один + абзац «своими словами» про паттерн урока или запрос, который пришлось написать. Это и + есть самопроверка (раздел 4); те же вопросы «своими словами» менти разбирает с ментором + на еженедельном созвоне. (Урок 0 — без правки, поэтому результат здесь — что менти + увидел при наблюдении.) + +### Шапка урока + +Кроме шести секций, каждый урок открывается единым блоком-цитатой (`>`) сверху — он задаёт +рамку до того, как менти дойдёт до первой секции: + +- **Формат:** одной фразой, что менти делает, — `**практика** — будешь сам запускать…` + или `**наблюдение** — ничего не запускаем и не меняем…`. Старые пометки «Статус: черновик» + и «Режим: руки» не возвращаем: менти читал их как «материал не готов» и «непонятно, как + понимать»; +- **Пререквизит:** какой урок пройден и что менти уже умеет; +- **Эталонный путь:** ссылка(и) на разбираемый файл; +- **Поток данных одной строкой:** та же стрелочная схема, что стоит в начале эталонного файла; +- **О чём урок простыми словами:** 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. Стандарт качества эталонного кода + +Эталонные пути полируем до **учебного качества**; остальной код стенда остаётся под +капотом — его не трогаем. + +**Чеклист (минимум).** Файл доведён, если: +1. комментарии на русском объясняют **зачем**, а не пересказывают код; +2. шаги явные и названы своими именами, без неочевидных трюков; +3. в начале файла — поток данных одной строкой (как в STG: + `Kafka → kafka_*_raw → MV → *_raw`); +4. ошибки и проверки качества данных видны — понятно, куда смотреть; +5. нет мусора: мёртвого кода, закомментированных экспериментов, разнобоя в именах. + +**Тест одного прохода (главный критерий и защита от громоздкости).** +> Менти читает файл один раз сверху вниз и может пересказать своими словами, +> *что* происходит и *зачем* — не прыгая по файлу, не гугля, не расшифровывая трюки. + +Если тест не проходит — правим строго одним из трёх способов: (1) упростить код; +(2) добавить ровно одну строку «зачем» там, где код честно неочевиден; (3) убрать +лишнее под капот или в короткую пометку. **Добавить ещё комментариев — не способ.** + +**Две защиты от раздувания:** +- комментируем только неочевидное «зачем» (очевидную строку комментировать нельзя — + лишний текст сам по себе мешает понимать); +- «близко к проду» ≠ «вся прод-сложность прямо в коде урока»: один прод-паттерн на + урок показываем чисто, остальные прод-заботы — короткой пометкой «в проде иначе: …». + +Вердикты аудита по конкретным путям — в `LEARNING_PLAN.md`. + +## 4. Самопроверка (по-простому) + +Отдельный тест-фреймворк не строим. Опираемся на то, что в стенде уже есть: +- штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`; +- встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или + нарушении целостности); +- `make clean/up/ddl/data/transform` для сброса и повтора. + +В каждом уроке — маленькая табличка самопроверки в формате +**действие → где смотреть → что ожидать**, а для управляемой правки — какой +видимый результат должен появиться. + +## 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`), не полагайся на память: именно типы и поведение функций уже один раз подвели. diff --git a/docs/course/PRD.md b/docs/course/PRD.md new file mode 100644 index 0000000..81b5c9f --- /dev/null +++ b/docs/course/PRD.md @@ -0,0 +1,146 @@ +# PRD: продвинутый курс «Кликстрим на ClickHouse» (со звёздочкой) + +> Статус: прообраз PRD. Дата: 2026-06-01. +> Поправка 2026-06-03 (разморозка по делу): середина пайплайна расщеплена — ODS и DDS +> теперь разные уроки (принцип «один паттерн на урок»), витрины DM демотированы в +> поверхность потребления. Обязательных уроков стало 0–5, опциональный Superset — урок 6. +> Затронуты §4 (скоуп) и §3/§5 (ожидаемый такт — ~день на урок). Это изменение рамки, +> а не план реализации. +> Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не +> «сессия»; менти проходит материал сам и ничего «не приносит», а на созвоне ментор +> разбирает затыки и проверяет глубину понимания. Затронуты §1, §3, §5. +> Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем +> он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план +> реализации и не стандарт уроков (см. раздел «Связанные документы»). +> +> Язык документа: уровни называем единообразно — **курс** (вся программа) состоит +> из **уроков**. Соседние программы (например, 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–5 (обязательные):** вводный урок по Kafka, заземление Kafka→CH, STG→ODS + (типизация + DQ), ODS→DDS (сборка сущностей), Airflow, мониторинг. Принцип нарезки — + один прод-паттерн на урок; контраст «где Materialized View, а где батч» проходит + мостом уроков 1→2. +- Витрины **DM — не отдельный урок**: их показываем в деле там, где их потребляют + (мониторинг и BI). См. `LEARNING_PLAN.md` §1–2. +- **Аудит и точечная полировка эталонных путей** этих уроков до учебного качества + (стандарт — в `LESSON_STANDARD.md`). +- Учебная часть вокруг каждого эталонного пути по единому шаблону урока. + +Подробная карта уроков (файлы стенда, статус, режим, вердикты аудита) — в плане +обучения `LEARNING_PLAN.md`. + +### Опционально +- **Урок 6: Superset (BI-витрина).** Делаем, если останется ресурс; обязательные + уроки он не блокирует. + +### Не входит +- Переписывание всего стенда: полируем только эталонные пути обязательных уроков, + остальной код стенда остаётся под капотом. +- Lakehouse (Spark / Iceberg / Trino) — отдельный стенд, отдельный курс. +- Подготовка к трудоустройству: резюме, легенда, мок-собесы, привязка к конкретному + работодателю. Любые материалы под конкретного менти — вне этого репозитория + (репозиторий публичный; приватное — в менторской базе). +- Доведение стенда до промышленной надёжности (отказоустойчивость, безопасность, + масштабирование) — кроме коротких пометок «в проде иначе». + +## 5. Критерии успеха + +- Менти проходит урок **сам**, без построчного разбора с ментором (ожидаемый срок — + около дня на урок: уроки короткие и односоставные). +- Может своими словами объяснить паттерн урока и привязать его к обычной + кликстрим-аналитике. +- На созвоне задаёт осмысленные вопросы по сути, а не «застрял на запуске». +- Эталонный код проходит «тест одного прохода» (см. `LESSON_STANDARD.md`). + +## 6. Связанные документы и порядок работ + +Порядок создания артефактов: + +1. **`PRD.md`** (этот документ) — рамка: что, зачем, скоуп. По умолчанию **заморожен**; + меняется только осознанной поправкой с датой и причиной в шапке (как 2026-06-03). +2. **`LEARNING_PLAN.md`** — высокоуровневый план обучения: карта уроков, маршрут + менти, результаты аудита путей. Пишется после согласования PRD. +3. **`LESSON_STANDARD.md`** — стандарт уроков: шаблон урока, стандарт качества кода, + самопроверка. Рабочий чеклист, открывается при написании каждого урока. +4. **Уроки** — по одному, по шаблону из `LESSON_STANDARD.md`, начиная с урока 1 + (Kafka→CH). + +Навигация по всем файлам курса — в `docs/course/README.md`. + +Материалы под конкретного менти (под кого первый прогон, привязка к работодателю, +подготовка к собесам) — **вне этого репозитория**, в приватной менторской базе. + +## 7. Открытые вопросы / на будущее + +Закрыто при написании уроков (2026-06-06): + +- ~~Глубина урока 5 (мониторинг): сколько устройства показывать против «просто + наблюдай дашборд».~~ **Решено:** взяли мини-правку «погаси сервис → алерт краснеет» + (зеркало урока 4) — урок 5 даёт «сломал-увидел», а не чистое наблюдение. См. + `LEARNING_PLAN.md` §3.1. +- ~~Нужна ли BI-витрина (Superset) уже в первой версии.~~ **Решено:** урок 6 написан и + синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не + блокирует). + +Остаётся на будущее: + +- Переиспользование: после **первого реального прогона менти** — ретроспектива и + обобщение материала под других менти. Это валидация уже собранного курса, а не + часть его подготовки. diff --git a/docs/course/README.md b/docs/course/README.md new file mode 100644 index 0000000..bfaa579 --- /dev/null +++ b/docs/course/README.md @@ -0,0 +1,85 @@ +# Курс «Кликстрим на ClickHouse» (со звёздочкой) + +Это продвинутый курс «со звёздочкой» для тех, кто уже прошёл базовую программу. Ты +поднимаешь у себя живой стенд (Kafka + ClickHouse + Airflow + мониторинг + BI) и +проходишь по нему основные паттерны инженерии данных. Курс самодостаточный — он +рассчитан на то, что ты идёшь по нему сам: каждый урок объясняет, зачем нужен паттерн, +даёт потрогать его руками и проверить себя в конце. + +Курс — блок «со звёздочкой» открытого роадмапа Data Engineer +([de.dementev.space](https://de.dementev.space/), он же +[на GitHub](https://github.com/dementev-dev/de-roadmap)). + +> **Пройти можно самому — а можно с ментором.** В одиночку реально — курс для того и +> сделан. С ментором — быстрее, понятнее и чаще с лучшим результатом: +> персональный план под твою ситуацию, разбор затыков на еженедельном созвоне, код-ревью +> твоих правок и проверка, что ты понял тему вглубь, а не просто прокликал. Хочешь так — +> напиши в Telegram [@dementev_dev](https://t.me/dementev_dev). + +## Что нужно до старта + +- **База пройдена:** SQL, моделирование данных, Python, Git, Docker, Airflow. ClickHouse + знать заранее не нужно — это и есть тема курса. +- **Обзорное видео по Kafka из роадмапа** — посмотри перед уроком 0. Оттуда ты возьмёшь + словарь («топик», «партиция», «offset», «consumer-группа», «lag»), а урок 0 свяжет эти + слова с живым стендом. +- **Железо:** стек тяжёлый — Kafka, ClickHouse, Airflow, Superset, Prometheus и Grafana + поднимаются одновременно. Нужна машина, которая это потянет. +- **Подними стенд и залей малый срез** (из корня репозитория) — этого хватит, чтобы + начать, и прогон быстрый: + + ```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) | Стандарт уроков: шаблон, качество кода, самопроверка — рабочий чеклист при написании | diff --git a/docs/course/lessons/00_kafka_intro.md b/docs/course/lessons/00_kafka_intro.md new file mode 100644 index 0000000..b24d436 --- /dev/null +++ b/docs/course/lessons/00_kafka_intro.md @@ -0,0 +1,146 @@ +# Урок 0. Вводный по Kafka (Kafka UI, наблюдение) + +> Формат: **наблюдение** — ничего не запускаем и не меняем, только смотрим. +> Пререквизит: обзорное видео по Kafka из роадмапа — оттуда ты уже знаешь слова +> «топик», «партиция», «offset», «consumer-группа», «lag». Этот урок связывает их +> с живым стендом, чтобы они перестали быть просто словами. +> Эталонный путь: сам Kafka UI на `http://localhost:8082`. +> +> Поток данных одной строкой: +> `make data → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает` +> +> О чём урок простыми словами: ходим по Kafka UI и разглядываем поток — где лежат события, +> кто их читает и как Kafka помнит, до какого места уже дочитано. + +--- + +## 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 «с другого конца». 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..2c12e1a --- /dev/null +++ b/docs/course/lessons/01_kafka_to_clickhouse.md @@ -0,0 +1,272 @@ +# Урок 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, +знакомый тебе по курсовой: первая «посадочная площадка», куда поток приземляется в сыром виде, +до любой обработки. + +Зачем хранить сырой JSON строкой и не парсить его сразу: + +- чтобы отладке было на что опереться: когда дальше по пайплайну что-то «не сходится», + всегда есть исходное сообщение для сверки; +- чтобы любой следующий слой можно было пересобрать из STG, не вычитывая Kafka заново; +- и, главное, чтобы приём не падал из-за одного кривого поля. Разбор JSON и проверки + качества — это уже следующий слой (урок 2), а STG принимает всё подряд. + +Отдельно про `kafka_offset` — это не просто справочная метка. Помнишь из урока 0: пара +«партиция + offset» однозначно указывает на конкретное сообщение в топике. По ней всегда +видно, та же это запись или другая, — пригодится, когда дальше начнём сверять данные между +слоями. + +При этом **повторы STG не отсеивает**. Движок этих таблиц — `MergeTree`, и он не +дедуплицирует, то есть не убирает строки-дубли: что пришло, то и легло, даже если две записи +окажутся одинаковыми. Если дубли потом помешают — их убирают уже на следующих слоях, а STG +держит всё подряд. + +> **В проде иначе.** На потоке в десятки тысяч сообщений в секунду читателей будет +> несколько, и 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` (кирпич 2) — это не хранилище, а «кран» к +топику: через неё ClickHouse читает сообщения, но **сами данные она не копит**. Забирает их +третий кирпич — **Materialized View** (MV). + +И тут стоит остановиться на самом слове. Обычное представление (view) — это сохранённый +запрос: данные оно считает только тогда, когда его спросишь. «Materialized» (материализованное) +значит другое: оно срабатывает **само** на каждую новую порцию из источника и сразу +складывает результат в постоянную таблицу. Получается цепочка: сообщение появилось в топике → +MV тут же подхватило его и положило в `MergeTree`-таблицу `*_raw` (кирпич 1), где оно и лежит. + +Дальше задержимся на двух местах файла. + +### Таблица-источник 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'; -- кривое сообщение не рвёт чтение топика +``` + +Две настройки тут — самые важные для всего урока: + +- `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 + 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/docs/course/lessons/02_stg_to_ods.md b/docs/course/lessons/02_stg_to_ods.md new file mode 100644 index 0000000..8265017 --- /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.location_event │ 50 │ + │ ods.device_by_click │ 26 │ + │ 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` у всех пустой. Это нормально — данные в демо аккуратные. +Ошибки мы увидим в секции 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/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md new file mode 100644 index 0000000..86ff12b --- /dev/null +++ b/docs/course/lessons/03_ods_to_dds.md @@ -0,0 +1,384 @@ +# Урок 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 │ + └────────────┴───────┴─────────────────────┴───────────────┴─────────────┘ +``` + +В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя +дата — не пугайся, если она не совпадёт с примером. + +`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`: левая таблица здесь — это полный список кликов, и **ни один клик терять +нельзя**. Не доехало гео — ладно, сохраним клик, а гео-поля (страна, координаты, IP) останутся +пустыми (`NULL`). И эта пустота — уже видимый сигнал: по ней сразу понятно, что контекст по клику +не подтянулся. Тот же принцип, что и в ODS: **не теряем, а оставляем видимый след**, — только +теперь не про кривое поле, а про пропавшую связь между таблицами. + +> Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location` +> связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу +> и UTM. Если `location` не доехал — событие остаётся, а поля страницы остаются пустыми. Разбирать +> этот блок построчно не будем — он повторяет ту же логику. + +### Сироты: событие без клика + +Мы собрали `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); +``` + +Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой +контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события** +нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по +пустым полям внутри карточки, вторую — отдельным счётчиком. На чистом срезе сирот ноль — сейчас +мы это изменим. + +--- + +## 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`, — что было бы с кликами без гео; +- что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки + клика. + +Если запнёшься на `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 не выкидываем клик без гео (оставляем его с пустыми полями) и событие без клика +(считаем такие сироты через `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/docs/course/lessons/04_airflow_orchestration.md b/docs/course/lessons/04_airflow_orchestration.md new file mode 100644 index 0000000..2b3ec5c --- /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/docs/course/lessons/05_monitoring.md b/docs/course/lessons/05_monitoring.md new file mode 100644 index 0000000..db96b03 --- /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`. Перед +экспериментом оно должно быть в состоянии `Normal` (не `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 показывают состояние системы. Дальше +этот же стенд можно использовать не как разовый набор уроков, а как тренажёр: менять данные, +ломать отдельные места, смотреть, где появляется сигнал, и объяснять по метрикам, что произошло. diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md new file mode 100644 index 0000000..df5b0ba --- /dev/null +++ b/docs/course/lessons/06_superset_bi.md @@ -0,0 +1,454 @@ +# Урок 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 +make data +make transform +``` + +`make transform` прогоняет цепочку STG → ODS → DDS → DM вне Airflow. Для этого урока так +быстрее: нам нужен готовый DM-слой, а не разбор DAG. Отладочный срез через +`LIMIT=50 make data` можно использовать для быстрых экспериментов, но эталонный dashboard +и числа урока рассчитаны на полном наборе данных. + +Теперь инициализируй Superset: + +```bash +make superset-init +``` + +Эта команда создаёт или обновляет: + +- подключение `clickhouse_dwh`; +- 6 datasets поверх `dm.*`. + +После этого создай или обнови charts и сам dashboard: + +```bash +make superset-dashboard +``` + +Эта команда создаёт или обновляет: + +- 10 charts; +- dashboard `E-commerce Analytics Dashboard`. + +Открой Superset: `http://localhost:8088` (логин `admin`, пароль `admin`). + +Если Superset предлагает сменить пароль после первого входа, для учебного стенда можно нажать +**Skip**. В проде так не делают, но локальный курс держит одинаковые инструкции для всех. + +### Открываем dashboard + +Открой готовый dashboard: + +```text +http://localhost:8088/superset/dashboard/ecommerce-analytics/ +``` + +Если URL не открылся, зайди через меню **Dashboards** и найди `E-commerce Analytics Dashboard`. + +На экране должны быть блоки: + +- KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`, + `Conversion to /confirmation`; +- динамика: `Events over Time`, `Traffic by Device`; +- география: `Geography Map`; +- маркетинг: `UTM Effectiveness Table`, `Page Funnel`; +- прохождение строк по слоям: `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 фильтр даты +открывается как `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; +``` + +И посмотри, откуда берётся график `Page Funnel`: + +```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` | метрики по слоям: строки (`total_rows`), ошибки, сироты | + +Открой [`sql/dm/40_dds_to_dm.sql`](../../../sql/dm/40_dds_to_dm.sql). Этот файл не пересчитывает +все `dm.v_*`: VIEW создаются в DDL. Здесь batch-часть наполняет `dm.dq_summary` метриками по +слоям (включая строку для слоя `dm`), чтобы 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. Один элемент списка — один график. +Ниже показаны ключевые поля `Page Funnel`; в реальном файле после них есть ещё +визуальные параметры funnel — схема цветов, легенда, подписи и формат чисел: + +```python +{ + "slice_name": "🪜 Page Funnel", + "viz_type": "funnel", + "dataset_name": "v_top_pages_daily", + "params": { + "groupby": ["page_url_path"], + "metric": { + "expressionType": "SQL", + "sqlExpression": "SUM(pageviews)", + "label": "Pageviews" + }, + "row_limit": 20, + "time_range": "No filter", + "sort_by_metric": True, + "percent_calculation_type": "first_step", + # Дальше в файле идут визуальные параметры funnel. + } +} +``` + +Здесь видно четыре идеи: + +- `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`. + +Эти фильтры задаются в левой панели 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`, поэтому +> дополнительно проверили установленный Superset 4.1.2: bundled example +> `Featured Charts/Funnel.yaml` использует `viz_type: funnel`. Поэтому в уроке не лезем в REST API +> Superset, а работаем через уже существующий скрипт стенда. + +--- + +## 4. Управляемая правка: уменьшаем Page Funnel + +Сейчас chart `Page Funnel` показывает до 20 страниц: + +```python +"row_limit": 20, +``` + +Сделай маленькую видимую правку: временно покажи только топ-3 страницы. + +Открой [`superset/create_dashboard.py`](../../../superset/create_dashboard.py), найди chart +`Page Funnel` и поменяй: + +```python +"row_limit": 20, +``` + +на: + +```python +"row_limit": 3, +``` + +Запусти обновление dashboard: + +```bash +make superset-dashboard +``` + +Вернись в Superset и обнови страницу dashboard. В chart `Page Funnel` должно остаться не больше +трёх страниц. Если фильтр даты снова скрыл данные, поставь **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 `Page Funnel` снова может показывать до 20 страниц. + +Если после экспериментов Superset выглядит странно, самый простой учебный возврат dashboard +metadata к конфигурации из репозитория: + +```bash +make superset-dashboard +``` + +Данные в ClickHouse эта команда не удаляет. Она повторно применяет charts и 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` у `Page Funnel` на `3` и запустить `make superset-dashboard` | chart `Page Funnel` | не больше трёх страниц | +| вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Page Funnel` | ограничение снова до 20 страниц | + +Вопросы для созвона: + +- чем DM-витрина отличается от Superset dataset; +- почему Superset не должен ходить напрямую в сырые STG-таблицы; +- зачем dashboard нужен `Date Range`, если SQL-витрина уже агрегирована; +- почему изменение `row_limit` — это BI-правка, а не изменение модели данных; +- где хранятся данные, а где metadata Superset. + +--- + +## 6. Что должно получиться + +К концу урока у тебя должен быть открытый dashboard `E-commerce Analytics Dashboard` в Superset. +Сделай скриншот после временной правки `Page Funnel`: на нём должно быть видно, что 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/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..ce13449 --- /dev/null +++ b/docs/specs/2026-06-06-superset-dashboard-redesign.md @@ -0,0 +1,173 @@ +# Редизайн KPI-полосы и состава чартов дашборда «E-commerce Analytics» + +Дата: 2026-06-06 +Статус: 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) + +## 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 + +- **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, + реалистичная воронка) — при возврате к генератору перенести в его + `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`. 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/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/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; diff --git a/sql/dds/30_ods_to_dds.sql b/sql/dds/30_ods_to_dds.sql index 20921d4..074eba6 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() для получения последней версии строк по ключу. @@ -97,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) @@ -161,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; diff --git a/sql/dm/40_dds_to_dm.sql b/sql/dm/40_dds_to_dm.sql index 58d20d7..d1e9c87 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) -- ============================================================================ +-- Поток данных: +-- stg.* + ods.* + dds.* + dm.v_events_enriched → 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) -- ---------------------------------------------------------------------------- @@ -44,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 -- Значение метрики @@ -119,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/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 diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 7d9e0ed..43bef21 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -25,12 +25,19 @@ logger = logging.getLogger(__name__) sys.path.insert(0, '/app') +OBSOLETE_CHART_NAMES = { + "🎯 Unique Sessions", +} + + # Конфигурация чартов 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 +48,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,54 +63,57 @@ 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", - "dataset_name": "v_events_enriched", - "params": { - "metric": { - "expressionType": "SQL", - "sqlExpression": "COUNT(DISTINCT click_id)", - "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", + "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" }, - "granularity_sqla": "event_ts", - "y_axis_format": ".2f", - "show_trend_line": False, + "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" } }, # Динамика { - "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", @@ -114,7 +122,7 @@ CHARTS_CONFIG = [ } ], "groupby": [], - "time_range": "Last week", + "time_range": "No filter", "adhoc_filters": [], "row_limit": 10000 } @@ -181,42 +189,81 @@ 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 } }, - # Качество данных + # Прохождение строк по слоям (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 } } @@ -225,12 +272,34 @@ CHARTS_CONFIG = [ # Конфигурация дашборда DASHBOARD_CONFIG = { "dashboard_title": "🛒 E-commerce Analytics Dashboard", - "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и качество данных.", + "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и прохождение строк по слоям.", "published": True, "slug": "ecommerce-analytics", } +# Раскладка дашборда по строкам (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), + ("📈 Avg Events/Visit", 3), ("🎯 Conversion to /confirmation", 3)], + # Динамика во времени + разрез по устройствам + [("📅 Events over Time", 8), ("📱 Traffic by Device", 4)], + # География + эффективность маркетинговых каналов + [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], + # Популярные страницы + прохождение строк по слоям + [("🪜 Page Funnel", 6), ("🧱 Rows by Layer (event)", 6)], +] + +# Высота строки в grid-units Superset (одинаковая для всех чартов строки — +# чтобы они выравнивались по нижней границе). +ROW_HEIGHTS = [30, 60, 60, 60] + + def sync_query_context(chart, params: dict, dataset_id: int) -> None: """ Синхронизирует сохраненный query_context с обновленными params чарта. @@ -260,18 +329,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"): # Для категориальных графиков синхронизируем колонки измерений. @@ -302,7 +371,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 +446,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: @@ -386,13 +463,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" @@ -432,6 +513,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. @@ -452,31 +542,63 @@ def main() -> bool: }, } - # Добавляем чарты в layout (grid: 12 columns) - y_position = 0 - chart_index = 0 + # Раскладываем dashboard строками (ROW): KPI в один ряд, затем + # аналитические блоки парами. Каждый CHART обязан лежать внутри ROW, + # иначе Superset игнорирует width и ставит чарты в одну колонку. + charts_by_title = {c["title"]: c for c in created_charts if c} - for chart in created_charts: - if chart: - chart_component_id = f"CHART-{chart['id']}" - positions[chart_component_id] = { - "id": chart_component_id, - "type": "CHART", - "children": [], + 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": 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, - }, + "meta": {"background": "BACKGROUND_TRANSPARENT"}, } - positions["GRID_ID"]["children"].append(chart_component_id) - chart_index += 1 - if chart_index % 4 == 0: - y_position += 50 + 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: 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) 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 для создания подключения к БД