124 lines
5.1 KiB
Python
124 lines
5.1 KiB
Python
"""Тесты `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
|