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