feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд приложений #3102

Merged
lekss361 merged 1 commit from feat/observability-app-metrics into main 2026-08-26 08:54:56 +00:00
Owner

Третья часть из пяти по #3078 и единственная, трогающая прод-код. Стек-приёмник уже в main (#3099); эта часть даёт ему то, что собирать с приложений.

Зачем

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

Появляются HTTP-счётчики, гистограмма времени ответа, число запросов в работе, версия сборки и process_* от реестра prometheus_client — память процесса, дескрипторы, сборщик мусора.

Решения, которые стоит проверить при ревью

Метка route — ШАБЛОН маршрута, а не путь запроса. /api/v1/parcels/{cad_num}, не /api/v1/parcels/66:41:0301001:123. Разница не косметическая: у Prometheus временной ряд стоит памяти постоянно, а не в момент запроса, поэтому кадастровый номер в метке кладёт приёмник за сутки — самый известный способ уронить мониторинг тем самым мониторингом. Отказ отложенный и не выглядит как ошибка кода, поэтому свойство закреплено тестом, а не комментарием: тест бьёт тремя разными идентификаторами и требует один ряд, а потом проверяет, что ни один идентификатор не встречается в выгрузке.

Незаматченные пути сведены в одну метку __unmatched__. Иначе тот же взрыв рядов устроит через чёрный ход любой бот, перебирающий адреса, — тест бьёт по /wp-admin/setup-config.php, /.env и кириллическому пути и требует один ряд.

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

Упавший исключением запрос считается как 500 в finally. Исключение проходит сквозь слой наружу, к ServerErrorMiddleware; без finally такие запросы просто отсутствовали бы в счётчике — то есть ровно тогда, когда метрики нужнее всего.

Чистый ASGI, а не BaseHTTPMiddleware. Последний заворачивает ответ в собственный поток и на потоковых ответах ведёт себя иначе. Такие ответы есть в обоих продуктах — выгрузки PDF/DXF/XLSX у «Птицы», PDF отчёта у «Меры», — и менять их обработку ради подсчёта запросов не стоит.

