Skip to content

Commit c28d8cc

Browse files
committed
feat: добавить сбор HTTP-метрик и middleware наблюдаемости
Что сделано: - добавлены Prometheus метрики по количеству и длительности запросов - добавлены middleware для request id, логирования и метрик - добавлен эндпоинт /metrics для scrape Prometheus
1 parent 2f27a19 commit c28d8cc

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

app/metrics.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
4+
5+
HTTP_REQUESTS_TOTAL = Counter(
6+
"http_requests_total",
7+
"Total HTTP requests.",
8+
["method", "path", "status"],
9+
)
10+
11+
HTTP_REQUEST_DURATION_SECONDS = Histogram(
12+
"http_request_duration_seconds",
13+
"HTTP request duration in seconds.",
14+
["method", "path"],
15+
)
16+
17+
18+
def observe_http_request(
19+
*,
20+
method: str,
21+
path: str,
22+
status: int,
23+
duration_seconds: float,
24+
) -> None:
25+
HTTP_REQUESTS_TOTAL.labels(method=method, path=path, status=str(status)).inc()
26+
HTTP_REQUEST_DURATION_SECONDS.labels(method=method, path=path).observe(
27+
duration_seconds
28+
)
29+
30+
31+
def render_metrics() -> tuple[bytes, str]:
32+
return generate_latest(), CONTENT_TYPE_LATEST

app/middleware.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import time
2+
import uuid
3+
4+
import structlog
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
from starlette.requests import Request
7+
from starlette.responses import Response
8+
9+
from app.metrics import observe_http_request
10+
11+
logger = structlog.get_logger()
12+
13+
14+
class RequestIDMiddleware(BaseHTTPMiddleware):
15+
"""Присваивает каждому запросу уникальный ID и возвращает его в заголовке ответа."""
16+
17+
async def dispatch(self, request: Request, call_next) -> Response:
18+
# берём из заголовка если клиент прислал свой, иначе генерируем
19+
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
20+
21+
# кладём в state чтобы было доступно из роутов
22+
request.state.request_id = request_id
23+
24+
response = await call_next(request)
25+
26+
response.headers["X-Request-ID"] = request_id
27+
return response
28+
29+
30+
class LoggingMiddleware(BaseHTTPMiddleware):
31+
"""Логирует каждый входящий запрос и ответ с временем выполнения."""
32+
33+
async def dispatch(self, request: Request, call_next) -> Response:
34+
# request_id уже есть в state, RequestIDMiddleware отработал раньше
35+
request_id = getattr(request.state, "request_id", "-")
36+
start_time = time.perf_counter()
37+
38+
await logger.ainfo(
39+
"request",
40+
request_id=request_id,
41+
method=request.method,
42+
path=request.url.path,
43+
)
44+
45+
response = await call_next(request)
46+
47+
duration_ms = round((time.perf_counter() - start_time) * 1000)
48+
49+
await logger.ainfo(
50+
"response",
51+
request_id=request_id,
52+
method=request.method,
53+
path=request.url.path,
54+
status=response.status_code,
55+
duration_ms=duration_ms,
56+
)
57+
58+
return response
59+
60+
61+
class MetricsMiddleware(BaseHTTPMiddleware):
62+
"""Собирает базовые HTTP-метрики для Prometheus."""
63+
64+
async def dispatch(self, request: Request, call_next) -> Response:
65+
# Не учитываем self-scrape endpoint, чтобы не шуметь метриками.
66+
if request.url.path == "/metrics":
67+
return await call_next(request)
68+
69+
start_time = time.perf_counter()
70+
status_code = 500
71+
try:
72+
response = await call_next(request)
73+
status_code = response.status_code
74+
return response
75+
finally:
76+
route = request.scope.get("route")
77+
route_path = getattr(route, "path", request.url.path)
78+
observe_http_request(
79+
method=request.method,
80+
path=route_path,
81+
status=status_code,
82+
duration_seconds=time.perf_counter() - start_time,
83+
)

app/routes/metrics.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from fastapi import APIRouter, Response
4+
5+
from app.metrics import render_metrics
6+
7+
router = APIRouter(include_in_schema=False)
8+
9+
10+
@router.get("/metrics", include_in_schema=False)
11+
async def metrics() -> Response:
12+
content, media_type = render_metrics()
13+
return Response(content=content, media_type=media_type)

0 commit comments

Comments
 (0)