Merge pull request 'Дашборд «Данные»: свежесть, поток, брак, сходимость' (#120) from feat/115-data-dashboard into main

Reviewed-on: #120
This commit was merged in pull request #120.
This commit is contained in:
ddmitry committed 2026-08-21 20:52:52 +03:00
commit d43a9b348b
6 files changed
+383 -18

No files matched your search

+5 -3
View File
@@ -351,9 +351,11 @@ docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
Prometheus собирает встроенные метрики двух серверов и keeper, а через
`kafka-exporter` — состояние Kafka. Устройство зоны описано в
[справочнике мониторинга](docs/architecture/monitoring.md); панелей и правил
оповещения пока нет.
`kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд
«Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание
чтения и дневной слепок заказов. Устройство зоны и разбор панелей — в
[справочнике мониторинга](docs/architecture/monitoring.md); правил оповещения
пока нет.
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
+7
View File
@@ -114,3 +114,10 @@ ClickHouse, Kafka и Airflow, 17 — в дашборде генератора, 1
(спека, раздел 10), но рассказать урок можно только про то, что дашборд
показывает. Скопируй мы состав предшественника — урок неизбежно получился бы
про пульс планировщика.
21 августа 2026 года дашборд «Данные» собран. Один пункт решения исполнен не
буквально: у панели потока нет ряда «против вчерашнего». Сдвинуть ряд на
модельные сутки значит записать ускорение мира в JSON дашборда — ровно ту
пометку, от которой этот же ADR отговаривает выше, — а реальные сутки на стенде
не с чем сравнивать. Довод целиком и остальное устройство дашборда —
[docs/architecture/monitoring.md](../architecture/monitoring.md).
+132 -10
View File
@@ -1,16 +1,17 @@
# Мониторинг: из чего собран и что скребёт
Документ описывает зону мониторинга: какие сервисы в неё входят, что собирает
Prometheus, какие источники данных подготовлены в Grafana и каким
пользователем она ходит в ClickHouse. Зачем зоне именно такой состав и какие
дашборды на нём задуманы — [ADR 0002](../adr/0002-monitoring-scope.md);
устройство доступа — [ADR 0007](../adr/0007-clickhouse-access.md).
Prometheus, какие источники данных подготовлены в Grafana, каким пользователем
она ходит в ClickHouse и что показывают собранные дашборды. Зачем зоне именно
такой состав и какие дашборды на нём задуманы — [ADR
0002](../adr/0002-monitoring-scope.md); устройство доступа — [ADR
0007](../adr/0007-clickhouse-access.md).
**Что здесь есть и чего ещё нет.** Собран пол: свой образ Grafana с плагином
ClickHouse, два подготовленных источника данных, экспортёр метрик Kafka и цель
для него у Prometheus. Дашбордов пока ни одного — каталог под них заведён
пустым, панели приносят отдельные тикеты. Правил оповещения и серверного
журнала `system.text_log` тоже пока нет.
для него у Prometheus. Поверх пола стоит главный дашборд — «Данные»; «Кластер»
и «Запросы» приносят отдельные тикеты. Правил оповещения и серверного журнала
`system.text_log` тоже пока нет.
## Состав
@@ -61,9 +62,107 @@ Grafana ходит пользователем `grafana` с ролью `monitorin
Каталог `infra/grafana/provisioning/dashboards/` смонтирован в контейнер и
объявлен провайдером в `provider.yml`: Grafana подхватывает оттуда файлы
дашбордов, удалять и править их через интерфейс запрещено. Сейчас в каталоге
только сам файл провайдера. Тикет с панелями кладёт туда JSON и в
`compose.yaml` уже не заходит — ради этого каталог и заведён заранее.
дашбордов, удалять и править их через интерфейс запрещено. Новый дашборд — это
новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради
этого каталог и заведён заранее.
Файлы каталога разбирает `make config-test`, рядом с файлами дагов и по тому же
доводу ([testing.md](testing.md)).
## Дашборд «Данные»
`infra/grafana/provisioning/dashboards/data.json` — шесть панелей на один
экран. Вопрос у экрана один: с потоком всё в порядке? Состав панелей и довод за
каждую — [ADR 0002](../adr/0002-monitoring-scope.md).
Порогов и цветовых вердиктов на панелях нет: на дашборд смотрят глазами,
красное и зелёное живёт в проверках и дагах DQ.
| Панель | Откуда берёт | О чём отвечает |
|---|---|---|
| Свежесть | `ods.event_dist`, `ods.order_dist` | сколько прошло с последней доехавшей строки |
| Поток | `stg.hits_raw_dist`, `stg.orders_raw_dist` | сколько строк приезжает в слой сырья |
| Брак | те же и `ods.*_errors_dist` | сколько приехавшего не разобралось и какая это доля |
| Сходимость | экспортёр Kafka и `stg.hits_raw_dist` | произведённое против приземлившегося |
| Отставание | `kafka_consumergroup_lag` | сколько сообщений не прочитано |
| Дневной слепок | `stg.orders_raw_dist` | какой слепок доехал, когда и после какой паузы |
### Почему слои у панелей разные
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
разбор — свежесть ODS это покажет, свежесть сырья нет.
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
здесь про доставку: местом диагностики доставленных версий физическая таблица
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
сильнее, чем дольше живёт стенд.
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
отдельная куча, а часть сырья, которая не прошла разбор.
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
приехавшего и однажды перевалила бы за сотню процентов.
Панель дневного слепка, наоборот, окна не знает. Её вопрос про состояние, а не
про отрезок, и последние десять слепков она показывает всегда.
Отдельного ряда «против вчерашнего» на панели потока нет, и это решение, а не
пропуск. Реальное «вчера» на стенде чаще всего приходится на время, когда
стенда ещё не было. А сдвинуть ряд на модельные сутки значит записать в JSON
дашборда ускорение мира — ровно ту пометку про тик, которую [ADR
0002](../adr/0002-monitoring-scope.md) в дашборде держать не велит: поменяют
ускорение, и панель молча соврёт. Сравнение остаётся глазу: модельные сутки
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
окно панели сами.
### Чтецов Kafka панели не читают
`stg.hits_raw_kafka` и `stg.orders_raw_kafka` — таблицы движка Kafka, а не
хранилище: запрос из панели у второго забрал бы слепок себе и сдвинул офсеты
(`kafka_commit_on_select`, см. `sql/ddl/10-stg-tables.sql`), а даг приёма
приехал бы к пустому топику. Приземлившееся панели считают по `stg.*_raw_dist`,
произведённое — по метрикам экспортёра.
### Как считается сходимость
Произведённое живёт в Kafka, приземлившееся — в ClickHouse, и со своей стороны
ни одна из них не видит другую. Поэтому панель смешанная: ряд «произведено в
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
Единицы общие — сообщений за интервал панели. Со стороны Kafka это разность
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
растягивает приращение на всё окно и на минутном интервале промахивается на
проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала
окна точнее обоих приростов, но отсчитывать его надо от края окна — а на
свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает
целиком.
Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это
прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается
к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один
столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в
пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там,
где ряд ClickHouse идёт гладко.
Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос
экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия
Kafka идёт чуть выше. Читается устойчивый разрыв между линиями, а не разница в
один столбец.
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
он приезжает полем `snapshot_date`.
## Что проверено
@@ -81,3 +180,26 @@ ClickHouse через MCP Context7; разбор — в
- Prometheus видит ровно четыре цели, все со значением `up = 1`; запрос
`kafka_consumergroup_lag` вернул ряды по группам `clickstream_*`.
- `make smoke` 21/21, `make check-clickhouse` 10/10, `make check-services` 7/7.
Дашборд «Данные» проверен на живом стенде в тот же день, `world_live` снят с
паузы.
- Дашборд поднимается из каталога сам: файл положен, панели появились без
ручного импорта. Сохранение и удаление через API отвергнуты («Cannot save
provisioned dashboard», «provisioned dashboard cannot be deleted»).
- Все шесть панелей отвечают через `/api/ds/query` — и в окне часа по
умолчанию, и в окне на шесть часов, которое начинается раньше, чем поднялся
экспортёр. Пустых нет ни при идущем потоке, ни при остановленном.
- Брак: подсунутое в топик `hits` негодное сообщение легло в
`ods.event_errors` с классом `keyset_mismatch`; панель показала единицу и
долю 0,039 % в десятиминутном окне. Строка убрана мутациями из
`stg.hits_raw_rep` и `ods.event_errors_rep`, обе таблицы вернулись к нулю.
- Сходимость: на живом потоке за двадцать пять минут медиана расхождения между
рядами 3 %, худший столбец 11 % — на переломе потока, как и описано выше.
Отказы тоже по замеру: `increase` расходился сильнее, а накопленный итог
пропадал целиком, стоило окну начаться раньше экспортёра.
- Свежесть реагирует на остановленный поток и возвращается. Через пять минут
после конца модельного дня панель показала 305 с у событий и 288 с у
заказов; стоило пустить следующий день, события вернулись к секунде.
Свежесть заказов при этом растёт до конца дня и садится, когда доедет его
слепок, — так и устроен второй источник.
+4 -4
View File
@@ -34,13 +34,13 @@
спрашивают его отдельно.
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make smoke` и `make check-services` перемерены 21 августа 2026 года,
`make check-clickhouse` — 19 августа, корневой `make lint` — 9 августа,
`make test` — 7 августа; остальные стоят с замера 6 августа.
`make config-test`, `make smoke` и `make check-services` перемерены 21 августа
2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` —
9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена |
|---|---|---|---|---|
| `make config-test` | корень | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
@@ -0,0 +1,231 @@
{
"uid": "data",
"title": "Данные",
"timezone": "utc",
"schemaVersion": 42,
"refresh": "1m",
"time": {
"from": "now-1h",
"to": "now"
},
"panels": [
{
"type": "stat",
"title": "Свежесть: сколько прошло с последней доехавшей строки",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 0
},
"options": {
"colorMode": "none",
"graphMode": "none"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"editorType": "sql",
"queryType": "table",
"format": 1,
"rawSql": "SELECT\n (SELECT dateDiff('second', max(_load_ts), now()) FROM ods.event_dist) AS \"события\",\n (SELECT dateDiff('second', max(_load_ts), now()) FROM ods.order_dist) AS \"заказы\""
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
}
},
{
"type": "table",
"title": "Брак: сколько приехавшего не разобралось",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"gridPos": {
"h": 5,
"w": 9,
"x": 6,
"y": 0
},
"options": {},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"editorType": "sql",
"queryType": "table",
"format": 1,
"rawSql": "SELECT\n source AS \"Источник\",\n countIf(NOT is_error) AS \"Приехало\",\n countIf(is_error) AS \"Из них в брак\",\n 100 * countIf(is_error) / countIf(NOT is_error) AS \"Доля брака, %\"\nFROM\n(\n SELECT 'события' AS source, 0 AS is_error, _load_ts FROM stg.hits_raw_dist\n UNION ALL\n SELECT 'события', 1, _load_ts FROM ods.event_errors_dist\n UNION ALL\n SELECT 'заказы', 0, _load_ts FROM stg.orders_raw_dist\n UNION ALL\n SELECT 'заказы', 1, _load_ts FROM ods.order_errors_dist\n)\nWHERE $__timeFilter(_load_ts)\nGROUP BY source\nORDER BY source"
}
]
},
{
"type": "table",
"title": "Дневной слепок заказов: какой доехал, когда и после какой паузы",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"gridPos": {
"h": 5,
"w": 9,
"x": 15,
"y": 0
},
"options": {},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"editorType": "sql",
"queryType": "table",
"format": 1,
"rawSql": "SELECT\n \"Слепок\",\n \"Доехал\",\n dateDiff('second', prev, \"Доехал\") AS \"После прошлого, с\",\n \"Строк\"\nFROM\n(\n SELECT\n JSONExtractString(raw, 'snapshot_date') AS \"Слепок\",\n max(_load_ts) AS \"Доехал\",\n count() AS \"Строк\",\n -- Обнуляемый тип нужен, чтобы у самого раннего слепка пауза осталась\n -- пустой, а не превратилась в разницу с началом эпохи.\n lagInFrame(toNullable(max(_load_ts))) OVER (ORDER BY \"Слепок\") AS prev\n FROM stg.orders_raw_dist\n GROUP BY \"Слепок\"\n)\nORDER BY \"Слепок\" DESC\nLIMIT 10"
}
]
},
{
"type": "timeseries",
"title": "Поток: сколько строк приезжает в слой сырья",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 5
},
"options": {
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"editorType": "sql",
"queryType": "timeseries",
"format": 0,
"rawSql": "SELECT\n $__timeInterval(_load_ts) AS t,\n countIf(source = 'события') AS \"события\",\n countIf(source = 'заказы') AS \"заказы\"\nFROM\n(\n SELECT 'события' AS source, _load_ts FROM stg.hits_raw_dist\n UNION ALL\n SELECT 'заказы', _load_ts FROM stg.orders_raw_dist\n)\nWHERE $__timeFilter(_load_ts)\nGROUP BY t\nORDER BY t"
}
]
},
{
"type": "timeseries",
"title": "Сходимость: произведено в Kafka против приземлившегося в ClickHouse",
"datasource": {
"type": "datasource",
"uid": "-- Mixed --"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 5
},
"options": {
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(kafka_topic_partition_current_offset{topic=\"hits\"}) - sum(kafka_topic_partition_current_offset{topic=\"hits\"} offset $__interval)",
"legendFormat": "произведено в Kafka",
"range": true
},
{
"refId": "B",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
},
"editorType": "sql",
"queryType": "timeseries",
"format": 0,
"rawSql": "SELECT\n -- Правый край, а не левый: ряд Kafka в этой точке — прирост за тот же\n -- интервал, а он набирается к его концу.\n $__timeInterval(_load_ts) + INTERVAL $__interval_s SECOND AS t,\n count() AS \"приземлилось в ClickHouse\"\nFROM stg.hits_raw_dist\nWHERE $__timeFilter(_load_ts)\nGROUP BY t\nORDER BY t"
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "displayName",
"value": "приземлилось в ClickHouse"
}
]
}
]
},
"interval": "1m"
},
{
"type": "timeseries",
"title": "Отставание чтения из Kafka: непрочитанных сообщений",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 14
},
"options": {
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (consumergroup) (kafka_consumergroup_lag)",
"legendFormat": "{{consumergroup}}",
"range": true
}
]
}
]
}
+4 -1
View File
@@ -70,6 +70,9 @@ jq -e '
# обратная связь. Скрипты стенда проверять так незачем: их запускают с этой же
# машины, и ошибка вылезает при первом же запуске с номером строки.
PYTHONPYCACHEPREFIX="$CACHE_DIR" uv run --no-project python -m compileall -q "$ROOT_DIR/dags"
# Дашборды Grafana — тот же случай и тот же довод: сломанный JSON не сообщает о
# себе, а молча оставляет в интерфейсе прежнюю версию дашборда.
jq . "$ROOT_DIR"/infra/grafana/provisioning/dashboards/*.json >/dev/null
git -C "$ROOT_DIR" diff --check
printf 'ЗЕЛЁНО: Compose, Python и пробельные ошибки diff проверены.\n'
printf 'ЗЕЛЁНО: Compose, Python, дашборды и пробельные ошибки diff проверены.\n'