Files
vidconf/backend/tests/test_metrics_api.py
Max Ronzhin 0e56960714 feat(metrics): метрики доступности БД и занятости пулов БД/Redis
vidconf_db_up проверяется отдельным от основного пула соединением
(NullPool, короткий таймаут) — иначе в момент исчерпания пула проверка
сама встала бы в очередь и не отличила бы «БД лежит» от «пул занят».
vidconf_db_pool_* читаются синхронно из engine.pool, без единого запроса
к БД. metrics_endpoint больше не виснет и не падает при недоступном
основном пуле: критичные gauge'и считаются первыми и не зависят от него,
а vidconf_pipeline_sessions (по-прежнему через Depends(get_session) —
тестовый харнесс подменяет её на savepoint-сессию) обёрнут таймаутом
и try/except.
2026-08-09 02:39:59 +03:00

222 lines
9.1 KiB
Python
Raw 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
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