Первоначальная версия VidConf
This commit is contained in:
125
backend/api/metrics.py
Normal file
125
backend/api/metrics.py
Normal 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 скрейпит
|
||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||
"""
|
||||
await _refresh_pipeline_sessions_gauge(session)
|
||||
await _refresh_celery_queue_depth_gauge()
|
||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
Reference in New Issue
Block a user