All checks were successful
CI / backend-tests (pull_request) Successful in 17m59s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI Trade-In / backend-tests (pull_request) Successful in 5m59s
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:
Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
зеркалит user_events.event_type=estimate_request (294 строки в БД),
insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
login_success/login_failed в auth.py (97/453 строк в БД).
Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
(md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.
Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.
Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.
ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.
Refs #3471
188 lines
11 KiB
Python
188 lines
11 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)
|
||
|
||
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
|
||
#
|
||
# `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json",
|
||
# "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное
|
||
# значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка —
|
||
# кардинальность ограничена набором форматов экспорта, а не количеством
|
||
# участков/пользователей.
|
||
REPORTS_EXPORTED = Counter(
|
||
"sitefinder_reports_exported_total",
|
||
"Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату",
|
||
labelnames=("format",),
|
||
)
|
||
|
||
|
||
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)
|