"""Тесты `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 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_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