# Operations Runbook

Операционный runbook для локального запуска и проверки пайплайна.

## Локальный запуск

Базовые команды:

- `make up` (поднимает базовый стенд без Superset init; пересобирает Airflow-образ)
- `make down` (остановить и удалить контейнеры/сети проекта)
- `make clean` (полная очистка: `down -v --remove-orphans`)
- `make generated-history-analytics` (штатный чистый прогон: стартовая история
  генератора -> Kafka/STG -> ODS -> DDS -> DM -> Superset)
- `CHECK_LIVE_SEAM=0 make generated-history-check` (повторяемая проверка
  ClickHouse и Superset после прогона только стартовой истории)
- `make generated-history-runtime-check` (короткая проверка стыка backfill/live)
- `make generated-history-chain-check` (проверка завершённых стыков между
  порциями истории)
- `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse)
- `make data` (архивный путь: заливает `data/*.jsonl` в Kafka; не основной источник аналитики)
- `make transform` (запускает batch-процесс ODS -> DDS -> DM)
- `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд)
- `make test` (быстрый предкоммитный набор: тесты генератора, верхние
  контрактные тесты и проверка compose-конфигурации)
- `make lint` (статические проверки: синтаксис Python и Bash, compose-конфиг,
  пробелы в diff)
- `docker compose ps`
- `docker compose logs -f --tail=200 <service>`
- `docker compose down` (сохраняет named volumes, включая `clickhouse-data`)
- `docker compose down -v` (удаляет named volumes, использовать осознанно)

`make test` и `make lint` не заменяют стендовые проверки, которые управляют
volumes или live-генератором. Для стыка backfill/live отдельно запускайте
`make generated-history-runtime-check`.

## Порты

Порты задаются в `docker-compose.yml`:

- 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` (`admin/admin`)

## Airflow DAGs

Штатный ручной путь начинается с `world_init`: пустая форма импортирует
эталонный мир, затем этот же DAG запускает ETL и проверку.
`kafka_load` остаётся для экспериментов и совместимости учебного стенда.

### `world_init`

- Запуск: ручной (`Trigger DAG`).
- Назначение: импорт или служебная сборка стартового мира.
- Операции:
  - `import` — операция по умолчанию: импортировать портативный артефакт, затем
    запустить `etl_pipeline` и дождаться `success`;
  - `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и
    дождаться `success`;
  - `check` — сверить ClickHouse с manifest из Kafka.
- Параметры:
  - `operation` (`import` / `backfill` / `check`);
  - `profile` — список берётся из `PROFILES` генератора;
  - `duration` — `6h`, `2d` и т.п.; пусто означает длительность профиля;
  - `seed`, `model_time_speed` — необязательные переопределения мира;
  - `artifact_path` — для `backfill` путь сохранения; для `import` путь чтения.
    При пустом поле импортируется эталонный мир из репозитория.

Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые
`stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue`
в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через
`make generator-continue`.

### `world_next_day`

- Запуск: вручную с пустой формой.
- Параметров нет.
- Один запуск восстанавливает мир из state, добавляет 24 модельных часа,
  запускает `etl_pipeline` с полной пересборкой и сверяет витрины с manifest.
- Расписание задано каждые 30 минут, но DAG создаётся на паузе; `catchup=False`.
- `max_active_runs=1` не даёт двум доливкам выполняться параллельно.

`world_next_day` работает на непустом стенде и не использует проверку чистоты.
Перед записью он требует manifest, state ровно на его `T_end` и остановленный
live-генератор. Настройки мира берутся из manifest. Один запуск добавляет
полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница появляется в
`boundaries`; старый manifest без поля читается как `[T0, T_end]`.

После двух доливок проверьте завершённые стыки:

```bash
make generated-history-chain-check
```

Проверка проходит по внутренним границам `boundaries`, ищет непарные строки и
смену browser/referer/utm внутри переходящих визитов. Она не меняет
`make generated-history-runtime-check` для стыка backfill/live.

Результат запуска `world_next_day` фиксируется новым manifest. Порядок записи:
data-топики, state, manifest. Автоматического отката нет. Если запуск упал до
публикации manifest,
не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте
последний исправный портативный артефакт, затем повторите запуск `world_next_day`.

`next-day` не перечитывает старые data-топики Kafka. Основная запись manifest
хранит суммы, катящуюся контрольную сумму и SHA-256-ссылку на цепочку точных
множеств. Сами `click_id` и `user_domain_id` разбиты на небольшие неизменяемые
фрагменты в отдельном compact-топике. Новый день записывает только новый
фрагмент, поэтому размер одной записи не растёт с возрастом мира, а загрузка
основного manifest не сканирует всю цепочку.
State ссылается на основную запись manifest, чтобы две точки продолжения нельзя
было случайно смешать. Если локальный state создан старым кодом и раздела в нём
нет, повторно запустите `world_init` с операцией `import` на чистом стенде.

Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если
стоит, задача падает сразу с подсказкой снять паузу в UI или командой:

```bash
docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline
```

После обновления репозитория выполните `make up`, чтобы Airflow получил новые
зависимости и DAG-и через пересборку образа. Superset metadata собирается после
готового DM: через `make generated-history-analytics` или `make superset-init`.

### `ddl_init`

- Запуск: ручной (`Trigger DAG`)
- Параметр: `verify_only` (`bool`, default `false`)
- Назначение: создаёт БД и таблицы в ClickHouse от `00_databases` до `40_dm`

### `kafka_load`

- Архивный путь, не основной источник аналитики.
- Запуск: ручной (`Trigger DAG with config`)
- Параметры:
  - `limit` (`int`, default `0`) — количество строк (`0` = все)
  - `reset_topics` (`bool`, default `true`) — пересоздать топики
- Примеры:

```json
{}
```

```json
{"limit": 100}
```

### `etl_pipeline`

- Запуск: ручной (`Trigger DAG with config`)
- Параметры:
  - `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой
  - `wait_stg_timeout_sec` (`int`, default `600`, minimum `30`) — сколько секунд задача `wait_for_stg_data` ждёт появления данных в STG, прежде чем упасть по таймауту
- Зависимость: требует наличия данных в STG. В штатном сценарии STG наполняет
  `make generated-history-analytics` через backfill генератора.
- Архивные `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-статус.

## Генератор событий (автономный стриминг)

Автономный сервис для непрерывной генерации событий в Kafka. Работает независимо
от Airflow DAGs. `make up` его не запускает: live включается только явной
командой.

### Управление

```bash
# Продолжить live-поток из state
make generator-continue

# Запустить генератор с текущими env напрямую
make generator-up

# Остановить генератор
make generator-down

# Перезапуск с пересборкой
make generator-restart

# Логи
make generator-logs
```

### Конфигурация (env)

| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
| `GEN_P_NEW_USER` | Доля визитов новых пользователей | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` |
| `GEN_MODEL_T_END` | Правая граница стартовой истории для `backfill` | пусто |
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
| `GEN_RUN_MODE` | Режим: `live`, `backfill` или `next-day` | `live` |
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `daily-wave` |
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | пусто |
| `GEN_STATE_ENABLED` | Сохранять state v3 между рестартами | `true` |
| `GEN_STATE_RESET` | Сбросить state при старте | `false` |

В `docker-compose.yml` через окружение переопределяются демо-параметры модели и
режима, например:

```bash
GEN_STATE_RESET=true GEN_LAMBDA_BASE_PER_MIN=60 docker compose up -d generator
```

Контейнерные `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose оставлены
внутренними значениями `kafka:29092` и `/data`.

### Стартовая история через backfill

Штатная команда чистого прогона:

```bash
make generated-history-analytics
```

Она выполняет полный сброс volumes, поднимает ClickHouse и Kafka, применяет DDL,
выполняет глагол `backfill`, прогоняет batch STG -> ODS -> DDS -> DM,
инициализирует Superset и запускает техническую проверку. Для координатора или
CI короткая повторная проверка такого готового стенда:

```bash
CHECK_LIVE_SEAM=0 make generated-history-check
```

По умолчанию команда требует live-строки после `T_end` и проверяет стык
backfill/live. Чистый `make generated-history-analytics` отключает эту часть
явно, потому что внутри него live-продолжение не запускается. Для ручной
проверки только стартовой истории используйте:

```bash
CHECK_LIVE_SEAM=0 make generated-history-check
```

Для проверки другого live-окна:

```bash
CHECK_LIVE_SEAM=1 GEN_LIVE_CHECK_MINUTES=10 make generated-history-check
```

Для commit gate issue 17 есть короткий runtime-путь без полного `daily-wave` на
3 суток и без Superset UI:

```bash
make generated-history-runtime-check
```

По умолчанию он берёт профиль `daily-wave`, но сжимает историю до `1h` и до
25 секунд ждёт в STG визит с browser-событиями по обе стороны границы. Затем
он делает второй batch и проверяет стык с `CHECK_LIVE_SEAM=1`.
При остановке generator получает SIGTERM и завершает текущий пакет. Параметр
`stop_grace_period: 1m` даёт время дописать четыре топика, сбросить буферы и
записать историю пакета до принудительной остановки контейнера.
Если машина медленная, можно увеличить только ожидания:

```bash
LIVE_SECONDS=45 WAIT_STG_SECONDS=10 make generated-history-runtime-check
```

По умолчанию команда использует учебный профиль `daily-wave`: 3 суток с
суточной волной. В live-продолжении он идёт с ×60 и тикает раз в секунду,
поэтому модельные сутки проходят примерно за 24 настенные минуты. Плоский
профиль `ci` на 6 часов остаётся служебным для автоматических тестов.

Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:

```bash
GEN_HISTORY_DURATION=2d make generated-history-analytics
```

`GEN_RUN_MODE=backfill` быстро проматывает модельное прошлое от `GEN_MODEL_T0`
до `GEN_MODEL_T_END` без сна. В Kafka попадают события только за полуоткрытый
отрезок `[T0, T_end)`. В compact-topic `generator_state` сохраняется state на
`T_end`, а в `generator_startup_history_manifest` — manifest с настройками и
контрольными числами. При live-запуске с теми же настройками генератор видит,
что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты.

Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day`
добавляет одну границу, сдвигает `model_t_end` и дополняет накопительные
счётчики только событиями нового дня. Полной перечитки Kafka в этом пути нет.

Если историю нужно сохранить в файл и восстановить на чистом стенде без новой
генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md).

Для чистого повтора пересоздавайте volumes. Это сбрасывает ClickHouse,
Kafka-топики данных, state и manifest генератора. `make generated-history-analytics`
делает это по умолчанию (`CLEAN_START=1`).

Ручной backfill без всего аналитического контура:

```bash
make generator-backfill
```

Перед записью команда проверяет с хоста, что Kafka data-топики и STG пустые.
Если там уже есть данные, она останавливается с подсказкой `make clean`.

Старый способ через `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END`
остаётся низкоуровневым путём для отладки прямого `docker compose run`. В обычной
проверке используйте `make generated-history-analytics`, чтобы не забыть
Superset и итоговый check.

Manifest можно посмотреть так:

```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server kafka:29092 \
  --topic generator_startup_history_manifest \
  --from-beginning \
  --property print.key=true \
  --timeout-ms 5000
```

Live-продолжение стартует с этого state. Используйте тот же профиль или ту же
длительность, что были у backfill. Команда сама выставит `GEN_STATE_RESET=false`.
Если state записан старой версией генератора, запуск теперь падает громко:
сначала очистите стенд через `make clean` и пересоздайте историю. Команда
`make generator-reset` подходит только для осознанного нового live-мира на
чистом стенде: она тоже проверяет, что Kafka data-топики и STG пустые.

```bash
make generator-continue

sleep 5
make generator-down
sleep 10
bash scripts/run_batch.sh
```

Базовая сверка формы данных после backfill:

```sql
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end
SELECT
    uniqExact(user_domain_id) AS users,
    uniqExact(click_id) AS visits,
    count() AS events,
    users < visits AND visits < events AS pyramid_ok,
    min(event_ts) AS min_event_ts,
    max(event_ts) AS max_event_ts,
    min_event_ts >= t0 AND max_event_ts < t_end AS half_open_ok
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end;
```

Повторяемость чистого прогона удобно сверять коротким digest по ключевым полям
ClickHouse. Запускайте запрос после `bash scripts/run_batch.sh`; при одинаковых
`GEN_SEED`, `T0`, `T_end` и настройках значение должно повторяться.

```bash
docker compose exec -T clickhouse clickhouse-client \
  --user=default \
  --password=123456 \
  --query "
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end
SELECT hex(sipHash128(groupArray(tuple(
    event_id,
    click_id,
    user_domain_id,
    event_ts,
    page_url_path
)))) AS digest
FROM (
    SELECT
        event_id,
        click_id,
        user_domain_id,
        event_ts,
        page_url_path
    FROM dm.v_events_enriched
    WHERE event_ts >= t0 AND event_ts < t_end
    ORDER BY event_id
  )"
```

Возвраты пользователей:

```sql
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
    users AS (
        SELECT user_domain_id, uniqExact(click_id) AS visits
        FROM dm.v_events_enriched
        WHERE event_ts >= t0 AND event_ts < t_end
          AND user_domain_id IS NOT NULL
        GROUP BY user_domain_id
    )
SELECT
    count() AS users,
    countIf(visits > 1) AS returning_users,
    returning_users / users AS returning_share
FROM users;
```

Форма длины визита: проверяем не только среднее, а долю коротких визитов,
медиану и долю визитов, срезанных потолком `GEN_MAX_SESSION_EVENTS`.

```sql
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
    30 AS max_session_events,
    sessions AS (
        SELECT
            click_id,
            count() AS events_count,
            dateDiff('second', min(event_ts), max(event_ts)) AS duration_sec
        FROM dm.v_events_enriched
        WHERE event_ts >= t0 AND event_ts < t_end
        GROUP BY click_id
    )
SELECT
    count() AS visits,
    countIf(events_count <= 2) / visits AS short_visit_share,
    quantileExact(0.5)(events_count) AS median_events_per_visit,
    avg(events_count) AS avg_events_per_visit,
    countIf(events_count = max_session_events) / visits AS capped_visit_share,
    quantileExact(0.5)(duration_sec) AS median_duration_sec
FROM sessions;
```

Воронка должна монотонно убывать, а доля дошедших до `/confirmation` должна быть
в согласованном коридоре для текущих настроек генератора. Для review gate
`confirmation_share` сравнивается по калибровочной форме «страница была в
визите». Строгий SQL ниже проверяет отдельное свойство: упорядоченный путь
`/home -> товары -> /cart -> /payment -> /confirmation`.

```sql
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
    sessions AS (
        SELECT
            click_id,
            minIf(event_ts, page_url_path = '/home') AS home_ts,
            minIf(event_ts, page_url_path IN ('/product_a', '/product_b')) AS product_ts,
            minIf(event_ts, page_url_path = '/cart') AS cart_ts,
            minIf(event_ts, page_url_path = '/payment') AS payment_ts,
            minIf(event_ts, page_url_path = '/confirmation') AS confirmation_ts
        FROM dm.v_events_enriched
        WHERE event_ts >= t0 AND event_ts < t_end
        GROUP BY click_id
    )
SELECT
    countIf(home_ts IS NOT NULL) AS home,
    countIf(home_ts IS NOT NULL AND product_ts > home_ts) AS products,
    countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts) AS cart,
    countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts AND payment_ts > cart_ts) AS payment,
    countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts AND payment_ts > cart_ts AND confirmation_ts > payment_ts) AS confirmation,
    products <= home AND cart <= products AND payment <= cart AND confirmation <= payment AS monotonic_ok,
    confirmation / home AS confirmation_share
FROM sessions;
```

Калибровочная форма воронки проверяет, что страница была в визите, без строгого
порядка событий. Именно эту форму используем для сравнения `confirmation_share`
в review gate.

```sql
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
    sessions AS (
        SELECT
            click_id,
            countIf(page_url_path = '/home') > 0 AS has_home,
            countIf(page_url_path IN ('/product_a', '/product_b')) > 0 AS has_product,
            countIf(page_url_path = '/cart') > 0 AS has_cart,
            countIf(page_url_path = '/payment') > 0 AS has_payment,
            countIf(page_url_path = '/confirmation') > 0 AS has_confirmation
        FROM dm.v_events_enriched
        WHERE event_ts >= t0 AND event_ts < t_end
        GROUP BY click_id
    )
SELECT
    countIf(has_home) AS home,
    countIf(has_home AND has_product) AS products,
    countIf(has_home AND has_product AND has_cart) AS cart,
    countIf(has_home AND has_product AND has_cart AND has_payment) AS payment,
    countIf(has_home AND has_product AND has_cart AND has_payment AND has_confirmation) AS confirmation,
    products <= home AND cart <= products AND payment <= cart AND confirmation <= payment AS monotonic_ok,
    confirmation / home AS confirmation_share
FROM sessions;
```

Стык backfill + live проверяется после короткого live-продолжения:

```sql
WITH
    toDateTime64('2026-01-01 00:00:00', 6) AS t0,
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
    toDateTime64('2026-01-01 06:10:00', 6) AS t_live_end
SELECT
    count() AS events,
    uniqExact(event_id) AS unique_events,
    events - unique_events AS duplicate_events,
    countIf(event_ts = t_end) AS boundary_events,
    min(event_ts) AS min_event_ts,
    max(event_ts) AS max_event_ts
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_live_end;
```

Однородность визитов, переходящих через `T_end`:

```sql
WITH
    toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
    crossing AS (
        SELECT
            click_id,
            min(event_ts) AS first_ts,
            max(event_ts) AS last_ts,
            groupUniqArray(user_domain_id) AS users,
            groupUniqArray(device_type) AS devices,
            groupUniqArray(os_name) AS os_names,
            groupUniqArray(geo_country) AS countries
        FROM dm.v_events_enriched
        WHERE event_ts >= t_end - INTERVAL 30 MINUTE
          AND event_ts < t_end + INTERVAL 30 MINUTE
        GROUP BY click_id
        HAVING first_ts < t_end AND last_ts >= t_end
    )
SELECT
    count() AS crossing_visits,
    countIf(
        length(users) = 1
        AND length(devices) = 1
        AND length(os_names) = 1
        AND length(countries) = 1
    ) AS homogeneous_visits,
    crossing_visits = homogeneous_visits AS context_ok
FROM crossing;
```

### Проверка модельного времени в ClickHouse

Для повторяемой проверки используйте чистый стенд и явный сброс состояния
генератора. `event_timestamp` в событиях — модельное время от `GEN_MODEL_T0`, а
не настенное время запуска процесса. При `GEN_MODEL_TIME_SPEED=K` один тик
покрывает `GEN_TICK_SECONDS * K` модельных секунд, и событийный бюджет считается
по этой модельной длительности.

```bash
make clean
docker compose up -d clickhouse kafka
make ddl

GEN_SEED=4242 \
GEN_MODEL_T0=2026-01-01T10:00:00+00:00 \
GEN_MODEL_TIMEZONE=UTC \
GEN_STATE_RESET=true \
GEN_TICK_SECONDS=60 \
GEN_LAMBDA_BASE_PER_MIN=60 \
GEN_JITTER_PCT=0 \
docker compose up -d --build generator

# Убедитесь по логам, что Tick 1 завершился, а Tick 2 ещё не стартовал.
sleep 9
docker compose logs --tail=40 generator
docker compose stop generator
sleep 5
bash scripts/run_batch.sh

docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "
SELECT
    count() AS events,
    min(event_ts) AS min_event_ts,
    max(event_ts) AS max_event_ts,
    uniqExact(event_id) AS unique_events,
    uniqExact(click_id) AS unique_clicks
FROM ods.browser_event
FORMAT Vertical"
```

Повторите блок с теми же значениями. При чистом стенде и `GEN_STATE_RESET=true`
контрольные числа должны совпасть, а `min_event_ts` должен начинаться от
`2026-01-01 10:00:00`.

### Топик истории

Генератор пишет историю батчей в топик `generator_batch_history` (JSON, ключ `batch_id`).

**Важно:** генератор требует работающей Kafka. Без Kafka генератор упадёт при старте или потеряет события.

```bash
# Чтение истории из Kafka
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server kafka:29092 \
  --topic generator_batch_history \
  --from-beginning
```

### Метрики

Prometheus метрики доступны на `http://localhost:9109/metrics`:
- `generator_events_total` — счётчик отправленных событий
- `generator_publish_errors_total` — ошибки публикации
- `generator_tick_duration_seconds` — длительность тика
- `generator_last_success_timestamp` — время последнего успешного или частично
  успешного тика

### Мониторинг через Grafana

**Dashboard URL:** `http://localhost:3000/d/generator-overview`

Дашборд "Generator Overview" автоматически загружается при старте Grafana и содержит:

| Раздел | Панели | Описание |
|--------|--------|----------|
| **Overview** | Events/min | Скорость генерации событий в минуту |
| | Tick Duration | Медиана и p99 длительности тика |
| | Last Successful Tick | Время последнего успешного тика |
| **Events by Topic** | Events per Hour (24h bar chart) | Распределение событий по часам и топикам |
| | Events Rate by Topic | График по 4 топикам (browser, location, device, geo) |
| | Total Events by Topic | Суммарные счётчики по каждому топику |
| **Errors** | Total Errors | Общее число ошибок публикации |
| | Error Rate | Скорость ошибок (err/min) |
| | Errors by Topic | Ошибки разбиты по топикам |
| **Tick Statistics** | Tick Duration Distribution | p50, p95, p99 длительности тиков |
| | Events per Tick | Среднее число событий на тик |
| | Hour Factor | Текущий временной множитель (0.7/1.0/1.2) |
| **Status** | Generator Status | Статус работы (enabled/disabled) |
| | Generator Health | Статус активности (heartbeat last tick), не проверяет state save |
| | Time Since Last Tick | Время с последнего тика |
| **Info** | Полезные команды и параметры конфигурации |

#### Troubleshooting генератора

**Нет данных на дашборде:**
1. Проверить, что генератор запущен: `docker compose ps generator`
2. Проверить метрики напрямую: `curl http://localhost:9109/metrics`
3. Проверить target в Prometheus: `http://localhost:9090/targets` (job: generator)

**Высокий error rate:**
- Проверить доступность Kafka: `docker compose ps kafka`
- Смотреть логи: `make generator-logs`
- Проверить consumer lag: дашборд Kafka Overview

**Длительные тики (p99 > 1s):**
- Проверить CPU/ресурсы контейнера
- Возможно, высокая нагрузка на Kafka — проверить дашборд Kafka

**Dashboard не загрузился:**
```bash
# Перезагрузить provisioning Grafana
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/dashboards/reload

# Или пересоздать контейнер
docker compose restart grafana
```

## Рекомендуемый сценарий

```bash
# Полный чистый путь: генерация -> STG -> ODS -> DDS -> DM -> Superset
make generated-history-analytics

# Повторная проверка данных стартовой истории без пересоздания
CHECK_LIVE_SEAM=0 make generated-history-check

# Проверка стыка backfill/live
make generated-history-runtime-check
```

Обычный `make generated-history-check` корректен только после
`make generator-continue` и повторного `make transform`, когда в витринах уже
есть строки после `T_end`.

## Быстрые проверки

- Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`.
- Airflow UI: `http://localhost:8080` показывает лестницу `ddl_init` →
  `world_init` → `world_next_day`, а также `kafka_load` и `etl_pipeline`.
- BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате.

---

## Мониторинг

### TL;DR после `git pull`

```bash
# Быстрый вариант (make)
make reload-monitoring
# Если мониторинг "залип" (No data/out of bounds) — жесткое восстановление:
make recover-monitoring

# Или вручную:
docker compose up -d prometheus grafana kafka-exporter statsd-exporter
docker compose restart prometheus statsd-exporter
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/datasources/reload
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/dashboards/reload
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/alerting/reload
```

Если менялся `configs/prometheus_ch.xml`: `docker compose restart clickhouse`.

### Prometheus + Grafana для ClickHouse, Kafka и Airflow

Стек мониторинга поднимается вместе с остальной инфраструктурой:

```bash
# Проверить статус сервисов мониторинга
docker compose ps prometheus grafana

# Проверить скрейп ClickHouse в Prometheus
curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"'
```

### Конфигурация

- **ClickHouse**: встроенный Prometheus endpoint (`/metrics` на порту `9126`)
- **Kafka**: через `kafka-exporter` (порт `9308`)
- **Airflow**: через `statsd-exporter` (StatsD → Prometheus, порт `9102`)
  - Airflow отправляет метрики в StatsD-формате на `statsd-exporter:8125`
  - Mapping конфигурация: `configs/statsd_mapping.yml`
- **Grafana provisioning** (`configs/grafana/provisioning/`):
  - Дашборды: ClickHouse Overview, Kafka Overview, Airflow Overview, Generator Overview
  - Алерты: ClickHouse, Kafka, Airflow

### После `git pull`: быстрый апдейт мониторинга

Если прилетели изменения в `configs/grafana/provisioning/*` или `configs/prometheus.yml`, примените их так:

```bash
# Рекомендуемый способ (через make)
make reload-monitoring
# Если метрики пропали/залипли:
make recover-monitoring

# Или вручную:
docker compose rm -sf prometheus statsd-exporter
docker compose up -d prometheus grafana kafka-exporter statsd-exporter
docker compose restart airflow-scheduler airflow-webserver
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/datasources/reload
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/dashboards/reload
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/alerting/reload
```

Проверка результата:

```bash
# Дашборды и алерты
curl -s -u admin:admin http://localhost:3000/api/v1/provisioning/alert-rules | grep -o '"title":"[^"]*"'

# Kafka метрики
curl -s http://localhost:9090/api/v1/targets | grep kafka
curl -s http://localhost:9308/metrics | grep "^kafka_brokers"
```

Если в пулле изменился `configs/prometheus_ch.xml`, дополнительно перезапустите ClickHouse:

```bash
docker compose restart clickhouse
```

Если дашборд Kafka не загрузился (ошибка "Dashboard title cannot be empty" в логах), пересоздайте контейнер Grafana:

```bash
docker compose stop grafana && docker compose rm -f grafana && docker compose up -d grafana
```

### Дашборд ClickHouse Overview

URL: `http://localhost:3000/d/clickhouse-overview/clickhouse-overview`

| Раздел | Метрики |
|--------|---------|
| System Health | CPU Usage, Memory Resident, Memory Code |
| 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*`).

### Дашборд Kafka Overview

URL: `http://localhost:3000/d/kafka-overview/kafka-overview`

| Раздел | Метрики |
|--------|---------|
| 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`).

### Проверка метрик

```bash
# Prometheus собирает метрики ClickHouse
curl -s "http://localhost:9090/api/v1/query?query=ClickHouseAsyncMetrics_MemoryResident"
curl -s "http://localhost:9090/api/v1/query?query=ClickHouseProfileEvents_Query"

# Prometheus собирает метрики Kafka
curl -s "http://localhost:9090/api/v1/query?query=kafka_brokers"
curl -s "http://localhost:9090/api/v1/query?query=kafka_consumergroup_lag"

# Прямая проверка kafka-exporter
curl -s http://localhost:9308/metrics | grep "^kafka_"
```

```bash
# Проверить, что Prometheus собирает метрики
curl -s "http://localhost:9090/api/v1/query?query=ClickHouseAsyncMetrics_MemoryResident"

# Проверить счётчик запросов
curl -s "http://localhost:9090/api/v1/query?query=ClickHouseProfileEvents_Query"
```

### Алерты Grafana

**ClickHouse Alerts** — provisioning-файл: `configs/grafana/provisioning/alerting/clickhouse-alert-rules.yml`

Настроены правила:
- `ClickHouse Failed Queries Rate` — `rate(ClickHouseProfileEvents_FailedQuery[5m]) > 0` в течение `2m`
- `ClickHouse Memory Resident High` — `MemoryResident / OSMemoryTotal * 100 > 85` в течение `5m`
- `ClickHouse Parts Active High` — `ClickHouseMetrics_PartsActive > 500` в течение `10m`

**Kafka Alerts** — provisioning-файл: `configs/grafana/provisioning/alerting/kafka-alert-rules.yml`

Настроены правила:
- `Kafka Broker Down` — `kafka_brokers < 1` в течение `1m`
- `Kafka Consumer Lag High` — `kafka_consumergroup_lag > 10000` в течение `5m`
- `Kafka No Messages Produced` — `rate(kafka_topic_partition_current_offset[5m]) < 0.1` в течение `10m`
- `Kafka Consumer Group Missing` не включён: для демо-стенда даёт шум на стартовых прогонах и не повышает диагностику по сравнению с lag/throughput.

Проверка и reload без рестарта контейнера:

```bash
# Список правил unified alerting
curl -s -u admin:admin http://localhost:3000/api/v1/provisioning/alert-rules

# Принудительно перечитать provisioning alerting
curl -s -X POST -u admin:admin http://localhost:3000/api/admin/provisioning/alerting/reload
```

### Troubleshooting мониторинга

**Общие проблемы:**
- **"No data" в Grafana**: проверить, что Prometheus видит target (`Status -> Targets` в UI)
- **Dashboard не загрузился**: проверить логи Grafana — provisioning работает при первом старте контейнера
- **Prometheus spam `out of bounds` и дашборды пустые**: выполнить `make recover-monitoring`

**ClickHouse:**
- **Метрики не обновляются**: ClickHouse экспортирует метрики на `0.0.0.0:9126` внутри сети Docker

**Kafka:**
- **`connection refused` к Kafka**: проверить, что kafka-exporter использует `kafka:29092` (внутренняя сеть), не `localhost:9092`
- **Метрики Kafka не появляются**: проверить, что kafka-exporter подключился к Kafka — `docker compose logs kafka-exporter`
- **Нет консьюмер-групп**: kafka-exporter показывает lag только при наличии активных консьюмеров с закоммиченными offset

## Troubleshooting

- `etl_pipeline` падает с ошибкой схемы: сначала запустить `ddl_init`.
- Airflow и Superset быстро завершают сеансы при одновременной работе:
  на `localhost` совпадали имена cookie, потому что порты не разделяют их.
  В Superset cookie переименована в `superset_session`.
- В Airflow всплывает `Auto-refresh Error: can't access property "find",
  p.dagRuns is undefined`: сеанс закончился, и авто-обновление Grid вместо
  данных получило страницу входа. Ошибка косметическая — достаточно обновить
  страницу и войти заново. Сам Grid не проверяет форму ответа
  (баг UI Airflow 2.10.x, в Airflow 3 интерфейс переписан).
- `git pull` падает с `Permission denied` на `data/*` или `configs/grafana/provisioning/*`:
  - Причина: локально есть файлы/каталоги не вашего пользователя (часто после запуска контейнеров с root-пользователем).
  - Диагностика:
    ```bash
    ls -ld data configs/grafana/provisioning
    ls -l data | head -n 20
    ```
  - Быстрое восстановление:
    ```bash
    # Владелец и права для рабочей копии репозитория
    sudo chown -R "$USER:$USER" .
    find . -type d -exec chmod u+rwx {} \;
    find . -type f -exec chmod u+rw {} \;
    ```
  - После восстановления повторить `git pull --ff-only`.
  - Не запускать `git` через `sudo`.
- `grafana` перезапускается с ошибкой `attempt to write a readonly database`:
  - Причина: старый `grafana_lib` содержит `grafana.db`, созданный root-пользователем.
  - Простой recovery (сбросить только volume Grafana):
    ```bash
    docker compose stop grafana
    docker volume ls | grep grafana_lib
    docker volume rm <project>_grafana_lib
    docker compose up -d grafana
    ```
- В Superset ошибки `DB engine Error` и `Cannot load filter`, а в логах есть `Can't load plugin: sqlalchemy.dialects:clickhouse.connect`:
  - Причина: некорректный URI диалекта ClickHouse (`clickhouse+connect://...`).
  - Используйте URI `clickhousedb://...` и пересоберите сервисы Superset:
    ```bash
    docker compose build superset superset-init
    docker compose up -d clickhouse
    docker compose up -d --force-recreate superset-init superset
    ```
- После `docker compose down -v` нужно повторно прогнать `make generated-history-analytics`.
- После `make clean`/`down -v` Superset стартует, но витрины `dm.*` ещё пустые или
  отсутствуют до прогона стартовой истории; используйте `make generated-history-analytics`.
- Архивную загрузку `make data` использовать только для ручных экспериментов.
