gendesign/tradein-mvp/backend/app/observability/metrics.py
bot-backend 690f1ef5d2
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
feat(metrics): продуктовые счётчики Prometheus для Меры и Птицы + дашборд
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:

Мера (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
2026-09-12 14:22:33 +03:00

212 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Метрики 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)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# Источник списка — реальные `event_type` из `user_events` (миграция 184) плюс
# ручки, которые сами в этот аудит-лог не пишут (suggest, PDF-экспорт). Метки
# везде — фиксированный литерал из кода вызова (outcome/found/channel/result),
# НЕ значение из запроса: username, адрес, estimate_id в метку не идут —
# это ровно то, что взрывает кардинальность ряда у Prometheus.
ESTIMATES = Counter(
"mera_estimates_total",
"Запрошенных оценок trade-in, по исходу расчёта",
labelnames=("outcome",), # ok | insufficient_data
)
ADDRESS_SUGGESTIONS = Counter(
"mera_address_suggestions_total",
"Запросов автокомплита адреса (/geocode/suggest), нашёлся ли результат",
labelnames=("found",), # yes | no
)
REPORTS_EXPORTED = Counter(
"mera_reports_exported_total",
"Скачанных PDF-отчётов по оценке trade-in",
)
LEADS = Counter(
"mera_leads_total",
"Сохранённых контактных заявок (телефон + согласие) с результата оценки",
)
SUPPORT_MESSAGES = Counter(
"mera_support_messages_total",
"Сообщений в поддержку, дошедших до Telegram-топика, по каналу",
labelnames=("channel",), # web | anon
)
LOGINS = Counter(
"mera_logins_total",
"Попыток входа в личный кабинет, по исходу",
labelnames=("result",), # success | failed
)
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)