diff --git a/backend/app/main.py b/backend/app/main.py index 5f6507ed..4fbddd64 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,6 +48,7 @@ from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings +from app.observability import metrics as app_metrics from app.observability.sentry_scrub import scrub_event from app.services.auth_session import resolve_session_token @@ -180,7 +181,15 @@ app.middleware("http")(audit_log_middleware) # `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать # `expired` как вводящий в заблуждение. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") -_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) +# `/metrics` публичен здесь и НЕ публичен снаружи — это два разных периметра, и +# путать их нельзя. Снимает его агент Alloy изнутри docker-сети, где заголовка +# `X-Authenticated-User` нет ни у кого, так что без записи в этом множестве +# скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не +# открывается: `caddy/sites/apps.caddy` отдаёт бэкенду «Птицы» только `/health` +# и `/api/*`, а `/metrics` там дополнительно закрыт явным `respond 404`. +_PUBLIC_PATHS = frozenset( + {"/health", "/metrics", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"} +) def _propagate_authenticated_user(request: Request, username: str) -> None: @@ -465,6 +474,15 @@ app.add_middleware( allow_headers=["*"], ) +# Метрики — СЛЕДОМ ЗА CORS и, значит, самым внешним слоем: `add_middleware` +# вставляет в начало списка, поэтому зарегистрированный последним оказывается +# снаружи всех. Порядок здесь несущий, а не вкусовой. Изнутри RBAC-гварда не +# видно ни отказов авторизации (401/403 — их отдаёт сам гвард), ни времени, +# которое он тратит на резолв сессии в БД `auth`; а именно этот путь уже давал +# инцидент (#1202, блокирующий I/O в middleware). Снаружи видно и то и другое. +app.add_middleware(app_metrics.MetricsMiddleware) + +app.include_router(app_metrics.router, tags=["observability"]) app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]) app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"]) app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"]) diff --git a/backend/app/observability/metrics.py b/backend/app/observability/metrics.py new file mode 100644 index 00000000..d8005281 --- /dev/null +++ b/backend/app/observability/metrics.py @@ -0,0 +1,175 @@ +"""Метрики 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) + + +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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f41dd24c..e6abb349 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "pytesseract>=0.3.13", # OCR сканов через Tesseract для изъятия ЕКБ (#1062) "contextily>=1.7.0", # OSM basemap-тайлы для серверного рендера карт отчёта (#2259 PR-C) "matplotlib>=3.11.0", # headless (Agg) рендер PNG-карт участка/концепции (#2259 PR-C) + "prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078) ] [dependency-groups] diff --git a/backend/tests/test_metrics.py b/backend/tests/test_metrics.py new file mode 100644 index 00000000..8454ba48 --- /dev/null +++ b/backend/tests/test_metrics.py @@ -0,0 +1,116 @@ +"""Слой метрик: метка маршрута не должна взрывать кардинальность (#3078). + +Проверяется не «эндпоинт отвечает 200», а ровно то, чем метрики убивают сами +себя. У Prometheus временной ряд стоит памяти постоянно, а не в момент запроса, +поэтому кадастровый номер, попавший в метку, кладёт приёмник за сутки. Отказ +при этом отложенный и не выглядит как ошибка кода — обычный тест на статус его +не увидит никогда. + +Маршруты-пробы названы уникально (`__metrics_probe__`): реестр +`prometheus_client` глобален на процесс, и совпади имя с боевым, тесты начали бы +влиять друг на друга через общий счётчик. По той же причине сравниваются +приращения, а не абсолютные значения. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from prometheus_client import REGISTRY + +from app.observability import metrics as m + +_ROUTE = "/__metrics_probe__/{item_id}" +_BOOM = "/__metrics_probe_boom__" + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(m.router) + + @app.get(_ROUTE) + def probe(item_id: str) -> dict[str, str]: + return {"item": item_id} + + @app.get(_BOOM) + def boom() -> dict[str, str]: + raise RuntimeError("нарочно — проверяем, что упавший запрос посчитан") + + # Последним, как в app/main.py: add_middleware вставляет в начало списка, + # значит зарегистрированный последним оказывается самым внешним. + app.add_middleware(m.MetricsMiddleware) + return TestClient(app, raise_server_exceptions=False) + + +def _count(route: str, status: str, method: str = "GET") -> float: + value = REGISTRY.get_sample_value( + "http_requests_total", {"method": method, "route": route, "status": status} + ) + return value or 0.0 + + +def test_route_label_is_template_not_path(client: TestClient) -> None: + """Три разных идентификатора дают ОДИН ряд, а не три.""" + before = _count(_ROUTE, "200") + + for item in ("66:41:0301001:1", "66:41:0301001:2", "66:41:0301001:3"): + assert client.get(f"/__metrics_probe__/{item}").status_code == 200 + + assert _count(_ROUTE, "200") - before == 3.0 + + body = client.get("/metrics").text + for item in ("0301001:1", "0301001:2", "0301001:3"): + assert item not in body, f"идентификатор утёк в метку: {item}" + + +def test_unmatched_paths_collapse_into_one_series(client: TestClient) -> None: + """Сканер, перебирающий адреса, не должен плодить ряды.""" + before = _count(m.UNMATCHED, "404") + + assert client.get("/wp-admin/setup-config.php").status_code == 404 + assert client.get("/.env").status_code == 404 + assert client.get("/явно-нет-такого-пути").status_code == 404 + + assert _count(m.UNMATCHED, "404") - before == 3.0 + assert "wp-admin" not in client.get("/metrics").text + + +def test_exception_is_counted_as_500(client: TestClient) -> None: + """Исключение проходит сквозь слой наружу — без finally запрос бы потерялся.""" + before = _count(_BOOM, "500") + assert client.get(_BOOM).status_code == 500 + assert _count(_BOOM, "500") - before == 1.0 + + +def test_in_progress_returns_to_baseline(client: TestClient) -> None: + """inc/dec сходятся, в том числе на упавшем запросе. + + Значение 1 — это сам скрейп, который в момент выгрузки ещё в обработке. + Разъехавшийся счётчик выглядел бы как вечно растущая линия «запросов в + работе» при простаивающем сервисе. + """ + client.get("/__metrics_probe__/x") + client.get(_BOOM) + body = client.get("/metrics").text + assert "http_requests_in_progress 1.0" in body + + +def test_exposition_carries_histogram_and_build_info(client: TestClient) -> None: + client.get("/__metrics_probe__/x") + body = client.get("/metrics").text + assert "http_request_duration_seconds_bucket{" in body + assert "http_request_duration_seconds_count{" in body + assert 'app_build_info{app="sitefinder"' in body + + +def test_metrics_path_is_public_for_the_in_network_agent() -> None: + """Без этой записи скрейп получал бы 401 и метрик не было бы вовсе. + + Наружу путь при этом не открыт: `caddy/sites/apps.caddy` отдаёт бэкенду + только `/health` и `/api/*`, а на `/metrics` там стоит явный `respond 404`. + """ + from app.main import _PUBLIC_PATHS + + assert "/metrics" in _PUBLIC_PATHS diff --git a/backend/uv.lock b/backend/uv.lock index edc9d2f7..0e4a8791 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -799,6 +799,7 @@ dependencies = [ { name = "pdfplumber" }, { name = "pillow" }, { name = "playwright" }, + { name = "prometheus-client" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -850,6 +851,7 @@ requires-dist = [ { name = "pdfplumber", specifier = ">=0.10.0" }, { name = "pillow", specifier = ">=10.4.0" }, { name = "playwright", specifier = ">=1.45.0" }, + { name = "prometheus-client", specifier = ">=0.21.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.3.0" }, @@ -1891,6 +1893,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 15335cdc..21401781 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -99,6 +99,21 @@ gendsgn.ru { } route { + # `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078). + # Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только + # /health и /api/*, остальное забирает фронт, у которого такого + # маршрута нет. Но в бэкенде путь ОТКРЫТ без авторизации — иначе + # агент Alloy изнутри docker-сети получал бы 401 (см. комментарий у + # `_PUBLIC_PATHS` в backend/app/main.py). Одной строки `handle + # /metrics { reverse_proxy backend:8000 }`, добавленной когда-нибудь + # по невнимательности, хватит, чтобы выставить наружу внутреннее + # устройство продукта. Явный 404 делает закрытость решением, а не + # следствием текущего порядка директив, и стоит первым — route + # матчит сверху вниз и short-circuit'ит. + handle /metrics { + respond 404 + } + # /health и /preview/* — public, без auth, short-circuit. handle /health { reverse_proxy backend:8000 @@ -276,6 +291,16 @@ meraocenka.ru { output file /var/log/caddy/meraocenka.ru.log } + # `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и + # финальный `handle { respond 404 }`, так что путь и без этой строки не + # проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента + # Alloy внутри docker-сети (см. `_PUBLIC_PATHS` в app/core/rbac.py). + # Явный отказ на публичном домене делает закрытость решением, а не + # следствием того, что список пока никто не расширил. + handle /metrics { + respond 404 + } + # Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на # полноценную страницу). rewrite добавляет basePath-префикс только для # Caddy→backend хопа, пользователь /trade-in никогда не видит. diff --git a/docs/observability.md b/docs/observability.md index 590c426c..ceb435db 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -51,6 +51,8 @@ Sentry DSN ни одним компонентом, а 549 строк скруб | `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` | разовая подготовка учёток | @@ -115,6 +117,56 @@ Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datas стектрейсы и 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: тот же чат, что @@ -155,7 +207,6 @@ Poincare, не должно идти через сервис на Poincare. ## Что ещё не сделано -- `/metrics` в бэкендах (часть 3) — единственная часть, трогающая прод-код - экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация, счётчик `cancelled` (часть 4) - алерты и синтетический heartbeat (часть 5) diff --git a/ops/metrics/grafana/dashboards/apps.json b/ops/metrics/grafana/dashboards/apps.json new file mode 100644 index 00000000..46b8fee0 --- /dev/null +++ b/ops/metrics/grafana/dashboards/apps.json @@ -0,0 +1,296 @@ +{ + "uid": "gendesign-apps", + "title": "Приложения", + "description": "Птица и МЕРА глазами их собственных счётчиков. Отвечает на класс вопросов, невидимый для GlitchTip: сервис отвечает, исключений нет, а продукт при этом не работает — потому что медленно, или потому что поток 401 и 429.", + "tags": ["gendesign", "apps"], + "timezone": "browser", + "editable": false, + "schemaVersion": 39, + "refresh": "1m", + "time": { "from": "now-6h", "to": "now" }, + "templating": { + "list": [ + { + "name": "app", + "label": "Приложение", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(app_build_info, app)", + "refresh": 1, + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } }, + + { + "type": "stat", + "title": "Приложение отвечает", + "description": "Скрейп удался или нет. Ноль здесь не то же самое, что «нет запросов»: это агент не смог снять метрики вовсе, то есть процесс не отвечает по сети.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "up{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}}" } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "не отвечает", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } } + ], + "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" } + }, + + { + "type": "stat", + "title": "Запросов в минуту", + "description": "Обвал до нуля при живом процессе — тоже отказ: значит, до приложения перестали доходить запросы (Caddy, сеть, фронт), и снаружи это выглядит как неработающий сайт.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\"}[5m])) * 60", "legendFormat": "{{app}}" } + ], + "fieldConfig": { + "defaults": { "unit": "short", "decimals": 1, "thresholds": { "mode": "absolute", "steps": [ { "color": "text", "value": null } ] } }, + "overrides": [] + }, + "options": { "colorMode": "none", "graphMode": "area", "textMode": "value_and_name" } + }, + + { + "type": "stat", + "title": "Доля 5xx за час", + "description": "Ошибка сервера — это всегда несделанная работа пользователя. Порог оранжевого стоит на 1 %: при нашем трафике это единицы запросов, и они должны быть заметны, а не тонуть в проценте от большого числа.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "sum by (app) (increase(http_requests_total{app=~\"$app\", status=~\"5..\"}[1h])) / clamp_min(sum by (app) (increase(http_requests_total{app=~\"$app\"}[1h])), 1)", "legendFormat": "{{app}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.01 }, { "color": "red", "value": 0.05 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" } + }, + + { + "type": "stat", + "title": "95-й перцентиль за час", + "description": "Не среднее: среднее прячет как раз тех, кому плохо. Считается по корзинам гистограммы, поэтому точность ограничена их границами — это нормально для порога, но не для точного измерения.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[1h])))", "legendFormat": "{{app}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 }, { "color": "red", "value": 5 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" } + }, + + { "type": "row", "title": "Трафик и отказы", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } }, + + { + "type": "timeseries", + "title": "Запросы по классам ответов", + "description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 }, + "targets": [ + { "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"2..\"}[5m]))", "legendFormat": "{{app}} · 2xx" }, + { "refId": "B", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"3..\"}[5m]))", "legendFormat": "{{app}} · 3xx" }, + { "refId": "C", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"4..\"}[5m]))", "legendFormat": "{{app}} · 4xx" }, + { "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }, + { "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] }, + { "matcher": { "id": "byRegexp", "options": ".*4xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }, + { "matcher": { "id": "byRegexp", "options": ".*5xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Отказы авторизации и лимитера", + "description": "401 и 403 считаются потому, что вход у «Меры» и «Птицы» общий, а инцидентов вокруг него уже было достаточно (#2552, эпик единого входа). 429 — срабатывания ограничителя частоты: устойчивая линия означает, что кому-то из живых пользователей регулярно отказывают.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 }, + "targets": [ + { "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"401\"}[5m]))", "legendFormat": "{{app}} · 401 не авторизован" }, + { "refId": "B", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"403\"}[5m]))", "legendFormat": "{{app}} · 403 запрещено" }, + { "refId": "C", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"429\"}[5m]))", "legendFormat": "{{app}} · 429 лимитер" } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": ".*429.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Ошибки сервера по маршрутам (топ-10)", + "description": "Здесь видно, какая именно ручка ломается. Метка route — шаблон маршрута, а не путь: идентификаторы в него не попадают намеренно, иначе каждый участок и каждая заявка давали бы отдельный ряд.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "targets": [ + { "refId": "A", "expr": "topk(10, sum by (app, route, status) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m])))", "legendFormat": "{{app}} · {{route}} · {{status}}" } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" } }, + "overrides": [] + } + }, + + { + "type": "table", + "title": "Самые медленные маршруты (95-й перцентиль за час)", + "description": "Считается по маршрутам, у которых за окно был хоть какой-то трафик. Пустая таблица означает тишину, а не быстроту.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "targets": [ + { + "refId": "A", + "instant": true, + "format": "table", + "expr": "topk(12, histogram_quantile(0.95, sum by (le, app, route) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[1h]))))" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { "Time": true, "job": true, "instance": true, "host": true, "cluster": true, "le": true }, + "renameByName": { "app": "Приложение", "route": "Маршрут", "Value": "95-й перцентиль" } + } + }, + { "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "95-й перцентиль", "desc": true } ] } } + ], + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "95-й перцентиль" }, + "properties": [ + { "id": "unit", "value": "s" }, + { "id": "custom.cellOptions", "value": { "type": "color-text" } }, + { "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 }, { "color": "red", "value": 5 } ] } } + ] + } + ] + } + }, + + { "type": "row", "title": "Задержка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } }, + + { + "type": "timeseries", + "title": "Перцентили времени ответа", + "description": "Три линии вместе, потому что расхождение между ними и есть сигнал: ровный 50-й при растущем 99-м означает, что плохо становится части пользователей, а не всем, — и по среднему это не увидеть никогда.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 9, "w": 16, "x": 0, "y": 24 }, + "targets": [ + { "refId": "A", "expr": "histogram_quantile(0.5, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 50-й" }, + { "refId": "B", "expr": "histogram_quantile(0.9, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 90-й" }, + { "refId": "C", "expr": "histogram_quantile(0.99, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 99-й" } + ], + "fieldConfig": { + "defaults": { "unit": "s", "min": 0, "custom": { "fillOpacity": 5, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [] + } + }, + + { + "type": "timeseries", + "title": "Запросов в работе", + "description": "Растущая линия при неизменном трафике означает, что запросы копятся: приложение принимает быстрее, чем отвечает. Обычно это блокирующий вызов в обработчике — ровно тот случай, что был в #1202.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 9, "w": 8, "x": 16, "y": 24 }, + "targets": [ + { "refId": "A", "expr": "http_requests_in_progress{app=~\"$app\"}", "legendFormat": "{{app}}" } + ], + "fieldConfig": { + "defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [] + } + }, + + { "type": "row", "title": "Процесс", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 } }, + + { + "type": "timeseries", + "title": "Память процесса", + "description": "Резидентная память самого процесса Python, а не контейнера. Монотонный рост между перезапусками — утечка; по контейнерной метрике её легко списать на кэш.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 34 }, + "targets": [ + { "refId": "A", "expr": "process_resident_memory_bytes{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}}" } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [] + } + }, + + { + "type": "timeseries", + "title": "Открытые дескрипторы", + "description": "Незакрытые соединения и файлы упираются в лимит и дают отказы, которые выглядят как что угодно, кроме своей причины. Линия, ползущая вверх и не спадающая, — это она.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 34 }, + "targets": [ + { "refId": "A", "expr": "process_open_fds{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}} · открыто" }, + { "refId": "B", "expr": "process_max_fds{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}} · предел" } + ], + "fieldConfig": { + "defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 5, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": ".*предел.*" }, "properties": [ { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [ 8, 6 ] } }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] } + ] + } + }, + + { + "type": "table", + "title": "Что задеплоено", + "description": "Версия, отвечавшая в выбранном окне. Первый вопрос при разборе — «а что там было в этот момент», и ответ на него должен быть в той же картинке, а не в истории деплоев.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 34 }, + "targets": [ + { "refId": "A", "instant": true, "format": "table", "expr": "app_build_info{app=~\"$app\"}" } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { "Time": true, "job": true, "instance": true, "host": true, "cluster": true, "Value": true }, + "renameByName": { "app": "Приложение", "release": "Версия" } + } + } + ], + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } }, + "overrides": [] + } + } + ] +} diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 56833013..7966280d 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -71,6 +71,14 @@ _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _PUBLIC_PATHS = frozenset( { "/health", + # Публичен ЗДЕСЬ и не публичен снаружи — это два разных периметра. + # Снимает `/metrics` агент Alloy изнутри docker-сети, где заголовка + # `X-Authenticated-User` нет ни у кого; без записи в этом множестве + # скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь не + # открывается: у `gendsgn.ru` бэкенду «Меры» отдаётся только + # `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих + # блоках на `/metrics` стоит явный `respond 404`. + "/metrics", "/docs", "/redoc", "/openapi.json", diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 5a8c34cb..bb06f624 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -45,6 +45,7 @@ from app.core.fdw import ensure_fdw_user_mapping from app.core.ratelimit import RateLimitMiddleware from app.core.rbac import rbac_guard from app.core.request_audit import RequestAuditMiddleware +from app.observability import metrics as app_metrics from app.observability.sentry_scrub import scrub_pii_event logger = logging.getLogger(__name__) @@ -240,6 +241,12 @@ app.add_middleware( app.add_middleware(RateLimitMiddleware) # Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation). app.add_middleware(RequestAuditMiddleware) +# Метрики — ПОСЛЕДНИМ и потому самым внешним слоем: `add_middleware` вставляет в +# начало списка. Порядок здесь несущий. Изнутри не видно ни 401 от гварда, ни 429 +# от ограничителя частоты — их отдают сами эти слои и до нас запрос бы не дошёл; +# а всплеск отказов авторизации и срабатывания лимитера это ровно тот сигнал, +# ради которого метрики и заводятся. +app.add_middleware(app_metrics.MetricsMiddleware) @app.get("/health") @@ -267,6 +274,7 @@ def health_head() -> Response: return Response(status_code=200, media_type="application/json") +app.include_router(app_metrics.router, tags=["observability"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) diff --git a/tradein-mvp/backend/app/observability/metrics.py b/tradein-mvp/backend/app/observability/metrics.py new file mode 100644 index 00000000..e9343d4d --- /dev/null +++ b/tradein-mvp/backend/app/observability/metrics.py @@ -0,0 +1,171 @@ +"""Метрики 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) diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index 8116d3fc..beaf308b 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py) "bcrypt>=4.2.0", # password hashing для DB-auth (#2550) "playwright>=1.45", # Playwright client для connect к tradein-browser (#905) + "prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078) "scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов; # резолвится из workspace (см. [tool.uv.sources]), не с PyPI. # Docker-context = tradein-mvp root, uv sync ставит editable. diff --git a/tradein-mvp/backend/tests/test_metrics.py b/tradein-mvp/backend/tests/test_metrics.py new file mode 100644 index 00000000..1107c9ac --- /dev/null +++ b/tradein-mvp/backend/tests/test_metrics.py @@ -0,0 +1,139 @@ +"""Слой метрик: метка маршрута не должна взрывать кардинальность (#3078). + +Проверяется не «эндпоинт отвечает 200», а ровно то, чем метрики убивают сами +себя. У Prometheus временной ряд стоит памяти постоянно, а не в момент запроса, +поэтому идентификатор заявки, попавший в метку, кладёт приёмник за сутки. Отказ +отложенный и не выглядит как ошибка кода — обычный тест на статус его не увидит. + +Маршруты-пробы названы уникально (`__metrics_probe__`): реестр +`prometheus_client` глобален на процесс, и совпади имя с боевым, тесты начали бы +влиять друг на друга через общий счётчик. По той же причине сравниваются +приращения, а не абсолютные значения. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from prometheus_client import REGISTRY + +from app.observability import metrics as m + +_ROUTE = "/__metrics_probe__/{item_id}" +_BOOM = "/__metrics_probe_boom__" + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(m.router) + + @app.get(_ROUTE) + def probe(item_id: str) -> dict[str, str]: + return {"item": item_id} + + @app.get(_BOOM) + def boom() -> dict[str, str]: + raise RuntimeError("нарочно — проверяем, что упавший запрос посчитан") + + # Последним, как в app/main.py: add_middleware вставляет в начало списка, + # значит зарегистрированный последним оказывается самым внешним. + app.add_middleware(m.MetricsMiddleware) + return TestClient(app, raise_server_exceptions=False) + + +def _count(route: str, status: str, method: str = "GET") -> float: + value = REGISTRY.get_sample_value( + "http_requests_total", {"method": method, "route": route, "status": status} + ) + return value or 0.0 + + +def test_route_label_is_template_not_path(client: TestClient) -> None: + """Три разные заявки дают ОДИН ряд, а не три.""" + before = _count(_ROUTE, "200") + + for item in ("lead-1001", "lead-1002", "lead-1003"): + assert client.get(f"/__metrics_probe__/{item}").status_code == 200 + + assert _count(_ROUTE, "200") - before == 3.0 + + body = client.get("/metrics").text + for item in ("lead-1001", "lead-1002", "lead-1003"): + assert item not in body, f"идентификатор утёк в метку: {item}" + + +def test_unmatched_paths_collapse_into_one_series(client: TestClient) -> None: + """Сканер, перебирающий адреса, не должен плодить ряды.""" + before = _count(m.UNMATCHED, "404") + + assert client.get("/wp-admin/setup-config.php").status_code == 404 + assert client.get("/.env").status_code == 404 + assert client.get("/явно-нет-такого-пути").status_code == 404 + + assert _count(m.UNMATCHED, "404") - before == 3.0 + assert "wp-admin" not in client.get("/metrics").text + + +def test_exception_is_counted_as_500(client: TestClient) -> None: + """Исключение проходит сквозь слой наружу — без finally запрос бы потерялся.""" + before = _count(_BOOM, "500") + assert client.get(_BOOM).status_code == 500 + assert _count(_BOOM, "500") - before == 1.0 + + +def test_in_progress_returns_to_baseline(client: TestClient) -> None: + """inc/dec сходятся, в том числе на упавшем запросе. + + Значение 1 — это сам скрейп, который в момент выгрузки ещё в обработке. + Разъехавшийся счётчик выглядел бы как вечно растущая линия «запросов в + работе» при простаивающем сервисе. + """ + client.get("/__metrics_probe__/x") + client.get(_BOOM) + assert "http_requests_in_progress 1.0" in client.get("/metrics").text + + +def test_exposition_carries_histogram_and_build_info(client: TestClient) -> None: + client.get("/__metrics_probe__/x") + body = client.get("/metrics").text + assert "http_request_duration_seconds_bucket{" in body + assert "http_request_duration_seconds_count{" in body + # Версия — из app/core/version.py, единственного источника правды; отдельного + # хардкода здесь быть не должно. + assert 'app_build_info{app="mera"' in body + + +def test_metrics_path_is_public_for_the_in_network_agent() -> None: + """Без этой записи скрейп получал бы 401 и метрик не было бы вовсе. + + Наружу путь при этом не открыт: у `gendsgn.ru` бэкенду «Меры» отдаётся + только `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих + блоках на `/metrics` стоит явный `respond 404`. + """ + from app.core.rbac import _PUBLIC_PATHS + + assert "/metrics" in _PUBLIC_PATHS + + +def test_metrics_survives_the_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None: + """Скрейп не должен ловить 429. + + Агент ходит раз в 30 секунд бесконечно. Попади `/metrics` под общий лимитер — + метрики начали бы пропадать пачками именно под нагрузкой, то есть ровно + тогда, когда нужны. Сегодня спасает то, что `ratelimit.py` смотрит только на + пути под `/api/`; тест сторожит это свойство, а не переписывает его. + """ + from app.core.config import settings + from app.core.ratelimit import RateLimitMiddleware + + monkeypatch.setattr(settings, "rate_limit", 2, raising=False) + + app = FastAPI() + app.include_router(m.router) + app.add_middleware(RateLimitMiddleware) + probe = TestClient(app) + + statuses = [probe.get("/metrics").status_code for _ in range(6)] + assert statuses == [200] * 6, statuses diff --git a/tradein-mvp/uv.lock b/tradein-mvp/uv.lock index e58985d0..828bd97f 100644 --- a/tradein-mvp/uv.lock +++ b/tradein-mvp/uv.lock @@ -1091,6 +1091,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + [[package]] name = "psycopg" version = "3.3.4" @@ -1685,6 +1694,7 @@ dependencies = [ { name = "matplotlib" }, { name = "pillow" }, { name = "playwright" }, + { name = "prometheus-client" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1721,6 +1731,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.9.0" }, { name = "pillow", specifier = ">=10.3.0" }, { name = "playwright", specifier = ">=1.45" }, + { name = "prometheus-client", specifier = ">=0.21.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.3.0" },