Files
vidconf/backend/api/metrics.py

126 lines
6.5 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.
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней
сети, а не через публичный `/api/`-гейтвей.
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно
НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике
запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их
асинхронные источники (БД, Redis) можно опросить обычным `await` вместо
реализации синхронного `prometheus_client.registry.Collector`.
"""
import time
from collections.abc import Awaitable, Callable
from fastapi import APIRouter, Depends, Request, Response
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_latest
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match
from core.db import get_session
from core.redis import redis_client
from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository
router = APIRouter()
# --- Латентность HTTP-запросов по маршрутам --------------------------------
HTTP_REQUEST_DURATION_SECONDS = Histogram(
"vidconf_http_request_duration_seconds",
"Латентность HTTP-запросов backend по маршрутам",
labelnames=("method", "path", "status"),
)
async def prometheus_latency_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Замерить латентность запроса и записать в `HTTP_REQUEST_DURATION_SECONDS`.
Метка `path` — шаблон маршрута (`/api/v1/conferences/{conference_id}`), а
не сырой URL: иначе каждый UUID/slug в пути породил бы собственную серию
меток (неограниченная кардинальность). Шаблон резолвится постфактум
поиском совпавшего маршрута среди `request.app.routes` (FastAPI/Starlette
не кладёт его в `request.scope` до входа в сам эндпоинт, а `call_next`
оборачивает вызов целиком) — тот же приём, что использует
`starlette.routing.Router` внутри себя для диспетчеризации.
"""
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
path_template = _match_route_path(request)
HTTP_REQUEST_DURATION_SECONDS.labels(
method=request.method, path=path_template, status=str(response.status_code)
).observe(duration)
return response
def _match_route_path(request: Request) -> str:
"""Найти шаблон пути совпавшего маршрута; сырой `request.url.path`, если не найден (404)."""
for route in request.app.routes:
match, _ = route.matches(request.scope)
if match == Match.FULL:
return getattr(route, "path", request.url.path)
return request.url.path
# --- Gauge числа сеансов по статусу пайплайна -------------------------------
PIPELINE_SESSIONS = Gauge(
"vidconf_pipeline_sessions",
"Число сеансов конференций в каждом статусе пайплайна пост-обработки",
labelnames=("status",),
)
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`."""
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
for status in PIPELINE_STATUSES:
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
# --- Gauge глубины очередей Celery (Redis) ----------------------------------
CELERY_QUEUES = ("transcription", "summarize", "notify", "celery")
"""Очереди, за которыми следим (`workers/celery_app.py::app.conf.task_routes`,
`docs/deploy/scaling.md`): выделенные `transcription`/`summarize`/`notify` +
дефолтная `celery` (обслуживающие задачи без явного маршрута)."""
CELERY_QUEUE_DEPTH = Gauge(
"vidconf_celery_queue_depth",
"Число задач, ожидающих обработки в очереди Celery (redis LLEN)",
labelnames=("queue",),
)
async def _refresh_celery_queue_depth_gauge() -> None:
"""Пересчитать `vidconf_celery_queue_depth` по всем отслеживаемым очередям.
Список Redis, лежащий за очередью Celery, называется так же, как сама
очередь (транспорт `kombu` с брокером `redis` кладёт задачи в список по
имени очереди) — `LLEN` даёт точную глубину backlog'а на момент scrape.
"""
for queue in CELERY_QUEUES:
depth = await redis_client.llen(queue)
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
@router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition.
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
коллектором) — значение в ответе всегда актуально на момент scrape,
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
"""
await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)