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:
commit
20184c9e18
42 files changed
+4057
-632
No files matched your search
@@ -171,3 +171,6 @@ $RECYCLE.BIN/
|
|||||||
*.msp
|
*.msp
|
||||||
*.lnk
|
*.lnk
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.playwright-cli
|
||||||
|
.playwright-mcp
|
||||||
@@ -27,6 +27,22 @@
|
|||||||
- Минимальный порядок: `resolve-library-id` -> `query-docs`.
|
- Минимальный порядок: `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 и обзор.
|
- [README.md](./README.md) — пользовательский quick start и обзор.
|
||||||
@@ -34,7 +50,9 @@
|
|||||||
- [docs/OPERATIONS.md](./docs/OPERATIONS.md) — запуск, DAG-параметры, проверки и troubleshooting.
|
- [docs/OPERATIONS.md](./docs/OPERATIONS.md) — запуск, DAG-параметры, проверки и troubleshooting.
|
||||||
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — детали по слоям STG/ODS/DDS/DM.
|
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — детали по слоям STG/ODS/DDS/DM.
|
||||||
- [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов.
|
- [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов.
|
||||||
|
- [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md).
|
||||||
- [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины).
|
- [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).
|
||||||
|
|
||||||
## Ограничения по структуре
|
## Ограничения по структуре
|
||||||
|
|
||||||
|
|||||||
+68
@@ -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,6 +1,6 @@
|
|||||||
.PHONY: up down clean ddl data transform logs \
|
.PHONY: up down clean ddl data transform logs \
|
||||||
reload-monitoring recover-monitoring \
|
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
|
COMPOSE ?= docker compose
|
||||||
|
|
||||||
@@ -77,10 +77,6 @@ superset-init:
|
|||||||
superset-dashboard:
|
superset-dashboard:
|
||||||
$(COMPOSE) exec -T superset bash -c "python /app/superset_init/create_dashboard.py"
|
$(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
|
||||||
superset-ui:
|
superset-ui:
|
||||||
@echo "Superset доступен по адресу: http://localhost:8088"
|
@echo "Superset доступен по адресу: http://localhost:8088"
|
||||||
|
|||||||
@@ -1,109 +1,85 @@
|
|||||||
# ClickHouse Mini DWH для кликстрима
|
# Учебный стенд DWH кликстрима
|
||||||
|
|
||||||
[](./docker-compose.yml)
|
[](./docker-compose.yml)
|
||||||
[](./docs/ARCHITECTURE.md)
|
[](./docs/ARCHITECTURE.md)
|
||||||
[]()
|
|
||||||
|
|
||||||
Мини-демо для решения задания [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
|
```bash
|
||||||
# 1) Поднять инфраструктуру
|
# 1. Поднять весь стек
|
||||||
make up
|
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)
|
1. `ddl_init` — создаёт базы, таблицы и представления в ClickHouse.
|
||||||
2. Включить (unpause) и запустить `ddl_init` (создаёт базы/таблицы/VIEW в ClickHouse)
|
2. `kafka_load` — заливает события из `data/*.jsonl` в Kafka.
|
||||||
|
3. `etl_pipeline` — прогоняет цепочку STG → ODS → DDS → DM.
|
||||||
|
|
||||||
|
Те же шаги можно запускать из командной строки — это удобно для скриптов:
|
||||||
|
|
||||||
Опционально можно триггернуть DAG из CLI (удобно для CI/скрипта):
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
|
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
|
||||||
```
|
|
||||||
|
|
||||||
Загрузка данных в Kafka через Airflow DAG:
|
# Загрузить первые 100 строк каждого файла (limit=0 — загрузить всё)
|
||||||
```bash
|
|
||||||
# Полная загрузка (по умолчанию limit=0)
|
|
||||||
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
|
|
||||||
--conf '{"reset_topics": true}'
|
|
||||||
|
|
||||||
# Ограниченная загрузка — первые 100 строк
|
|
||||||
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
|
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
|
||||||
--conf '{"limit": 100, "reset_topics": true}'
|
--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 \
|
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
|
||||||
--conf '{"full_refresh": true}'
|
--conf '{"full_refresh": true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Smoke-check результата в ClickHouse:
|
Проверить, что данные дошли до витрин:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \
|
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \
|
||||||
"SELECT 'ods.browser_event' AS t, count() AS rows FROM ods.browser_event"
|
--query "SELECT count() FROM dm.v_events_enriched"
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Подробный сценарий запуска, параметры DAG-ов и разбор частых проблем — в
|
||||||
|
[OPERATIONS](./docs/OPERATIONS.md).
|
||||||
|
|
||||||
## Доступные сервисы
|
## Сервисы и доступы
|
||||||
|
|
||||||
| Сервис | URL | Назначение | Логин/Пароль |
|
| Сервис | Адрес | Назначение | Логин/пароль |
|
||||||
|--------|-----|------------|--------------|
|
|--------|-------|------------|--------------|
|
||||||
| ClickHouse HTTP | http://localhost:9123/play | SQL-запросы | default/123456 |
|
| Airflow | `http://localhost:8080` | оркестрация ETL | admin/admin |
|
||||||
| Kafka UI | http://localhost:8082 | Просмотр топиков | — |
|
| ClickHouse | `http://localhost:9123/play` | SQL-запросы | default/123456 |
|
||||||
| Airflow | http://localhost:8080 | Оркестрация ETL | admin/admin |
|
| Kafka UI | `http://localhost:8082` | просмотр топиков | — |
|
||||||
| Superset | http://localhost:8088 | BI-дашборды | admin/admin |
|
| Superset | `http://localhost:8088` | дашборды | admin/admin |
|
||||||
| Prometheus | http://localhost:9090 | Метрики | — |
|
| Prometheus | `http://localhost:9090` | метрики | — |
|
||||||
| Grafana | http://localhost:3000 | Визуализация метрик | admin/admin |
|
| 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -131,222 +107,19 @@ flowchart LR
|
|||||||
D3 -.->|batch| ODS & DDS
|
D3 -.->|batch| ODS & DDS
|
||||||
```
|
```
|
||||||
|
|
||||||
Особенность задания про "грязные данные": парсинг не валит pipeline, ошибки фиксируются в `ods.*_errors` и в поле `parse_errors`.
|
«Грязные» записи не роняют пайплайн: ошибки разбора складываются в `ods.*_errors` и в
|
||||||
|
поле `parse_errors`, а обработка продолжается.
|
||||||
|
|
||||||
[Подробное описание архитектуры →](./docs/ARCHITECTURE.md)
|
Подробное описание слоёв STG/ODS/DDS/DM, диаграммы и обоснование решений —
|
||||||
|
в [ARCHITECTURE](./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 мониторинг по расписанию
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [Архитектура и слои](./docs/ARCHITECTURE.md) — подробное описание STG/ODS/DDS/DM, ER-диаграммы, обоснование решений
|
- [Архитектура и слои](./docs/ARCHITECTURE.md) — устройство STG/ODS/DDS/DM, диаграммы,
|
||||||
- [DE-task.md](./docs/DE-task.md) — исходное задание
|
обоснование решений.
|
||||||
|
- [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов,
|
||||||
|
мониторинг, частые проблемы.
|
||||||
|
- [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять.
|
||||||
|
- [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом
|
||||||
|
стенде.
|
||||||
|
- [DE-task.md](./docs/DE-task.md) — задание, из которого вырос стенд.
|
||||||
@@ -9,7 +9,6 @@ DAG инициализации DDL в ClickHouse.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -20,6 +19,8 @@ from airflow.operators.empty import EmptyOperator
|
|||||||
from airflow.operators.python import BranchPythonOperator, PythonOperator
|
from airflow.operators.python import BranchPythonOperator, PythonOperator
|
||||||
from airflow.utils.trigger_rule import TriggerRule
|
from airflow.utils.trigger_rule import TriggerRule
|
||||||
from airflow_clickhouse_plugin.operators.clickhouse import ClickHouseOperator
|
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, ...]:
|
def load_sql_statements(relative_path: str) -> tuple[str, ...]:
|
||||||
"""Читает SQL-файл и делит его на отдельные команды по ';'."""
|
"""Читает SQL-файл и делит его на отдельные команды по ';'."""
|
||||||
file_path = SQL_ROOT / relative_path
|
return load_sql_file_statements(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)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -96,7 +81,10 @@ def choose_ddl_mode(**context) -> str:
|
|||||||
"""Выбирает ветку выполнения: full DDL или только verify."""
|
"""Выбирает ветку выполнения: full DDL или только verify."""
|
||||||
dag_run = context.get("dag_run")
|
dag_run = context.get("dag_run")
|
||||||
conf = dag_run.conf if dag_run else {}
|
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"
|
return "skip_ddl" if verify_only else "ddl_00_databases"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного проекта.
|
DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного проекта.
|
||||||
|
|
||||||
|
Поток задач:
|
||||||
|
precheck -> transform: wait -> ods -> dq -> branch -> dds -> integrity -> dm -> validate
|
||||||
|
|
||||||
Принципы реализации:
|
Принципы реализации:
|
||||||
- SQL выполняется явными task на ClickHouseOperator;
|
- SQL выполняется явными task на ClickHouseOperator;
|
||||||
- SQL-файлы вызываются по фиксированным путям;
|
- SQL-файлы вызываются по фиксированным путям;
|
||||||
@@ -9,7 +12,6 @@ DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного про
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,6 +25,8 @@ from airflow.utils.task_group import TaskGroup
|
|||||||
from airflow.utils.trigger_rule import TriggerRule
|
from airflow.utils.trigger_rule import TriggerRule
|
||||||
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
|
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
|
||||||
from airflow_clickhouse_plugin.operators.clickhouse import ClickHouseOperator
|
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, ...]:
|
def load_sql_statements(relative_path: str) -> tuple[str, ...]:
|
||||||
"""Читает SQL-файл и делит его на отдельные команды по ';'."""
|
"""Читает SQL-файл и делит его на отдельные команды по ';'."""
|
||||||
file_path = SQL_ROOT / relative_path
|
return load_sql_file_statements(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)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -228,7 +216,10 @@ def choose_full_refresh(**context) -> str:
|
|||||||
"""Ветвление: делать TRUNCATE DDS или пропустить."""
|
"""Ветвление: делать TRUNCATE DDS или пропустить."""
|
||||||
dag_run = context.get("dag_run")
|
dag_run = context.get("dag_run")
|
||||||
conf = dag_run.conf if dag_run else {}
|
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"
|
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.")
|
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(
|
with DAG(
|
||||||
dag_id="etl_pipeline",
|
dag_id="etl_pipeline",
|
||||||
description="ETL STG -> ODS -> DDS -> DM для demo-проекта",
|
description="ETL STG -> ODS -> DDS -> DM для demo-проекта",
|
||||||
@@ -342,6 +349,12 @@ with DAG(
|
|||||||
database="default",
|
database="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert_dds_integrity_task = PythonOperator(
|
||||||
|
task_id="assert_dds_integrity",
|
||||||
|
python_callable=assert_dds_integrity,
|
||||||
|
retries=0,
|
||||||
|
)
|
||||||
|
|
||||||
load_dm_summary = ClickHouseOperator(
|
load_dm_summary = ClickHouseOperator(
|
||||||
task_id="load_dm_summary",
|
task_id="load_dm_summary",
|
||||||
sql=load_sql_statements("dm/40_dds_to_dm.sql"),
|
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
|
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 >> truncate_dds_click >> truncate_dds_event >> truncate_complete
|
||||||
choose_refresh_mode >> skip_truncate >> 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
|
precheck >> transform
|
||||||
@@ -24,6 +24,7 @@ from airflow.operators.python import PythonOperator
|
|||||||
from airflow.utils.task_group import TaskGroup
|
from airflow.utils.task_group import TaskGroup
|
||||||
|
|
||||||
# Импортируем helper-функции
|
# Импортируем helper-функции
|
||||||
|
from utils.airflow_params import parse_bool_param
|
||||||
from utils.kafka_helpers import (
|
from utils.kafka_helpers import (
|
||||||
check_input_files,
|
check_input_files,
|
||||||
check_kafka_ready,
|
check_kafka_ready,
|
||||||
@@ -69,7 +70,10 @@ def _validate_params(**context) -> None:
|
|||||||
def _prepare_topics(**context) -> None:
|
def _prepare_topics(**context) -> None:
|
||||||
"""Подготовка топиков Kafka (создание/сброс)."""
|
"""Подготовка топиков Kafka (создание/сброс)."""
|
||||||
conf = context.get("dag_run", {}).conf or {}
|
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)
|
prepare_topics(reset=reset_topics)
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
@@ -146,7 +146,7 @@ CREATE TABLE stg.browser_raw (
|
|||||||
| `location_event` | event_id | ReplacingMergeTree(src_ingest_ts) | Данные страниц |
|
| `location_event` | event_id | ReplacingMergeTree(src_ingest_ts) | Данные страниц |
|
||||||
| `device_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Устройства |
|
| `device_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Устройства |
|
||||||
| `geo_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:**
|
**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` |
|
| `load_ods` (`sql/ods/20_stg_to_ods.sql`) | Полная пересборка ODS из STG в рамках DAG `etl_pipeline` |
|
||||||
| `TRUNCATE ods.*` | Очистка перед пересборкой для детерминированного результата |
|
| `TRUNCATE ods.*` | Очистка перед пересборкой для детерминированного результата |
|
||||||
| `INSERT ... SELECT` | Типизация валидных строк в основные ODS таблицы |
|
| `INSERT ... SELECT` | Типизация валидных строк в основные ODS таблицы |
|
||||||
| `INSERT ... SELECT` в `*_errors` | Сохранение строк с критичными ошибками парсинга |
|
| `INSERT ... SELECT` в `*_errors` | Сохранение копии строк с любой ошибкой разбора (Kafka-метаданные + `raw` + `error_reason`) |
|
||||||
|
|
||||||
**Логика разделения:**
|
**Логика разделения (DQ-split):**
|
||||||
- **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`).
|
- **Основная таблица** `ods.*`: строки с валидным бизнес-ключом (`WHERE key IS NOT NULL`). Ошибка по *неключевому* полю не выкидывает строку — она остаётся, но помечается в массиве `parse_errors`.
|
||||||
- **Таблица ошибок**: строки с критичными ошибками (невалидный key, timestamp/координаты и т.п.) через отдельный `INSERT ... SELECT`.
|
- **Таблица ошибок** `ods.*_errors`: **копия** строк, где при разборе случилась *любая* ошибка (отдельный `INSERT ... SELECT`). У неё своя схема — не типизированное событие, а сырьё для разбора.
|
||||||
|
- Условия пересекаются нарочно: строка с валидным ключом, но битым неключевым полем попадает **и в основную таблицу, и в `*_errors`**. Подробный разбор — в разделе [«Обработка ошибок в ODS»](#обработка-ошибок-в-ods-dq-split) и в уроке 2 курса.
|
||||||
|
|
||||||
**Пример структуры:**
|
**Пример структуры:**
|
||||||
```sql
|
```sql
|
||||||
@@ -202,7 +203,7 @@ GROUP BY error;
|
|||||||
|
|
||||||
| Таблица | PK | Источники | JOIN-ключ |
|
| Таблица | 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 | — |
|
| `click` | click_id | device_by_click + geo_by_click | — |
|
||||||
|
|
||||||
**Структура:**
|
**Структура:**
|
||||||
@@ -301,7 +302,7 @@ LEFT JOIN (
|
|||||||
| `v_daily_traffic` | Агрегация трафика | День × страна × устройство × браузер × UTM |
|
| `v_daily_traffic` | Агрегация трафика | День × страна × устройство × браузер × UTM |
|
||||||
| `v_top_pages_daily` | Популярность страниц | День × URL path |
|
| `v_top_pages_daily` | Популярность страниц | День × URL path |
|
||||||
| `v_utm_effectiveness` | Маркетинговая аналитика | День × UTM source/medium/campaign |
|
| `v_utm_effectiveness` | Маркетинговая аналитика | День × UTM source/medium/campaign |
|
||||||
| `v_session_overview` | Сессионная аналитика | День × пользователь × сессия |
|
| `v_session_overview` | Сессионная аналитика (только идентифицированные пользователи, `user_domain_id IS NOT NULL`) | День × пользователь × сессия |
|
||||||
| `v_dq_errors_daily` | Мониторинг качества | День × тип ошибки |
|
| `v_dq_errors_daily` | Мониторинг качества | День × тип ошибки |
|
||||||
|
|
||||||
**Пример:**
|
**Пример:**
|
||||||
@@ -328,7 +329,7 @@ FROM ...
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `TRUNCATE` предотвращает накопление дубликатов при повторных запусках
|
- `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:**
|
**Почему VIEW:**
|
||||||
- Для демо: достаточно производительности
|
- Для демо: достаточно производительности
|
||||||
@@ -395,7 +396,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
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| DEVICE_BY_CLICK : "click_id"
|
||||||
BROWSER_EVENT ||--o| GEO_BY_CLICK : "click_id"
|
BROWSER_EVENT ||--o| GEO_BY_CLICK : "click_id"
|
||||||
|
|
||||||
@@ -424,6 +425,7 @@ erDiagram
|
|||||||
UUID click_id PK
|
UUID click_id PK
|
||||||
String os
|
String os
|
||||||
String os_name
|
String os_name
|
||||||
|
String os_timezone
|
||||||
String device_type
|
String device_type
|
||||||
UInt8 device_is_mobile
|
UInt8 device_is_mobile
|
||||||
String user_custom_id
|
String user_custom_id
|
||||||
@@ -443,11 +445,11 @@ erDiagram
|
|||||||
|
|
||||||
### Сборка DDS-сущностей
|
### Сборка DDS-сущностей
|
||||||
|
|
||||||
**event** (browser + location):
|
**event** (browser + location), `browser_event` — ведущая таблица:
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph ODS["ODS"]
|
subgraph ODS["ODS"]
|
||||||
B["browser_event"]
|
B["browser_event<br/>(ведущая)"]
|
||||||
L["location_event"]
|
L["location_event"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -455,10 +457,12 @@ flowchart LR
|
|||||||
EV["event"]
|
EV["event"]
|
||||||
end
|
end
|
||||||
|
|
||||||
B -->|JOIN event_id| EV
|
B -->|все event_id| EV
|
||||||
L -->|JOIN 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:
|
**click** (device + geo) с поддержкой partial data:
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -512,24 +516,37 @@ flowchart LR
|
|||||||
| **MV + JOIN** | Реалтайм | Eventual consistency, дубли при late arrival |
|
| **MV + JOIN** | Реалтайм | Eventual consistency, дубли при late arrival |
|
||||||
| **Batch (выбрано)** | Согласованность, контроль | Задержка до следующего запуска |
|
| **Batch (выбрано)** | Согласованность, контроль | Задержка до следующего запуска |
|
||||||
|
|
||||||
### Обработка ошибок в ODS
|
### Обработка ошибок в ODS (DQ-split)
|
||||||
|
|
||||||
**Проблема:** Грязные данные могут содержать не только невалидные ключи, но и невалидные timestamp/координаты/ID.
|
**Проблема:** Грязные данные могут содержать не только невалидные ключи, но и невалидные timestamp/координаты/ID.
|
||||||
|
|
||||||
**Решение:**
|
**Решение — DQ-split:**
|
||||||
1. **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`)
|
1. **Основная таблица** `ods.*`: строки с валидным бизнес-ключом (`WHERE key IS NOT NULL`). Ошибки по *неключевым* полям не выкидывают строку — она остаётся, но помечается массивом `parse_errors`.
|
||||||
2. **Таблица ошибок**: строки с критичными ошибками парсинга через отдельные `INSERT ... SELECT`
|
2. **Таблица ошибок** `ods.*_errors`: **копия** строк, где при разборе случилась *любая* ошибка. Это не типизированная копия события, а сырьё для разбора — метаданные доставки из Kafka, исходный JSON и причина ошибки:
|
||||||
3. **DQ-метрики**: массив `parse_errors` для аудита
|
- `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
|
```sql
|
||||||
-- Основная таблица
|
-- event_id, event_ts, click_id, parse_errors — это не колонки stg.browser_raw,
|
||||||
INSERT INTO ods.browser_event
|
-- а алиасы из блока WITH, где raw (сырой JSON) разбирается через JSONExtract*/*OrNull.
|
||||||
SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL;
|
-- Здесь 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
|
INSERT INTO ods.browser_event_errors
|
||||||
SELECT ... FROM stg.browser_raw
|
SELECT ingest_ts, kafka_topic, kafka_partition, kafka_offset, kafka_ts,
|
||||||
WHERE event_id IS NULL OR event_ts IS NULL OR click_id IS NULL;
|
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
|
### 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 из обоих источников
|
1. **UNION DISTINCT** всех click_id из обоих источников
|
||||||
2. **LEFT JOIN** для получения данных (обрабатываем device-only и geo-only)
|
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
@@ -10,7 +10,7 @@
|
|||||||
- `make down` (остановить и удалить контейнеры/сети проекта)
|
- `make down` (остановить и удалить контейнеры/сети проекта)
|
||||||
- `make clean` (полная очистка: `down -v --remove-orphans`)
|
- `make clean` (полная очистка: `down -v --remove-orphans`)
|
||||||
- `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse)
|
- `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 transform` (запускает batch-процесс ODS -> DDS -> DM)
|
||||||
- `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд)
|
- `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд)
|
||||||
- `docker compose ps`
|
- `docker compose ps`
|
||||||
@@ -22,13 +22,14 @@
|
|||||||
|
|
||||||
Порты задаются в `docker-compose.yml`:
|
Порты задаются в `docker-compose.yml`:
|
||||||
|
|
||||||
- ClickHouse native: `localhost:8002`
|
- ClickHouse native: `localhost:8002` (пользователь `default`, пароль `123456`)
|
||||||
- ClickHouse HTTP: `localhost:9123`
|
- ClickHouse HTTP / play-консоль: `http://localhost:9123/play` (`default` / `123456`)
|
||||||
- Kafka: `localhost:9092`
|
- Kafka: `localhost:9092`
|
||||||
- Kafka UI: `http://localhost:8082`
|
- Kafka UI: `http://localhost:8082`
|
||||||
- Airflow: `http://localhost:8080` (`admin/admin`)
|
- Airflow: `http://localhost:8080` (`admin/admin`)
|
||||||
|
- Superset: `http://localhost:8088` (`admin/admin`)
|
||||||
- Prometheus: `http://localhost:9090`
|
- Prometheus: `http://localhost:9090`
|
||||||
- Grafana: `http://localhost:3000`
|
- Grafana: `http://localhost:3000` (`admin/admin`)
|
||||||
|
|
||||||
## Airflow DAGs
|
## Airflow DAGs
|
||||||
|
|
||||||
@@ -57,8 +58,15 @@
|
|||||||
### `etl_pipeline`
|
### `etl_pipeline`
|
||||||
|
|
||||||
- Запуск: ручной (`Trigger DAG with config`)
|
- Запуск: ручной (`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`)
|
- Зависимость: требует наличия данных в 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)
|
## Рекомендуемый сценарий (фаза 2)
|
||||||
|
|
||||||
@@ -183,8 +191,8 @@ URL: `http://localhost:3000/d/clickhouse-overview/clickhouse-overview`
|
|||||||
| Раздел | Метрики |
|
| Раздел | Метрики |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| System Health | CPU Usage, Memory Resident, Memory Code |
|
| System Health | CPU Usage, Memory Resident, Memory Code |
|
||||||
| Query Performance | Queries/sec, Active Queries, Failed Queries, Total Queries, Inserted Rows/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/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*`).
|
Принятое решение по метрикам: сверили 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 |
|
| Cluster Health | Brokers Up, Topics, Total Partitions, Consumer Groups |
|
||||||
| Throughput | Messages In/sec by Topic |
|
| Throughput | Messages In / sec by Topic |
|
||||||
| Consumers | Consumer Lag by Group, Consumer Lag Table |
|
| Consumers | Consumer Lag by Group |
|
||||||
| Partitions | Partition Offsets (Current), Oldest vs Current Offset Gap |
|
| Partitions | Partition Offsets (Current) |
|
||||||
|
|
||||||
**Источник метрик:** `kafka-exporter` (danielqsj/kafka-exporter), формат конфигурации подтверждён через Context7 (`/danielqsj/kafka_exporter`, `/prometheus/docs`).
|
**Источник метрик:** `kafka-exporter` (danielqsj/kafka-exporter), формат конфигурации подтверждён через Context7 (`/danielqsj/kafka_exporter`, `/prometheus/docs`).
|
||||||
|
|
||||||
|
|||||||
+24
-5
@@ -4,25 +4,40 @@
|
|||||||
|
|
||||||
## Исполняемые файлы
|
## Исполняемые файлы
|
||||||
|
|
||||||
### Airflow
|
### Airflow (основной путь запуска)
|
||||||
|
|
||||||
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
|
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
|
||||||
- `airflow/dags/kafka_load_dag.py` — загрузка в Kafka из JSONL
|
- `airflow/dags/kafka_load_dag.py` — загрузка в Kafka из JSONL
|
||||||
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
|
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
|
||||||
- `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka
|
- `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
|
- `airflow/requirements.txt` — зависимости Airflow/ClickHouse plugin
|
||||||
|
|
||||||
### SQL
|
### SQL
|
||||||
|
|
||||||
|
DDL (форма таблиц):
|
||||||
|
|
||||||
- `sql/ddl/00_databases.sql` — создание БД `stg`/`ods`/`dds`/`dm`
|
- `sql/ddl/00_databases.sql` — создание БД `stg`/`ods`/`dds`/`dm`
|
||||||
- `sql/ddl/stg/10_stg.sql` — STG (Kafka Engine + MV)
|
- `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/dds/30_dds.sql` — DDS (таблицы для batch-загрузки)
|
||||||
- `sql/ddl/dm/40_dm.sql` — DM (витрины VIEW)
|
- `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/apply_clickhouse_ddl.sh` — применение DDL
|
||||||
- `scripts/load_kafka_data.sh` — загрузка в Kafka
|
- `scripts/load_kafka_data.sh` — загрузка в Kafka
|
||||||
@@ -48,8 +63,12 @@
|
|||||||
- `README.md` — быстрый старт и обзор проекта
|
- `README.md` — быстрый старт и обзор проекта
|
||||||
- `docs/ARCHITECTURE.md` — техническая архитектура
|
- `docs/ARCHITECTURE.md` — техническая архитектура
|
||||||
- `docs/OPERATIONS.md` — запуск, проверки, troubleshooting
|
- `docs/OPERATIONS.md` — запуск, проверки, troubleshooting
|
||||||
|
- `docs/SUPERSET_DASHBOARD.md` — настройка и использование дашборда Superset
|
||||||
- `docs/DE-task.md` — исходное задание
|
- `docs/DE-task.md` — исходное задание
|
||||||
- `docs/COMMIT_RULES.md` — правила коммитов
|
- `docs/COMMIT_RULES.md` — правила коммитов
|
||||||
|
- `docs/course/` — продвинутый учебный курс на базе стенда (PRD, план, уроки)
|
||||||
|
- `docs/adr/` — архитектурные решения (ADR)
|
||||||
|
- `docs/agents/` — контракты для агентских скиллов (issue-tracker, triage, domain)
|
||||||
|
|
||||||
## Legacy-планы
|
## Legacy-планы
|
||||||
|
|
||||||
|
|||||||
+61
-26
@@ -28,7 +28,7 @@ make transform
|
|||||||
# Автоматическая инициализация (создание подключения и датасетов)
|
# Автоматическая инициализация (создание подключения и датасетов)
|
||||||
make superset-init
|
make superset-init
|
||||||
|
|
||||||
# Создание дашборда с чартами
|
# Создание дашборда с чартами; при необходимости обновляет metadata колонок датасетов
|
||||||
make superset-dashboard
|
make superset-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,18 +52,24 @@ make superset-dashboard
|
|||||||
| **UTM Effectiveness** | `dm.v_utm_effectiveness` | Эффективность маркетинговых каналов |
|
| **UTM Effectiveness** | `dm.v_utm_effectiveness` | Эффективность маркетинговых каналов |
|
||||||
| **Top Pages** | `dm.v_top_pages_daily` | Популярность страниц |
|
| **Top Pages** | `dm.v_top_pages_daily` | Популярность страниц |
|
||||||
| **Session Overview** | `dm.v_session_overview` | Анализ сессий |
|
| **Session Overview** | `dm.v_session_overview` | Анализ сессий |
|
||||||
| **DQ Summary** | `dm.dq_summary` | Качество данных |
|
| **DQ Summary** | `dm.dq_summary` | Метрики по слоям (строки, ошибки, сироты) |
|
||||||
|
|
||||||
### Чарты (Charts)
|
### Чарты (Charts)
|
||||||
|
|
||||||
#### KPI-блок (верх дашборда)
|
#### KPI-блок (верх дашборда)
|
||||||
- **📊 Total Events** — общее количество событий
|
- **📊 Total Events** — общее количество событий
|
||||||
- **👤 Unique Users** — уникальные пользователи
|
- **👤 Unique Users** — уникальные пользователи
|
||||||
- **🎯 Unique Sessions** — уникальные сессии (click_id)
|
- **📈 Avg Events/Visit** — среднее количество событий на визит (`click_id`)
|
||||||
- **📈 Avg Events/Session** — среднее количество событий на сессию
|
- **🎯 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 распределения по устройствам
|
- **📱 Traffic by Device** — pie chart распределения по устройствам
|
||||||
|
|
||||||
#### География
|
#### География
|
||||||
@@ -71,19 +77,51 @@ make superset-dashboard
|
|||||||
|
|
||||||
#### Маркетинг
|
#### Маркетинг
|
||||||
- **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток
|
- **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток
|
||||||
- **📄 Top Pages** — bar chart топ-20 страниц
|
- **🪜 Page Funnel** — funnel chart по просмотрам страниц, от `/home` к `/confirmation`
|
||||||
|
|
||||||
#### Качество данных
|
> **Что проверили по Superset 4.1.2.** Через MCP Context7 проверили официальную
|
||||||
- **🔍 Data Quality Summary** — статистика по слоям STG/ODS/DDS
|
> библиотеку `/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)
|
### Фильтры (Native Filters)
|
||||||
|
|
||||||
| Фильтр | Поле | Тип | Применение |
|
| Фильтр | Поле | Тип | Применение |
|
||||||
|--------|------|-----|------------|
|
|--------|------|-----|------------|
|
||||||
| 📅 Date Range | `event_date` | Time Range | Все чарты |
|
| 📅 Date Range | `event_date` | Time Range | Charts с `event_date`; по умолчанию `No filter`, чтобы демо-данные 2022 года не скрывались |
|
||||||
| 🌍 Country | `geo_country` | Multi-select | Все чарты |
|
| 🌍 Country | `geo_country` | Multi-select | Charts на `dm.v_events_enriched` |
|
||||||
| 📱 Device Type | `device_type` | Multi-select | Все чарты |
|
| 📱 Device Type | `device_type` | Multi-select | Charts на `dm.v_events_enriched` |
|
||||||
| 🌐 Browser | `browser_name` | Multi-select | Все чарты |
|
| 🌐 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
|
# Superset
|
||||||
make superset-init # Инициализация (подключение + датасеты)
|
make superset-init # Инициализация (подключение + датасеты)
|
||||||
make superset-dashboard # Создание дашборда
|
make superset-dashboard # Создание дашборда
|
||||||
make superset-export # Экспорт дашборда в JSON
|
|
||||||
make superset-ui # Показать URL и логин
|
make superset-ui # Показать URL и логин
|
||||||
make superset-restart # Перезапуск сервиса
|
make superset-restart # Перезапуск сервиса
|
||||||
```
|
```
|
||||||
@@ -157,22 +194,16 @@ python /app/superset_init/init_superset.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Экспорт и импорт дашборда
|
## Импорт дашборда
|
||||||
|
|
||||||
### Экспорт
|
Основной способ собрать дашборд — `make superset-dashboard` (скрипт `create_dashboard.py`).
|
||||||
|
Готовый экспорт дашборда лежит в репозитории на случай ручного импорта:
|
||||||
```bash
|
`superset/dashboards/ecommerce_analytics.zip.json` (внутри контейнера —
|
||||||
# Автоматический экспорт в JSON
|
`/app/superset_init/dashboards/ecommerce_analytics.zip.json`).
|
||||||
make superset-export
|
|
||||||
|
|
||||||
# Результат: superset/dashboards/ecommerce_analytics.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Импорт
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Импорт через CLI
|
# Импорт через 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
|
# Или через UI: Settings → Import Dashboards
|
||||||
```
|
```
|
||||||
@@ -217,7 +248,11 @@ make superset-restart
|
|||||||
# Полная переинициализация
|
# Полная переинициализация
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
make ddl
|
||||||
|
make data
|
||||||
|
make transform
|
||||||
make superset-init
|
make superset-init
|
||||||
|
make superset-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Нет данных в чартах
|
### Нет данных в чартах
|
||||||
@@ -247,7 +282,7 @@ docker compose exec superset bash -c "curl clickhouse:8123"
|
|||||||
| Сервис | URL | Логин/Пароль |
|
| Сервис | URL | Логин/Пароль |
|
||||||
|--------|-----|--------------|
|
|--------|-----|--------------|
|
||||||
| Superset | http://localhost:8088 | admin / admin |
|
| 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 |
|
| Airflow | http://localhost:8080 | admin / admin |
|
||||||
| Grafana | http://localhost:3000 | admin / admin |
|
| Grafana | http://localhost:3000 | admin / admin |
|
||||||
| Prometheus | http://localhost:9090 | - |
|
| Prometheus | http://localhost:9090 | - |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 (<тема>) — но стоит переоткрыть, потому что…_
|
||||||
@@ -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>/` (каталог создать при необходимости).
|
||||||
|
|
||||||
|
## Когда скилл говорит «достать тикет»
|
||||||
|
|
||||||
|
Прочитать файл по указанному пути. Обычно путь или номер задачи передаёт пользователь.
|
||||||
@@ -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` | Не будет сделано |
|
||||||
|
|
||||||
|
Правый столбец можно поменять под свою лексику. Сейчас — дефолт (строка = имя роли).
|
||||||
@@ -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
|
||||||
|
применяем в составе соответствующего урока.
|
||||||
@@ -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`), не полагайся на память: именно типы и поведение функций уже один раз подвели.
|
||||||
@@ -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 написан и
|
||||||
|
синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не
|
||||||
|
блокирует).
|
||||||
|
|
||||||
|
Остаётся на будущее:
|
||||||
|
|
||||||
|
- Переиспользование: после **первого реального прогона менти** — ретроспектива и
|
||||||
|
обобщение материала под других менти. Это валидация уже собранного курса, а не
|
||||||
|
часть его подготовки.
|
||||||
@@ -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) | Стандарт уроков: шаблон, качество кода, самопроверка — рабочий чеклист при написании |
|
||||||
@@ -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: когда доходит до типизации и
|
||||||
|
проверок качества, важнее видеть и держать под контролем каждый пересчёт, чем экономить
|
||||||
|
доли секунды на задержке.
|
||||||
@@ -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) — тема следующего урока.
|
||||||
@@ -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, а как систему, за которой
|
||||||
|
можно наблюдать постоянно.
|
||||||
@@ -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 показывают состояние системы. Дальше
|
||||||
|
этот же стенд можно использовать не как разовый набор уроков, а как тренажёр: менять данные,
|
||||||
|
ломать отдельные места, смотреть, где появляется сигнал, и объяснять по метрикам, что произошло.
|
||||||
@@ -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`.
|
||||||
@@ -139,7 +139,9 @@ SELECT
|
|||||||
arrayDistinct(groupArray(page_url_path)) AS pages_visited, -- Уникальные страницы
|
arrayDistinct(groupArray(page_url_path)) AS pages_visited, -- Уникальные страницы
|
||||||
arrayDistinct(groupArray(geo_country)) AS countries, -- Страны (если менялась)
|
arrayDistinct(groupArray(geo_country)) AS countries, -- Страны (если менялась)
|
||||||
arrayDistinct(groupArray(device_type)) AS devices, -- Устройства (если менялось)
|
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_source)[1] AS utm_source_last,
|
||||||
groupArraySample(1, 1919)(utm_medium)[1] AS utm_medium_last
|
groupArraySample(1, 1919)(utm_medium)[1] AS utm_medium_last
|
||||||
FROM dm.v_events_enriched
|
FROM dm.v_events_enriched
|
||||||
|
|||||||
+4
-13
@@ -9,21 +9,9 @@
|
|||||||
--
|
--
|
||||||
-- Важно:
|
-- Важно:
|
||||||
-- - Наполнение ODS выполняется batch-процессом из sql/ods/20_stg_to_ods.sql
|
-- - Наполнение 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
|
-- BROWSER EVENTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -46,6 +34,8 @@ CREATE TABLE IF NOT EXISTS ods.browser_event
|
|||||||
parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть)
|
parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть)
|
||||||
)
|
)
|
||||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||||
|
-- Партиционируем по бизнес-дате события: у browser_event есть собственное время (event_ts).
|
||||||
|
-- У click-контекста ниже (location/device/geo) такого времени нет — там партиция по дате загрузки.
|
||||||
PARTITION BY toYYYYMM(event_date)
|
PARTITION BY toYYYYMM(event_date)
|
||||||
ORDER BY (event_id)
|
ORDER BY (event_id)
|
||||||
SETTINGS allow_nullable_key = 1;
|
SETTINGS allow_nullable_key = 1;
|
||||||
@@ -87,6 +77,7 @@ CREATE TABLE IF NOT EXISTS ods.location_event
|
|||||||
parse_errors Array(LowCardinality(String))
|
parse_errors Array(LowCardinality(String))
|
||||||
)
|
)
|
||||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||||
|
-- По дате загрузки: у location нет собственного времени события (только время приёма в STG)
|
||||||
PARTITION BY toYYYYMM(toDate(src_ingest_ts))
|
PARTITION BY toYYYYMM(toDate(src_ingest_ts))
|
||||||
ORDER BY (event_id)
|
ORDER BY (event_id)
|
||||||
SETTINGS allow_nullable_key = 1;
|
SETTINGS allow_nullable_key = 1;
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ SELECT
|
|||||||
_topic AS kafka_topic,
|
_topic AS kafka_topic,
|
||||||
_partition AS kafka_partition,
|
_partition AS kafka_partition,
|
||||||
_offset AS kafka_offset,
|
_offset AS kafka_offset,
|
||||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
_timestamp_ms AS kafka_ts,
|
||||||
raw
|
raw
|
||||||
FROM stg.kafka_browser_raw;
|
FROM stg.kafka_browser_raw;
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ SELECT
|
|||||||
_topic AS kafka_topic,
|
_topic AS kafka_topic,
|
||||||
_partition AS kafka_partition,
|
_partition AS kafka_partition,
|
||||||
_offset AS kafka_offset,
|
_offset AS kafka_offset,
|
||||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
_timestamp_ms AS kafka_ts,
|
||||||
raw
|
raw
|
||||||
FROM stg.kafka_location_raw;
|
FROM stg.kafka_location_raw;
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ SELECT
|
|||||||
_topic AS kafka_topic,
|
_topic AS kafka_topic,
|
||||||
_partition AS kafka_partition,
|
_partition AS kafka_partition,
|
||||||
_offset AS kafka_offset,
|
_offset AS kafka_offset,
|
||||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
_timestamp_ms AS kafka_ts,
|
||||||
raw
|
raw
|
||||||
FROM stg.kafka_device_raw;
|
FROM stg.kafka_device_raw;
|
||||||
|
|
||||||
@@ -164,6 +164,6 @@ SELECT
|
|||||||
_topic AS kafka_topic,
|
_topic AS kafka_topic,
|
||||||
_partition AS kafka_partition,
|
_partition AS kafka_partition,
|
||||||
_offset AS kafka_offset,
|
_offset AS kafka_offset,
|
||||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
_timestamp_ms AS kafka_ts,
|
||||||
raw
|
raw
|
||||||
FROM stg.kafka_geo_raw;
|
FROM stg.kafka_geo_raw;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Batch-трансформация: ODS → DDS
|
-- Batch-трансформация: ODS → DDS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
-- Поток данных:
|
||||||
|
-- ods.device_by_click + ods.geo_by_click → dds.click (контекст клика)
|
||||||
|
-- ods.browser_event + ods.location_event → dds.event (контекст события)
|
||||||
|
--
|
||||||
-- Что делает:
|
-- Что делает:
|
||||||
-- Собирает "чистые" сущности из типизированных данных ODS для аналитики.
|
-- Собирает "чистые" сущности из типизированных данных ODS для аналитики.
|
||||||
-- Использует argMax() для получения последней версии строк по ключу.
|
-- Использует argMax() для получения последней версии строк по ключу.
|
||||||
@@ -97,7 +101,11 @@ LEFT JOIN (
|
|||||||
FROM ods.geo_by_click
|
FROM ods.geo_by_click
|
||||||
WHERE click_id IS NOT NULL
|
WHERE click_id IS NOT NULL
|
||||||
GROUP BY click_id
|
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)
|
-- Сущность: dds.event (объединяет browser + location)
|
||||||
@@ -161,4 +169,7 @@ LEFT JOIN (
|
|||||||
FROM ods.location_event
|
FROM ods.location_event
|
||||||
WHERE event_id IS NOT NULL
|
WHERE event_id IS NOT NULL
|
||||||
GROUP BY event_id
|
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
@@ -1,6 +1,10 @@
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Batch-трансформация: DDS → DM (Data Quality summary)
|
-- 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) для мониторинга качества данных.
|
-- Собирает статистику по всем слоям (stg/ods/dds) для мониторинга качества данных.
|
||||||
-- Позволяет быстро проверить, сколько данных прошло через каждый слой
|
-- Позволяет быстро проверить, сколько данных прошло через каждый слой
|
||||||
@@ -9,33 +13,12 @@
|
|||||||
-- Важно:
|
-- Важно:
|
||||||
-- Таблица dq_summary пересоздаётся при каждом запуске (TRUNCATE + INSERT),
|
-- Таблица 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)
|
-- Таблица для сводки по качеству данных (DQ summary)
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
@@ -44,7 +27,7 @@
|
|||||||
CREATE TABLE IF NOT EXISTS dm.dq_summary
|
CREATE TABLE IF NOT EXISTS dm.dq_summary
|
||||||
(
|
(
|
||||||
check_date Date, -- Дата проверки
|
check_date Date, -- Дата проверки
|
||||||
layer LowCardinality(String), -- Слой: stg, ods, dds
|
layer LowCardinality(String), -- Слой: stg, ods, dds, dm
|
||||||
table_name LowCardinality(String), -- Имя таблицы
|
table_name LowCardinality(String), -- Имя таблицы
|
||||||
check_name LowCardinality(String), -- Тип проверки: total_rows, rows_with_errors и т.д.
|
check_name LowCardinality(String), -- Тип проверки: total_rows, rows_with_errors и т.д.
|
||||||
check_value UInt64 -- Значение метрики
|
check_value UInt64 -- Значение метрики
|
||||||
@@ -122,4 +105,11 @@ UNION ALL
|
|||||||
SELECT today(), 'dds', 'event_without_click', 'orphan_events', count()
|
SELECT today(), 'dds', 'event_without_click', 'orphan_events', count()
|
||||||
FROM dds.event
|
FROM dds.event
|
||||||
WHERE click_id IS NOT NULL
|
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;
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Batch-трансформация: STG → ODS
|
-- Batch-трансформация: STG → ODS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
-- Поток данных:
|
||||||
|
-- stg.*_raw → ods.* (валидный ключ) + ods.*_errors (любая ошибка)
|
||||||
|
--
|
||||||
-- Назначение:
|
-- Назначение:
|
||||||
-- - Перенос типизации STG → ODS из Materialized View в управляемый batch
|
-- - Перенос типизации STG → ODS из Materialized View в управляемый batch
|
||||||
-- - Полная пересборка ODS для прозрачного мониторинга в Airflow
|
-- - Полная пересборка ODS для прозрачного мониторинга в Airflow
|
||||||
-- - Сохранение DQ-логики: parse_errors + отдельные *_errors таблицы
|
-- - Сохранение DQ-логики: parse_errors + отдельные *_errors таблицы
|
||||||
--
|
--
|
||||||
|
-- DQ-split (почему одна строка может попасть в оба места):
|
||||||
|
-- - Основная таблица ods.* — строки с валидным КЛЮЧОМ (event_id / click_id).
|
||||||
|
-- В ней допустимы parse_errors по НЕключевым полям — строка остаётся, но помечена.
|
||||||
|
-- - Таблица ods.*_errors — копия строк с ЛЮБОЙ ошибкой парсинга (для разбора).
|
||||||
|
-- Поэтому строка с валидным ключом, но битым неключевым полем, попадёт И туда, И туда.
|
||||||
|
--
|
||||||
-- Когда запускать:
|
-- Когда запускать:
|
||||||
-- - В DAG etl_pipeline перед ODS → DDS
|
-- - В DAG etl_pipeline перед ODS → DDS
|
||||||
-- - После загрузки очередного среза данных в Kafka/STG
|
-- - После загрузки очередного среза данных в Kafka/STG
|
||||||
|
|||||||
+198
-76
@@ -25,12 +25,19 @@ logger = logging.getLogger(__name__)
|
|||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
|
|
||||||
|
|
||||||
|
OBSOLETE_CHART_NAMES = {
|
||||||
|
"🎯 Unique Sessions",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Конфигурация чартов
|
# Конфигурация чартов
|
||||||
CHARTS_CONFIG = [
|
CHARTS_CONFIG = [
|
||||||
# KPI блок
|
# KPI блок
|
||||||
{
|
{
|
||||||
"slice_name": "📊 Total Events",
|
"slice_name": "📊 Total Events",
|
||||||
"viz_type": "big_number",
|
# big_number_total = итог по всему срезу (без granularity), а не последний
|
||||||
|
# временной бакет, как у big_number.
|
||||||
|
"viz_type": "big_number_total",
|
||||||
"dataset_name": "v_events_enriched",
|
"dataset_name": "v_events_enriched",
|
||||||
"params": {
|
"params": {
|
||||||
"metric": {
|
"metric": {
|
||||||
@@ -41,15 +48,13 @@ CHARTS_CONFIG = [
|
|||||||
"label": "Total Events",
|
"label": "Total Events",
|
||||||
"optionName": "metric_1"
|
"optionName": "metric_1"
|
||||||
},
|
},
|
||||||
"granularity_sqla": "event_ts",
|
|
||||||
"y_axis_format": ",d",
|
"y_axis_format": ",d",
|
||||||
"show_trend_line": False,
|
|
||||||
"time_range": "No filter"
|
"time_range": "No filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slice_name": "👤 Unique Users",
|
"slice_name": "👤 Unique Users",
|
||||||
"viz_type": "big_number",
|
"viz_type": "big_number_total",
|
||||||
"dataset_name": "v_events_enriched",
|
"dataset_name": "v_events_enriched",
|
||||||
"params": {
|
"params": {
|
||||||
"metric": {
|
"metric": {
|
||||||
@@ -58,54 +63,57 @@ CHARTS_CONFIG = [
|
|||||||
"label": "Unique Users",
|
"label": "Unique Users",
|
||||||
"optionName": "metric_2"
|
"optionName": "metric_2"
|
||||||
},
|
},
|
||||||
"granularity_sqla": "event_ts",
|
|
||||||
"y_axis_format": ",d",
|
"y_axis_format": ",d",
|
||||||
"show_trend_line": False,
|
|
||||||
"time_range": "No filter"
|
"time_range": "No filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slice_name": "🎯 Unique Sessions",
|
"slice_name": "📈 Avg Events/Visit",
|
||||||
"viz_type": "big_number",
|
"previous_slice_names": ["📈 Avg Events/Session"],
|
||||||
"dataset_name": "v_events_enriched",
|
"viz_type": "big_number_total",
|
||||||
"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",
|
|
||||||
"dataset_name": "v_events_enriched",
|
"dataset_name": "v_events_enriched",
|
||||||
"params": {
|
"params": {
|
||||||
"metric": {
|
"metric": {
|
||||||
"expressionType": "SQL",
|
"expressionType": "SQL",
|
||||||
"sqlExpression": "COUNT(*) / COUNT(DISTINCT click_id)",
|
"sqlExpression": "COUNT(*) / COUNT(DISTINCT click_id)",
|
||||||
"label": "Avg Events/Session",
|
"label": "Avg Events/Visit",
|
||||||
"optionName": "metric_4"
|
"optionName": "metric_4"
|
||||||
},
|
},
|
||||||
"granularity_sqla": "event_ts",
|
"y_axis_format": ".1f",
|
||||||
"y_axis_format": ".2f",
|
"time_range": "No filter"
|
||||||
"show_trend_line": False,
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
"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",
|
"viz_type": "echarts_timeseries_line",
|
||||||
"dataset_name": "v_events_enriched",
|
"dataset_name": "v_events_enriched",
|
||||||
"params": {
|
"params": {
|
||||||
"granularity_sqla": "event_ts",
|
"granularity_sqla": "event_ts",
|
||||||
"time_grain_sqla": "PT1H",
|
"time_grain_sqla": "PT5M",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
{
|
{
|
||||||
"expressionType": "SQL",
|
"expressionType": "SQL",
|
||||||
@@ -114,7 +122,7 @@ CHARTS_CONFIG = [
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"groupby": [],
|
"groupby": [],
|
||||||
"time_range": "Last week",
|
"time_range": "No filter",
|
||||||
"adhoc_filters": [],
|
"adhoc_filters": [],
|
||||||
"row_limit": 10000
|
"row_limit": 10000
|
||||||
}
|
}
|
||||||
@@ -181,42 +189,81 @@ CHARTS_CONFIG = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slice_name": "📄 Top Pages",
|
"slice_name": "🪜 Page Funnel",
|
||||||
"viz_type": "dist_bar",
|
"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",
|
"dataset_name": "v_top_pages_daily",
|
||||||
"params": {
|
"params": {
|
||||||
"groupby": ["page_url_path"],
|
"groupby": ["page_url_path"],
|
||||||
"metrics": [
|
"metric": {
|
||||||
{"expressionType": "SQL", "sqlExpression": "SUM(pageviews)", "label": "Pageviews"}
|
"expressionType": "SQL",
|
||||||
],
|
"sqlExpression": "SUM(pageviews)",
|
||||||
|
"label": "Pageviews"
|
||||||
|
},
|
||||||
"row_limit": 20,
|
"row_limit": 20,
|
||||||
"time_range": "No filter",
|
"time_range": "No filter",
|
||||||
"orientation": "vertical",
|
"sort_by_metric": True,
|
||||||
"show_legend": False
|
"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",
|
"viz_type": "dist_bar",
|
||||||
"dataset_name": "dq_summary",
|
"dataset_name": "dq_summary",
|
||||||
"params": {
|
"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": [
|
"metrics": [
|
||||||
{"expressionType": "SQL", "sqlExpression": "SUM(check_value)", "label": "Row Count"}
|
{"expressionType": "SQL", "sqlExpression": "SUM(check_value)", "label": "Rows"}
|
||||||
],
|
],
|
||||||
"adhoc_filters": [
|
"adhoc_filters": [
|
||||||
{
|
{
|
||||||
"clause": "WHERE",
|
"clause": "WHERE",
|
||||||
"expressionType": "SQL",
|
"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,
|
"subject": None,
|
||||||
"operator": None,
|
"operator": None,
|
||||||
"comparator": None
|
"comparator": None
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"order_bars": True,
|
||||||
"row_limit": 100,
|
"row_limit": 100,
|
||||||
"time_range": "No filter",
|
"time_range": "No filter",
|
||||||
|
"y_axis_format": ",d",
|
||||||
"show_legend": False
|
"show_legend": False
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,12 +272,34 @@ CHARTS_CONFIG = [
|
|||||||
# Конфигурация дашборда
|
# Конфигурация дашборда
|
||||||
DASHBOARD_CONFIG = {
|
DASHBOARD_CONFIG = {
|
||||||
"dashboard_title": "🛒 E-commerce Analytics Dashboard",
|
"dashboard_title": "🛒 E-commerce Analytics Dashboard",
|
||||||
"description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и качество данных.",
|
"description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и прохождение строк по слоям.",
|
||||||
"published": True,
|
"published": True,
|
||||||
"slug": "ecommerce-analytics",
|
"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:
|
def sync_query_context(chart, params: dict, dataset_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
Синхронизирует сохраненный query_context с обновленными params чарта.
|
Синхронизирует сохраненный 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)
|
chart.query_context = json.dumps(query_context)
|
||||||
return
|
return
|
||||||
|
|
||||||
if chart.viz_type == "big_number":
|
if chart.viz_type == "big_number_total":
|
||||||
|
# Итоговое число по всему срезу: не time-series, без granularity и
|
||||||
|
# time-grain — иначе запрос группируется по времени и число «прыгает»
|
||||||
|
# на значение последнего бакета.
|
||||||
query = queries[0]
|
query = queries[0]
|
||||||
granularity = params.get("granularity_sqla")
|
query["is_timeseries"] = False
|
||||||
if granularity:
|
query.pop("granularity", None)
|
||||||
query["granularity"] = granularity
|
|
||||||
query["is_timeseries"] = True
|
|
||||||
query["time_range"] = params.get("time_range", query.get("time_range"))
|
query["time_range"] = params.get("time_range", query.get("time_range"))
|
||||||
if "metric" in params:
|
if "metric" in params:
|
||||||
query["metrics"] = [params["metric"]]
|
query["metrics"] = [params["metric"]]
|
||||||
extras = query.get("extras") if isinstance(query.get("extras"), dict) else {}
|
extras = query.get("extras") if isinstance(query.get("extras"), dict) else {}
|
||||||
if "time_grain_sqla" in params:
|
extras.pop("time_grain_sqla", None)
|
||||||
extras["time_grain_sqla"] = params.get("time_grain_sqla")
|
|
||||||
query["extras"] = extras
|
query["extras"] = extras
|
||||||
elif params.get("x_axis") or params.get("groupby"):
|
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",
|
"name": "📅 Date Range",
|
||||||
"filterType": "filter_time",
|
"filterType": "filter_time",
|
||||||
"targets": [{"datasetId": filter_dataset_id, "column": {"name": "event_date"}}],
|
"targets": [{"datasetId": filter_dataset_id, "column": {"name": "event_date"}}],
|
||||||
"defaultValue": "Last week",
|
"defaultValue": "No filter",
|
||||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||||
"cascadeParentIds": [],
|
"cascadeParentIds": [],
|
||||||
"isInstant": True
|
"isInstant": True
|
||||||
@@ -377,6 +446,14 @@ def main() -> bool:
|
|||||||
if not dataset:
|
if not dataset:
|
||||||
logger.warning(f"Dataset '{chart_config['dataset_name']}' not found, skipping chart")
|
logger.warning(f"Dataset '{chart_config['dataset_name']}' not found, skipping chart")
|
||||||
continue
|
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
|
datasets_by_name[chart_config["dataset_name"]] = dataset.id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -386,13 +463,17 @@ def main() -> bool:
|
|||||||
params["viz_type"] = chart_config["viz_type"]
|
params["viz_type"] = chart_config["viz_type"]
|
||||||
serialized_params = json.dumps(params)
|
serialized_params = json.dumps(params)
|
||||||
|
|
||||||
# Проверяем, существует ли уже чарт
|
# Проверяем, существует ли уже чарт. Старые имена нужны для
|
||||||
existing = db.session.query(Slice).filter_by(
|
# идемпотентного rename без дублей в списке Charts.
|
||||||
slice_name=chart_config["slice_name"]
|
chart_names = [chart_config["slice_name"]]
|
||||||
).first()
|
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:
|
if existing:
|
||||||
# Синхронизируем параметры существующего чарта с конфигом.
|
# Синхронизируем параметры существующего чарта с конфигом.
|
||||||
|
existing.slice_name = chart_config["slice_name"]
|
||||||
existing.viz_type = chart_config["viz_type"]
|
existing.viz_type = chart_config["viz_type"]
|
||||||
existing.datasource_id = dataset.id
|
existing.datasource_id = dataset.id
|
||||||
existing.datasource_type = "table"
|
existing.datasource_type = "table"
|
||||||
@@ -432,6 +513,15 @@ def main() -> bool:
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
logger.info(f"Created/Found {len(created_charts)} charts")
|
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"))
|
metadata_json = build_dashboard_metadata(datasets_by_name.get("v_events_enriched"))
|
||||||
|
|
||||||
# Создаём позиции чартов для layout.
|
# Создаём позиции чартов для layout.
|
||||||
@@ -452,31 +542,63 @@ def main() -> bool:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Добавляем чарты в layout (grid: 12 columns)
|
# Раскладываем dashboard строками (ROW): KPI в один ряд, затем
|
||||||
y_position = 0
|
# аналитические блоки парами. Каждый CHART обязан лежать внутри ROW,
|
||||||
chart_index = 0
|
# иначе Superset игнорирует width и ставит чарты в одну колонку.
|
||||||
|
charts_by_title = {c["title"]: c for c in created_charts if c}
|
||||||
|
|
||||||
for chart in created_charts:
|
def add_chart(component_id_owner_row: str, chart: dict, width: int, height: int) -> str:
|
||||||
if chart:
|
"""Регистрирует CHART-компонент и возвращает его id."""
|
||||||
chart_component_id = f"CHART-{chart['id']}"
|
cid = f"CHART-{chart['id']}"
|
||||||
positions[chart_component_id] = {
|
positions[cid] = {
|
||||||
"id": chart_component_id,
|
"id": cid,
|
||||||
"type": "CHART",
|
"type": "CHART",
|
||||||
"children": [],
|
"children": [],
|
||||||
"parents": ["ROOT_ID", "GRID_ID"],
|
"parents": ["ROOT_ID", "GRID_ID", component_id_owner_row],
|
||||||
"meta": {
|
"meta": {
|
||||||
"chartId": chart['id'],
|
"chartId": chart["id"],
|
||||||
"sliceName": chart['title'],
|
"sliceName": chart["title"],
|
||||||
"height": 50,
|
"width": width,
|
||||||
"width": 4 if chart_index < 4 else 6,
|
"height": height,
|
||||||
"x": (chart_index % 3) * 4 if chart_index < 4 else (chart_index % 2) * 6,
|
|
||||||
"y": y_position,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
positions["GRID_ID"]["children"].append(chart_component_id)
|
return cid
|
||||||
chart_index += 1
|
|
||||||
if chart_index % 4 == 0:
|
placed_titles = set()
|
||||||
y_position += 50
|
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": {"background": "BACKGROUND_TRANSPARENT"},
|
||||||
|
}
|
||||||
|
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:
|
if created_charts:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
python /app/superset_init/init_superset.py
|
python /app/superset_init/init_superset.py
|
||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
Superset использует SQLite по умолчанию (не PostgreSQL).
|
Superset использует PostgreSQL metadata store через configs/superset_config.py.
|
||||||
|
|
||||||
Текущий подход:
|
Текущий подход:
|
||||||
1. CLI для создания подключения к БД
|
1. CLI для создания подключения к БД
|
||||||
|
|||||||
Reference in new issue
Block a user