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