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
Третья часть #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
296 lines
18 KiB
JSON
296 lines
18 KiB
JSON
{
|
||
"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": []
|
||
}
|
||
}
|
||
]
|
||
}
|