Merge ветки docs/advanced-clickstream-course в main

- Зачем:
  - фича готова: продвинутый курс (уроки 0–6), редизайн Superset, гейт целостности
    DDS, мониторинг и выверенные по коду профильные доки. Codex работу завершил.
- Что:
  - --no-ff merge ветки docs/advanced-clickstream-course.
  - артефакты .scratch/ (handoff'ы, аудит) намеренно исключены из дерева main.
- Проверка:
  - git ls-tree -r HEAD не содержит .scratch; git show --summary HEAD — два родителя.
This commit is contained in:
ddadmin committed 2026-06-06 22:08:58 +03:00
commit 20184c9e18
42 files changed
+4060 -635

No files matched your search

+3
View File
@@ -171,3 +171,6 @@ $RECYCLE.BIN/
*.msp
*.lnk
node_modules
.playwright-cli
.playwright-mcp
+18
View File
@@ -27,6 +27,22 @@
- Минимальный порядок: `resolve-library-id` -> `query-docs`.
- Принятое решение фиксировать в коде и/или документации (кратко: что проверили и почему выбрали именно этот вариант).
## Agent skills
Конфигурация для инженерных скиллов (набор Matt Pocock). Подробности — в `docs/agents/`.
### Issue tracker
Локальный markdown: задачи и PRD живут файлами в `.scratch/<feature>/` (коммитятся, `.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-<slug>.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md).
## Ограничения по структуре
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+68
View File
@@ -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 строятся на статическом сиде, не на потоке.
+1 -5
View File
@@ -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"
+62 -289
View File
@@ -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) — задание, из которого вырос стенд.
+7 -19
View File
@@ -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"
+41 -20
View File
@@ -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
+5 -1
View File
@@ -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)
+33
View File
@@ -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}")
+153
View File
@@ -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
+57 -25
View File
@@ -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<br/>(ведущая)"]
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 не переносятся.
---
+19 -11
View File
@@ -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`).
+24 -5
View File
@@ -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-планы
+61 -26
View File
@@ -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 | - |
+43
View File
@@ -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/<feature>/PRD.md`, коммитятся
(`.scratch/` не в `.gitignore`). Снимок понимания на момент работы, не для
перечитывания. Продюсер — `to-prd`.
- **Долговечные решения** — ADR в `docs/adr/NNNN-<slug>.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 окажутся самодостаточными, спеки можно будет гитигнорить — переключение
одной строкой, без слома схемы.
@@ -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-<slug>.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.
+33
View File
@@ -0,0 +1,33 @@
# ADR-0003: Handoff'ы — одноразовые леса́ в `.scratch/handoffs/`
Принято: 2026-06-06
Статус: accepted
## Решение
Handoff (документ «где остановился и как продолжить» для следующей сессии/агента)
кладём в `.scratch/handoffs/YYYY-MM-DD-<slug>.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.
+43
View File
@@ -0,0 +1,43 @@
# Domain docs
Как инженерные скиллы должны читать доменную документацию этого репозитория при
исследовании кода.
## Перед исследованием кода прочитать
- `CONTEXT.md` в корне — глоссарий проекта (доменный язык).
- `docs/adr/` — ADR, затрагивающие область, в которой собираешься работать.
Если файла нет — **молча продолжай**. Не сигналить об отсутствии и не предлагать
создать заранее. Продюсер (`grill-with-docs`) создаёт их лениво, когда термин или
решение реально кристаллизуются.
## Раскладка
Single-context (один контекст на репозиторий):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-<slug>.md
│ └── 0002-<slug>.md
└── ...
```
## Использовать лексику глоссария
Когда вывод называет доменное понятие (заголовок спеки, гипотеза, имя теста,
рефактор-предложение) — использовать термин так, как он определён в `CONTEXT.md`.
Не уходить в синонимы, которые глоссарий помечает `_Avoid_`.
Если нужного понятия в глоссарии ещё нет — это сигнал: либо изобретаешь язык,
которого в проекте нет (пересмотреть), либо реальный пробел (отметить для
`grill-with-docs`).
## Флагать конфликты с ADR
Если вывод противоречит существующему ADR — явно об этом сказать, а не молча
переопределять:
> _Противоречит ADR-0003 (<тема>) — но стоит переоткрыть, потому что…_
+22
View File
@@ -0,0 +1,22 @@
# Issue tracker: локальный markdown
Задачи и PRD этого репозитория живут markdown-файлами в `.scratch/`. GitHub Issues
не используются — не вызывать `gh issue create`. Файлы в `.scratch/` коммитятся
(`.scratch/` не в `.gitignore`).
## Соглашения
- Одна фича — один каталог: `.scratch/<feature-slug>/`
- PRD фичи — `.scratch/<feature-slug>/PRD.md` (по сути транзиентная спека: снимок
понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`)
- Задачи реализации — `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, нумерация с `01`
- Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`)
- Комментарии и история — в конец файла под заголовком `## Comments`
## Когда скилл говорит «опубликовать в issue tracker»
Создать файл в `.scratch/<feature-slug>/` (каталог создать при необходимости).
## Когда скилл говорит «достать тикет»
Прочитать файл по указанному пути. Обычно путь или номер задачи передаёт пользователь.
+14
View File
@@ -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` | Не будет сделано |
Правый столбец можно поменять под свою лексику. Сейчас — дефолт (строка = имя роли).
+149
View File
@@ -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
применяем в составе соответствующего урока.
+136
View File
@@ -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`), не полагайся на память: именно типы и поведение функций уже один раз подвели.
+146
View File
@@ -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 написан и
синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не
блокирует).
Остаётся на будущее:
- Переиспользование: после **первого реального прогона менти** — ретроспектива и
обобщение материала под других менти. Это валидация уже собранного курса, а не
часть его подготовки.
+85
View File
@@ -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) | Стандарт уроков: шаблон, качество кода, самопроверка — рабочий чеклист при написании |
+146
View File
@@ -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 «с другого конца».
@@ -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: когда доходит до типизации и
проверок качества, важнее видеть и держать под контролем каждый пересчёт, чем экономить
доли секунды на задержке.
+362
View File
@@ -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) — тема следующего урока.
+384
View File
@@ -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.
@@ -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, а как систему, за которой
можно наблюдать постоянно.
+371
View File
@@ -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 показывают состояние системы. Дальше
этот же стенд можно использовать не как разовый набор уроков, а как тренажёр: менять данные,
ломать отдельные места, смотреть, где появляется сигнал, и объяснять по метрикам, что произошло.
+454
View File
@@ -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 в репозитории.
@@ -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/<id>/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`.
+3 -1
View File
@@ -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
+4 -13
View File
@@ -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;
+4 -4
View File
@@ -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;
+13 -2
View File
@@ -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;
+17 -27
View File
@@ -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 -- Значение метрики
@@ -122,4 +105,11 @@ UNION ALL
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);
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;
+9
View File
@@ -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
+201 -79
View File
@@ -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:
-107
View File
@@ -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)
+1 -1
View File
@@ -12,7 +12,7 @@
python /app/superset_init/init_superset.py
Важно:
Superset использует SQLite по умолчанию (не PostgreSQL).
Superset использует PostgreSQL metadata store через configs/superset_config.py.
Текущий подход:
1. CLI для создания подключения к БД