"""Метрики 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)