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

Merged
lekss361 merged 1 commit from feat/observability-app-metrics into main 2026-08-26 08:54:56 +00:00
14 changed files with 1033 additions and 2 deletions

View file

@ -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"])

View file

@ -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)

View file

@ -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]

View file

@ -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

11
backend/uv.lock generated
View file

@ -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"

View file

@ -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 никогда не видит.

View file

@ -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)

View file

@ -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": []
}
}
]
}

View file

@ -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",

View file

@ -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"])

View file

@ -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)

View file

@ -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.

View file

@ -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

11
tradein-mvp/uv.lock generated
View file

@ -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" },