"""Метрики Prometheus для API «Птицы»: счётчики, гистограмма задержки, `/metrics`. Часть 3 задачи #3078. До неё числовых рядов у приложения не было вовсе — только логи и исключения в GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 4xx потоком» в такой картине невидим: исключения нет, строка в логе выглядит обычной, а пользователь видит неработающий продукт. ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК `route` — это ШАБЛОН маршрута (`/api/v1/parcels/{cad_num}`), а не путь запроса. Разница принципиальная, а не косметическая: кадастровый номер в метке дал бы новый временной ряд на каждый участок. У Prometheus ряд стоит памяти постоянно, а не в момент запроса, и такая метка кладёт приёмник за сутки — это самый известный способ уронить мониторинг тем самым мониторингом. Незаматченные пути (404, сканеры, чужие боты) сведены в одну метку ``__unmatched__``. Иначе достаточно одного бота, перебирающего адреса, чтобы получить тот же взрыв рядов через чёрный ход. Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит сквозь этот слой наружу, к `ServerErrorMiddleware`, и без `finally` такие запросы просто не попали бы в счётчик — то есть отсутствовали бы ровно в тот момент, когда метрики нужнее всего. ОДИН ПРОЦЕСС — ОДИН РЕЕСТР `Dockerfile:75` запускает `uvicorn` без `--workers`, то есть процесс один и значения счётчиков целостны. Появится `--workers` или gunicorn — счётчики станут per-process, и каждый скрейп будет попадать в случайный воркер: график начнёт пилить вверх-вниз без всякой связи с нагрузкой. Лечится штатным многопроцессным режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` + `MultiProcessCollector`), но это отдельная работа, и делать её заранее «на всякий случай» не стоит. Здесь оставлена явная отметка, чтобы связь между `--workers` и сломанными графиками не пришлось искать заново. ДОСТУП `/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь недостижим: `caddy/sites/apps.caddy` проксирует на бэкенд «Птицы» лишь `/health` и `/api/*`, а `/metrics` там вдобавок закрыт явным `respond 404` — чтобы это осталось решением, а не побочным следствием текущего порядка директив. """ from __future__ import annotations import os 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 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__" # Границы гистограммы подобраны под «Птицу», а не взяты из примера в документации. # Быстрые ручки (`/health`, справочники) укладываются в десятки миллисекунд; # `POST /api/v1/parcels/{cad_num}/analyze` уходит в десятки секунд, потому что # внутри поход в OSRM и подсчёт геометрии. Без верхних корзин весь тяжёлый хвост # слипся бы в `+Inf`, и «стало вдвое медленнее» было бы не увидеть. _DURATION_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.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"), ) BUILD_INFO.labels( app="sitefinder", release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown", ).set(1) # ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════ # # `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json", # "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное # значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка — # кардинальность ограничена набором форматов экспорта, а не количеством # участков/пользователей. REPORTS_EXPORTED = Counter( "sitefinder_reports_exported_total", "Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату", labelnames=("format",), ) 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/DXF/XLSX идут телом ответа, — и ставить ради подсчёта запросов слой, который меняет их обработку, не стоит. Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то есть последний зарегистрированный оказывается самым внешним. Именно это и нужно — иначе 401 от RBAC-гварда не попадёт в счётчик, а поток отказов авторизации это ровно то, что нужно видеть. """ 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)