"""Тесты `GET /metrics`. Правила общей dev-БД: проверяем факт наличия метрики/дельту, а не абсолютные значения — dev-инстанс уже содержит "живые" сеансы и запросы, накопленные другими тестами/ручным использованием. Метрика глубины очереди Redis проверяется на throwaway-ключе (не на реальных `transcription`/`summarize`/`notify`/`celery`) — запись фиктивной "задачи" в настоящую очередь рисковала бы её подхватом реальным Celery-воркером dev-стенда. """ import uuid from collections.abc import Iterable from datetime import UTC, datetime import httpx import pytest from prometheus_client.parser import text_string_to_metric_families from prometheus_client.samples import Sample from sqlalchemy.ext.asyncio import AsyncSession from api import metrics as metrics_module from core.config import get_settings from core.redis import redis_client from models.conference import Conference from models.session import ConferenceSession from services.conference_ids import generate_number, generate_slug def _samples(body: str, family_name: str) -> list[Sample]: """Все сэмплы семейства метрик `family_name` из тела ответа `/metrics`.""" result: list[Sample] = [] for family in text_string_to_metric_families(body): if family.name == family_name: result.extend(family.samples) return result def _sample_value(samples: Iterable[Sample], *, suffix: str, **labels: str) -> float | None: """Значение сэмпла (`name` заканчивается на `suffix`) с точным совпадением меток.""" for sample in samples: if sample.name.endswith(suffix) and all( sample.labels.get(k) == v for k, v in labels.items() ): return sample.value return None async def test_metrics_endpoint_returns_prometheus_exposition_format( client: httpx.AsyncClient, ) -> None: """`/metrics` без авторизации отдаёт текстовый формат Prometheus с нашими метриками.""" response = await client.get("/metrics") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/plain") families = {family.name for family in text_string_to_metric_families(response.text)} assert "vidconf_http_request_duration_seconds" in families assert "vidconf_pipeline_sessions" in families assert "vidconf_celery_queue_depth" in families assert "vidconf_host_info" in families assert "vidconf_db_up" in families assert "vidconf_db_pool_size" in families assert "vidconf_db_pool_max_overflow" in families assert "vidconf_db_pool_checked_out" in families assert "vidconf_redis_pool_in_use" in families assert "vidconf_redis_pool_max_connections" in families async def test_metrics_host_info_gauge_reflects_settings( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """`vidconf_host_info` отдаёт `HW_*` из настроек как лейблы, значение всегда 1.""" settings = get_settings() monkeypatch.setattr(settings, "hw_cpus", 4) monkeypatch.setattr(settings, "hw_ram_mb", 7937) monkeypatch.setattr(settings, "hw_gpu_name", None) monkeypatch.setattr(settings, "hw_vram_mb", None) response = await client.get("/metrics") samples = _samples(response.text, "vidconf_host_info") value = _sample_value( samples, suffix="vidconf_host_info", cpus="4", ram_mb="7937", gpu_name="—", vram_mb="—", ) assert value == 1 async def test_metrics_records_http_latency_by_route(client: httpx.AsyncClient) -> None: """Латентность известного маршрута (`GET /api/health`) попадает в histogram с этим `path`.""" await client.get("/api/health") response = await client.get("/metrics") samples = _samples(response.text, "vidconf_http_request_duration_seconds") count = _sample_value(samples, suffix="_count", method="GET", path="/api/health") assert count is not None and count >= 1 async def test_metrics_pipeline_sessions_gauge_reflects_new_session( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Добавление сеанса в статусе `failed` увеличивает gauge для этого статуса ровно на 1.""" before_response = await client.get("/metrics") before = _sample_value( _samples(before_response.text, "vidconf_pipeline_sessions"), suffix="vidconf_pipeline_sessions", status="failed", ) assert before is not None conference = Conference(number=generate_number(), slug=generate_slug(), title="Metrics Test") db_session.add(conference) await db_session.flush() db_session.add( ConferenceSession( conference_id=conference.id, title="Metrics Test", t_start=datetime.now(UTC), pipeline_status="failed", ) ) await db_session.flush() after_response = await client.get("/metrics") after = _sample_value( _samples(after_response.text, "vidconf_pipeline_sessions"), suffix="vidconf_pipeline_sessions", status="failed", ) assert after == before + 1 async def test_metrics_db_up_gauge_reflects_real_connectivity(client: httpx.AsyncClient) -> None: """Против реального тестового Postgres (см. докстринг conftest) `vidconf_db_up` == 1.""" response = await client.get("/metrics") value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up") assert value == 1 async def test_metrics_db_up_gauge_reports_down_without_crashing_endpoint( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """Недоступность БД (проверка вне пула не удалась) не роняет `/metrics` — отдаёт 0, не 500.""" async def _fail() -> bool: return False monkeypatch.setattr(metrics_module, "check_db_up", _fail) response = await client.get("/metrics") assert response.status_code == 200 value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up") assert value == 0 async def test_metrics_db_pool_gauges_reflect_settings_not_usage( client: httpx.AsyncClient, ) -> None: """`vidconf_db_pool_size`/`_max_overflow` — конфигурация из `Settings`, не текущая занятость.""" settings = get_settings() response = await client.get("/metrics") samples_size = _samples(response.text, "vidconf_db_pool_size") samples_overflow = _samples(response.text, "vidconf_db_pool_max_overflow") size = _sample_value(samples_size, suffix="vidconf_db_pool_size") max_overflow = _sample_value(samples_overflow, suffix="vidconf_db_pool_max_overflow") assert size == settings.db_pool_size assert max_overflow == settings.db_max_overflow async def test_metrics_endpoint_survives_pipeline_gauge_failure( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """Падение/таймаут основного пула на одном gauge не роняет весь `/metrics`. Симулирует ровно ситуацию инцидента 07.08 (`api/metrics.py` падал вместе со всем остальным при исчерпанном пуле): `count_by_pipeline_status` поднимает исключение — `vidconf_db_up`/`vidconf_db_pool_*` (не зависящие от основного пула) при этом всё равно приходят в ответе. """ async def _raise(*args: object, **kwargs: object) -> dict[str, int]: raise TimeoutError("основной пул занят (симуляция теста)") monkeypatch.setattr( "repositories.conferences.ConferenceSessionRepository.count_by_pipeline_status", _raise, ) response = await client.get("/metrics") assert response.status_code == 200 db_up = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up") assert db_up == 1 async def test_metrics_celery_queue_depth_gauge( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """Gauge глубины очереди отражает реальный `LLEN` отслеживаемого списка Redis.""" throwaway_queue = f"test-queue-{uuid.uuid4()}" monkeypatch.setattr(metrics_module, "CELERY_QUEUES", (throwaway_queue,)) await redis_client.rpush(throwaway_queue, "a", "b", "c") try: response = await client.get("/metrics") finally: await redis_client.delete(throwaway_queue) value = _sample_value( _samples(response.text, "vidconf_celery_queue_depth"), suffix="vidconf_celery_queue_depth", queue=throwaway_queue, ) assert value == 3