Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

125
backend/api/metrics.py Normal file
View File

@@ -0,0 +1,125 @@
"""Метрики 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)