Два периметра, а не один. /metrics публичен внутри и закрыт снаружи. Скрейп идёт из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому путь внесён в _PUBLIC_PATHS обоих бэкендов — без этого агент получал бы 401 и метрик не было бы вовсе. Наружу он при этом не открывается: gendsgn.ru отдаёт бэкенду «Птицы» только /health и /api/*, бэкенду «Меры» — только /trade-in/api/*, а meraocenka.ru работает по белому списку. Сверх этого в обоих site-блоках стоит явный respond 404 на /metrics: одной строки handle /metrics { reverse_proxy backend:8000 }, добавленной когда-нибудь по невнимательности, хватит, чтобы выставить наружу внутреннее устройство продукта, — явный отказ делает закрытость решением, а не следствием текущего порядка директив.

Ограничитель частоты и аудит «Меры» не трогались. Первый смотрит только на пути под /api/, второй — на /api/ и известного пользователя, так что скрейп раз в 30 секунд не попадает ни под один. Иначе метрики пропадали бы пачками именно под нагрузкой, а user_events получала бы 2880 строк в сутки ни о чём. Проверено тестом (реальный RateLimitMiddleware, лимит понижен до 2, шесть запросов подряд — все 200), а не чтением кода.

Границы гистограммы разные у двух продуктов, и это намеренно. У «Птицы» верхняя корзина 60 с: POST /api/v1/parcels/{cad_num}/analyze уходит в десятки секунд, внутри поход в OSRM и подсчёт геометрии. У «Меры» шаг плотнее снизу — расчёт укладывается в 90 мс (замер на живом запросе), — но верхние корзины оставлены из-за внешних зависимостей с непредсказуемым временем: геокодер, банк-эквайер.

Проверено

Тесты гоняются в изолированном окружении обоих проектов:

backend/tests/test_metrics.py                 5 passed
tradein-mvp/backend/tests/test_metrics.py     7 passed

Один тест «Птицы» (_PUBLIC_PATHS из app.main) в изолированном прогоне не поднялся — не хватало sentry_sdk; в CI окружение полное. Одноимённый тест «Меры» с полными зависимостями прошёл.

Отдельно, на живом Beget'е, caddy validate в обоих режимах после правки apps.caddyValid configuration; caddy adapt подтверждает, что оба блока /metrics → 404 реально попали в конфиг:

host ['meraocenka.ru'] path ['/metrics'] -> 404
host ['gendsgn.ru']    path ['/metrics'] -> 404

Проверка стоит до перезагрузки и в workflow тоже: тот же Caddy обслуживает git., errors. и obsidian., включая Forgejo, из которого идёт деплой.

ruff чист по всем изменённым файлам обоих проектов. prometheus-client добавлен в оба pyproject.toml, оба lock-файла обновлены через uv lock (0.26.0).

Что осталось незакрытым сознательно

Один процесс — один реестр. Оба контейнера запускают uvicorn без --workers, поэтому счётчики целостны. Появятся воркеры — счётчики станут per-process, каждый скрейп попадёт в случайный, и график начнёт пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным режимом prometheus_client; делать это заранее незачем, но связь --workers → сломанные графики отмечена в коде, чтобы её не искали заново.

Celery-воркеры своего /metrics не отдают — у них нет HTTP-сервера, а поднимать его в каждом воркере ради счётчиков это отдельная конструкция со своим временем жизни. Прогоны фоновых задач будут видны иначе, через scrape_runs (часть 4), и это лучше: там уже лежит история, а не только происходящее прямо сейчас.

process_* появляются только на Linux — коллектор читает /proc. Боевой рантайм это контейнер, там метрики есть; на Windows их нет, и это не отказ.

Test plan

  • route — шаблон: три идентификатора → один ряд, ни один не утёк в выгрузку
  • незаматченные пути схлопываются в один ряд
  • исключение посчитано как 500
  • in_progress сходится в ноль, в том числе после упавшего запроса
  • /metrics не ловит 429 от реального лимитера «Меры»
  • caddy validate в обоих режимах + оба /metrics → 404 в адаптированном конфиге
  • ruff по обоим проектам
  • после мержа: up{job="app"} равен 1 для sitefinder и mera (сейчас 0 — цель видна как недоступная, это ожидаемо)
  • после мержа: дашборд «Приложения» рисует трафик с обоих
  • после мержа: curl -sI https://gendsgn.ru/metrics и https://meraocenka.ru/metrics → 404
  • после мержа: /, /trade-in, /estimate и /health отвечают как раньше

Refs #3078

Третья часть из пяти по #3078 и **единственная, трогающая прод-код**. Стек-приёмник уже в main (#3099); эта часть даёт ему то, что собирать с приложений. ## Зачем До неё числовых рядов у «Птицы» и «Меры» не было вовсе — только логи и исключения в GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой картине невидим: исключения нет, строка в логе выглядит обычной, а продукт при этом не работает. Появляются HTTP-счётчики, гистограмма времени ответа, число запросов в работе, версия сборки и `process_*` от реестра `prometheus_client` — память процесса, дескрипторы, сборщик мусора. ## Решения, которые стоит проверить при ревью **Метка `route` — ШАБЛОН маршрута, а не путь запроса.** `/api/v1/parcels/{cad_num}`, не `/api/v1/parcels/66:41:0301001:123`. Разница не косметическая: у Prometheus временной ряд стоит памяти постоянно, а не в момент запроса, поэтому кадастровый номер в метке кладёт приёмник за сутки — самый известный способ уронить мониторинг тем самым мониторингом. Отказ отложенный и не выглядит как ошибка кода, поэтому свойство закреплено тестом, а не комментарием: тест бьёт тремя разными идентификаторами и требует **один** ряд, а потом проверяет, что ни один идентификатор не встречается в выгрузке. **Незаматченные пути сведены в одну метку** `__unmatched__`. Иначе тот же взрыв рядов устроит через чёрный ход любой бот, перебирающий адреса, — тест бьёт по `/wp-admin/setup-config.php`, `/.env` и кириллическому пути и требует один ряд. **Слой регистрируется последним и потому оказывается самым внешним** (`add_middleware` вставляет в начало списка). Порядок несущий. Изнутри RBAC-гварда не видно ни отказов авторизации — их отдаёт сам гвард, — ни времени, которое он тратит на резолв сессии в БД `auth`; а именно этот путь уже давал инцидент с блокирующим I/O в middleware (#1202). У «Меры» так же не были бы видны 429 от ограничителя частоты. **Упавший исключением запрос считается как 500** в `finally`. Исключение проходит сквозь слой наружу, к `ServerErrorMiddleware`; без `finally` такие запросы просто отсутствовали бы в счётчике — то есть ровно тогда, когда метрики нужнее всего. **Чистый ASGI, а не `BaseHTTPMiddleware`.** Последний заворачивает ответ в собственный поток и на потоковых ответах ведёт себя иначе. Такие ответы есть в обоих продуктах — выгрузки PDF/DXF/XLSX у «Птицы», PDF отчёта у «Меры», — и менять их обработку ради подсчёта запросов не стоит. **Два периметра, а не один.** `/metrics` **публичен внутри** и **закрыт снаружи**. Скрейп идёт из docker-сети, где заголовка `X-Authenticated-User` нет ни у кого, поэтому путь внесён в `_PUBLIC_PATHS` обоих бэкендов — без этого агент получал бы 401 и метрик не было бы вовсе. Наружу он при этом не открывается: `gendsgn.ru` отдаёт бэкенду «Птицы» только `/health` и `/api/*`, бэкенду «Меры» — только `/trade-in/api/*`, а `meraocenka.ru` работает по белому списку. Сверх этого в обоих site-блоках стоит **явный `respond 404`** на `/metrics`: одной строки `handle /metrics { reverse_proxy backend:8000 }`, добавленной когда-нибудь по невнимательности, хватит, чтобы выставить наружу внутреннее устройство продукта, — явный отказ делает закрытость решением, а не следствием текущего порядка директив. **Ограничитель частоты и аудит «Меры» не трогались.** Первый смотрит только на пути под `/api/`, второй — на `/api/` и известного пользователя, так что скрейп раз в 30 секунд не попадает ни под один. Иначе метрики пропадали бы пачками именно под нагрузкой, а `user_events` получала бы 2880 строк в сутки ни о чём. Проверено тестом (реальный `RateLimitMiddleware`, лимит понижен до 2, шесть запросов подряд — все 200), а не чтением кода. **Границы гистограммы разные у двух продуктов**, и это намеренно. У «Птицы» верхняя корзина 60 с: `POST /api/v1/parcels/{cad_num}/analyze` уходит в десятки секунд, внутри поход в OSRM и подсчёт геометрии. У «Меры» шаг плотнее снизу — расчёт укладывается в 90 мс (замер на живом запросе), — но верхние корзины оставлены из-за внешних зависимостей с непредсказуемым временем: геокодер, банк-эквайер. ## Проверено Тесты гоняются в изолированном окружении обоих проектов: ``` backend/tests/test_metrics.py 5 passed tradein-mvp/backend/tests/test_metrics.py 7 passed ``` Один тест «Птицы» (`_PUBLIC_PATHS` из `app.main`) в изолированном прогоне не поднялся — не хватало `sentry_sdk`; в CI окружение полное. Одноимённый тест «Меры» с полными зависимостями прошёл. Отдельно, на живом Beget'е, `caddy validate` в **обоих** режимах после правки `apps.caddy` → `Valid configuration`; `caddy adapt` подтверждает, что оба блока `/metrics → 404` реально попали в конфиг: ``` host ['meraocenka.ru'] path ['/metrics'] -> 404 host ['gendsgn.ru'] path ['/metrics'] -> 404 ``` Проверка стоит **до** перезагрузки и в workflow тоже: тот же Caddy обслуживает `git.`, `errors.` и `obsidian.`, включая Forgejo, из которого идёт деплой. `ruff` чист по всем изменённым файлам обоих проектов. `prometheus-client` добавлен в оба `pyproject.toml`, оба lock-файла обновлены через `uv lock` (0.26.0). ## Что осталось незакрытым сознательно **Один процесс — один реестр.** Оба контейнера запускают `uvicorn` без `--workers`, поэтому счётчики целостны. Появятся воркеры — счётчики станут per-process, каждый скрейп попадёт в случайный, и график начнёт пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным режимом `prometheus_client`; делать это заранее незачем, но связь `--workers` → сломанные графики отмечена в коде, чтобы её не искали заново. **Celery-воркеры своего `/metrics` не отдают** — у них нет HTTP-сервера, а поднимать его в каждом воркере ради счётчиков это отдельная конструкция со своим временем жизни. Прогоны фоновых задач будут видны иначе, через `scrape_runs` (часть 4), и это лучше: там уже лежит история, а не только происходящее прямо сейчас. **`process_*` появляются только на Linux** — коллектор читает `/proc`. Боевой рантайм это контейнер, там метрики есть; на Windows их нет, и это не отказ. ## Test plan - [x] `route` — шаблон: три идентификатора → один ряд, ни один не утёк в выгрузку - [x] незаматченные пути схлопываются в один ряд - [x] исключение посчитано как 500 - [x] `in_progress` сходится в ноль, в том числе после упавшего запроса - [x] `/metrics` не ловит 429 от реального лимитера «Меры» - [x] `caddy validate` в обоих режимах + оба `/metrics → 404` в адаптированном конфиге - [x] `ruff` по обоим проектам - [ ] после мержа: `up{job="app"}` равен 1 для `sitefinder` и `mera` (сейчас 0 — цель видна как недоступная, это ожидаемо) - [ ] после мержа: дашборд «Приложения» рисует трафик с обоих - [ ] после мержа: `curl -sI https://gendsgn.ru/metrics` и `https://meraocenka.ru/metrics` → 404 - [ ] после мержа: `/`, `/trade-in`, `/estimate` и `/health` отвечают как раньше Refs #3078
lekss361 added 1 commit 2026-08-26 08:31:51 +00:00
feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд
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
124cfb3d5d
Третья часть #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
lekss361 merged commit ad6fc1b2d6 into main 2026-08-26 08:54:56 +00:00
lekss361 deleted branch feat/observability-app-metrics 2026-08-26 08:54:56 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3102
No description provided.