Первоначальная версия VidConf
This commit is contained in:
123
backend/tests/test_metrics_api.py
Normal file
123
backend/tests/test_metrics_api.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Тесты `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.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
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user