gendesign/docs/observability.md
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

215 lines
17 KiB
Markdown
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.

# Наблюдаемость: метрики, логи, алерты
Задача #3078. Стек живёт по адресу `https://metrics.gendsgn.ru/`.
## Зачем это заведено
Метрик в проекте не было ни одной — ни Prometheus, ни экспортеров, ни `/metrics`
в бэкендах. Единственным каналом наблюдения оставался journald, а единственным
сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был
невидим в принципе: задача рапортует `done`, строк ноль, исключения нет, метрики
нет. Так протухли данные на семь месяцев (#2846/#2998), так 34 дня был мёртв
`house_imv_backfill` (#2698), так 8 суток писал ноль записей `newbuilding_enrich`
(#2767), так 91 день копилось раздутие `listings` (#2992).
Grafana **не заменяет** GlitchTip. Ошибки остаются там: Grafana OSS не принимает
Sentry DSN ни одним компонентом, а 549 строк скрубберов в `before_send` — это
требование 152-ФЗ, а не фича трекера. Здесь появляется другой класс данных —
числовые ряды и алерты по трендам вместо алертов по событиям.
## Топология
```
Poincare (188.246.224.93) — продукт Beget (46.173.16.127) — инфраструктура
┌──────────────────────────────┐ ┌────────────────────────────────────┐
│ node-exporter │ │ Prometheus ← приёмник remote_write │
│ cAdvisor │ HTTPS │ Loki ← приёмник логов │
│ postgres-exporter × 2 │ ──────────► │ Grafana ← витрина │
│ Alloy ──── push ────────────┼─ 443 ─────► │ Alertmanager (за профилем alerts) │
└──────────────────────────────┘ │ node-exporter, cAdvisor, Alloy │
│ postgres-exporter (infra) │
└────────────────────────────────────┘
```
**Наблюдатель стоит у другого провайдера, чем наблюдаемое.** Если ляжет Poincare,
мониторинг обязан выжить и сказать об этом, а не лечь вместе с ним.
**Транспорт — push.** Prometheus не ходит на Poincare: там агент сам шлёт
исходящим HTTPS. Поэтому на продуктовом хосте не открыто ни одного входящего
порта сверх 22/80/443. Побочный выигрыш: при обрыве канала Alloy копит в WAL и
досылает, а pull-скрейп в той же ситуации просто потерял бы точки — то есть
именно в аварии, ради которой мониторинг и заводится.
## Что где лежит
| Файл | Назначение |
|---|---|
| `docker-compose.metrics.yml` | серверная сторона, только Beget |
| `docker-compose.metrics-agent.yml` | агенты, оба хоста; профили `apps` / `infra` |
| `ops/metrics/prometheus/` | конфиг и правила алертов |
| `ops/metrics/loki/` | конфиг Loki |
| `ops/metrics/alloy/alloy-infra.alloy` | агент на Beget — пишет напрямую по docker-сети |
| `ops/metrics/alloy/alloy-apps.alloy` | агент на Poincare — пишет по HTTPS под basic_auth |
| `ops/metrics/postgres/queries.yml` | дополнительные запросы экспортера БД |
| `backend/app/observability/metrics.py` | счётчики и `/metrics` «Птицы» |
| `tradein-mvp/backend/app/observability/metrics.py` | то же для «Меры» |
| `ops/metrics/grafana/` | источники данных и дашборды |
| `caddy/sites/infra.caddy` | site-блок `metrics.gendsgn.ru` |
| `scripts/setup-metrics-secrets.sh` | разовая подготовка учёток |
## Первый запуск
```bash
# 1. На инфраструктурном хосте — один раз. Пароли не печатает, печатает хеши.
ssh gendesign 'cd /opt/gendesign && bash scripts/setup-metrics-secrets.sh'
# 2. Хеши из вывода вставить в caddy/metrics-ui.caddy.snippet и
# caddy/metrics-ingest.caddy.snippet, закоммитить.
# 3. Деплой.
# Forgejo → Actions → Deploy Metrics → Run workflow
```
Пароль витрины смотреть на хосте, в переписку не копировать:
```bash
ssh gendesign "grep '^METRICS_UI_PASSWORD=' /opt/gendesign/backend/.env.runtime"
```
## Два контура доступа, две учётки
`metrics.gendsgn.ru/ingest/*` — машинный, для агента. Пароль этого контура по
построению лежит в открытом виде на продуктовом хосте: агенту нужно им
авторизоваться. Значит, компрометация Poincare раскрывает его автоматически.
Если бы это была учётка витрины, вместе с ней утёк бы доступ к дашбордам и к
Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью.
Всё остальное — витрина Grafana под отдельным паролем. Это **второй слой**: у
Grafana остаётся собственный вход с ролями. Внешний basic_auth отсекает сканеры
и любую будущую дыру в самой Grafana до того, как она станет достижимой снаружи.
Если два запроса пароля подряд неудобны — убрать одну строку `import
caddy/metrics-ui.caddy.snippet` из `infra.caddy`; вход Grafana останется.
## GlitchTip читается прямым SQL, а не плагином
Плагин `grafana/sentry-datasource` GlitchTip официально документирует, но на
нашей 6.1.6 половина путей нерабочая: `stats_v2` с фильтром по проекту отдаёт
500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 (#416),
Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datasource` слово
«glitchtip» не встречается ни разу — апстрим связку не тестирует.
Прямой SQL правок в GlitchTip не требует вовсе, нужен только read-only
пользователь (`scripts/setup-metrics-grafana-role.sh`, прав на запись нет).
Схема проверена на живой базе 26.08:
- `issue_events_issue``count`, `first_seen`, `last_seen`, `status`, `level`,
`project_id`, `title`, `culprit`
- `issue_events_issueaggregate``(issue_id, organization_id, date, count)`,
партиционирована по неделям с почасовыми под-партициями
- `issue_events_issueevent` — колонка времени называется **`timestamp`**
(плюс `created`); колонки `received` в этой версии нет
Отдельной таблицы `IssueIndex` **не существует** — все агрегаты лежат на самой
`issue_events_issue`. В более ранних описаниях этой задачи она упоминалась;
это была ошибка, проверено глазами.
**Граница:** доступ read-only. Тренды — в Grafana, а assign / resolve / ignore,
стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и
рабочее место. Это осознанно, а не недоделка.
## Метрики приложений
У «Птицы» и «Меры» появился `/metrics` — HTTP-счётчики, гистограмма времени
ответа, число запросов в работе и версия сборки, плюс `process_*` от реестра
`prometheus_client` (память процесса, дескрипторы, сборщик мусора).
**Метка `route` — это шаблон маршрута**, `/api/v1/parcels/{cad_num}`, а не путь
запроса. Разница принципиальная, а не косметическая: кадастровый номер в метке
даёт новый временной ряд на каждый участок, а ряд у Prometheus стоит памяти
постоянно, а не в момент запроса. Это самый известный способ уронить мониторинг
тем самым мониторингом. Всё незаматченное сведено в одну метку
``__unmatched__`` — иначе тот же взрыв рядов устроит любой бот, перебирающий
адреса. Свойство сторожится тестами (`tests/test_metrics.py` в обоих проектах),
а не комментарием.
**Слой регистрируется последним и потому оказывается самым внешним**
(`add_middleware` вставляет в начало списка). Порядок несущий: изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД `auth`, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202).
**Путь публичен внутри и закрыт снаружи** — это два разных периметра. Скрейп
идёт изнутри docker-сети, где заголовка `X-Authenticated-User` нет ни у кого,
поэтому `/metrics` внесён в `_PUBLIC_PATHS` обоих бэкендов: без этого агент
получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не открывается —
ни через `gendsgn.ru`, ни через `meraocenka.ru`, и вдобавок закрыт явным
`respond 404` в обоих site-блоках. Явный отказ стоит там ради регрессии: одной
строки `handle /metrics { reverse_proxy backend:8000 }`, добавленной
когда-нибудь по невнимательности, хватит, чтобы выставить наружу внутреннее
устройство продукта.
Ограничитель частоты и аудит запросов «Меры» трогать не пришлось: первый
смотрит только на пути под `/api/`, второй — на `/api/` и известного
пользователя. Скрейп раз в 30 секунд не попадает ни под один; иначе метрики
пропадали бы пачками под нагрузкой, а `user_events` получала бы 2880 строк в
сутки ни о чём.
**Один процесс — один реестр.** Оба контейнера запускают `uvicorn` без
`--workers`, поэтому значения счётчиков целостны. Появятся воркеры — счётчики
станут per-process, каждый скрейп попадёт в случайный из них, и график начнёт
пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным
режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` +
`MultiProcessCollector`); делать это заранее незачем, но связь `--workers` →
сломанные графики стоит знать до, а не после.
Celery-воркеры своего `/metrics` не отдают: у них нет HTTP-сервера, а поднимать
его в каждом воркере ради счётчиков — отдельная конструкция со своим временем
жизни. Прогоны фоновых задач будут видны иначе — через `scrape_runs` (часть 4),
и это лучше: там уже лежит история, а не только то, что происходит прямо сейчас.
## Алерты
Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что
у вебхука GlitchTip, или отдельный, и при каком пороге будить ночью. Стек метрик
работает и без них, но **при срабатывании правила никто не будет уведомлён** —
деплой пишет об этом предупреждением, чтобы это не стало сюрпризом.
Включение: задать `METRICS_TELEGRAM_BOT_TOKEN` и `METRICS_TELEGRAM_CHAT_ID` в
окружении инфраструктурного хоста и перезапустить деплой. Профиль `alerts`
включится сам.
Alertmanager шлёт в Telegram **напрямую с Beget**, а не через бэкенд МЕРЫ, хотя
рабочий путь доставки там уже есть. Причина простая: сообщение о том, что лёг
Poincare, не должно идти через сервис на Poincare.
Отдельно живёт правило `Watchdog` — оно горит всегда и раз в 12 часов
подтверждает, что цепочка правило → Alertmanager → Telegram → человек цела.
Существует ради того, чтобы его отсутствие было заметно: молчащий канал — самый
частый способ узнать об аварии последним. В проекте МЕРЫ наружу не ушло ни одного
сообщения с 30 мая, и выяснилось это случайно (#2673).
## Известные грабли
- **Alloy запущен от root.** Штатный пользователь `alloy` требует членства в
группах `adm` и `systemd-journal`; внутри контейнера этих групп с нужными gid
нет, и агент молча читает ноль записей журнала. Отказ выглядит как «логов
просто нет».
- **Путь к журналу задан явно** (`/var/log/journal`). Без него libsystemd
применяет `SD_JOURNAL_LOCAL_ONLY`, и хостовый журнал из контейнера не виден —
тоже без ошибки.
- **`retention_period` у Loki не работает без `retention_enabled` у компактора.**
Loki примет конфиг и будет копить вечно.
- **Ретенция Prometheus задана и по времени, и по размеру.** Только по времени —
значит, объём в байтах зависит от числа рядов, которое растёт само.
- **Caddy проверяется до перезагрузки.** На Beget тот же Caddy обслуживает
`git.`, `errors.` и `obsidian.`; ошибка в `infra.caddy` положила бы их все,
включая Forgejo, из которого идёт деплой.
## Что ещё не сделано
- экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация,
счётчик `cancelled` (часть 4)
- алерты и синтетический heartbeat (часть 5)
- `pg_stat_statements` для кластера Птицы — требует рестарта прод-БД, отдельно
- честные `started_at` / `finished_at` у прогонов (#2702) — предусловие для
графиков пропускной способности: пока start/finish врут, врут и графики