gendesign/tradein-mvp/backend/app/observability/metrics.py
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

171 lines
9.7 KiB
Python
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.

"""Метрики Prometheus для API «Меры»: счётчики, гистограмма задержки, `/metrics`.
Часть 3 задачи #3078. Близнец `backend/app/observability/metrics.py` из «Птицы»:
это два независимых Python-проекта со своими зависимостями и своим деплоем,
общего пакета между ними нет и заводить его ради полутора сотен строк дороже,
чем держать две копии. Расхождения намеренные и отмечены по месту.
ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК
`route` — ШАБЛОН маршрута (`/api/v1/trade-in/{lead_id}`), а не путь запроса.
Разница принципиальная: идентификатор в метке даёт новый временной ряд на каждую
заявку, а ряд у Prometheus стоит памяти постоянно, а не в момент запроса. Всё
незаматченное сведено в одну метку ``__unmatched__`` — иначе тот же взрыв рядов
устроит любой бот, перебирающий адреса.
Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит
сквозь этот слой наружу, и без `finally` такие запросы не попали бы в счётчик —
то есть отсутствовали бы ровно тогда, когда метрики нужнее всего.
ОДИН ПРОЦЕСС — ОДИН РЕЕСТР
`backend/Dockerfile:111` запускает `uvicorn` без `--workers`. Появятся воркеры —
счётчики станут per-process, каждый скрейп попадёт в случайный из них, и график
начнёт пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным
режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` + `MultiProcessCollector`);
делать это заранее незачем, но связь `--workers` → сломанные графики стоит знать
до, а не после.
ДОСТУП
`/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь
недостижим: у `gendsgn.ru` бэкенду «Меры» отдаётся лишь `/trade-in/api/*`, а у
`meraocenka.ru` действует белый список с `handle { respond 404 }` в конце. Плюс
явный `respond 404` на `/metrics` в обоих блоках — чтобы закрытость осталась
решением, а не побочным следствием текущего порядка директив.
Ограничитель частоты трогать не пришлось: `app/core/ratelimit.py:75` пропускает
всё, что не начинается на `/api/`, и скрейп раз в 30 секунд под него не попадает.
Аудит запросов — тоже: `app/core/request_audit.py:84` пишет строку только для
путей под `/api/` и только при известном пользователе.
"""
from __future__ import annotations
import time
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from fastapi import APIRouter, Response
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
from app.core.version import APP_VERSION, BUILD_SHA
Scope = MutableMapping[str, Any]
Message = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[Message]]
Send = Callable[[Message], Awaitable[None]]
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
# Явная строка, а не пустое значение: пустая метка в PromQL неотличима от
# отсутствующей.
UNMATCHED = "__unmatched__"
# Границы плотнее, чем у «Птицы», и верхняя ниже. У «Меры» другой профиль:
# расчёт стоимости укладывается в десятые доли секунды (замер 26.08 — 90 мс на
# живом запросе), тяжёлого геометрического хвоста здесь нет. Зато есть внешние
# зависимости с непредсказуемым временем — геокодер, банк-эквайер, — поэтому
# верхние корзины оставлены: их отсутствие слепило бы весь хвост в `+Inf`.
_DURATION_BUCKETS = (0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, float("inf"))
REQUESTS = Counter(
"http_requests_total",
"Запросов обслужено",
labelnames=("method", "route", "status"),
)
DURATION = Histogram(
"http_request_duration_seconds",
"Время ответа целиком, включая авторизацию и middleware",
labelnames=("method", "route"),
buckets=_DURATION_BUCKETS,
)
# Без меток намеренно: gauge с меткой маршрута не возвращается в ноль сам, ряд
# остаётся навсегда после единственного запроса.
IN_PROGRESS = Gauge(
"http_requests_in_progress",
"Запросов обрабатывается прямо сейчас",
)
BUILD_INFO = Gauge(
"app_build_info",
"Всегда 1; полезны метки — по ним видно, какая версия отвечала в момент сбоя",
labelnames=("app", "release"),
)
# Версия берётся из `app/core/version.py` — там единственный источник правды
# (файл `VERSION` плюс build-args образа), тот же, что показывают PDF-колонтитул
# и `GET /api/v1/trade-in/version`. Отдельного хардкода здесь быть не должно:
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
`scope["route"]` проставляет роутер Starlette в момент матчинга. Наш слой
внешний, поэтому к возврату управления сюда поле уже заполнено: scope — один
и тот же dict на весь стек, между слоями он не копируется.
"""
route = scope.get("route")
path = getattr(route, "path", None)
if isinstance(path, str) and path:
return path
return UNMATCHED
class MetricsMiddleware:
"""Чистый ASGI-слой, без `BaseHTTPMiddleware`.
`BaseHTTPMiddleware` заворачивает ответ в собственный поток и на потоковых
ответах ведёт себя иначе, чем голый ASGI. У «Меры» телом ответа уходит PDF
отчёта, и менять его обработку ради подсчёта запросов не стоит.
Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то есть
последний зарегистрированный оказывается самым внешним. Иначе 401 от гварда и
429 от ограничителя частоты не попадут в счётчик — а поток отказов авторизации
и срабатывания лимитера это ровно то, ради чего метрики и заводятся.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
method = scope.get("method", "UNKNOWN")
# 500 по умолчанию: при падении исключением `http.response.start` мы не
# увидим, и запрос обязан быть посчитан как ошибка, а не пропасть.
status = 500
async def send_wrapper(message: Message) -> None:
nonlocal status
if message["type"] == "http.response.start":
status = message["status"]
await send(message)
IN_PROGRESS.inc()
started = time.perf_counter()
try:
await self.app(scope, receive, send_wrapper)
finally:
IN_PROGRESS.dec()
route = route_label(scope)
DURATION.labels(method, route).observe(time.perf_counter() - started)
REQUESTS.labels(method, route, str(status)).inc()
router = APIRouter()
@router.get("/metrics", include_in_schema=False)
def metrics() -> Response:
"""Выгрузка в текстовом формате Prometheus.
Реестр по умолчанию, а не свой: вместе с нашими метриками он отдаёт
`process_resident_memory_bytes`, `process_open_fds` и счётчики сборщика
мусора — утечка памяти и исчерпание дескрипторов видны по ним напрямую.
"""
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)