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
|
||||
*.lnk
|
||||
node_modules
|
||||
|
||||
.playwright-cli
|
||||
.playwright-mcp
|
||||
@@ -27,6 +27,22 @@
|
||||
- Минимальный порядок: `resolve-library-id` -> `query-docs`.
|
||||
- Принятое решение фиксировать в коде и/или документации (кратко: что проверили и почему выбрали именно этот вариант).
|
||||
|
||||
## Agent skills
|
||||
|
||||
Конфигурация для инженерных скиллов (набор Matt Pocock). Подробности — в `docs/agents/`.
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Локальный markdown: задачи и PRD живут файлами в `.scratch/<feature>/` (коммитятся, `.scratch/` не в `.gitignore`). GitHub Issues не используются. См. `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Пять канонических ролей, строки совпадают с именами (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: `CONTEXT.md` и `docs/adr/` в корне репозитория. См. `docs/agents/domain.md`.
|
||||
|
||||
## Навигация по документации
|
||||
|
||||
- [README.md](./README.md) — пользовательский quick start и обзор.
|
||||
@@ -34,7 +50,9 @@
|
||||
- [docs/OPERATIONS.md](./docs/OPERATIONS.md) — запуск, DAG-параметры, проверки и troubleshooting.
|
||||
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — детали по слоям STG/ODS/DDS/DM.
|
||||
- [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов.
|
||||
- [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md).
|
||||
- [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины).
|
||||
- `.scratch/handoffs/YYYY-MM-DD-<slug>.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md).
|
||||
|
||||
## Ограничения по структуре
|
||||
|
||||
|
||||
+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 \
|
||||
reload-monitoring recover-monitoring \
|
||||
superset-init superset-dashboard superset-export superset-ui superset-restart
|
||||
superset-init superset-dashboard superset-ui superset-restart
|
||||
|
||||
COMPOSE ?= docker compose
|
||||
|
||||
@@ -77,10 +77,6 @@ superset-init:
|
||||
superset-dashboard:
|
||||
$(COMPOSE) exec -T superset bash -c "python /app/superset_init/create_dashboard.py"
|
||||
|
||||
# Экспорт дашборда в JSON
|
||||
superset-export:
|
||||
$(COMPOSE) exec -T superset bash -c "python /app/superset_init/export_dashboard.py"
|
||||
|
||||
# Открыть Superset UI
|
||||
superset-ui:
|
||||
@echo "Superset доступен по адресу: http://localhost:8088"
|
||||
|
||||
@@ -1,109 +1,85 @@
|
||||
# ClickHouse Mini DWH для кликстрима
|
||||
# Учебный стенд DWH кликстрима
|
||||
|
||||
[](./docker-compose.yml)
|
||||
[](./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
|
||||
# 1) Поднять инфраструктуру
|
||||
# 1. Поднять весь стек
|
||||
make up
|
||||
|
||||
# Проверить статусы контейнеров
|
||||
docker compose ps
|
||||
docker compose ps # убедиться, что контейнеры запустились
|
||||
```
|
||||
|
||||
Дальше основной путь идёт через Airflow (как в задании).
|
||||
Дальше — три шага в Airflow (веб-интерфейс `http://localhost:8080`, логин и пароль
|
||||
`admin`/`admin`). Сними каждый DAG с паузы (кнопка Unpause) и запусти по очереди:
|
||||
|
||||
1. Открыть Airflow UI: `http://localhost:8080` (admin/admin)
|
||||
2. Включить (unpause) и запустить `ddl_init` (создаёт базы/таблицы/VIEW в ClickHouse)
|
||||
1. `ddl_init` — создаёт базы, таблицы и представления в ClickHouse.
|
||||
2. `kafka_load` — заливает события из `data/*.jsonl` в Kafka.
|
||||
3. `etl_pipeline` — прогоняет цепочку STG → ODS → DDS → DM.
|
||||
|
||||
Те же шаги можно запускать из командной строки — это удобно для скриптов:
|
||||
|
||||
Опционально можно триггернуть DAG из CLI (удобно для CI/скрипта):
|
||||
```bash
|
||||
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
|
||||
```
|
||||
|
||||
Загрузка данных в Kafka через Airflow DAG:
|
||||
```bash
|
||||
# Полная загрузка (по умолчанию limit=0)
|
||||
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
|
||||
--conf '{"reset_topics": true}'
|
||||
|
||||
# Ограниченная загрузка — первые 100 строк
|
||||
# Загрузить первые 100 строк каждого файла (limit=0 — загрузить всё)
|
||||
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
|
||||
--conf '{"limit": 100, "reset_topics": true}'
|
||||
```
|
||||
|
||||
Запуск batch-трансформации (STG -> ODS -> DDS -> DM) в Airflow (если DAG выключен, сначала unpause):
|
||||
```bash
|
||||
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
|
||||
--conf '{"full_refresh": true}'
|
||||
```
|
||||
|
||||
Smoke-check результата в ClickHouse:
|
||||
Проверить, что данные дошли до витрин:
|
||||
|
||||
```bash
|
||||
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \
|
||||
"SELECT 'ods.browser_event' AS t, count() AS rows FROM ods.browser_event"
|
||||
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \
|
||||
"SELECT 'dds.click' AS t, count() AS rows FROM dds.click"
|
||||
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \
|
||||
"SELECT 'dds.event' AS t, count() AS rows FROM dds.event"
|
||||
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query \
|
||||
"SELECT 'dm.dq_summary' AS t, count() AS rows FROM dm.dq_summary"
|
||||
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \
|
||||
--query "SELECT count() FROM dm.v_events_enriched"
|
||||
```
|
||||
|
||||
---
|
||||
Подробный сценарий запуска, параметры DAG-ов и разбор частых проблем — в
|
||||
[OPERATIONS](./docs/OPERATIONS.md).
|
||||
|
||||
## Доступные сервисы
|
||||
## Сервисы и доступы
|
||||
|
||||
| Сервис | URL | Назначение | Логин/Пароль |
|
||||
|--------|-----|------------|--------------|
|
||||
| ClickHouse HTTP | http://localhost:9123/play | SQL-запросы | default/123456 |
|
||||
| Kafka UI | http://localhost:8082 | Просмотр топиков | — |
|
||||
| Airflow | http://localhost:8080 | Оркестрация ETL | admin/admin |
|
||||
| Superset | http://localhost:8088 | BI-дашборды | admin/admin |
|
||||
| Prometheus | http://localhost:9090 | Метрики | — |
|
||||
| Grafana | http://localhost:3000 | Визуализация метрик | admin/admin |
|
||||
| Сервис | Адрес | Назначение | Логин/пароль |
|
||||
|--------|-------|------------|--------------|
|
||||
| Airflow | `http://localhost:8080` | оркестрация ETL | admin/admin |
|
||||
| ClickHouse | `http://localhost:9123/play` | SQL-запросы | default/123456 |
|
||||
| Kafka UI | `http://localhost:8082` | просмотр топиков | — |
|
||||
| Superset | `http://localhost:8088` | дашборды | admin/admin |
|
||||
| Prometheus | `http://localhost:9090` | метрики | — |
|
||||
| Grafana | `http://localhost:3000` | графики метрик | admin/admin |
|
||||
|
||||
Superset: UI доступен после `make up` по адресу http://localhost:8088, дашборд — http://localhost:8088/superset/dashboard/1/
|
||||
Готовый дашборд в Superset:
|
||||
`http://localhost:8088/superset/dashboard/ecommerce-analytics/` — он создаётся
|
||||
автоматически через минуту-две после `make up`. Состав и настройка дашборда описаны в
|
||||
[SUPERSET_DASHBOARD](./docs/SUPERSET_DASHBOARD.md).
|
||||
|
||||
---
|
||||
|
||||
## Подключение DBeaver (кратко)
|
||||
|
||||
После прогона `ddl_init -> kafka_load -> etl_pipeline` можно быстро проверить витрины в DBeaver (удобно для демо бизнесу).
|
||||
|
||||
1. `Database -> New Database Connection -> ClickHouse`
|
||||
2. Параметры:
|
||||
- `Host`: `localhost`
|
||||
- `Port`: `9123` (HTTP)
|
||||
- `Database`: `default`
|
||||
- `Username`: `default`
|
||||
- `Password`: `123456`
|
||||
3. Нажать `Test Connection` -> `Finish`
|
||||
|
||||
Если ваш драйвер просит native-протокол, используйте порт `8002`.
|
||||
|
||||
Полезные быстрые запросы для первичного анализа:
|
||||
```sql
|
||||
SELECT count() AS rows FROM dm.v_events_enriched;
|
||||
SELECT * FROM dm.v_daily_traffic ORDER BY event_date DESC LIMIT 20;
|
||||
SELECT * FROM dm.v_utm_effectiveness ORDER BY clicks DESC LIMIT 20;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Архитектура (в двух словах)
|
||||
## Как устроен поток данных
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -131,222 +107,19 @@ flowchart LR
|
||||
D3 -.->|batch| ODS & DDS
|
||||
```
|
||||
|
||||
Особенность задания про "грязные данные": парсинг не валит pipeline, ошибки фиксируются в `ods.*_errors` и в поле `parse_errors`.
|
||||
«Грязные» записи не роняют пайплайн: ошибки разбора складываются в `ods.*_errors` и в
|
||||
поле `parse_errors`, а обработка продолжается.
|
||||
|
||||
[Подробное описание архитектуры →](./docs/ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── sql/
|
||||
│ ├── ddl/ # DDL по слоям
|
||||
│ │ ├── 00_databases.sql
|
||||
│ │ ├── stg/10_stg.sql
|
||||
│ │ ├── ods/20_ods.sql
|
||||
│ │ ├── dds/30_dds.sql
|
||||
│ │ └── dm/40_dm.sql
|
||||
│ ├── ods/ # Batch SQL: STG -> ODS
|
||||
│ ├── dds/ # Batch SQL: ODS -> DDS
|
||||
│ └── dm/ # Batch SQL: DDS -> DM
|
||||
├── configs/ # Конфигурации сервисов
|
||||
│ ├── superset_config.py # Конфиг Superset (PostgreSQL metadata)
|
||||
│ ├── prometheus/ # Prometheus конфигурация
|
||||
│ └── grafana/ # Grafana dashboards & datasources
|
||||
├── scripts/ # Служебные shell-скрипты (legacy fallback, не основной путь)
|
||||
├── airflow/ # Конфигурация Airflow
|
||||
│ ├── dags/ # Airflow DAGs для оркестрации
|
||||
│ └── requirements.txt
|
||||
├── superset/ # Скрипты инициализации Superset
|
||||
│ ├── init_superset.py # Подключение к ClickHouse + датасеты
|
||||
│ └── create_dashboard.py # Создание дашборда с чартами
|
||||
├── docs/ # Документация
|
||||
│ └── ARCHITECTURE.md # Подробное описание слоёв
|
||||
├── data/ # Исходные JSONL файлы
|
||||
├── docker-compose.yml
|
||||
└── Makefile # Команды: up, ddl, transform, superset-*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Команды Makefile
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `make up` | Поднять инфраструктуру |
|
||||
| `make ddl` | Применить DDL в ClickHouse (вне Airflow) |
|
||||
| `make transform` | Запустить batch-процесс `STG -> ODS -> DDS -> DM` (вне Airflow) |
|
||||
| `make superset-init` | Подключение к ClickHouse + импорт датасетов |
|
||||
| `make superset-dashboard` | Создание дашборда с чартами |
|
||||
| `make superset-ui` | Показать URL Superset |
|
||||
| `make superset-restart` | Перезапуск Superset |
|
||||
|
||||
Примечания про сохранность данных:
|
||||
- Данные ClickHouse сохраняются в Docker volume `clickhouse-data`.
|
||||
- Данные Kafka сохраняются в Docker volume `kafka-data`.
|
||||
- `docker compose down` сохраняет named volumes, `docker compose down -v` удаляет их (и данные пропадут).
|
||||
|
||||
---
|
||||
|
||||
## Ключи данных (как джойним)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Sources["Источники"]
|
||||
BE["browser_events (event_id, click_id)"]
|
||||
LE["location_events (event_id)"]
|
||||
DE["device_events (click_id)"]
|
||||
GE["geo_events (click_id)"]
|
||||
end
|
||||
|
||||
subgraph DDS["DDS"]
|
||||
EV["event (event_id PK)"]
|
||||
CL["click (click_id PK)"]
|
||||
end
|
||||
|
||||
subgraph DM["DM"]
|
||||
V1[v_events_enriched]
|
||||
V2[v_daily_traffic]
|
||||
V3[v_utm_effectiveness]
|
||||
end
|
||||
|
||||
BE -->|event_id| EV
|
||||
LE -->|event_id| EV
|
||||
BE -->|click_id| CL
|
||||
DE -->|click_id| CL
|
||||
GE -->|click_id| CL
|
||||
|
||||
EV -->|LEFT JOIN click_id| V1
|
||||
CL --> V1
|
||||
EV --> V2 & V3
|
||||
CL --> V2 & V3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Дашборд в Superset (опционально, но полезно)
|
||||
|
||||
Superset развёрнут с автоматической инициализацией: подключение к ClickHouse, датасеты и дашборд создаются автоматически при первом запуске.
|
||||
|
||||
### Быстрый доступ
|
||||
|
||||
| URL | Назначение | Логин/Пароль |
|
||||
|-----|------------|--------------|
|
||||
| http://localhost:8088 | Superset UI | admin/admin |
|
||||
| http://localhost:8088/superset/dashboard/1/ | Готовый дашборд | — |
|
||||
|
||||
### Автоматическая инициализация (рекомендуется)
|
||||
|
||||
```bash
|
||||
# При первом запуске инфраструктуры
|
||||
make up
|
||||
|
||||
# Дашборд создаётся автоматически через 30-60 секунд
|
||||
# Проверить готовность:
|
||||
curl http://localhost:8088/health # должно вернуть 200
|
||||
```
|
||||
|
||||
Что создаётся автоматически:
|
||||
- **Подключение к ClickHouse**: `clickhouse_dwh` (URI: `clickhousedb://default:123456@clickhouse:8123/default`)
|
||||
- **Датасеты** (6 шт.): `v_events_enriched`, `v_daily_traffic`, `v_utm_effectiveness`, `v_top_pages_daily`, `v_session_overview`, `dq_summary`
|
||||
- **Чарты** (10 шт.): KPI метрики, графики трафика, география, UTM-эффективность, качество данных
|
||||
- **Дашборд**: "🛒 E-commerce Analytics Dashboard"
|
||||
|
||||
### Ручная инициализация (если автоматика не сработала)
|
||||
|
||||
```bash
|
||||
# Подключение к ClickHouse + датасеты
|
||||
make superset-init
|
||||
|
||||
# Создание дашборда с чартами
|
||||
make superset-dashboard
|
||||
```
|
||||
|
||||
### Структура дашборда
|
||||
|
||||
Дашборд "E-commerce Analytics Dashboard" включает:
|
||||
|
||||
| Блок | Чарты | Датасет |
|
||||
|------|-------|---------|
|
||||
| **KPI** | Total Events, Unique Users, Unique Sessions, Avg Events/Session | `v_events_enriched` |
|
||||
| **Динамика** | Events by Hour (timeline), Traffic by Device (pie) | `v_events_enriched` |
|
||||
| **География** | World Map по странам | `v_events_enriched` |
|
||||
| **Маркетинг** | UTM Effectiveness Table, Top Pages | `v_utm_effectiveness`, `v_top_pages_daily` |
|
||||
| **Quality** | Data Quality Summary | `dq_summary` |
|
||||
|
||||
### Архитектура Superset
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Superset UI │────▶│ PostgreSQL │───▶│ ClickHouse │
|
||||
│ (localhost) │ │ (metadata) │ │ (данные) │
|
||||
│ :8088 │ │ dashboards, │ │ dm.v_* VIEW │
|
||||
└─────────────────┘ │ datasets, charts│ └─────────────────┘
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Особенности конфигурации:
|
||||
- **Metadata**: PostgreSQL (shared с Airflow) — данные сохраняются при перезапуске
|
||||
- **Data**: ClickHouse через `clickhouse-connect` (HTTP порт 8123)
|
||||
- **Config**: `configs/superset_config.py` (PostgreSQL URI, секретный ключ)
|
||||
|
||||
---
|
||||
|
||||
## Мониторинг инфраструктуры (Grafana + Prometheus)
|
||||
|
||||
Для оценки состояния ClickHouse доступен дашборд мониторинга:
|
||||
|
||||
1. Открыть Grafana: `http://localhost:3000` (admin/admin)
|
||||
2. Дашборд "ClickHouse Overview" загружается автоматически
|
||||
3. Проверить метрики Prometheus: `http://localhost:9090` → Status → Targets
|
||||
|
||||
Что отслеживается:
|
||||
- System Health: CPU, Memory (Resident/Code)
|
||||
- Query Performance: queries/sec, active queries, failed queries
|
||||
- MergeTree Storage: parts count, merge rate
|
||||
|
||||
Что алертится:
|
||||
- Failed queries rate (`rate(ClickHouseProfileEvents_FailedQuery[5m]) > 0`)
|
||||
- Memory Resident > 85% от `OSMemoryTotal`
|
||||
- Active parts > 500
|
||||
|
||||
Конфигурация provisioning находится в [`configs/grafana/provisioning/`](configs/grafana/provisioning/). Подробнее в [`docs/OPERATIONS.md`](docs/OPERATIONS.md#мониторинг).
|
||||
|
||||
---
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
- `etl_pipeline` падает с сообщением про схему: сначала запустите `ddl_init`.
|
||||
- После `docker compose down -v` схема и данные исчезнут: нужно заново запустить `ddl_init`, затем `kafka_load`, затем `etl_pipeline`.
|
||||
- Подключения используют разные протоколы:
|
||||
- Airflow (ClickHouseOperator) ходит в ClickHouse по native TCP (порт `9000` внутри сети Docker).
|
||||
- Superset (clickhouse-connect) ходит по HTTP (порт `8123` внутри сети Docker).
|
||||
- **Superset**: дашборд не появился сразу — подождите 30-60 секунд после `make up`, затем проверьте `curl http://localhost:8088/health`.
|
||||
- **Superset**: после `make clean` витрины `dm.*` ещё не созданы, поэтому чарты могут быть пустыми до запуска `ddl_init -> kafka_load -> etl_pipeline`; после этого выполните `make superset-init`.
|
||||
- **Superset**: при полном сбросе (`docker compose down -v`) метаданные Superset пропадут т.к. используется общая PostgreSQL. Для чистого перезапуска Superset удалите только БД `superset` в PostgreSQL и перезапустите контейнеры.
|
||||
|
||||
---
|
||||
|
||||
## Статус проекта
|
||||
|
||||
Реализовано:
|
||||
- **Инфраструктура**: Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana
|
||||
- **Ingest**: DAG `ddl_init` (DDL + проверка схемы), DAG `kafka_load` (параметры `limit`, `reset_topics`)
|
||||
- **Трансформации**: DAG `etl_pipeline` (pre-check, batch STG→ODS→DDS→DM, валидация)
|
||||
- **Витрины**: VIEW в DM для бизнес-дашбордов (трафик, UTM, качество данных)
|
||||
- **Superset**: Автоматическая инициализация (подключение ClickHouse, 6 датасетов, 10 чартов, дашборд), метаданные в PostgreSQL
|
||||
- **Надёжность**: ошибки парсинга сохраняются в ODS, пайплайн не падает на "грязных" данных
|
||||
- **Мониторинг**: Prometheus скрейпит ClickHouse метрики, Grafana дашборд и alert rules
|
||||
|
||||
В планах (не требуется для MVP задания):
|
||||
- Инкрементальный batch (watermark вместо `full_refresh`)
|
||||
- DQ мониторинг по расписанию
|
||||
|
||||
---
|
||||
Подробное описание слоёв STG/ODS/DDS/DM, диаграммы и обоснование решений —
|
||||
в [ARCHITECTURE](./docs/ARCHITECTURE.md).
|
||||
|
||||
## Документация
|
||||
|
||||
- [Архитектура и слои](./docs/ARCHITECTURE.md) — подробное описание STG/ODS/DDS/DM, ER-диаграммы, обоснование решений
|
||||
- [DE-task.md](./docs/DE-task.md) — исходное задание
|
||||
- [Архитектура и слои](./docs/ARCHITECTURE.md) — устройство STG/ODS/DDS/DM, диаграммы,
|
||||
обоснование решений.
|
||||
- [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов,
|
||||
мониторинг, частые проблемы.
|
||||
- [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять.
|
||||
- [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом
|
||||
стенде.
|
||||
- [DE-task.md](./docs/DE-task.md) — задание, из которого вырос стенд.
|
||||
@@ -9,7 +9,6 @@ DAG инициализации DDL в ClickHouse.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,6 +19,8 @@ from airflow.operators.empty import EmptyOperator
|
||||
from airflow.operators.python import BranchPythonOperator, PythonOperator
|
||||
from airflow.utils.trigger_rule import TriggerRule
|
||||
from airflow_clickhouse_plugin.operators.clickhouse import ClickHouseOperator
|
||||
from utils.airflow_params import parse_bool_param
|
||||
from utils.sql_helpers import load_sql_statements as load_sql_file_statements
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -55,23 +56,7 @@ SQL_ROOT = resolve_sql_root()
|
||||
|
||||
def load_sql_statements(relative_path: str) -> tuple[str, ...]:
|
||||
"""Читает SQL-файл и делит его на отдельные команды по ';'."""
|
||||
file_path = SQL_ROOT / relative_path
|
||||
if not file_path.is_file():
|
||||
raise AirflowException(f"SQL-файл не найден: {file_path}")
|
||||
|
||||
sql_text = file_path.read_text(encoding="utf-8")
|
||||
statements: list[str] = []
|
||||
for segment in sql_text.split(";"):
|
||||
# Убираем блочные и строковые комментарии, чтобы не отправлять "пустые" запросы.
|
||||
no_block_comments = re.sub(r"/\*.*?\*/", "", segment, flags=re.S)
|
||||
lines = [line for line in no_block_comments.splitlines() if not line.strip().startswith("--")]
|
||||
cleaned = "\n".join(lines).strip()
|
||||
if cleaned:
|
||||
statements.append(cleaned)
|
||||
|
||||
if not statements:
|
||||
raise AirflowException(f"SQL-файл пустой: {file_path}")
|
||||
return tuple(statements)
|
||||
return load_sql_file_statements(SQL_ROOT, relative_path)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -96,7 +81,10 @@ def choose_ddl_mode(**context) -> str:
|
||||
"""Выбирает ветку выполнения: full DDL или только verify."""
|
||||
dag_run = context.get("dag_run")
|
||||
conf = dag_run.conf if dag_run else {}
|
||||
verify_only = bool(conf.get("verify_only", context["params"]["verify_only"]))
|
||||
verify_only = parse_bool_param(
|
||||
conf.get("verify_only", context["params"]["verify_only"]),
|
||||
"verify_only",
|
||||
)
|
||||
return "skip_ddl" if verify_only else "ddl_00_databases"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного проекта.
|
||||
|
||||
Поток задач:
|
||||
precheck -> transform: wait -> ods -> dq -> branch -> dds -> integrity -> dm -> validate
|
||||
|
||||
Принципы реализации:
|
||||
- SQL выполняется явными task на ClickHouseOperator;
|
||||
- SQL-файлы вызываются по фиксированным путям;
|
||||
@@ -9,7 +12,6 @@ DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного про
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -23,6 +25,8 @@ from airflow.utils.task_group import TaskGroup
|
||||
from airflow.utils.trigger_rule import TriggerRule
|
||||
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
|
||||
from airflow_clickhouse_plugin.operators.clickhouse import ClickHouseOperator
|
||||
from utils.airflow_params import parse_bool_param
|
||||
from utils.sql_helpers import load_sql_statements as load_sql_file_statements
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -58,23 +62,7 @@ SQL_ROOT = resolve_sql_root()
|
||||
|
||||
def load_sql_statements(relative_path: str) -> tuple[str, ...]:
|
||||
"""Читает SQL-файл и делит его на отдельные команды по ';'."""
|
||||
file_path = SQL_ROOT / relative_path
|
||||
if not file_path.is_file():
|
||||
raise AirflowException(f"SQL-файл не найден: {file_path}")
|
||||
|
||||
sql_text = file_path.read_text(encoding="utf-8")
|
||||
statements: list[str] = []
|
||||
for segment in sql_text.split(";"):
|
||||
# Убираем блочные и строковые комментарии, чтобы не отправлять "пустые" запросы.
|
||||
no_block_comments = re.sub(r"/\*.*?\*/", "", segment, flags=re.S)
|
||||
lines = [line for line in no_block_comments.splitlines() if not line.strip().startswith("--")]
|
||||
cleaned = "\n".join(lines).strip()
|
||||
if cleaned:
|
||||
statements.append(cleaned)
|
||||
|
||||
if not statements:
|
||||
raise AirflowException(f"SQL-файл пустой: {file_path}")
|
||||
return tuple(statements)
|
||||
return load_sql_file_statements(SQL_ROOT, relative_path)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -228,7 +216,10 @@ def choose_full_refresh(**context) -> str:
|
||||
"""Ветвление: делать TRUNCATE DDS или пропустить."""
|
||||
dag_run = context.get("dag_run")
|
||||
conf = dag_run.conf if dag_run else {}
|
||||
full_refresh = bool(conf.get("full_refresh", context["params"]["full_refresh"]))
|
||||
full_refresh = parse_bool_param(
|
||||
conf.get("full_refresh", context["params"]["full_refresh"]),
|
||||
"full_refresh",
|
||||
)
|
||||
return "transform.truncate_dds_click" if full_refresh else "transform.skip_truncate"
|
||||
|
||||
|
||||
@@ -245,6 +236,22 @@ def assert_dm_summary_not_empty(**context) -> None:
|
||||
raise AirflowException("dm.dq_summary пуста после load_dm_summary.")
|
||||
|
||||
|
||||
def assert_dds_integrity(**context) -> None:
|
||||
"""Падает, если события ссылаются на отсутствующие клики."""
|
||||
ti = context["ti"]
|
||||
result = ti.xcom_pull(task_ids="transform.check_dds_integrity")
|
||||
|
||||
if not result or not result[0] or len(result[0]) != 1:
|
||||
raise AirflowException(f"Некорректный результат check_dds_integrity: {result}")
|
||||
|
||||
orphan_events = int(result[0][0])
|
||||
if orphan_events > 0:
|
||||
raise AirflowException(
|
||||
f"DDS integrity check failed: orphan_events={orphan_events}. "
|
||||
"Есть события, чей click_id отсутствует в dds.click."
|
||||
)
|
||||
|
||||
|
||||
with DAG(
|
||||
dag_id="etl_pipeline",
|
||||
description="ETL STG -> ODS -> DDS -> DM для demo-проекта",
|
||||
@@ -342,6 +349,12 @@ with DAG(
|
||||
database="default",
|
||||
)
|
||||
|
||||
assert_dds_integrity_task = PythonOperator(
|
||||
task_id="assert_dds_integrity",
|
||||
python_callable=assert_dds_integrity,
|
||||
retries=0,
|
||||
)
|
||||
|
||||
load_dm_summary = ClickHouseOperator(
|
||||
task_id="load_dm_summary",
|
||||
sql=load_sql_statements("dm/40_dds_to_dm.sql"),
|
||||
@@ -364,6 +377,14 @@ with DAG(
|
||||
wait_for_stg_data_task >> load_ods >> check_ods_quality >> choose_refresh_mode
|
||||
choose_refresh_mode >> truncate_dds_click >> truncate_dds_event >> truncate_complete
|
||||
choose_refresh_mode >> skip_truncate >> truncate_complete
|
||||
truncate_complete >> load_dds >> check_dds_integrity >> load_dm_summary >> validate_dm_summary_sql >> validate_dm_summary
|
||||
(
|
||||
truncate_complete
|
||||
>> load_dds
|
||||
>> check_dds_integrity
|
||||
>> assert_dds_integrity_task
|
||||
>> load_dm_summary
|
||||
>> validate_dm_summary_sql
|
||||
>> validate_dm_summary
|
||||
)
|
||||
|
||||
precheck >> transform
|
||||
@@ -24,6 +24,7 @@ from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.task_group import TaskGroup
|
||||
|
||||
# Импортируем helper-функции
|
||||
from utils.airflow_params import parse_bool_param
|
||||
from utils.kafka_helpers import (
|
||||
check_input_files,
|
||||
check_kafka_ready,
|
||||
@@ -69,7 +70,10 @@ def _validate_params(**context) -> None:
|
||||
def _prepare_topics(**context) -> None:
|
||||
"""Подготовка топиков Kafka (создание/сброс)."""
|
||||
conf = context.get("dag_run", {}).conf or {}
|
||||
reset_topics = bool(conf.get("reset_topics", context["params"]["reset_topics"]))
|
||||
reset_topics = parse_bool_param(
|
||||
conf.get("reset_topics", context["params"]["reset_topics"]),
|
||||
"reset_topics",
|
||||
)
|
||||
|
||||
prepare_topics(reset=reset_topics)
|
||||
|
||||
|
||||
@@ -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) | Данные страниц |
|
||||
| `device_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Устройства |
|
||||
| `geo_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Гео-данные |
|
||||
| `*_errors` | — | MergeTree | Строки с критичными ошибками парсинга |
|
||||
| `*_errors` | — | MergeTree | Копия строк с любой ошибкой разбора: Kafka-метаданные + `raw` + `error_reason` (не типизированная копия события) |
|
||||
|
||||
**Batch шаг наполнения ODS:**
|
||||
|
||||
@@ -155,11 +155,12 @@ CREATE TABLE stg.browser_raw (
|
||||
| `load_ods` (`sql/ods/20_stg_to_ods.sql`) | Полная пересборка ODS из STG в рамках DAG `etl_pipeline` |
|
||||
| `TRUNCATE ods.*` | Очистка перед пересборкой для детерминированного результата |
|
||||
| `INSERT ... SELECT` | Типизация валидных строк в основные ODS таблицы |
|
||||
| `INSERT ... SELECT` в `*_errors` | Сохранение строк с критичными ошибками парсинга |
|
||||
| `INSERT ... SELECT` в `*_errors` | Сохранение копии строк с любой ошибкой разбора (Kafka-метаданные + `raw` + `error_reason`) |
|
||||
|
||||
**Логика разделения:**
|
||||
- **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`).
|
||||
- **Таблица ошибок**: строки с критичными ошибками (невалидный key, timestamp/координаты и т.п.) через отдельный `INSERT ... SELECT`.
|
||||
**Логика разделения (DQ-split):**
|
||||
- **Основная таблица** `ods.*`: строки с валидным бизнес-ключом (`WHERE key IS NOT NULL`). Ошибка по *неключевому* полю не выкидывает строку — она остаётся, но помечается в массиве `parse_errors`.
|
||||
- **Таблица ошибок** `ods.*_errors`: **копия** строк, где при разборе случилась *любая* ошибка (отдельный `INSERT ... SELECT`). У неё своя схема — не типизированное событие, а сырьё для разбора.
|
||||
- Условия пересекаются нарочно: строка с валидным ключом, но битым неключевым полем попадает **и в основную таблицу, и в `*_errors`**. Подробный разбор — в разделе [«Обработка ошибок в ODS»](#обработка-ошибок-в-ods-dq-split) и в уроке 2 курса.
|
||||
|
||||
**Пример структуры:**
|
||||
```sql
|
||||
@@ -202,7 +203,7 @@ GROUP BY error;
|
||||
|
||||
| Таблица | PK | Источники | JOIN-ключ |
|
||||
|---------|-----|-----------|-----------|
|
||||
| `event` | event_id | browser_event + location_event | click_id → click |
|
||||
| `event` | event_id | browser_event (ведущая) + location_event (LEFT) | click_id → click |
|
||||
| `click` | click_id | device_by_click + geo_by_click | — |
|
||||
|
||||
**Структура:**
|
||||
@@ -301,7 +302,7 @@ LEFT JOIN (
|
||||
| `v_daily_traffic` | Агрегация трафика | День × страна × устройство × браузер × UTM |
|
||||
| `v_top_pages_daily` | Популярность страниц | День × URL path |
|
||||
| `v_utm_effectiveness` | Маркетинговая аналитика | День × UTM source/medium/campaign |
|
||||
| `v_session_overview` | Сессионная аналитика | День × пользователь × сессия |
|
||||
| `v_session_overview` | Сессионная аналитика (только идентифицированные пользователи, `user_domain_id IS NOT NULL`) | День × пользователь × сессия |
|
||||
| `v_dq_errors_daily` | Мониторинг качества | День × тип ошибки |
|
||||
|
||||
**Пример:**
|
||||
@@ -328,7 +329,7 @@ FROM ...
|
||||
```
|
||||
|
||||
- `TRUNCATE` предотвращает накопление дубликатов при повторных запусках
|
||||
- Хранит статистику по всем слоям (stg/ods/dds) для быстрой проверки
|
||||
- Хранит статистику по всем слоям (`stg`/`ods`/`dds`/`dm`): `total_rows` по таблицам, `rows_with_errors` в ODS, `orphan_events` в DDS (события без своего клика) и `total_rows` финальной витрины `dm.v_events_enriched` — чтобы lineage замыкался на одном (event) зерне вплоть до DM
|
||||
|
||||
**Почему VIEW:**
|
||||
- Для демо: достаточно производительности
|
||||
@@ -395,7 +396,7 @@ sequenceDiagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
BROWSER_EVENT ||--|| LOCATION_EVENT : "event_id"
|
||||
BROWSER_EVENT ||--o| LOCATION_EVENT : "event_id (LEFT)"
|
||||
BROWSER_EVENT ||--o| DEVICE_BY_CLICK : "click_id"
|
||||
BROWSER_EVENT ||--o| GEO_BY_CLICK : "click_id"
|
||||
|
||||
@@ -424,6 +425,7 @@ erDiagram
|
||||
UUID click_id PK
|
||||
String os
|
||||
String os_name
|
||||
String os_timezone
|
||||
String device_type
|
||||
UInt8 device_is_mobile
|
||||
String user_custom_id
|
||||
@@ -443,11 +445,11 @@ erDiagram
|
||||
|
||||
### Сборка DDS-сущностей
|
||||
|
||||
**event** (browser + location):
|
||||
**event** (browser + location), `browser_event` — ведущая таблица:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph ODS["ODS"]
|
||||
B["browser_event"]
|
||||
B["browser_event<br/>(ведущая)"]
|
||||
L["location_event"]
|
||||
end
|
||||
|
||||
@@ -455,10 +457,12 @@ flowchart LR
|
||||
EV["event"]
|
||||
end
|
||||
|
||||
B -->|JOIN event_id| EV
|
||||
L -->|JOIN event_id| EV
|
||||
B -->|все event_id| EV
|
||||
L -.->|LEFT JOIN event_id| EV
|
||||
```
|
||||
|
||||
**Важно:** сборка идёт **от browser** через `LEFT JOIN location`. `event_id`, которые есть только в `location_event` (без browser), в `dds.event` **не попадают**. Если у события нет своей location-строки — событие остаётся, поля страницы/UTM пустые (`NULL`), и в `ods_parse_errors` ставится маркер `location_not_found`. Тот же принцип, что и в `dds.click`: не теряем, а оставляем видимый след. Подробнее — урок 3 курса (`docs/course/lessons/03_ods_to_dds.md`).
|
||||
|
||||
**click** (device + geo) с поддержкой partial data:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -512,24 +516,37 @@ flowchart LR
|
||||
| **MV + JOIN** | Реалтайм | Eventual consistency, дубли при late arrival |
|
||||
| **Batch (выбрано)** | Согласованность, контроль | Задержка до следующего запуска |
|
||||
|
||||
### Обработка ошибок в ODS
|
||||
### Обработка ошибок в ODS (DQ-split)
|
||||
|
||||
**Проблема:** Грязные данные могут содержать не только невалидные ключи, но и невалидные timestamp/координаты/ID.
|
||||
|
||||
**Решение:**
|
||||
1. **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`)
|
||||
2. **Таблица ошибок**: строки с критичными ошибками парсинга через отдельные `INSERT ... SELECT`
|
||||
3. **DQ-метрики**: массив `parse_errors` для аудита
|
||||
**Решение — DQ-split:**
|
||||
1. **Основная таблица** `ods.*`: строки с валидным бизнес-ключом (`WHERE key IS NOT NULL`). Ошибки по *неключевым* полям не выкидывают строку — она остаётся, но помечается массивом `parse_errors`.
|
||||
2. **Таблица ошибок** `ods.*_errors`: **копия** строк, где при разборе случилась *любая* ошибка. Это не типизированная копия события, а сырьё для разбора — метаданные доставки из Kafka, исходный JSON и причина ошибки:
|
||||
- `ingest_ts`, `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_ts` — координаты сообщения в Kafka;
|
||||
- `raw` — исходный JSON «как пришёл»;
|
||||
- `error_reason` — список несработавших полей одной строкой (`arrayStringConcat(parse_errors, ',')`).
|
||||
3. **DQ-метрики**: массив `parse_errors` в основной таблице для аудита.
|
||||
|
||||
**Одна строка может попасть в оба места — это не баг, а замысел.** Строка с валидным ключом, но битым неключевым полем (например, валидный `event_id`, но `event_ts IS NULL`) и **остаётся** в основной таблице (с меткой в `parse_errors`), и **копируется** в `*_errors`. Основная таблица отвечает на вопрос «что есть для работы», таблица ошибок — «что пришло битым и требует разбора». Подробный разбор — в уроке 2 курса (`docs/course/lessons/02_stg_to_ods.md`).
|
||||
|
||||
```sql
|
||||
-- Основная таблица
|
||||
INSERT INTO ods.browser_event
|
||||
SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL;
|
||||
-- event_id, event_ts, click_id, parse_errors — это не колонки stg.browser_raw,
|
||||
-- а алиасы из блока WITH, где raw (сырой JSON) разбирается через JSONExtract*/*OrNull.
|
||||
-- Здесь WITH опущен для краткости; полная версия — в sql/ods/20_stg_to_ods.sql.
|
||||
|
||||
-- Таблица ошибок
|
||||
-- Основная таблица: валидный ключ; parse_errors помечает битые неключевые поля
|
||||
INSERT INTO ods.browser_event
|
||||
SELECT ..., parse_errors FROM stg.browser_raw WHERE event_id IS NOT NULL;
|
||||
|
||||
-- Таблица ошибок: другая схема (Kafka-метаданные + raw + error_reason);
|
||||
-- сюда едет копия любой строки с хотя бы одной ошибкой разбора
|
||||
INSERT INTO ods.browser_event_errors
|
||||
SELECT ... FROM stg.browser_raw
|
||||
WHERE event_id IS NULL OR event_ts IS NULL OR click_id IS NULL;
|
||||
SELECT ingest_ts, kafka_topic, kafka_partition, kafka_offset, kafka_ts,
|
||||
raw, arrayStringConcat(parse_errors, ',') AS error_reason
|
||||
FROM stg.browser_raw
|
||||
WHERE length(parse_errors) > 0
|
||||
AND (event_id IS NULL OR event_ts IS NULL OR click_id IS NULL);
|
||||
```
|
||||
|
||||
### Partial data в DDS
|
||||
@@ -539,7 +556,22 @@ WHERE event_id IS NULL OR event_ts IS NULL OR click_id IS NULL;
|
||||
**Решение:**
|
||||
1. **UNION DISTINCT** всех click_id из обоих источников
|
||||
2. **LEFT JOIN** для получения данных (обрабатываем device-only и geo-only)
|
||||
3. **DQ-маркеры**: `device_not_found`, `geo_not_found` в `parse_errors`
|
||||
3. **DQ-маркеры** в `ods_parse_errors`: `device_not_found`, `geo_not_found` (нет соответствующего источника по `click_id`), `geo_country_missing` (гео есть, но страна не определена)
|
||||
|
||||
### Перечень DQ-маркеров
|
||||
|
||||
Маркеры качества копятся в массивах `parse_errors` (ODS) и `ods_parse_errors` (DDS). Полный список:
|
||||
|
||||
| Слой / таблица | Маркеры | Когда ставится |
|
||||
|----------------|---------|----------------|
|
||||
| ODS `browser_event` | `bad_event_id`, `bad_event_timestamp`, `bad_click_id` | поле не разобралось в нужный тип |
|
||||
| ODS `location_event` | `bad_event_id` | не разобрался `event_id` |
|
||||
| ODS `device_by_click` | `bad_click_id`, `bad_user_domain_id` | не разобрались `click_id` / `user_domain_id` |
|
||||
| ODS `geo_by_click` | `bad_click_id`, `bad_geo_latitude`, `bad_geo_longitude` | не разобрались ключ или координаты |
|
||||
| DDS `click` | `device_not_found`, `geo_not_found`, `geo_country_missing` | нет источника по `click_id` либо страна не определена |
|
||||
| DDS `event` | `location_not_found` | у события нет своей location-строки |
|
||||
|
||||
В DDS наследуются `parse_errors` только **ведущего** источника (device → `dds.click`, browser → `dds.event`); к ним добавляются маркеры стыковки (`*_not_found`, `*_missing`). `parse_errors` из geo/location в DDS не переносятся.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+19
-11
@@ -10,7 +10,7 @@
|
||||
- `make down` (остановить и удалить контейнеры/сети проекта)
|
||||
- `make clean` (полная очистка: `down -v --remove-orphans`)
|
||||
- `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse)
|
||||
- `make data` (пересоздаёт топики и заливает небольшой срез данных в Kafka; полный режим — `FULL=1 make data`)
|
||||
- `make data` (пересоздаёт топики и заливает данные в Kafka; по умолчанию полный объём, срез — `LIMIT=50 make data`)
|
||||
- `make transform` (запускает batch-процесс ODS -> DDS -> DM)
|
||||
- `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд)
|
||||
- `docker compose ps`
|
||||
@@ -22,13 +22,14 @@
|
||||
|
||||
Порты задаются в `docker-compose.yml`:
|
||||
|
||||
- ClickHouse native: `localhost:8002`
|
||||
- ClickHouse HTTP: `localhost:9123`
|
||||
- ClickHouse native: `localhost:8002` (пользователь `default`, пароль `123456`)
|
||||
- ClickHouse HTTP / play-консоль: `http://localhost:9123/play` (`default` / `123456`)
|
||||
- Kafka: `localhost:9092`
|
||||
- Kafka UI: `http://localhost:8082`
|
||||
- Airflow: `http://localhost:8080` (`admin/admin`)
|
||||
- Superset: `http://localhost:8088` (`admin/admin`)
|
||||
- Prometheus: `http://localhost:9090`
|
||||
- Grafana: `http://localhost:3000`
|
||||
- Grafana: `http://localhost:3000` (`admin/admin`)
|
||||
|
||||
## Airflow DAGs
|
||||
|
||||
@@ -57,8 +58,15 @@
|
||||
### `etl_pipeline`
|
||||
|
||||
- Запуск: ручной (`Trigger DAG with config`)
|
||||
- Параметр: `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой
|
||||
- Параметры:
|
||||
- `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой
|
||||
- `wait_stg_timeout_sec` (`int`, default `600`, minimum `30`) — сколько секунд задача `wait_for_stg_data` ждёт появления данных в STG, прежде чем упасть по таймауту
|
||||
- Зависимость: требует наличия данных в STG (от `kafka_load` или `make data`)
|
||||
- Гейт целостности DDS: `check_dds_integrity` считает события без клика, а
|
||||
`assert_dds_integrity` роняет DAG при `orphan_events > 0`. Проверка идёт после
|
||||
`load_dds` и до `load_dm_summary`, чтобы DM не собирался поверх нарушенной связи
|
||||
`dds.event -> dds.click`. Для `assert_dds_integrity` задано `retries=0`: повтор не
|
||||
чинит уже собранную сироту и только задерживает явный failed-статус.
|
||||
|
||||
## Рекомендуемый сценарий (фаза 2)
|
||||
|
||||
@@ -183,8 +191,8 @@ URL: `http://localhost:3000/d/clickhouse-overview/clickhouse-overview`
|
||||
| Раздел | Метрики |
|
||||
|--------|---------|
|
||||
| System Health | CPU Usage, Memory Resident, Memory Code |
|
||||
| Query Performance | Queries/sec, Active Queries, Failed Queries, Total Queries, Inserted Rows/sec |
|
||||
| MergeTree Storage | Total Parts, Parts by State, Total Merges, Merges/sec |
|
||||
| Query Performance | Queries per Second, Active Queries, Failed Queries (total), Total Queries, Inserted Rows/sec |
|
||||
| MergeTree Storage | Total Parts, Parts by State, Total Merges, Merges per Second |
|
||||
|
||||
Принятое решение по метрикам: сверили naming через Context7 (`/clickhouse/clickhouse-docs`, раздел Prometheus interface) и заменили недоступные в `25.1` серии на фактически экспортируемые (`ClickHouseProfileEvents_InsertedRows`, `ClickHouseAsyncMetrics_TotalPartsOfMergeTreeTables`, `ClickHouseMetrics_Parts*`).
|
||||
|
||||
@@ -194,10 +202,10 @@ URL: `http://localhost:3000/d/kafka-overview/kafka-overview`
|
||||
|
||||
| Раздел | Метрики |
|
||||
|--------|---------|
|
||||
| Cluster Health | Brokers Up, Topics Count, Total Partitions, Consumer Groups |
|
||||
| Throughput | Messages In/sec by Topic |
|
||||
| Consumers | Consumer Lag by Group, Consumer Lag Table |
|
||||
| Partitions | Partition Offsets (Current), Oldest vs Current Offset Gap |
|
||||
| Cluster Health | Brokers Up, Topics, Total Partitions, Consumer Groups |
|
||||
| Throughput | Messages In / sec by Topic |
|
||||
| Consumers | Consumer Lag by Group |
|
||||
| Partitions | Partition Offsets (Current) |
|
||||
|
||||
**Источник метрик:** `kafka-exporter` (danielqsj/kafka-exporter), формат конфигурации подтверждён через Context7 (`/danielqsj/kafka_exporter`, `/prometheus/docs`).
|
||||
|
||||
|
||||
+24
-5
@@ -4,25 +4,40 @@
|
||||
|
||||
## Исполняемые файлы
|
||||
|
||||
### Airflow
|
||||
### Airflow (основной путь запуска)
|
||||
|
||||
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
|
||||
- `airflow/dags/kafka_load_dag.py` — загрузка в Kafka из JSONL
|
||||
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
|
||||
- `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka
|
||||
- `airflow/dags/utils/sql_helpers.py` — чтение и подготовка SQL-файлов для DAG
|
||||
- `airflow/dags/utils/airflow_params.py` — разбор и валидация параметров DAG
|
||||
- `airflow/requirements.txt` — зависимости Airflow/ClickHouse plugin
|
||||
|
||||
### SQL
|
||||
|
||||
DDL (форма таблиц):
|
||||
|
||||
- `sql/ddl/00_databases.sql` — создание БД `stg`/`ods`/`dds`/`dm`
|
||||
- `sql/ddl/stg/10_stg.sql` — STG (Kafka Engine + MV)
|
||||
- `sql/ddl/ods/20_ods.sql` — ODS (типизация + MV для ошибок)
|
||||
- `sql/ddl/ods/20_ods.sql` — ODS: типизированные таблицы и `*_errors` (наполняются batch, не MV)
|
||||
- `sql/ddl/dds/30_dds.sql` — DDS (таблицы для batch-загрузки)
|
||||
- `sql/ddl/dm/40_dm.sql` — DM (витрины VIEW)
|
||||
- `sql/dds/30_ods_to_dds.sql` — ODS -> DDS (argMax + JOIN)
|
||||
- `sql/dm/40_dds_to_dm.sql` — обновление `dq_summary`
|
||||
|
||||
### Скрипты
|
||||
Трансформации (наполнение, шаги `etl_pipeline`):
|
||||
|
||||
- `sql/ods/20_stg_to_ods.sql` — STG -> ODS: типизация + DQ-split (валидный ключ → `ods.*`, любая ошибка → `ods.*_errors`)
|
||||
- `sql/dds/30_ods_to_dds.sql` — ODS -> DDS (argMax + LEFT JOIN)
|
||||
- `sql/dm/40_dds_to_dm.sql` — DDS -> DM: пересборка `dm.dq_summary` (TRUNCATE+INSERT) по всем слоям; сами витрины `dm.v_*` — это VIEW из DDL
|
||||
|
||||
### Superset
|
||||
|
||||
- `superset/init_superset.py` — подключение к ClickHouse + создание датасетов
|
||||
- `superset/create_dashboard.py` — сборка дашборда с чартами
|
||||
|
||||
### Скрипты (запасной путь, не основной)
|
||||
|
||||
Shell-скрипты `scripts/*` (и обёртки `make ddl`/`make data`/`make transform`) — это локальный fallback в обход Airflow. Основной путь запуска — DAG-и Airflow (см. выше).
|
||||
|
||||
- `scripts/apply_clickhouse_ddl.sh` — применение DDL
|
||||
- `scripts/load_kafka_data.sh` — загрузка в Kafka
|
||||
@@ -48,8 +63,12 @@
|
||||
- `README.md` — быстрый старт и обзор проекта
|
||||
- `docs/ARCHITECTURE.md` — техническая архитектура
|
||||
- `docs/OPERATIONS.md` — запуск, проверки, troubleshooting
|
||||
- `docs/SUPERSET_DASHBOARD.md` — настройка и использование дашборда Superset
|
||||
- `docs/DE-task.md` — исходное задание
|
||||
- `docs/COMMIT_RULES.md` — правила коммитов
|
||||
- `docs/course/` — продвинутый учебный курс на базе стенда (PRD, план, уроки)
|
||||
- `docs/adr/` — архитектурные решения (ADR)
|
||||
- `docs/agents/` — контракты для агентских скиллов (issue-tracker, triage, domain)
|
||||
|
||||
## Legacy-планы
|
||||
|
||||
|
||||
+61
-26
@@ -28,7 +28,7 @@ make transform
|
||||
# Автоматическая инициализация (создание подключения и датасетов)
|
||||
make superset-init
|
||||
|
||||
# Создание дашборда с чартами
|
||||
# Создание дашборда с чартами; при необходимости обновляет metadata колонок датасетов
|
||||
make superset-dashboard
|
||||
```
|
||||
|
||||
@@ -52,18 +52,24 @@ make superset-dashboard
|
||||
| **UTM Effectiveness** | `dm.v_utm_effectiveness` | Эффективность маркетинговых каналов |
|
||||
| **Top Pages** | `dm.v_top_pages_daily` | Популярность страниц |
|
||||
| **Session Overview** | `dm.v_session_overview` | Анализ сессий |
|
||||
| **DQ Summary** | `dm.dq_summary` | Качество данных |
|
||||
| **DQ Summary** | `dm.dq_summary` | Метрики по слоям (строки, ошибки, сироты) |
|
||||
|
||||
### Чарты (Charts)
|
||||
|
||||
#### KPI-блок (верх дашборда)
|
||||
- **📊 Total Events** — общее количество событий
|
||||
- **👤 Unique Users** — уникальные пользователи
|
||||
- **🎯 Unique Sessions** — уникальные сессии (click_id)
|
||||
- **📈 Avg Events/Session** — среднее количество событий на сессию
|
||||
- **📈 Avg Events/Visit** — среднее количество событий на визит (`click_id`)
|
||||
- **🎯 Conversion to /confirmation** — доля просмотров `/confirmation` от просмотров `/home`
|
||||
|
||||
KPI разложены в одну строку по 12-колоночной сетке Superset: четыре блока по 3 колонки.
|
||||
`Unique Sessions` не вынесен отдельной KPI-плиткой, потому что в демо-данных
|
||||
`user_domain_id` и `click_id` идут 1:1 и дают то же число, что `Unique Users`.
|
||||
|
||||
#### Динамика трафика
|
||||
- **📅 Events by Hour** — линейный график событий по часам
|
||||
- **📅 Events over Time** — линейный график событий с 5-минутными бакетами
|
||||
(все события стенда укладываются в ~50 минут, поэтому часовая гранулярность
|
||||
давала бы всего 2 точки и прямую линию)
|
||||
- **📱 Traffic by Device** — pie chart распределения по устройствам
|
||||
|
||||
#### География
|
||||
@@ -71,19 +77,51 @@ make superset-dashboard
|
||||
|
||||
#### Маркетинг
|
||||
- **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток
|
||||
- **📄 Top Pages** — bar chart топ-20 страниц
|
||||
- **🪜 Page Funnel** — funnel chart по просмотрам страниц, от `/home` к `/confirmation`
|
||||
|
||||
#### Качество данных
|
||||
- **🔍 Data Quality Summary** — статистика по слоям STG/ODS/DDS
|
||||
> **Что проверили по Superset 4.1.2.** Через MCP Context7 проверили официальную
|
||||
> библиотеку `/apache/superset`; документация не дала точной строки `viz_type`.
|
||||
> В установленном Superset 4.1.2 дополнительно проверили bundled example
|
||||
> `Featured Charts/Funnel.yaml` и frontend assets: для воронки используется
|
||||
> `viz_type: funnel`, поэтому dashboard создаёт именно funnel chart.
|
||||
|
||||
#### Прохождение строк по слоям
|
||||
- **🧱 Rows by Layer (event)** — `dist_bar` по `dm.dq_summary`: сколько строк
|
||||
одного **event-зерна** в каждом слое конвейера `STG → ODS → DDS → DM`.
|
||||
|
||||
> **Почему именно одно зерно, а не сумма по слою.** Чарт берёт по одной
|
||||
> канонической таблице на слой (`browser_raw → browser_event → event →
|
||||
> v_events_enriched`). Если суммировать `total_rows` по всем таблицам слоя,
|
||||
> в один столбец складываются таблицы разного зерна (события `1000` + визиты `99`
|
||||
> + пустые error-таблицы) и получается **ложная «воронка потерь»**, которой нет.
|
||||
> На одном зерне убывание становится настоящим: видимый шаг **1050 → 1000** —
|
||||
> это дедупликация at-least-once потока по `event_id` в ODS
|
||||
> (`ReplacingMergeTree`), а дальше число стабильно до витрины.
|
||||
>
|
||||
> Настоящие сигналы качества (`rows_with_errors` в ODS, `orphan_events` в DDS)
|
||||
> на чистых демо-данных равны нулю и живут в `dm.dq_summary` отдельными
|
||||
> `check_name` — их разбирают уроки 3–4, а не этот чарт.
|
||||
|
||||
> **Порядок столбцов.** В groupby подпись слоя получает числовой префикс
|
||||
> (`1 · stg`, `2 · ods`, …), а `order_bars` сортирует бары по подписи — иначе
|
||||
> `dist_bar` ставит их по убыванию значения, а не по порядку конвейера.
|
||||
|
||||
### Фильтры (Native Filters)
|
||||
|
||||
| Фильтр | Поле | Тип | Применение |
|
||||
|--------|------|-----|------------|
|
||||
| 📅 Date Range | `event_date` | Time Range | Все чарты |
|
||||
| 🌍 Country | `geo_country` | Multi-select | Все чарты |
|
||||
| 📱 Device Type | `device_type` | Multi-select | Все чарты |
|
||||
| 🌐 Browser | `browser_name` | Multi-select | Все чарты |
|
||||
| 📅 Date Range | `event_date` | Time Range | Charts с `event_date`; по умолчанию `No filter`, чтобы демо-данные 2022 года не скрывались |
|
||||
| 🌍 Country | `geo_country` | Multi-select | Charts на `dm.v_events_enriched` |
|
||||
| 📱 Device Type | `device_type` | Multi-select | Charts на `dm.v_events_enriched` |
|
||||
| 🌐 Browser | `browser_name` | Multi-select | Charts на `dm.v_events_enriched` |
|
||||
|
||||
Фильтры работают через левую панель Superset. Click-to-filter между виджетами не включен:
|
||||
клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts.
|
||||
|
||||
Фильтр применяется только к charts, где есть нужное поле. Агрегированные витрины
|
||||
`dm.v_utm_effectiveness` и `dm.v_top_pages_daily` содержат `event_date`, но не содержат
|
||||
`geo_country`, `device_type` и `browser_name`. `dm.dq_summary` использует `check_date`;
|
||||
бизнес-фильтры на него не рассчитаны.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,7 +142,6 @@ make transform # Запуск batch-процесса
|
||||
# Superset
|
||||
make superset-init # Инициализация (подключение + датасеты)
|
||||
make superset-dashboard # Создание дашборда
|
||||
make superset-export # Экспорт дашборда в JSON
|
||||
make superset-ui # Показать URL и логин
|
||||
make superset-restart # Перезапуск сервиса
|
||||
```
|
||||
@@ -157,22 +194,16 @@ python /app/superset_init/init_superset.py
|
||||
|
||||
---
|
||||
|
||||
## Экспорт и импорт дашборда
|
||||
## Импорт дашборда
|
||||
|
||||
### Экспорт
|
||||
|
||||
```bash
|
||||
# Автоматический экспорт в JSON
|
||||
make superset-export
|
||||
|
||||
# Результат: superset/dashboards/ecommerce_analytics.json
|
||||
```
|
||||
|
||||
### Импорт
|
||||
Основной способ собрать дашборд — `make superset-dashboard` (скрипт `create_dashboard.py`).
|
||||
Готовый экспорт дашборда лежит в репозитории на случай ручного импорта:
|
||||
`superset/dashboards/ecommerce_analytics.zip.json` (внутри контейнера —
|
||||
`/app/superset_init/dashboards/ecommerce_analytics.zip.json`).
|
||||
|
||||
```bash
|
||||
# Импорт через CLI
|
||||
docker compose exec superset superset import-dashboards -p /app/superset_init/dashboards/ecommerce_analytics.json
|
||||
docker compose exec superset superset import-dashboards -p /app/superset_init/dashboards/ecommerce_analytics.zip.json
|
||||
|
||||
# Или через UI: Settings → Import Dashboards
|
||||
```
|
||||
@@ -217,7 +248,11 @@ make superset-restart
|
||||
# Полная переинициализация
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
make ddl
|
||||
make data
|
||||
make transform
|
||||
make superset-init
|
||||
make superset-dashboard
|
||||
```
|
||||
|
||||
### Нет данных в чартах
|
||||
@@ -247,7 +282,7 @@ docker compose exec superset bash -c "curl clickhouse:8123"
|
||||
| Сервис | URL | Логин/Пароль |
|
||||
|--------|-----|--------------|
|
||||
| Superset | http://localhost:8088 | admin / admin |
|
||||
| ClickHouse HTTP | http://localhost:9123 | default / (пустой) |
|
||||
| ClickHouse HTTP | http://localhost:9123 | default / 123456 |
|
||||
| Airflow | http://localhost:8080 | admin / admin |
|
||||
| Grafana | http://localhost:3000 | admin / admin |
|
||||
| Prometheus | http://localhost:9090 | - |
|
||||
|
||||
@@ -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(geo_country)) AS countries, -- Страны (если менялась)
|
||||
arrayDistinct(groupArray(device_type)) AS devices, -- Устройства (если менялось)
|
||||
-- Берём последние UTM-метки сессии (для атрибуции)
|
||||
-- Берём одну UTM-метку сессии (для атрибуции).
|
||||
-- groupArraySample(1, 1919) выбирает 1 случайный элемент; 1919 — фиксированный seed,
|
||||
-- чтобы выборка была воспроизводимой между прогонами (одинаковый seed → один результат).
|
||||
groupArraySample(1, 1919)(utm_source)[1] AS utm_source_last,
|
||||
groupArraySample(1, 1919)(utm_medium)[1] AS utm_medium_last
|
||||
FROM dm.v_events_enriched
|
||||
|
||||
+4
-13
@@ -9,21 +9,9 @@
|
||||
--
|
||||
-- Важно:
|
||||
-- - Наполнение ODS выполняется batch-процессом из sql/ods/20_stg_to_ods.sql
|
||||
-- - Materialized View STG → ODS в target-state не используются
|
||||
-- (а не Materialized View, как в STG): батч ради наблюдаемости пересчёта
|
||||
-- ============================================================================
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Удаляем legacy MV STG → ODS (если ранее были созданы)
|
||||
-- ----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS stg.mv_browser_raw_to_ods;
|
||||
DROP TABLE IF EXISTS stg.mv_browser_raw_to_ods_errors;
|
||||
DROP TABLE IF EXISTS stg.mv_location_raw_to_ods;
|
||||
DROP TABLE IF EXISTS stg.mv_location_raw_to_ods_errors;
|
||||
DROP TABLE IF EXISTS stg.mv_device_raw_to_ods;
|
||||
DROP TABLE IF EXISTS stg.mv_device_raw_to_ods_errors;
|
||||
DROP TABLE IF EXISTS stg.mv_geo_raw_to_ods;
|
||||
DROP TABLE IF EXISTS stg.mv_geo_raw_to_ods_errors;
|
||||
|
||||
-- ============================================================================
|
||||
-- BROWSER EVENTS
|
||||
-- ============================================================================
|
||||
@@ -46,6 +34,8 @@ CREATE TABLE IF NOT EXISTS ods.browser_event
|
||||
parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть)
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||
-- Партиционируем по бизнес-дате события: у browser_event есть собственное время (event_ts).
|
||||
-- У click-контекста ниже (location/device/geo) такого времени нет — там партиция по дате загрузки.
|
||||
PARTITION BY toYYYYMM(event_date)
|
||||
ORDER BY (event_id)
|
||||
SETTINGS allow_nullable_key = 1;
|
||||
@@ -87,6 +77,7 @@ CREATE TABLE IF NOT EXISTS ods.location_event
|
||||
parse_errors Array(LowCardinality(String))
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||
-- По дате загрузки: у location нет собственного времени события (только время приёма в STG)
|
||||
PARTITION BY toYYYYMM(toDate(src_ingest_ts))
|
||||
ORDER BY (event_id)
|
||||
SETTINGS allow_nullable_key = 1;
|
||||
|
||||
@@ -128,7 +128,7 @@ SELECT
|
||||
_topic AS kafka_topic,
|
||||
_partition AS kafka_partition,
|
||||
_offset AS kafka_offset,
|
||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
||||
_timestamp_ms AS kafka_ts,
|
||||
raw
|
||||
FROM stg.kafka_browser_raw;
|
||||
|
||||
@@ -140,7 +140,7 @@ SELECT
|
||||
_topic AS kafka_topic,
|
||||
_partition AS kafka_partition,
|
||||
_offset AS kafka_offset,
|
||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
||||
_timestamp_ms AS kafka_ts,
|
||||
raw
|
||||
FROM stg.kafka_location_raw;
|
||||
|
||||
@@ -152,7 +152,7 @@ SELECT
|
||||
_topic AS kafka_topic,
|
||||
_partition AS kafka_partition,
|
||||
_offset AS kafka_offset,
|
||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
||||
_timestamp_ms AS kafka_ts,
|
||||
raw
|
||||
FROM stg.kafka_device_raw;
|
||||
|
||||
@@ -164,6 +164,6 @@ SELECT
|
||||
_topic AS kafka_topic,
|
||||
_partition AS kafka_partition,
|
||||
_offset AS kafka_offset,
|
||||
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
|
||||
_timestamp_ms AS kafka_ts,
|
||||
raw
|
||||
FROM stg.kafka_geo_raw;
|
||||
@@ -1,6 +1,10 @@
|
||||
-- ============================================================================
|
||||
-- Batch-трансформация: ODS → DDS
|
||||
-- ============================================================================
|
||||
-- Поток данных:
|
||||
-- ods.device_by_click + ods.geo_by_click → dds.click (контекст клика)
|
||||
-- ods.browser_event + ods.location_event → dds.event (контекст события)
|
||||
--
|
||||
-- Что делает:
|
||||
-- Собирает "чистые" сущности из типизированных данных ODS для аналитики.
|
||||
-- Использует argMax() для получения последней версии строк по ключу.
|
||||
@@ -97,7 +101,11 @@ LEFT JOIN (
|
||||
FROM ods.geo_by_click
|
||||
WHERE click_id IS NOT NULL
|
||||
GROUP BY click_id
|
||||
) AS g ON g.click_id = c.click_id;
|
||||
) AS g ON g.click_id = c.click_id
|
||||
-- join_use_nulls=1: при несовпадении LEFT JOIN кладёт в правые колонки NULL, а не дефолт.
|
||||
-- Без этого assumeNotNull(click_id) на несовпавшей строке стал бы нулевым UUID (не NULL),
|
||||
-- и проверки if(d.click_id IS NULL, ...) / if(g.click_id IS NULL, ...) молча не сработали бы.
|
||||
SETTINGS join_use_nulls = 1;
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Сущность: dds.event (объединяет browser + location)
|
||||
@@ -161,4 +169,7 @@ LEFT JOIN (
|
||||
FROM ods.location_event
|
||||
WHERE event_id IS NOT NULL
|
||||
GROUP BY event_id
|
||||
) AS l ON l.event_id = b.event_id;
|
||||
) AS l ON l.event_id = b.event_id
|
||||
-- join_use_nulls=1: чтобы при отсутствии location поле l.event_id было NULL, а не нулевым UUID,
|
||||
-- и проверка if(l.event_id IS NULL, ['location_not_found'], []) реально срабатывала.
|
||||
SETTINGS join_use_nulls = 1;
|
||||
+17
-27
@@ -1,6 +1,10 @@
|
||||
-- ============================================================================
|
||||
-- Batch-трансформация: DDS → DM (Data Quality summary)
|
||||
-- ============================================================================
|
||||
-- Поток данных:
|
||||
-- stg.* + ods.* + dds.* + dm.v_events_enriched → dm.dq_summary (сводка по слоям)
|
||||
-- Сами витрины (dm.v_*) — это VIEW поверх DDS, создаются в sql/ddl/dm/40_dm.sql.
|
||||
--
|
||||
-- Что делает:
|
||||
-- Собирает статистику по всем слоям (stg/ods/dds) для мониторинга качества данных.
|
||||
-- Позволяет быстро проверить, сколько данных прошло через каждый слой
|
||||
@@ -9,33 +13,12 @@
|
||||
-- Важно:
|
||||
-- Таблица dq_summary пересоздаётся при каждом запуске (TRUNCATE + INSERT),
|
||||
-- чтобы не накапливать дубликаты при повторных прогонах.
|
||||
--
|
||||
-- Витрины DM сейчас — это VIEW (логика без копии данных). Если тяжёлая агрегация
|
||||
-- начнёт тормозить, её материализуют в таблицу — пример в docs/ARCHITECTURE.md,
|
||||
-- раздел «Материализация витрин».
|
||||
-- ============================================================================
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Пример материализации тяжёлой витрины (закомментировано)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Если VIEW dm.v_daily_traffic работает медленно, можно создать таблицу:
|
||||
--
|
||||
-- CREATE TABLE IF NOT EXISTS dm.daily_traffic_mart
|
||||
-- (
|
||||
-- event_date Date,
|
||||
-- geo_country LowCardinality(Nullable(String)),
|
||||
-- device_type LowCardinality(Nullable(String)),
|
||||
-- browser_name LowCardinality(Nullable(String)),
|
||||
-- utm_source LowCardinality(Nullable(String)),
|
||||
-- utm_medium LowCardinality(Nullable(String)),
|
||||
-- events UInt64,
|
||||
-- uniq_clicks UInt64,
|
||||
-- uniq_users UInt64
|
||||
-- )
|
||||
-- ENGINE = ReplacingMergeTree(event_date)
|
||||
-- PARTITION BY toYYYYMM(event_date)
|
||||
-- ORDER BY (event_date, geo_country, device_type, browser_name, utm_source, utm_medium);
|
||||
--
|
||||
-- TRUNCATE TABLE dm.daily_traffic_mart;
|
||||
-- INSERT INTO dm.daily_traffic_mart SELECT * FROM dm.v_daily_traffic;
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Таблица для сводки по качеству данных (DQ summary)
|
||||
-- ----------------------------------------------------------------------------
|
||||
@@ -44,7 +27,7 @@
|
||||
CREATE TABLE IF NOT EXISTS dm.dq_summary
|
||||
(
|
||||
check_date Date, -- Дата проверки
|
||||
layer LowCardinality(String), -- Слой: stg, ods, dds
|
||||
layer LowCardinality(String), -- Слой: stg, ods, dds, dm
|
||||
table_name LowCardinality(String), -- Имя таблицы
|
||||
check_name LowCardinality(String), -- Тип проверки: total_rows, rows_with_errors и т.д.
|
||||
check_value UInt64 -- Значение метрики
|
||||
@@ -122,4 +105,11 @@ UNION ALL
|
||||
SELECT today(), 'dds', 'event_without_click', 'orphan_events', count()
|
||||
FROM dds.event
|
||||
WHERE click_id IS NOT NULL
|
||||
AND click_id NOT IN (SELECT click_id FROM dds.click);
|
||||
AND click_id NOT IN (SELECT click_id FROM dds.click)
|
||||
|
||||
UNION ALL
|
||||
-- DM-слой: финальная витрина событий (VIEW поверх dds.event).
|
||||
-- Нужна, чтобы lineage-чарт замыкал цепочку stg→ods→dds→dm на одном (event) зерне.
|
||||
-- VIEW создаётся в DDL (sql/ddl/dm/40_dm.sql) до трансформаций, а этот шаг идёт
|
||||
-- после наполнения dds.event — поэтому count() здесь корректен.
|
||||
SELECT today(), 'dm', 'v_events_enriched', 'total_rows', count() FROM dm.v_events_enriched;
|
||||
@@ -1,11 +1,20 @@
|
||||
-- ============================================================================
|
||||
-- Batch-трансформация: STG → ODS
|
||||
-- ============================================================================
|
||||
-- Поток данных:
|
||||
-- stg.*_raw → ods.* (валидный ключ) + ods.*_errors (любая ошибка)
|
||||
--
|
||||
-- Назначение:
|
||||
-- - Перенос типизации STG → ODS из Materialized View в управляемый batch
|
||||
-- - Полная пересборка ODS для прозрачного мониторинга в Airflow
|
||||
-- - Сохранение DQ-логики: parse_errors + отдельные *_errors таблицы
|
||||
--
|
||||
-- DQ-split (почему одна строка может попасть в оба места):
|
||||
-- - Основная таблица ods.* — строки с валидным КЛЮЧОМ (event_id / click_id).
|
||||
-- В ней допустимы parse_errors по НЕключевым полям — строка остаётся, но помечена.
|
||||
-- - Таблица ods.*_errors — копия строк с ЛЮБОЙ ошибкой парсинга (для разбора).
|
||||
-- Поэтому строка с валидным ключом, но битым неключевым полем, попадёт И туда, И туда.
|
||||
--
|
||||
-- Когда запускать:
|
||||
-- - В DAG etl_pipeline перед ODS → DDS
|
||||
-- - После загрузки очередного среза данных в Kafka/STG
|
||||
|
||||
+198
-76
@@ -25,12 +25,19 @@ logger = logging.getLogger(__name__)
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
|
||||
OBSOLETE_CHART_NAMES = {
|
||||
"🎯 Unique Sessions",
|
||||
}
|
||||
|
||||
|
||||
# Конфигурация чартов
|
||||
CHARTS_CONFIG = [
|
||||
# KPI блок
|
||||
{
|
||||
"slice_name": "📊 Total Events",
|
||||
"viz_type": "big_number",
|
||||
# big_number_total = итог по всему срезу (без granularity), а не последний
|
||||
# временной бакет, как у big_number.
|
||||
"viz_type": "big_number_total",
|
||||
"dataset_name": "v_events_enriched",
|
||||
"params": {
|
||||
"metric": {
|
||||
@@ -41,15 +48,13 @@ CHARTS_CONFIG = [
|
||||
"label": "Total Events",
|
||||
"optionName": "metric_1"
|
||||
},
|
||||
"granularity_sqla": "event_ts",
|
||||
"y_axis_format": ",d",
|
||||
"show_trend_line": False,
|
||||
"time_range": "No filter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slice_name": "👤 Unique Users",
|
||||
"viz_type": "big_number",
|
||||
"viz_type": "big_number_total",
|
||||
"dataset_name": "v_events_enriched",
|
||||
"params": {
|
||||
"metric": {
|
||||
@@ -58,54 +63,57 @@ CHARTS_CONFIG = [
|
||||
"label": "Unique Users",
|
||||
"optionName": "metric_2"
|
||||
},
|
||||
"granularity_sqla": "event_ts",
|
||||
"y_axis_format": ",d",
|
||||
"show_trend_line": False,
|
||||
"time_range": "No filter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slice_name": "🎯 Unique Sessions",
|
||||
"viz_type": "big_number",
|
||||
"dataset_name": "v_events_enriched",
|
||||
"params": {
|
||||
"metric": {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "COUNT(DISTINCT click_id)",
|
||||
"label": "Unique Sessions",
|
||||
"optionName": "metric_3"
|
||||
},
|
||||
"granularity_sqla": "event_ts",
|
||||
"y_axis_format": ",d",
|
||||
"show_trend_line": False,
|
||||
"time_range": "No filter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slice_name": "📈 Avg Events/Session",
|
||||
"viz_type": "big_number",
|
||||
"slice_name": "📈 Avg Events/Visit",
|
||||
"previous_slice_names": ["📈 Avg Events/Session"],
|
||||
"viz_type": "big_number_total",
|
||||
"dataset_name": "v_events_enriched",
|
||||
"params": {
|
||||
"metric": {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "COUNT(*) / COUNT(DISTINCT click_id)",
|
||||
"label": "Avg Events/Session",
|
||||
"label": "Avg Events/Visit",
|
||||
"optionName": "metric_4"
|
||||
},
|
||||
"granularity_sqla": "event_ts",
|
||||
"y_axis_format": ".2f",
|
||||
"show_trend_line": False,
|
||||
"y_axis_format": ".1f",
|
||||
"time_range": "No filter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slice_name": "🎯 Conversion to /confirmation",
|
||||
"viz_type": "big_number_total",
|
||||
"dataset_name": "v_events_enriched",
|
||||
"params": {
|
||||
"metric": {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": (
|
||||
"if(countIf(page_url_path = '/home') = 0, 0, "
|
||||
"countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home'))"
|
||||
),
|
||||
"label": "Conversion to /confirmation",
|
||||
"optionName": "metric_5"
|
||||
},
|
||||
"y_axis_format": ".1%",
|
||||
"time_range": "No filter"
|
||||
}
|
||||
},
|
||||
# Динамика
|
||||
{
|
||||
"slice_name": "📅 Events by Hour",
|
||||
"slice_name": "📅 Events over Time",
|
||||
"previous_slice_names": ["📅 Events by Hour"],
|
||||
# Все события стенда укладываются в ~50 минут (20:51–21:41 28.11.2022),
|
||||
# поэтому часовая гранулярность давала всего 2 точки и прямую линию,
|
||||
# читавшуюся как ошибка. 5-минутные бакеты дают ~10 точек — реальную
|
||||
# форму трафика. Поэтому и название не «by Hour», а «over Time».
|
||||
"viz_type": "echarts_timeseries_line",
|
||||
"dataset_name": "v_events_enriched",
|
||||
"params": {
|
||||
"granularity_sqla": "event_ts",
|
||||
"time_grain_sqla": "PT1H",
|
||||
"time_grain_sqla": "PT5M",
|
||||
"metrics": [
|
||||
{
|
||||
"expressionType": "SQL",
|
||||
@@ -114,7 +122,7 @@ CHARTS_CONFIG = [
|
||||
}
|
||||
],
|
||||
"groupby": [],
|
||||
"time_range": "Last week",
|
||||
"time_range": "No filter",
|
||||
"adhoc_filters": [],
|
||||
"row_limit": 10000
|
||||
}
|
||||
@@ -181,42 +189,81 @@ CHARTS_CONFIG = [
|
||||
}
|
||||
},
|
||||
{
|
||||
"slice_name": "📄 Top Pages",
|
||||
"viz_type": "dist_bar",
|
||||
"slice_name": "🪜 Page Funnel",
|
||||
"previous_slice_names": ["📄 Top Pages"],
|
||||
# В Superset 4.1.2 `funnel` есть в frontend-плагинах и примерах
|
||||
# поставки, хотя legacy registry `superset.viz.viz_types` его не
|
||||
# показывает. Параметры взяты из bundled Featured Charts/Funnel.yaml.
|
||||
"viz_type": "funnel",
|
||||
"dataset_name": "v_top_pages_daily",
|
||||
"params": {
|
||||
"groupby": ["page_url_path"],
|
||||
"metrics": [
|
||||
{"expressionType": "SQL", "sqlExpression": "SUM(pageviews)", "label": "Pageviews"}
|
||||
],
|
||||
"metric": {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "SUM(pageviews)",
|
||||
"label": "Pageviews"
|
||||
},
|
||||
"row_limit": 20,
|
||||
"time_range": "No filter",
|
||||
"orientation": "vertical",
|
||||
"show_legend": False
|
||||
"sort_by_metric": True,
|
||||
"percent_calculation_type": "first_step",
|
||||
"color_scheme": "supersetColors",
|
||||
"show_legend": True,
|
||||
"legendOrientation": "top",
|
||||
"legendMargin": 50,
|
||||
"tooltip_label_type": 5,
|
||||
"number_format": "SMART_NUMBER",
|
||||
"show_labels": True,
|
||||
"show_tooltip_labels": True
|
||||
}
|
||||
},
|
||||
# Качество данных
|
||||
# Прохождение строк по слоям (lineage одного event-зерна)
|
||||
{
|
||||
"slice_name": "🔍 Data Quality Summary",
|
||||
"slice_name": "🧱 Rows by Layer (event)",
|
||||
"previous_slice_names": ["🔍 Data Quality Summary"],
|
||||
# Честный row-lineage одного event-зерна через слои stg→ods→dds→dm.
|
||||
# Берём ПО ОДНОЙ канонической таблице на слой (browser_raw → browser_event
|
||||
# → event → v_events_enriched). Прежний вариант суммировал total_rows по
|
||||
# ВСЕМ таблицам слоя — таблицы разного зерна (события 1000 + визиты 99 +
|
||||
# error-таблицы 0) складывались в один столбец и рисовали ложную «воронку
|
||||
# потерь», которой нет. На одном зерне убывание становится настоящим:
|
||||
# видимый шаг 1050→1000 — это дедупликация at-least-once потока по
|
||||
# event_id в ODS (ReplacingMergeTree), а не потеря данных.
|
||||
# Префикс "N · " в groupby задаёт порядок слоёв (order_bars сортирует по
|
||||
# подписи), иначе бары встают по убыванию значения, а не по конвейеру.
|
||||
"viz_type": "dist_bar",
|
||||
"dataset_name": "dq_summary",
|
||||
"params": {
|
||||
"groupby": ["layer"],
|
||||
"groupby": [
|
||||
{
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": (
|
||||
"multiIf(layer = 'stg', '1 · stg', layer = 'ods', '2 · ods', "
|
||||
"layer = 'dds', '3 · dds', '4 · dm')"
|
||||
),
|
||||
"label": "Layer"
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{"expressionType": "SQL", "sqlExpression": "SUM(check_value)", "label": "Row Count"}
|
||||
{"expressionType": "SQL", "sqlExpression": "SUM(check_value)", "label": "Rows"}
|
||||
],
|
||||
"adhoc_filters": [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "check_name = 'total_rows'",
|
||||
"sqlExpression": (
|
||||
"check_name = 'total_rows' AND table_name IN "
|
||||
"('browser_raw', 'browser_event', 'event', 'v_events_enriched')"
|
||||
),
|
||||
"subject": None,
|
||||
"operator": None,
|
||||
"comparator": None
|
||||
}
|
||||
],
|
||||
"order_bars": True,
|
||||
"row_limit": 100,
|
||||
"time_range": "No filter",
|
||||
"y_axis_format": ",d",
|
||||
"show_legend": False
|
||||
}
|
||||
}
|
||||
@@ -225,12 +272,34 @@ CHARTS_CONFIG = [
|
||||
# Конфигурация дашборда
|
||||
DASHBOARD_CONFIG = {
|
||||
"dashboard_title": "🛒 E-commerce Analytics Dashboard",
|
||||
"description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и качество данных.",
|
||||
"description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и прохождение строк по слоям.",
|
||||
"published": True,
|
||||
"slug": "ecommerce-analytics",
|
||||
}
|
||||
|
||||
|
||||
# Раскладка дашборда по строкам (Superset layout v2 — флекс-модель ROW/CHART).
|
||||
# Superset НЕ использует абсолютные координаты x/y: ширина чарта (1..12) имеет
|
||||
# смысл только внутри строки-контейнера ROW. Без ROW каждый чарт занимает всю
|
||||
# ширину и валится в одну колонку. Поэтому раскладываем именно строками.
|
||||
# Каждая строка — список (slice_name, width); сумма width в строке должна быть ≤ 12.
|
||||
DASHBOARD_ROWS = [
|
||||
# KPI-полоса: 4 числа в один ряд
|
||||
[("📊 Total Events", 3), ("👤 Unique Users", 3),
|
||||
("📈 Avg Events/Visit", 3), ("🎯 Conversion to /confirmation", 3)],
|
||||
# Динамика во времени + разрез по устройствам
|
||||
[("📅 Events over Time", 8), ("📱 Traffic by Device", 4)],
|
||||
# География + эффективность маркетинговых каналов
|
||||
[("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)],
|
||||
# Популярные страницы + прохождение строк по слоям
|
||||
[("🪜 Page Funnel", 6), ("🧱 Rows by Layer (event)", 6)],
|
||||
]
|
||||
|
||||
# Высота строки в grid-units Superset (одинаковая для всех чартов строки —
|
||||
# чтобы они выравнивались по нижней границе).
|
||||
ROW_HEIGHTS = [30, 60, 60, 60]
|
||||
|
||||
|
||||
def sync_query_context(chart, params: dict, dataset_id: int) -> None:
|
||||
"""
|
||||
Синхронизирует сохраненный query_context с обновленными params чарта.
|
||||
@@ -260,18 +329,18 @@ def sync_query_context(chart, params: dict, dataset_id: int) -> None:
|
||||
chart.query_context = json.dumps(query_context)
|
||||
return
|
||||
|
||||
if chart.viz_type == "big_number":
|
||||
if chart.viz_type == "big_number_total":
|
||||
# Итоговое число по всему срезу: не time-series, без granularity и
|
||||
# time-grain — иначе запрос группируется по времени и число «прыгает»
|
||||
# на значение последнего бакета.
|
||||
query = queries[0]
|
||||
granularity = params.get("granularity_sqla")
|
||||
if granularity:
|
||||
query["granularity"] = granularity
|
||||
query["is_timeseries"] = True
|
||||
query["is_timeseries"] = False
|
||||
query.pop("granularity", None)
|
||||
query["time_range"] = params.get("time_range", query.get("time_range"))
|
||||
if "metric" in params:
|
||||
query["metrics"] = [params["metric"]]
|
||||
extras = query.get("extras") if isinstance(query.get("extras"), dict) else {}
|
||||
if "time_grain_sqla" in params:
|
||||
extras["time_grain_sqla"] = params.get("time_grain_sqla")
|
||||
extras.pop("time_grain_sqla", None)
|
||||
query["extras"] = extras
|
||||
elif params.get("x_axis") or params.get("groupby"):
|
||||
# Для категориальных графиков синхронизируем колонки измерений.
|
||||
@@ -302,7 +371,7 @@ def build_dashboard_metadata(filter_dataset_id: int | None) -> str:
|
||||
"name": "📅 Date Range",
|
||||
"filterType": "filter_time",
|
||||
"targets": [{"datasetId": filter_dataset_id, "column": {"name": "event_date"}}],
|
||||
"defaultValue": "Last week",
|
||||
"defaultValue": "No filter",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"cascadeParentIds": [],
|
||||
"isInstant": True
|
||||
@@ -377,6 +446,14 @@ def main() -> bool:
|
||||
if not dataset:
|
||||
logger.warning(f"Dataset '{chart_config['dataset_name']}' not found, skipping chart")
|
||||
continue
|
||||
|
||||
if not dataset.columns:
|
||||
# Если dataset создали до DDL/DM, в metadata Superset нет колонок.
|
||||
# Обновляем их здесь, чтобы dashboard восстанавливался через make superset-dashboard.
|
||||
dataset.fetch_metadata()
|
||||
db.session.commit()
|
||||
logger.info("Refreshed dataset metadata: %s", dataset.table_name)
|
||||
|
||||
datasets_by_name[chart_config["dataset_name"]] = dataset.id
|
||||
|
||||
try:
|
||||
@@ -386,13 +463,17 @@ def main() -> bool:
|
||||
params["viz_type"] = chart_config["viz_type"]
|
||||
serialized_params = json.dumps(params)
|
||||
|
||||
# Проверяем, существует ли уже чарт
|
||||
existing = db.session.query(Slice).filter_by(
|
||||
slice_name=chart_config["slice_name"]
|
||||
).first()
|
||||
# Проверяем, существует ли уже чарт. Старые имена нужны для
|
||||
# идемпотентного rename без дублей в списке Charts.
|
||||
chart_names = [chart_config["slice_name"]]
|
||||
chart_names.extend(chart_config.get("previous_slice_names", []))
|
||||
existing = db.session.query(Slice).filter(
|
||||
Slice.slice_name.in_(chart_names)
|
||||
).order_by(Slice.id.asc()).first()
|
||||
|
||||
if existing:
|
||||
# Синхронизируем параметры существующего чарта с конфигом.
|
||||
existing.slice_name = chart_config["slice_name"]
|
||||
existing.viz_type = chart_config["viz_type"]
|
||||
existing.datasource_id = dataset.id
|
||||
existing.datasource_type = "table"
|
||||
@@ -432,6 +513,15 @@ def main() -> bool:
|
||||
db.session.rollback()
|
||||
|
||||
logger.info(f"Created/Found {len(created_charts)} charts")
|
||||
|
||||
current_chart_names = {chart_config["slice_name"] for chart_config in CHARTS_CONFIG}
|
||||
for obsolete_name in sorted(OBSOLETE_CHART_NAMES - current_chart_names):
|
||||
obsolete_charts = db.session.query(Slice).filter_by(slice_name=obsolete_name).all()
|
||||
for obsolete in obsolete_charts:
|
||||
db.session.delete(obsolete)
|
||||
logger.info("Deleted obsolete chart: %s (ID: %s)", obsolete_name, obsolete.id)
|
||||
db.session.flush()
|
||||
|
||||
metadata_json = build_dashboard_metadata(datasets_by_name.get("v_events_enriched"))
|
||||
|
||||
# Создаём позиции чартов для layout.
|
||||
@@ -452,31 +542,63 @@ def main() -> bool:
|
||||
},
|
||||
}
|
||||
|
||||
# Добавляем чарты в layout (grid: 12 columns)
|
||||
y_position = 0
|
||||
chart_index = 0
|
||||
# Раскладываем dashboard строками (ROW): KPI в один ряд, затем
|
||||
# аналитические блоки парами. Каждый CHART обязан лежать внутри ROW,
|
||||
# иначе Superset игнорирует width и ставит чарты в одну колонку.
|
||||
charts_by_title = {c["title"]: c for c in created_charts if c}
|
||||
|
||||
for chart in created_charts:
|
||||
if chart:
|
||||
chart_component_id = f"CHART-{chart['id']}"
|
||||
positions[chart_component_id] = {
|
||||
"id": chart_component_id,
|
||||
def add_chart(component_id_owner_row: str, chart: dict, width: int, height: int) -> str:
|
||||
"""Регистрирует CHART-компонент и возвращает его id."""
|
||||
cid = f"CHART-{chart['id']}"
|
||||
positions[cid] = {
|
||||
"id": cid,
|
||||
"type": "CHART",
|
||||
"children": [],
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", component_id_owner_row],
|
||||
"meta": {
|
||||
"chartId": chart['id'],
|
||||
"sliceName": chart['title'],
|
||||
"height": 50,
|
||||
"width": 4 if chart_index < 4 else 6,
|
||||
"x": (chart_index % 3) * 4 if chart_index < 4 else (chart_index % 2) * 6,
|
||||
"y": y_position,
|
||||
"chartId": chart["id"],
|
||||
"sliceName": chart["title"],
|
||||
"width": width,
|
||||
"height": height,
|
||||
},
|
||||
}
|
||||
positions["GRID_ID"]["children"].append(chart_component_id)
|
||||
chart_index += 1
|
||||
if chart_index % 4 == 0:
|
||||
y_position += 50
|
||||
return cid
|
||||
|
||||
placed_titles = set()
|
||||
for row_idx, row in enumerate(DASHBOARD_ROWS):
|
||||
row_id = f"ROW-{row_idx}"
|
||||
height = ROW_HEIGHTS[row_idx] if row_idx < len(ROW_HEIGHTS) else 50
|
||||
row_children = []
|
||||
for title, width in row:
|
||||
chart = charts_by_title.get(title)
|
||||
if not chart:
|
||||
continue
|
||||
row_children.append(add_chart(row_id, chart, width, height))
|
||||
placed_titles.add(title)
|
||||
if row_children:
|
||||
positions[row_id] = {
|
||||
"id": row_id,
|
||||
"type": "ROW",
|
||||
"children": row_children,
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
"meta": {"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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Важно:
|
||||
Superset использует SQLite по умолчанию (не PostgreSQL).
|
||||
Superset использует PostgreSQL metadata store через configs/superset_config.py.
|
||||
|
||||
Текущий подход:
|
||||
1. CLI для создания подключения к БД
|
||||
|
||||
Reference in new issue
Block a user