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
175 lines
9.7 KiB
Python
175 lines
9.7 KiB
Python
"""Метрики Prometheus для API «Птицы»: счётчики, гистограмма задержки, `/metrics`.
|
||
|
||
Часть 3 задачи #3078. До неё числовых рядов у приложения не было вовсе — только
|
||
логи и исключения в GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт
|
||
4xx потоком» в такой картине невидим: исключения нет, строка в логе выглядит
|
||
обычной, а пользователь видит неработающий продукт.
|
||
|
||
ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК
|
||
|
||
`route` — это ШАБЛОН маршрута (`/api/v1/parcels/{cad_num}`), а не путь запроса.
|
||
Разница принципиальная, а не косметическая: кадастровый номер в метке дал бы
|
||
новый временной ряд на каждый участок. У Prometheus ряд стоит памяти постоянно,
|
||
а не в момент запроса, и такая метка кладёт приёмник за сутки — это самый
|
||
известный способ уронить мониторинг тем самым мониторингом.
|
||
|
||
Незаматченные пути (404, сканеры, чужие боты) сведены в одну метку
|
||
``__unmatched__``. Иначе достаточно одного бота, перебирающего адреса, чтобы
|
||
получить тот же взрыв рядов через чёрный ход.
|
||
|
||
Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит
|
||
сквозь этот слой наружу, к `ServerErrorMiddleware`, и без `finally` такие
|
||
запросы просто не попали бы в счётчик — то есть отсутствовали бы ровно в тот
|
||
момент, когда метрики нужнее всего.
|
||
|
||
ОДИН ПРОЦЕСС — ОДИН РЕЕСТР
|
||
|
||
`Dockerfile:75` запускает `uvicorn` без `--workers`, то есть процесс один и
|
||
значения счётчиков целостны. Появится `--workers` или gunicorn — счётчики
|
||
станут per-process, и каждый скрейп будет попадать в случайный воркер: график
|
||
начнёт пилить вверх-вниз без всякой связи с нагрузкой. Лечится штатным
|
||
многопроцессным режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` +
|
||
`MultiProcessCollector`), но это отдельная работа, и делать её заранее «на
|
||
всякий случай» не стоит. Здесь оставлена явная отметка, чтобы связь между
|
||
`--workers` и сломанными графиками не пришлось искать заново.
|
||
|
||
ДОСТУП
|
||
|
||
`/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь
|
||
недостижим: `caddy/sites/apps.caddy` проксирует на бэкенд «Птицы» лишь
|
||
`/health` и `/api/*`, а `/metrics` там вдобавок закрыт явным `respond 404` —
|
||
чтобы это осталось решением, а не побочным следствием текущего порядка
|
||
директив.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
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
|
||
|
||
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__"
|
||
|
||
# Границы гистограммы подобраны под «Птицу», а не взяты из примера в документации.
|
||
# Быстрые ручки (`/health`, справочники) укладываются в десятки миллисекунд;
|
||
# `POST /api/v1/parcels/{cad_num}/analyze` уходит в десятки секунд, потому что
|
||
# внутри поход в OSRM и подсчёт геометрии. Без верхних корзин весь тяжёлый хвост
|
||
# слипся бы в `+Inf`, и «стало вдвое медленнее» было бы не увидеть.
|
||
_DURATION_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.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"),
|
||
)
|
||
BUILD_INFO.labels(
|
||
app="sitefinder",
|
||
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
|
||
).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/DXF/XLSX идут телом ответа, — и ставить ради подсчёта запросов
|
||
слой, который меняет их обработку, не стоит.
|
||
|
||
Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то
|
||
есть последний зарегистрированный оказывается самым внешним. Именно это и
|
||
нужно — иначе 401 от RBAC-гварда не попадёт в счётчик, а поток отказов
|
||
авторизации это ровно то, что нужно видеть.
|
||
"""
|
||
|
||
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)
|