Files
vidconf/backend/tests/test_metrics_api.py
Max Ronzhin afd4daee65 feat(backend): метрика vidconf_host_info для плашки характеристик сервера
Info-метрика (значение всегда 1, лейблы cpus/ram_mb/gpu_name/vram_mb) —
источник GPU для новой панели «Характеристики сервера» в дашборде «Хост
и контейнеры» (живые CPU/RAM/диск там же берутся из node-exporter,
GPU node-exporter не знает). Данные — уже читаемые Settings.hw_* из .env,
которые install.sh пишет по ADR-004. «—» вместо None/пустой строки —
однозначный прочерк на панели вместо пустого текста.
2026-07-28 01:39:58 +03:00

150 lines
5.9 KiB
Python
Raw Permalink 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.
"""Тесты `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