From 180ef5b94fa257a0003b289a9d9449a013c8271a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 21 Aug 2026 11:06:43 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(monitoring):=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=20=C2=AB=D0=94=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=C2=BB=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=88=D0=B5=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - главный дашборд ADR 0002 отвечает на вопрос «с потоком всё в порядке?»; три его вопроса — свежесть, брак и сходимость — у предшественника не заданы вовсе, и в этом весь смысл экрана. - Что: - добавлен infra/grafana/provisioning/dashboards/data.json: свежесть, поток, брак, сходимость, отставание чтения из Kafka и дневной слепок заказов; порогов и цветовых вердиктов на панелях нет. - в monitoring.md записано устройство дашборда: почему слои у панелей разные и как считается сходимость — с доводом и двумя отвергнутыми способами. - make config-test разбирает JSON дашбордов тем же доводом, что и файлы дагов: сломанный файл Grafana молча оставляет прежнюю версию. - Проверка: - make config-test; на живом стенде make smoke 21/21 и make check-clickhouse 10/10; все шесть панелей отвечают через /api/ds/query. --- docs/architecture/monitoring.md | 108 +++++++- docs/architecture/testing.md | 2 +- .../grafana/provisioning/dashboards/data.json | 236 ++++++++++++++++++ scripts/config-test.sh | 5 +- 4 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 infra/grafana/provisioning/dashboards/data.json diff --git a/docs/architecture/monitoring.md b/docs/architecture/monitoring.md index a523ba4..bf06b9a 100644 --- a/docs/architecture/monitoring.md +++ b/docs/architecture/monitoring.md @@ -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,96 @@ Grafana ходит пользователем `grafana` с ролью `monitorin Каталог `infra/grafana/provisioning/dashboards/` смонтирован в контейнер и объявлен провайдером в `provider.yml`: Grafana подхватывает оттуда файлы -дашбордов, удалять и править их через интерфейс запрещено. Сейчас в каталоге -только сам файл провайдера. Тикет с панелями кладёт туда JSON и в -`compose.yaml` уже не заходит — ради этого каталог и заведён заранее. +дашбордов, удалять и править их через интерфейс запрещено. Новый дашборд — это +новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради +этого каталог и заведён заранее. + +Сломанный JSON Grafana не выносит сообщением: она пишет строку в свой журнал и +оставляет прежнюю версию дашборда, то есть правка молча не появляется. Поэтому +файлы каталога разбирает `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 это покажет, свежесть сырья нет. + +Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего +не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, +и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем +сильнее, чем дольше живёт стенд. + +Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не +отдельная куча, а часть сырья, которая не прошла разбор. + +Окно у панели брака — окно дашборда, и это не косметика. У `ods.*_errors_dist` +срок жизни месяц, а у `ods.event_dist` его нет вовсе: доля за всё время +сравнивала бы разные сроки хранения. + +Отдельного ряда «против вчерашнего» на панели потока нет. Модельные сутки при +ускорении ×60 укладываются примерно в двадцать четыре реальные минуты, так что +соседние дни мира и без того помещаются в одно окно панели, — а реальное «вчера» +на стенде чаще всего приходится на время, когда стенда не было. + +### Чтецов Kafka панели не читают + +`stg.hits_raw_kafka` и `stg.orders_raw_kafka` — таблицы движка Kafka, а не +хранилище. У второго стоит `kafka_commit_on_select = 1`: запрос из панели забрал +бы слепок себе и сдвинул офсеты, а даг приёма приехал бы к пустому топику. +Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам +экспортёра. + +### Как считается сходимость + +Произведённое живёт в Kafka, приземлившееся — в ClickHouse, и со своей стороны +ни одна из них не видит другую. Поэтому панель смешанная: ряд «произведено в +Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse. +Единицы общие — сообщений за интервал панели. Со стороны Kafka это разность +офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со +стороны ClickHouse — `count()` строк сырья по тому же интервалу. + +Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase` +растягивает приращение на всё окно и на минутном интервале промахивается на +проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала +окна точнее их обоих, но отсчитывать его надо от края окна — а на свежем стенде +окно начинается раньше, чем появился экспортёр, и ряд пропадает целиком. + +Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это +прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается +к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один +столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в +пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там, +где ряд ClickHouse идёт гладко. + +Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос +экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия +Kafka идёт чуть выше. Читается устойчивый разрыв между линиями, а не разница в +один столбец. + +Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден +сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему +другой — не «сходится ли поток», а «доехала ли выгрузка и когда». На него +отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё +он приезжает полем `snapshot_date`. ## Что проверено diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index d970744..df1e46b 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -40,7 +40,7 @@ | Цель | Откуда | Что утверждает | Стенд | Цена | |---|---|---|---|---| -| `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 с | diff --git a/infra/grafana/provisioning/dashboards/data.json b/infra/grafana/provisioning/dashboards/data.json new file mode 100644 index 0000000..ae41842 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/data.json @@ -0,0 +1,236 @@ +{ + "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 dateDiff('second', max(_load_ts), now()) AS \"события\"\nFROM ods.event_dist" + }, + { + "refId": "B", + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse" + }, + "editorType": "sql", + "queryType": "table", + "format": 1, + "rawSql": "SELECT dateDiff('second', max(_load_ts), now()) AS \"заказы\"\nFROM ods.order_dist" + } + ], + "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 JSONExtractString(raw, 'snapshot_date') AS \"Слепок\",\n max(_load_ts) AS \"Доехал\",\n count() AS \"Строк\"\nFROM stg.orders_raw_dist\nGROUP BY \"Слепок\"\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 count() AS \"события\"\nFROM stg.hits_raw_dist\nWHERE $__timeFilter(_load_ts)\nGROUP BY t\nORDER BY t" + }, + { + "refId": "B", + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse" + }, + "editorType": "sql", + "queryType": "timeseries", + "format": 0, + "rawSql": "SELECT\n $__timeInterval(_load_ts) AS t,\n count() AS \"заказы\"\nFROM stg.orders_raw_dist\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" + } + ], + "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 + } + ] + } + ] +} diff --git a/scripts/config-test.sh b/scripts/config-test.sh index a4abb2f..5a7d377 100755 --- a/scripts/config-test.sh +++ b/scripts/config-test.sh @@ -70,6 +70,9 @@ jq -e ' # обратная связь. Скрипты стенда проверять так незачем: их запускают с этой же # машины, и ошибка вылезает при первом же запуске с номером строки. PYTHONPYCACHEPREFIX="$CACHE_DIR" uv run --no-project python -m compileall -q "$ROOT_DIR/dags" +# Дашборды Grafana — тот же случай и тот же довод: сломанный JSON не сообщает о +# себе, а молча оставляет в интерфейсе прежнюю версию дашборда. +jq -e . "$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' From e5c573bb25d443fd629048486a92c011fbe580ee Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 21 Aug 2026 11:23:05 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(monitoring):=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B0=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - две линии ревью нашли, что колонки панели брака читаются как непересекающиеся кучи, «вовремя ли» из ADR 0002 не считается вовсе, а отказ от ряда «против вчерашнего» нигде не записан рядом с решением. - Что: - в панели брака столбец назван «Из них в брак»: брак — часть приехавшего, а не соседняя куча. - панель слепка считает паузу после прошлого слепка — это и есть «вовремя ли» из ADR 0002; пустая пауза у самого раннего слепка держится обнуляемым типом. - отказ от ряда «против вчерашнего» записан в ADR 0002 и в monitoring.md с доводом: сдвиг на модельные сутки прописал бы ускорение мира в JSON. - в monitoring.md сказано, почему свежесть читает физическую _dist, а не ods.*_v, и почему панель слепка не знает окна; довод про сломанный JSON оставлен у кода, а не задвоен в доке. - README больше не утверждает, что панелей нет. - у разбора дашбордов убран флаг -e: он обещал утверждение, которого нет. - Проверка: - make config-test (краснеет на нарочно сломанном JSON дашборда), make lint, make smoke 21/21, make check-clickhouse 10/10; все шесть панелей отвечают через /api/ds/query. Опыты приёмки и их следы — monitoring.md, «Что проверено». --- README.md | 8 ++- docs/adr/0002-monitoring-scope.md | 7 ++ docs/architecture/monitoring.md | 72 ++++++++++++++----- docs/architecture/testing.md | 6 +- .../grafana/provisioning/dashboards/data.json | 8 +-- scripts/config-test.sh | 2 +- 6 files changed, 73 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 26f4395..b7cf56a 100644 --- a/README.md +++ b/README.md @@ -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 сверены с diff --git a/docs/adr/0002-monitoring-scope.md b/docs/adr/0002-monitoring-scope.md index aeb2483..6debe50 100644 --- a/docs/adr/0002-monitoring-scope.md +++ b/docs/adr/0002-monitoring-scope.md @@ -114,3 +114,10 @@ ClickHouse, Kafka и Airflow, 17 — в дашборде генератора, 1 (спека, раздел 10), но рассказать урок можно только про то, что дашборд показывает. Скопируй мы состав предшественника — урок неизбежно получился бы про пульс планировщика. + +21 августа 2026 года дашборд «Данные» собран. Один пункт решения исполнен не +буквально: у панели потока нет ряда «против вчерашнего». Сдвинуть ряд на +модельные сутки значит записать ускорение мира в JSON дашборда — ровно ту +пометку, от которой этот же ADR отговаривает выше, — а реальные сутки на стенде +не с чем сравнивать. Довод целиком и остальное устройство дашборда — +[docs/architecture/monitoring.md](../architecture/monitoring.md). diff --git a/docs/architecture/monitoring.md b/docs/architecture/monitoring.md index bf06b9a..e8cb677 100644 --- a/docs/architecture/monitoring.md +++ b/docs/architecture/monitoring.md @@ -66,10 +66,8 @@ Grafana ходит пользователем `grafana` с ролью `monitorin новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради этого каталог и заведён заранее. -Сломанный JSON Grafana не выносит сообщением: она пишет строку в свой журнал и -оставляет прежнюю версию дашборда, то есть правка молча не появляется. Поэтому -файлы каталога разбирает `make config-test` — тем же доводом, что и файлы дагов -([testing.md](testing.md)). +Файлы каталога разбирает `make config-test`, рядом с файлами дагов и по тому же +доводу ([testing.md](testing.md)). ## Дашборд «Данные» @@ -87,7 +85,7 @@ Grafana ходит пользователем `grafana` с ролью `monitorin | Брак | те же и `ods.*_errors_dist` | сколько приехавшего не разобралось и какая это доля | | Сходимость | экспортёр Kafka и `stg.hits_raw_dist` | произведённое против приземлившегося | | Отставание | `kafka_consumergroup_lag` | сколько сообщений не прочитано | -| Дневной слепок | `stg.orders_raw_dist` | какой слепок доехал и когда | +| Дневной слепок | `stg.orders_raw_dist` | какой слепок доехал, когда и после какой паузы | ### Почему слои у панелей разные @@ -95,6 +93,11 @@ Grafana ходит пользователем `grafana` с ролью `monitorin длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань разбор — свежесть ODS это покажет, свежесть сырья нет. +Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление +прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос +здесь про доставку: местом диагностики доставленных версий физическая таблица +названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`). + Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем @@ -103,22 +106,29 @@ Grafana ходит пользователем `grafana` с ролью `monitorin Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не отдельная куча, а часть сырья, которая не прошла разбор. -Окно у панели брака — окно дашборда, и это не косметика. У `ods.*_errors_dist` -срок жизни месяц, а у `ods.event_dist` его нет вовсе: доля за всё время -сравнивала бы разные сроки хранения. +Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое +суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток +приехавшего и однажды перевалила бы за сотню процентов. -Отдельного ряда «против вчерашнего» на панели потока нет. Модельные сутки при -ускорении ×60 укладываются примерно в двадцать четыре реальные минуты, так что -соседние дни мира и без того помещаются в одно окно панели, — а реальное «вчера» -на стенде чаще всего приходится на время, когда стенда не было. +Панель дневного слепка, наоборот, окна не знает. Её вопрос про состояние, а не +про отрезок, и последние десять слепков она показывает всегда. + +Отдельного ряда «против вчерашнего» на панели потока нет, и это решение, а не +пропуск. Реальное «вчера» на стенде чаще всего приходится на время, когда +стенда ещё не было. А сдвинуть ряд на модельные сутки значит записать в JSON +дашборда ускорение мира — ровно ту пометку про тик, которую [ADR +0002](../adr/0002-monitoring-scope.md) в дашборде держать не велит: поменяют +ускорение, и панель молча соврёт. Сравнение остаётся глазу: модельные сутки +укладываются в минуты реального времени, и соседние дни мира помещаются в одно +окно панели сами. ### Чтецов Kafka панели не читают `stg.hits_raw_kafka` и `stg.orders_raw_kafka` — таблицы движка Kafka, а не -хранилище. У второго стоит `kafka_commit_on_select = 1`: запрос из панели забрал -бы слепок себе и сдвинул офсеты, а даг приёма приехал бы к пустому топику. -Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам -экспортёра. +хранилище: запрос из панели у второго забрал бы слепок себе и сдвинул офсеты +(`kafka_commit_on_select`, см. `sql/ddl/10-stg-tables.sql`), а даг приёма +приехал бы к пустому топику. Приземлившееся панели считают по `stg.*_raw_dist`, +произведённое — по метрикам экспортёра. ### Как считается сходимость @@ -132,8 +142,9 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase` растягивает приращение на всё окно и на минутном интервале промахивается на проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала -окна точнее их обоих, но отсчитывать его надо от края окна — а на свежем стенде -окно начинается раньше, чем появился экспортёр, и ряд пропадает целиком. +окна точнее обоих приростов, но отсчитывать его надо от края окна — а на +свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает +целиком. Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается @@ -149,7 +160,7 @@ Kafka идёт чуть выше. Читается устойчивый разр Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему -другой — не «сходится ли поток», а «доехала ли выгрузка и когда». На него +другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё он приезжает полем `snapshot_date`. @@ -169,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 с у + заказов; стоило пустить следующий день, события вернулись к секунде. + Свежесть заказов при этом растёт до конца дня и садится, когда доедет его + слепок, — так и устроен второй источник. diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index df1e46b..e11cbce 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -34,9 +34,9 @@ спрашивают его отдельно. Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». -`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 августа. | Цель | Откуда | Что утверждает | Стенд | Цена | |---|---|---|---|---| diff --git a/infra/grafana/provisioning/dashboards/data.json b/infra/grafana/provisioning/dashboards/data.json index ae41842..f60ecbf 100644 --- a/infra/grafana/provisioning/dashboards/data.json +++ b/infra/grafana/provisioning/dashboards/data.json @@ -81,13 +81,13 @@ "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" + "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": "Дневной слепок заказов: какой доехал и когда", + "title": "Дневной слепок заказов: какой доехал, когда и после какой паузы", "datasource": { "type": "grafana-clickhouse-datasource", "uid": "clickhouse" @@ -109,7 +109,7 @@ "editorType": "sql", "queryType": "table", "format": 1, - "rawSql": "SELECT\n JSONExtractString(raw, 'snapshot_date') AS \"Слепок\",\n max(_load_ts) AS \"Доехал\",\n count() AS \"Строк\"\nFROM stg.orders_raw_dist\nGROUP BY \"Слепок\"\nORDER BY \"Слепок\" DESC\nLIMIT 10" + "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" } ] }, @@ -195,7 +195,7 @@ "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" + "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" } ], "interval": "1m" diff --git a/scripts/config-test.sh b/scripts/config-test.sh index 5a7d377..b8f71fe 100755 --- a/scripts/config-test.sh +++ b/scripts/config-test.sh @@ -72,7 +72,7 @@ jq -e ' PYTHONPYCACHEPREFIX="$CACHE_DIR" uv run --no-project python -m compileall -q "$ROOT_DIR/dags" # Дашборды Grafana — тот же случай и тот же довод: сломанный JSON не сообщает о # себе, а молча оставляет в интерфейсе прежнюю версию дашборда. -jq -e . "$ROOT_DIR"/infra/grafana/provisioning/dashboards/*.json >/dev/null +jq . "$ROOT_DIR"/infra/grafana/provisioning/dashboards/*.json >/dev/null git -C "$ROOT_DIR" diff --check printf 'ЗЕЛЁНО: Compose, Python, дашборды и пробельные ошибки diff проверены.\n' From f7123f2ad7876b40fc14ec44df203b5bc6d76ccf Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 21 Aug 2026 13:28:22 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(monitoring):=20=D1=80=D1=8F=D0=B4=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D1=8B=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?,=20=D0=B0=20=D0=BD=D0=B5=20=D0=B1=D1=83=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - на живом дашборде видно то, чего не показывает API: при нескольких запросах в панели Grafana ставит перед именем ряда его букву — «A события», «B приземлилось в ClickHouse». Читателю панели это шум. - Что: - свежесть и поток собраны одним запросом на панель: два столбца вместо двух запросов. У потока это заодно вернуло ряд заказов, который пропадал, когда в окне не было ни одного слепка. - у сходимости ряды из разных источников слить нельзя, поэтому ряду ClickHouse имя задано переопределением поля. - Проверка: - make config-test; на живом стенде подписи прочитаны прямо со страницы дашборда: «события», «заказы», «произведено в Kafka», «приземлилось в ClickHouse»; все шесть панелей отвечают через /api/ds/query. --- .../grafana/provisioning/dashboards/data.json | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/infra/grafana/provisioning/dashboards/data.json b/infra/grafana/provisioning/dashboards/data.json index f60ecbf..6c9e65a 100644 --- a/infra/grafana/provisioning/dashboards/data.json +++ b/infra/grafana/provisioning/dashboards/data.json @@ -36,18 +36,7 @@ "editorType": "sql", "queryType": "table", "format": 1, - "rawSql": "SELECT dateDiff('second', max(_load_ts), now()) AS \"события\"\nFROM ods.event_dist" - }, - { - "refId": "B", - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "clickhouse" - }, - "editorType": "sql", - "queryType": "table", - "format": 1, - "rawSql": "SELECT dateDiff('second', max(_load_ts), now()) AS \"заказы\"\nFROM ods.order_dist" + "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": { @@ -141,18 +130,7 @@ "editorType": "sql", "queryType": "timeseries", "format": 0, - "rawSql": "SELECT\n $__timeInterval(_load_ts) AS t,\n count() AS \"события\"\nFROM stg.hits_raw_dist\nWHERE $__timeFilter(_load_ts)\nGROUP BY t\nORDER BY t" - }, - { - "refId": "B", - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "clickhouse" - }, - "editorType": "sql", - "queryType": "timeseries", - "format": 0, - "rawSql": "SELECT\n $__timeInterval(_load_ts) AS t,\n count() AS \"заказы\"\nFROM stg.orders_raw_dist\nWHERE $__timeFilter(_load_ts)\nGROUP BY t\nORDER BY t" + "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" } ] }, @@ -198,6 +176,23 @@ "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" }, {