gendesign/ops/metrics/grafana/dashboards/apps.json
bot-backend 124cfb3d5d
All checks were successful
CI / backend-tests (pull_request) Successful in 17m30s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд
Третья часть #3078 и единственная, трогающая прод-код.

До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.

Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.

Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.

Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.

Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.

Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.

Refs #3078
2026-08-26 11:30:18 +03:00

296 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"uid": "gendesign-apps",
"title": "Приложения",
"description": "Птица и МЕРА глазами их собственных счётчиков. Отвечает на класс вопросов, невидимый для GlitchTip: сервис отвечает, исключений нет, а продукт при этом не работает — потому что медленно, или потому что поток 401 и 429.",
"tags": ["gendesign", "apps"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "1m",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{
"name": "app",
"label": "Приложение",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(app_build_info, app)",
"refresh": 1,
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{ "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
{
"type": "stat",
"title": "Приложение отвечает",
"description": "Скрейп удался или нет. Ноль здесь не то же самое, что «нет запросов»: это агент не смог снять метрики вовсе, то есть процесс не отвечает по сети.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
"targets": [
{ "refId": "A", "expr": "up{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}}" }
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "не отвечает", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } }
],
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }
},
"overrides": []
},
"options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "Запросов в минуту",
"description": "Обвал до нуля при живом процессе — тоже отказ: значит, до приложения перестали доходить запросы (Caddy, сеть, фронт), и снаружи это выглядит как неработающий сайт.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
"targets": [
{ "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\"}[5m])) * 60", "legendFormat": "{{app}}" }
],
"fieldConfig": {
"defaults": { "unit": "short", "decimals": 1, "thresholds": { "mode": "absolute", "steps": [ { "color": "text", "value": null } ] } },
"overrides": []
},
"options": { "colorMode": "none", "graphMode": "area", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "Доля 5xx за час",
"description": "Ошибка сервера — это всегда несделанная работа пользователя. Порог оранжевого стоит на 1 %: при нашем трафике это единицы запросов, и они должны быть заметны, а не тонуть в проценте от большого числа.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
"targets": [
{ "refId": "A", "expr": "sum by (app) (increase(http_requests_total{app=~\"$app\", status=~\"5..\"}[1h])) / clamp_min(sum by (app) (increase(http_requests_total{app=~\"$app\"}[1h])), 1)", "legendFormat": "{{app}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.01 }, { "color": "red", "value": 0.05 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "95-й перцентиль за час",
"description": "Не среднее: среднее прячет как раз тех, кому плохо. Считается по корзинам гистограммы, поэтому точность ограничена их границами — это нормально для порога, но не для точного измерения.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
"targets": [
{ "refId": "A", "expr": "histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[1h])))", "legendFormat": "{{app}}" }
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 }, { "color": "red", "value": 5 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{ "type": "row", "title": "Трафик и отказы", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } },
{
"type": "timeseries",
"title": "Запросы по классам ответов",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"targets": [
{ "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"2..\"}[5m]))", "legendFormat": "{{app}} · 2xx" },
{ "refId": "B", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"3..\"}[5m]))", "legendFormat": "{{app}} · 3xx" },
{ "refId": "C", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"4..\"}[5m]))", "legendFormat": "{{app}} · 4xx" },
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
],
"fieldConfig": {
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*4xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*5xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Отказы авторизации и лимитера",
"description": "401 и 403 считаются потому, что вход у «Меры» и «Птицы» общий, а инцидентов вокруг него уже было достаточно (#2552, эпик единого входа). 429 — срабатывания ограничителя частоты: устойчивая линия означает, что кому-то из живых пользователей регулярно отказывают.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
"targets": [
{ "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"401\"}[5m]))", "legendFormat": "{{app}} · 401 не авторизован" },
{ "refId": "B", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"403\"}[5m]))", "legendFormat": "{{app}} · 403 запрещено" },
{ "refId": "C", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"429\"}[5m]))", "legendFormat": "{{app}} · 429 лимитер" }
],
"fieldConfig": {
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*429.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Ошибки сервера по маршрутам (топ-10)",
"description": "Здесь видно, какая именно ручка ломается. Метка route — шаблон маршрута, а не путь: идентификаторы в него не попадают намеренно, иначе каждый участок и каждая заявка давали бы отдельный ряд.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
"targets": [
{ "refId": "A", "expr": "topk(10, sum by (app, route, status) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m])))", "legendFormat": "{{app}} · {{route}} · {{status}}" }
],
"fieldConfig": {
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" } },
"overrides": []
}
},
{
"type": "table",
"title": "Самые медленные маршруты (95-й перцентиль за час)",
"description": "Считается по маршрутам, у которых за окно был хоть какой-то трафик. Пустая таблица означает тишину, а не быстроту.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
"targets": [
{
"refId": "A",
"instant": true,
"format": "table",
"expr": "topk(12, histogram_quantile(0.95, sum by (le, app, route) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[1h]))))"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "Time": true, "job": true, "instance": true, "host": true, "cluster": true, "le": true },
"renameByName": { "app": "Приложение", "route": "Маршрут", "Value": "95-й перцентиль" }
}
},
{ "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "95-й перцентиль", "desc": true } ] } }
],
"fieldConfig": {
"defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
"overrides": [
{
"matcher": { "id": "byName", "options": "95-й перцентиль" },
"properties": [
{ "id": "unit", "value": "s" },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 }, { "color": "red", "value": 5 } ] } }
]
}
]
}
},
{ "type": "row", "title": "Задержка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } },
{
"type": "timeseries",
"title": "Перцентили времени ответа",
"description": "Три линии вместе, потому что расхождение между ними и есть сигнал: ровный 50-й при растущем 99-м означает, что плохо становится части пользователей, а не всем, — и по среднему это не увидеть никогда.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 16, "x": 0, "y": 24 },
"targets": [
{ "refId": "A", "expr": "histogram_quantile(0.5, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 50-й" },
{ "refId": "B", "expr": "histogram_quantile(0.9, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 90-й" },
{ "refId": "C", "expr": "histogram_quantile(0.99, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 99-й" }
],
"fieldConfig": {
"defaults": { "unit": "s", "min": 0, "custom": { "fillOpacity": 5, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{
"type": "timeseries",
"title": "Запросов в работе",
"description": "Растущая линия при неизменном трафике означает, что запросы копятся: приложение принимает быстрее, чем отвечает. Обычно это блокирующий вызов в обработчике — ровно тот случай, что был в #1202.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 8, "x": 16, "y": 24 },
"targets": [
{ "refId": "A", "expr": "http_requests_in_progress{app=~\"$app\"}", "legendFormat": "{{app}}" }
],
"fieldConfig": {
"defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{ "type": "row", "title": "Процесс", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 } },
{
"type": "timeseries",
"title": "Память процесса",
"description": "Резидентная память самого процесса Python, а не контейнера. Монотонный рост между перезапусками — утечка; по контейнерной метрике её легко списать на кэш.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 34 },
"targets": [
{ "refId": "A", "expr": "process_resident_memory_bytes{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}}" }
],
"fieldConfig": {
"defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{
"type": "timeseries",
"title": "Открытые дескрипторы",
"description": "Незакрытые соединения и файлы упираются в лимит и дают отказы, которые выглядят как что угодно, кроме своей причины. Линия, ползущая вверх и не спадающая, — это она.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 34 },
"targets": [
{ "refId": "A", "expr": "process_open_fds{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}} · открыто" },
{ "refId": "B", "expr": "process_max_fds{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}} · предел" }
],
"fieldConfig": {
"defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 5, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*предел.*" }, "properties": [ { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [ 8, 6 ] } }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
]
}
},
{
"type": "table",
"title": "Что задеплоено",
"description": "Версия, отвечавшая в выбранном окне. Первый вопрос при разборе — «а что там было в этот момент», и ответ на него должен быть в той же картинке, а не в истории деплоев.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 34 },
"targets": [
{ "refId": "A", "instant": true, "format": "table", "expr": "app_build_info{app=~\"$app\"}" }
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "Time": true, "job": true, "instance": true, "host": true, "cluster": true, "Value": true },
"renameByName": { "app": "Приложение", "release": "Версия" }
}
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
"overrides": []
}
}
]
}