All checks were successful
CI / backend-tests (pull_request) Successful in 17m30s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
Третья часть #3078 и единственная, трогающая прод-код. До неё числовых рядов у приложений не было вовсе: только логи и исключения в GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой картине невидим — исключения нет, строка в логе выглядит обычной, а продукт при этом не работает. Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент запроса. Самый известный способ уронить мониторинг тем самым мониторингом. Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд. Слой регистрируется последним и потому оказывается самым внешним. Изнутри RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим I/O в middleware (#1202). Упавший исключением запрос считается как 500 в finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда, когда метрики нужнее всего. Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому /metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках — чтобы закрытость осталась решением, а не следствием текущего порядка директив. Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути под /api/, скрейп под них не попадает. Проверено тестом, а не чтением. Прод-поведение не меняется ничем, кроме нового публичного пути: ни один существующий обработчик, гвард или маршрут не тронут. Refs #3078
171 lines
9.7 KiB
Python
171 lines
9.7 KiB
Python
"""Метрики Prometheus для API «Меры»: счётчики, гистограмма задержки, `/metrics`.
|
||
|
||
Часть 3 задачи #3078. Близнец `backend/app/observability/metrics.py` из «Птицы»:
|
||
это два независимых Python-проекта со своими зависимостями и своим деплоем,
|
||
общего пакета между ними нет и заводить его ради полутора сотен строк дороже,
|
||
чем держать две копии. Расхождения намеренные и отмечены по месту.
|
||
|
||
ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК
|
||
|
||
`route` — ШАБЛОН маршрута (`/api/v1/trade-in/{lead_id}`), а не путь запроса.
|
||
Разница принципиальная: идентификатор в метке даёт новый временной ряд на каждую
|
||
заявку, а ряд у Prometheus стоит памяти постоянно, а не в момент запроса. Всё
|
||
незаматченное сведено в одну метку ``__unmatched__`` — иначе тот же взрыв рядов
|
||
устроит любой бот, перебирающий адреса.
|
||
|
||
Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит
|
||
сквозь этот слой наружу, и без `finally` такие запросы не попали бы в счётчик —
|
||
то есть отсутствовали бы ровно тогда, когда метрики нужнее всего.
|
||
|
||
ОДИН ПРОЦЕСС — ОДИН РЕЕСТР
|
||
|
||
`backend/Dockerfile:111` запускает `uvicorn` без `--workers`. Появятся воркеры —
|
||
счётчики станут per-process, каждый скрейп попадёт в случайный из них, и график
|
||
начнёт пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным
|
||
режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` + `MultiProcessCollector`);
|
||
делать это заранее незачем, но связь `--workers` → сломанные графики стоит знать
|
||
до, а не после.
|
||
|
||
ДОСТУП
|
||
|
||
`/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь
|
||
недостижим: у `gendsgn.ru` бэкенду «Меры» отдаётся лишь `/trade-in/api/*`, а у
|
||
`meraocenka.ru` действует белый список с `handle { respond 404 }` в конце. Плюс
|
||
явный `respond 404` на `/metrics` в обоих блоках — чтобы закрытость осталась
|
||
решением, а не побочным следствием текущего порядка директив.
|
||
|
||
Ограничитель частоты трогать не пришлось: `app/core/ratelimit.py:75` пропускает
|
||
всё, что не начинается на `/api/`, и скрейп раз в 30 секунд под него не попадает.
|
||
Аудит запросов — тоже: `app/core/request_audit.py:84` пишет строку только для
|
||
путей под `/api/` и только при известном пользователе.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from collections.abc import Awaitable, Callable, MutableMapping
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Response
|
||
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
|
||
|
||
from app.core.version import APP_VERSION, BUILD_SHA
|
||
|
||
Scope = MutableMapping[str, Any]
|
||
Message = MutableMapping[str, Any]
|
||
Receive = Callable[[], Awaitable[Message]]
|
||
Send = Callable[[Message], Awaitable[None]]
|
||
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
||
|
||
# Явная строка, а не пустое значение: пустая метка в PromQL неотличима от
|
||
# отсутствующей.
|
||
UNMATCHED = "__unmatched__"
|
||
|
||
# Границы плотнее, чем у «Птицы», и верхняя ниже. У «Меры» другой профиль:
|
||
# расчёт стоимости укладывается в десятые доли секунды (замер 26.08 — 90 мс на
|
||
# живом запросе), тяжёлого геометрического хвоста здесь нет. Зато есть внешние
|
||
# зависимости с непредсказуемым временем — геокодер, банк-эквайер, — поэтому
|
||
# верхние корзины оставлены: их отсутствие слепило бы весь хвост в `+Inf`.
|
||
_DURATION_BUCKETS = (0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, float("inf"))
|
||
|
||
REQUESTS = Counter(
|
||
"http_requests_total",
|
||
"Запросов обслужено",
|
||
labelnames=("method", "route", "status"),
|
||
)
|
||
|
||
DURATION = Histogram(
|
||
"http_request_duration_seconds",
|
||
"Время ответа целиком, включая авторизацию и middleware",
|
||
labelnames=("method", "route"),
|
||
buckets=_DURATION_BUCKETS,
|
||
)
|
||
|
||
# Без меток намеренно: gauge с меткой маршрута не возвращается в ноль сам, ряд
|
||
# остаётся навсегда после единственного запроса.
|
||
IN_PROGRESS = Gauge(
|
||
"http_requests_in_progress",
|
||
"Запросов обрабатывается прямо сейчас",
|
||
)
|
||
|
||
BUILD_INFO = Gauge(
|
||
"app_build_info",
|
||
"Всегда 1; полезны метки — по ним видно, какая версия отвечала в момент сбоя",
|
||
labelnames=("app", "release"),
|
||
)
|
||
# Версия берётся из `app/core/version.py` — там единственный источник правды
|
||
# (файл `VERSION` плюс build-args образа), тот же, что показывают PDF-колонтитул
|
||
# и `GET /api/v1/trade-in/version`. Отдельного хардкода здесь быть не должно:
|
||
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
|
||
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
|
||
|
||
|
||
def route_label(scope: Scope) -> str:
|
||
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
||
|
||
`scope["route"]` проставляет роутер Starlette в момент матчинга. Наш слой
|
||
внешний, поэтому к возврату управления сюда поле уже заполнено: scope — один
|
||
и тот же dict на весь стек, между слоями он не копируется.
|
||
"""
|
||
route = scope.get("route")
|
||
path = getattr(route, "path", None)
|
||
if isinstance(path, str) and path:
|
||
return path
|
||
return UNMATCHED
|
||
|
||
|
||
class MetricsMiddleware:
|
||
"""Чистый ASGI-слой, без `BaseHTTPMiddleware`.
|
||
|
||
`BaseHTTPMiddleware` заворачивает ответ в собственный поток и на потоковых
|
||
ответах ведёт себя иначе, чем голый ASGI. У «Меры» телом ответа уходит PDF
|
||
отчёта, и менять его обработку ради подсчёта запросов не стоит.
|
||
|
||
Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то есть
|
||
последний зарегистрированный оказывается самым внешним. Иначе 401 от гварда и
|
||
429 от ограничителя частоты не попадут в счётчик — а поток отказов авторизации
|
||
и срабатывания лимитера это ровно то, ради чего метрики и заводятся.
|
||
"""
|
||
|
||
def __init__(self, app: ASGIApp) -> None:
|
||
self.app = app
|
||
|
||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||
if scope.get("type") != "http":
|
||
await self.app(scope, receive, send)
|
||
return
|
||
|
||
method = scope.get("method", "UNKNOWN")
|
||
# 500 по умолчанию: при падении исключением `http.response.start` мы не
|
||
# увидим, и запрос обязан быть посчитан как ошибка, а не пропасть.
|
||
status = 500
|
||
|
||
async def send_wrapper(message: Message) -> None:
|
||
nonlocal status
|
||
if message["type"] == "http.response.start":
|
||
status = message["status"]
|
||
await send(message)
|
||
|
||
IN_PROGRESS.inc()
|
||
started = time.perf_counter()
|
||
try:
|
||
await self.app(scope, receive, send_wrapper)
|
||
finally:
|
||
IN_PROGRESS.dec()
|
||
route = route_label(scope)
|
||
DURATION.labels(method, route).observe(time.perf_counter() - started)
|
||
REQUESTS.labels(method, route, str(status)).inc()
|
||
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/metrics", include_in_schema=False)
|
||
def metrics() -> Response:
|
||
"""Выгрузка в текстовом формате Prometheus.
|
||
|
||
Реестр по умолчанию, а не свой: вместе с нашими метриками он отдаёт
|
||
`process_resident_memory_bytes`, `process_open_fds` и счётчики сборщика
|
||
мусора — утечка памяти и исчерпание дескрипторов видны по ним напрямую.
|
||
"""
|
||
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|