gendesign/tradein-mvp/backend/tests/test_metrics.py
bot-backend 124cfb3d5d
All checks were successful
CI / backend-tests (pull_request) Successful in 17m30s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд
Третья часть #3078 и единственная, трогающая прод-код.

До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.

Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.

Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.

Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.

Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.

Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.

Refs #3078
2026-08-26 11:30:18 +03:00

139 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Слой метрик: метка маршрута не должна взрывать кардинальность (#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