feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд приложений #3102
14 changed files with 1033 additions and 2 deletions
|
|
@ -48,6 +48,7 @@ from app.core import auth_db
|
|||
from app.core.audit_middleware import audit_log_middleware
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.observability import metrics as app_metrics
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
from app.services.auth_session import resolve_session_token
|
||||
|
||||
|
|
@ -180,7 +181,15 @@ app.middleware("http")(audit_log_middleware)
|
|||
# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать
|
||||
# `expired` как вводящий в заблуждение.
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
|
||||
# `/metrics` публичен здесь и НЕ публичен снаружи — это два разных периметра, и
|
||||
# путать их нельзя. Снимает его агент Alloy изнутри docker-сети, где заголовка
|
||||
# `X-Authenticated-User` нет ни у кого, так что без записи в этом множестве
|
||||
# скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не
|
||||
# открывается: `caddy/sites/apps.caddy` отдаёт бэкенду «Птицы» только `/health`
|
||||
# и `/api/*`, а `/metrics` там дополнительно закрыт явным `respond 404`.
|
||||
_PUBLIC_PATHS = frozenset(
|
||||
{"/health", "/metrics", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}
|
||||
)
|
||||
|
||||
|
||||
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||
|
|
@ -465,6 +474,15 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Метрики — СЛЕДОМ ЗА CORS и, значит, самым внешним слоем: `add_middleware`
|
||||
# вставляет в начало списка, поэтому зарегистрированный последним оказывается
|
||||
# снаружи всех. Порядок здесь несущий, а не вкусовой. Изнутри RBAC-гварда не
|
||||
# видно ни отказов авторизации (401/403 — их отдаёт сам гвард), ни времени,
|
||||
# которое он тратит на резолв сессии в БД `auth`; а именно этот путь уже давал
|
||||
# инцидент (#1202, блокирующий I/O в middleware). Снаружи видно и то и другое.
|
||||
app.add_middleware(app_metrics.MetricsMiddleware)
|
||||
|
||||
app.include_router(app_metrics.router, tags=["observability"])
|
||||
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||||
app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"])
|
||||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||||
|
|
|
|||
175
backend/app/observability/metrics.py
Normal file
175
backend/app/observability/metrics.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""Метрики 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)
|
||||
|
|
@ -40,6 +40,7 @@ dependencies = [
|
|||
"pytesseract>=0.3.13", # OCR сканов через Tesseract для изъятия ЕКБ (#1062)
|
||||
"contextily>=1.7.0", # OSM basemap-тайлы для серверного рендера карт отчёта (#2259 PR-C)
|
||||
"matplotlib>=3.11.0", # headless (Agg) рендер PNG-карт участка/концепции (#2259 PR-C)
|
||||
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
|
|||
116
backend/tests/test_metrics.py
Normal file
116
backend/tests/test_metrics.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Слой метрик: метка маршрута не должна взрывать кардинальность (#3078).
|
||||
|
||||
Проверяется не «эндпоинт отвечает 200», а ровно то, чем метрики убивают сами
|
||||
себя. У Prometheus временной ряд стоит памяти постоянно, а не в момент запроса,
|
||||
поэтому кадастровый номер, попавший в метку, кладёт приёмник за сутки. Отказ
|
||||
при этом отложенный и не выглядит как ошибка кода — обычный тест на статус его
|
||||
не увидит никогда.
|
||||
|
||||
Маршруты-пробы названы уникально (`__metrics_probe__`): реестр
|
||||
`prometheus_client` глобален на процесс, и совпади имя с боевым, тесты начали бы
|
||||
влиять друг на друга через общий счётчик. По той же причине сравниваются
|
||||
приращения, а не абсолютные значения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from app.observability import metrics as m
|
||||
|
||||
_ROUTE = "/__metrics_probe__/{item_id}"
|
||||
_BOOM = "/__metrics_probe_boom__"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(m.router)
|
||||
|
||||
@app.get(_ROUTE)
|
||||
def probe(item_id: str) -> dict[str, str]:
|
||||
return {"item": item_id}
|
||||
|
||||
@app.get(_BOOM)
|
||||
def boom() -> dict[str, str]:
|
||||
raise RuntimeError("нарочно — проверяем, что упавший запрос посчитан")
|
||||
|
||||
# Последним, как в app/main.py: add_middleware вставляет в начало списка,
|
||||
# значит зарегистрированный последним оказывается самым внешним.
|
||||
app.add_middleware(m.MetricsMiddleware)
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _count(route: str, status: str, method: str = "GET") -> float:
|
||||
value = REGISTRY.get_sample_value(
|
||||
"http_requests_total", {"method": method, "route": route, "status": status}
|
||||
)
|
||||
return value or 0.0
|
||||
|
||||
|
||||
def test_route_label_is_template_not_path(client: TestClient) -> None:
|
||||
"""Три разных идентификатора дают ОДИН ряд, а не три."""
|
||||
before = _count(_ROUTE, "200")
|
||||
|
||||
for item in ("66:41:0301001:1", "66:41:0301001:2", "66:41:0301001:3"):
|
||||
assert client.get(f"/__metrics_probe__/{item}").status_code == 200
|
||||
|
||||
assert _count(_ROUTE, "200") - before == 3.0
|
||||
|
||||
body = client.get("/metrics").text
|
||||
for item in ("0301001:1", "0301001:2", "0301001:3"):
|
||||
assert item not in body, f"идентификатор утёк в метку: {item}"
|
||||
|
||||
|
||||
def test_unmatched_paths_collapse_into_one_series(client: TestClient) -> None:
|
||||
"""Сканер, перебирающий адреса, не должен плодить ряды."""
|
||||
before = _count(m.UNMATCHED, "404")
|
||||
|
||||
assert client.get("/wp-admin/setup-config.php").status_code == 404
|
||||
assert client.get("/.env").status_code == 404
|
||||
assert client.get("/явно-нет-такого-пути").status_code == 404
|
||||
|
||||
assert _count(m.UNMATCHED, "404") - before == 3.0
|
||||
assert "wp-admin" not in client.get("/metrics").text
|
||||
|
||||
|
||||
def test_exception_is_counted_as_500(client: TestClient) -> None:
|
||||
"""Исключение проходит сквозь слой наружу — без finally запрос бы потерялся."""
|
||||
before = _count(_BOOM, "500")
|
||||
assert client.get(_BOOM).status_code == 500
|
||||
assert _count(_BOOM, "500") - before == 1.0
|
||||
|
||||
|
||||
def test_in_progress_returns_to_baseline(client: TestClient) -> None:
|
||||
"""inc/dec сходятся, в том числе на упавшем запросе.
|
||||
|
||||
Значение 1 — это сам скрейп, который в момент выгрузки ещё в обработке.
|
||||
Разъехавшийся счётчик выглядел бы как вечно растущая линия «запросов в
|
||||
работе» при простаивающем сервисе.
|
||||
"""
|
||||
client.get("/__metrics_probe__/x")
|
||||
client.get(_BOOM)
|
||||
body = client.get("/metrics").text
|
||||
assert "http_requests_in_progress 1.0" in body
|
||||
|
||||
|
||||
def test_exposition_carries_histogram_and_build_info(client: TestClient) -> None:
|
||||
client.get("/__metrics_probe__/x")
|
||||
body = client.get("/metrics").text
|
||||
assert "http_request_duration_seconds_bucket{" in body
|
||||
assert "http_request_duration_seconds_count{" in body
|
||||
assert 'app_build_info{app="sitefinder"' in body
|
||||
|
||||
|
||||
def test_metrics_path_is_public_for_the_in_network_agent() -> None:
|
||||
"""Без этой записи скрейп получал бы 401 и метрик не было бы вовсе.
|
||||
|
||||
Наружу путь при этом не открыт: `caddy/sites/apps.caddy` отдаёт бэкенду
|
||||
только `/health` и `/api/*`, а на `/metrics` там стоит явный `respond 404`.
|
||||
"""
|
||||
from app.main import _PUBLIC_PATHS
|
||||
|
||||
assert "/metrics" in _PUBLIC_PATHS
|
||||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
|
|
@ -799,6 +799,7 @@ dependencies = [
|
|||
{ name = "pdfplumber" },
|
||||
{ name = "pillow" },
|
||||
{ name = "playwright" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
|
|
@ -850,6 +851,7 @@ requires-dist = [
|
|||
{ name = "pdfplumber", specifier = ">=0.10.0" },
|
||||
{ name = "pillow", specifier = ">=10.4.0" },
|
||||
{ name = "playwright", specifier = ">=1.45.0" },
|
||||
{ name = "prometheus-client", specifier = ">=0.21.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.3.0" },
|
||||
|
|
@ -1891,6 +1893,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
|
|
|
|||
|
|
@ -99,6 +99,21 @@ gendsgn.ru {
|
|||
}
|
||||
|
||||
route {
|
||||
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
|
||||
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
||||
# /health и /api/*, остальное забирает фронт, у которого такого
|
||||
# маршрута нет. Но в бэкенде путь ОТКРЫТ без авторизации — иначе
|
||||
# агент Alloy изнутри docker-сети получал бы 401 (см. комментарий у
|
||||
# `_PUBLIC_PATHS` в backend/app/main.py). Одной строки `handle
|
||||
# /metrics { reverse_proxy backend:8000 }`, добавленной когда-нибудь
|
||||
# по невнимательности, хватит, чтобы выставить наружу внутреннее
|
||||
# устройство продукта. Явный 404 делает закрытость решением, а не
|
||||
# следствием текущего порядка директив, и стоит первым — route
|
||||
# матчит сверху вниз и short-circuit'ит.
|
||||
handle /metrics {
|
||||
respond 404
|
||||
}
|
||||
|
||||
# /health и /preview/* — public, без auth, short-circuit.
|
||||
handle /health {
|
||||
reverse_proxy backend:8000
|
||||
|
|
@ -276,6 +291,16 @@ meraocenka.ru {
|
|||
output file /var/log/caddy/meraocenka.ru.log
|
||||
}
|
||||
|
||||
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
|
||||
# финальный `handle { respond 404 }`, так что путь и без этой строки не
|
||||
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
||||
# Alloy внутри docker-сети (см. `_PUBLIC_PATHS` в app/core/rbac.py).
|
||||
# Явный отказ на публичном домене делает закрытость решением, а не
|
||||
# следствием того, что список пока никто не расширил.
|
||||
handle /metrics {
|
||||
respond 404
|
||||
}
|
||||
|
||||
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
|
||||
# полноценную страницу). rewrite добавляет basePath-префикс только для
|
||||
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ Sentry DSN ни одним компонентом, а 549 строк скруб
|
|||
| `ops/metrics/alloy/alloy-infra.alloy` | агент на Beget — пишет напрямую по docker-сети |
|
||||
| `ops/metrics/alloy/alloy-apps.alloy` | агент на Poincare — пишет по HTTPS под basic_auth |
|
||||
| `ops/metrics/postgres/queries.yml` | дополнительные запросы экспортера БД |
|
||||
| `backend/app/observability/metrics.py` | счётчики и `/metrics` «Птицы» |
|
||||
| `tradein-mvp/backend/app/observability/metrics.py` | то же для «Меры» |
|
||||
| `ops/metrics/grafana/` | источники данных и дашборды |
|
||||
| `caddy/sites/infra.caddy` | site-блок `metrics.gendsgn.ru` |
|
||||
| `scripts/setup-metrics-secrets.sh` | разовая подготовка учёток |
|
||||
|
|
@ -115,6 +117,56 @@ Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datas
|
|||
стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и
|
||||
рабочее место. Это осознанно, а не недоделка.
|
||||
|
||||
## Метрики приложений
|
||||
|
||||
У «Птицы» и «Меры» появился `/metrics` — HTTP-счётчики, гистограмма времени
|
||||
ответа, число запросов в работе и версия сборки, плюс `process_*` от реестра
|
||||
`prometheus_client` (память процесса, дескрипторы, сборщик мусора).
|
||||
|
||||
**Метка `route` — это шаблон маршрута**, `/api/v1/parcels/{cad_num}`, а не путь
|
||||
запроса. Разница принципиальная, а не косметическая: кадастровый номер в метке
|
||||
даёт новый временной ряд на каждый участок, а ряд у Prometheus стоит памяти
|
||||
постоянно, а не в момент запроса. Это самый известный способ уронить мониторинг
|
||||
тем самым мониторингом. Всё незаматченное сведено в одну метку
|
||||
``__unmatched__`` — иначе тот же взрыв рядов устроит любой бот, перебирающий
|
||||
адреса. Свойство сторожится тестами (`tests/test_metrics.py` в обоих проектах),
|
||||
а не комментарием.
|
||||
|
||||
**Слой регистрируется последним и потому оказывается самым внешним**
|
||||
(`add_middleware` вставляет в начало списка). Порядок несущий: изнутри
|
||||
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
|
||||
резолв сессии в БД `auth`, — а именно этот путь уже давал инцидент с блокирующим
|
||||
I/O в middleware (#1202).
|
||||
|
||||
**Путь публичен внутри и закрыт снаружи** — это два разных периметра. Скрейп
|
||||
идёт изнутри docker-сети, где заголовка `X-Authenticated-User` нет ни у кого,
|
||||
поэтому `/metrics` внесён в `_PUBLIC_PATHS` обоих бэкендов: без этого агент
|
||||
получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не открывается —
|
||||
ни через `gendsgn.ru`, ни через `meraocenka.ru`, и вдобавок закрыт явным
|
||||
`respond 404` в обоих site-блоках. Явный отказ стоит там ради регрессии: одной
|
||||
строки `handle /metrics { reverse_proxy backend:8000 }`, добавленной
|
||||
когда-нибудь по невнимательности, хватит, чтобы выставить наружу внутреннее
|
||||
устройство продукта.
|
||||
|
||||
Ограничитель частоты и аудит запросов «Меры» трогать не пришлось: первый
|
||||
смотрит только на пути под `/api/`, второй — на `/api/` и известного
|
||||
пользователя. Скрейп раз в 30 секунд не попадает ни под один; иначе метрики
|
||||
пропадали бы пачками под нагрузкой, а `user_events` получала бы 2880 строк в
|
||||
сутки ни о чём.
|
||||
|
||||
**Один процесс — один реестр.** Оба контейнера запускают `uvicorn` без
|
||||
`--workers`, поэтому значения счётчиков целостны. Появятся воркеры — счётчики
|
||||
станут per-process, каждый скрейп попадёт в случайный из них, и график начнёт
|
||||
пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным
|
||||
режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` +
|
||||
`MultiProcessCollector`); делать это заранее незачем, но связь `--workers` →
|
||||
сломанные графики стоит знать до, а не после.
|
||||
|
||||
Celery-воркеры своего `/metrics` не отдают: у них нет HTTP-сервера, а поднимать
|
||||
его в каждом воркере ради счётчиков — отдельная конструкция со своим временем
|
||||
жизни. Прогоны фоновых задач будут видны иначе — через `scrape_runs` (часть 4),
|
||||
и это лучше: там уже лежит история, а не только то, что происходит прямо сейчас.
|
||||
|
||||
## Алерты
|
||||
|
||||
Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что
|
||||
|
|
@ -155,7 +207,6 @@ Poincare, не должно идти через сервис на Poincare.
|
|||
|
||||
## Что ещё не сделано
|
||||
|
||||
- `/metrics` в бэкендах (часть 3) — единственная часть, трогающая прод-код
|
||||
- экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация,
|
||||
счётчик `cancelled` (часть 4)
|
||||
- алерты и синтетический heartbeat (часть 5)
|
||||
|
|
|
|||
296
ops/metrics/grafana/dashboards/apps.json
Normal file
296
ops/metrics/grafana/dashboards/apps.json
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
{
|
||||
"uid": "gendesign-apps",
|
||||
"title": "Приложения",
|
||||
"description": "Птица и МЕРА глазами их собственных счётчиков. Отвечает на класс вопросов, невидимый для GlitchTip: сервис отвечает, исключений нет, а продукт при этом не работает — потому что медленно, или потому что поток 401 и 429.",
|
||||
"tags": ["gendesign", "apps"],
|
||||
"timezone": "browser",
|
||||
"editable": false,
|
||||
"schemaVersion": 39,
|
||||
"refresh": "1m",
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "app",
|
||||
"label": "Приложение",
|
||||
"type": "query",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"query": "label_values(app_build_info, app)",
|
||||
"refresh": 1,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"current": { "text": "All", "value": "$__all" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{ "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
|
||||
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Приложение отвечает",
|
||||
"description": "Скрейп удался или нет. Ноль здесь не то же самое, что «нет запросов»: это агент не смог снять метрики вовсе, то есть процесс не отвечает по сети.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "up{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "не отвечает", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } }
|
||||
],
|
||||
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" }
|
||||
},
|
||||
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Запросов в минуту",
|
||||
"description": "Обвал до нуля при живом процессе — тоже отказ: значит, до приложения перестали доходить запросы (Caddy, сеть, фронт), и снаружи это выглядит как неработающий сайт.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\"}[5m])) * 60", "legendFormat": "{{app}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "decimals": 1, "thresholds": { "mode": "absolute", "steps": [ { "color": "text", "value": null } ] } },
|
||||
"overrides": []
|
||||
},
|
||||
"options": { "colorMode": "none", "graphMode": "area", "textMode": "value_and_name" }
|
||||
},
|
||||
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Доля 5xx за час",
|
||||
"description": "Ошибка сервера — это всегда несделанная работа пользователя. Порог оранжевого стоит на 1 %: при нашем трафике это единицы запросов, и они должны быть заметны, а не тонуть в проценте от большого числа.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "sum by (app) (increase(http_requests_total{app=~\"$app\", status=~\"5..\"}[1h])) / clamp_min(sum by (app) (increase(http_requests_total{app=~\"$app\"}[1h])), 1)", "legendFormat": "{{app}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.01 }, { "color": "red", "value": 0.05 } ] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
|
||||
},
|
||||
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "95-й перцентиль за час",
|
||||
"description": "Не среднее: среднее прячет как раз тех, кому плохо. Считается по корзинам гистограммы, поэтому точность ограничена их границами — это нормально для порога, но не для точного измерения.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[1h])))", "legendFormat": "{{app}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 }, { "color": "red", "value": 5 } ] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
|
||||
},
|
||||
|
||||
{ "type": "row", "title": "Трафик и отказы", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } },
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Запросы по классам ответов",
|
||||
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"2..\"}[5m]))", "legendFormat": "{{app}} · 2xx" },
|
||||
{ "refId": "B", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"3..\"}[5m]))", "legendFormat": "{{app}} · 3xx" },
|
||||
{ "refId": "C", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"4..\"}[5m]))", "legendFormat": "{{app}} · 4xx" },
|
||||
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*4xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*5xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Отказы авторизации и лимитера",
|
||||
"description": "401 и 403 считаются потому, что вход у «Меры» и «Птицы» общий, а инцидентов вокруг него уже было достаточно (#2552, эпик единого входа). 429 — срабатывания ограничителя частоты: устойчивая линия означает, что кому-то из живых пользователей регулярно отказывают.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"401\"}[5m]))", "legendFormat": "{{app}} · 401 не авторизован" },
|
||||
{ "refId": "B", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"403\"}[5m]))", "legendFormat": "{{app}} · 403 запрещено" },
|
||||
{ "refId": "C", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=\"429\"}[5m]))", "legendFormat": "{{app}} · 429 лимитер" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*429.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Ошибки сервера по маршрутам (топ-10)",
|
||||
"description": "Здесь видно, какая именно ручка ломается. Метка route — шаблон маршрута, а не путь: идентификаторы в него не попадают намеренно, иначе каждый участок и каждая заявка давали бы отдельный ряд.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "topk(10, sum by (app, route, status) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m])))", "legendFormat": "{{app}} · {{route}} · {{status}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" } },
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "table",
|
||||
"title": "Самые медленные маршруты (95-й перцентиль за час)",
|
||||
"description": "Считается по маршрутам, у которых за окно был хоть какой-то трафик. Пустая таблица означает тишину, а не быстроту.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"instant": true,
|
||||
"format": "table",
|
||||
"expr": "topk(12, histogram_quantile(0.95, sum by (le, app, route) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[1h]))))"
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": { "Time": true, "job": true, "instance": true, "host": true, "cluster": true, "le": true },
|
||||
"renameByName": { "app": "Приложение", "route": "Маршрут", "Value": "95-й перцентиль" }
|
||||
}
|
||||
},
|
||||
{ "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "95-й перцентиль", "desc": true } ] } }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "95-й перцентиль" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "s" },
|
||||
{ "id": "custom.cellOptions", "value": { "type": "color-text" } },
|
||||
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 }, { "color": "red", "value": 5 } ] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{ "type": "row", "title": "Задержка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } },
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Перцентили времени ответа",
|
||||
"description": "Три линии вместе, потому что расхождение между ними и есть сигнал: ровный 50-й при растущем 99-м означает, что плохо становится части пользователей, а не всем, — и по среднему это не увидеть никогда.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 9, "w": 16, "x": 0, "y": 24 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "histogram_quantile(0.5, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 50-й" },
|
||||
{ "refId": "B", "expr": "histogram_quantile(0.9, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 90-й" },
|
||||
{ "refId": "C", "expr": "histogram_quantile(0.99, sum by (le, app) (rate(http_request_duration_seconds_bucket{app=~\"$app\"}[5m])))", "legendFormat": "{{app}} · 99-й" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "s", "min": 0, "custom": { "fillOpacity": 5, "showPoints": "never", "lineWidth": 2 } },
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Запросов в работе",
|
||||
"description": "Растущая линия при неизменном трафике означает, что запросы копятся: приложение принимает быстрее, чем отвечает. Обычно это блокирующий вызов в обработчике — ровно тот случай, что был в #1202.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 9, "w": 8, "x": 16, "y": 24 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "http_requests_in_progress{app=~\"$app\"}", "legendFormat": "{{app}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 2 } },
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
|
||||
{ "type": "row", "title": "Процесс", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 } },
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Память процесса",
|
||||
"description": "Резидентная память самого процесса Python, а не контейнера. Монотонный рост между перезапусками — утечка; по контейнерной метрике её легко списать на кэш.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 34 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "process_resident_memory_bytes{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}}" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Открытые дескрипторы",
|
||||
"description": "Незакрытые соединения и файлы упираются в лимит и дают отказы, которые выглядят как что угодно, кроме своей причины. Линия, ползущая вверх и не спадающая, — это она.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 34 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "process_open_fds{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}} · открыто" },
|
||||
{ "refId": "B", "expr": "process_max_fds{job=\"app\", app=~\"$app\"}", "legendFormat": "{{app}} · предел" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 5, "showPoints": "never", "lineWidth": 2 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*предел.*" }, "properties": [ { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [ 8, 6 ] } }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "table",
|
||||
"title": "Что задеплоено",
|
||||
"description": "Версия, отвечавшая в выбранном окне. Первый вопрос при разборе — «а что там было в этот момент», и ответ на него должен быть в той же картинке, а не в истории деплоев.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 34 },
|
||||
"targets": [
|
||||
{ "refId": "A", "instant": true, "format": "table", "expr": "app_build_info{app=~\"$app\"}" }
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": { "Time": true, "job": true, "instance": true, "host": true, "cluster": true, "Value": true },
|
||||
"renameByName": { "app": "Приложение", "release": "Версия" }
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -71,6 +71,14 @@ _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
|||
_PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/health",
|
||||
# Публичен ЗДЕСЬ и не публичен снаружи — это два разных периметра.
|
||||
# Снимает `/metrics` агент Alloy изнутри docker-сети, где заголовка
|
||||
# `X-Authenticated-User` нет ни у кого; без записи в этом множестве
|
||||
# скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь не
|
||||
# открывается: у `gendsgn.ru` бэкенду «Меры» отдаётся только
|
||||
# `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих
|
||||
# блоках на `/metrics` стоит явный `respond 404`.
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from app.core.fdw import ensure_fdw_user_mapping
|
|||
from app.core.ratelimit import RateLimitMiddleware
|
||||
from app.core.rbac import rbac_guard
|
||||
from app.core.request_audit import RequestAuditMiddleware
|
||||
from app.observability import metrics as app_metrics
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -240,6 +241,12 @@ app.add_middleware(
|
|||
app.add_middleware(RateLimitMiddleware)
|
||||
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
# Метрики — ПОСЛЕДНИМ и потому самым внешним слоем: `add_middleware` вставляет в
|
||||
# начало списка. Порядок здесь несущий. Изнутри не видно ни 401 от гварда, ни 429
|
||||
# от ограничителя частоты — их отдают сами эти слои и до нас запрос бы не дошёл;
|
||||
# а всплеск отказов авторизации и срабатывания лимитера это ровно тот сигнал,
|
||||
# ради которого метрики и заводятся.
|
||||
app.add_middleware(app_metrics.MetricsMiddleware)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
@ -267,6 +274,7 @@ def health_head() -> Response:
|
|||
return Response(status_code=200, media_type="application/json")
|
||||
|
||||
|
||||
app.include_router(app_metrics.router, tags=["observability"])
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
|
|
|
|||
171
tradein-mvp/backend/app/observability/metrics.py
Normal file
171
tradein-mvp/backend/app/observability/metrics.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Метрики 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)
|
||||
|
|
@ -30,6 +30,7 @@ dependencies = [
|
|||
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
||||
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
||||
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
||||
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
||||
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
||||
# Docker-context = tradein-mvp root, uv sync ставит editable.
|
||||
|
|
|
|||
139
tradein-mvp/backend/tests/test_metrics.py
Normal file
139
tradein-mvp/backend/tests/test_metrics.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Слой метрик: метка маршрута не должна взрывать кардинальность (#3078).
|
||||
|
||||
Проверяется не «эндпоинт отвечает 200», а ровно то, чем метрики убивают сами
|
||||
себя. У Prometheus временной ряд стоит памяти постоянно, а не в момент запроса,
|
||||
поэтому идентификатор заявки, попавший в метку, кладёт приёмник за сутки. Отказ
|
||||
отложенный и не выглядит как ошибка кода — обычный тест на статус его не увидит.
|
||||
|
||||
Маршруты-пробы названы уникально (`__metrics_probe__`): реестр
|
||||
`prometheus_client` глобален на процесс, и совпади имя с боевым, тесты начали бы
|
||||
влиять друг на друга через общий счётчик. По той же причине сравниваются
|
||||
приращения, а не абсолютные значения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from app.observability import metrics as m
|
||||
|
||||
_ROUTE = "/__metrics_probe__/{item_id}"
|
||||
_BOOM = "/__metrics_probe_boom__"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(m.router)
|
||||
|
||||
@app.get(_ROUTE)
|
||||
def probe(item_id: str) -> dict[str, str]:
|
||||
return {"item": item_id}
|
||||
|
||||
@app.get(_BOOM)
|
||||
def boom() -> dict[str, str]:
|
||||
raise RuntimeError("нарочно — проверяем, что упавший запрос посчитан")
|
||||
|
||||
# Последним, как в app/main.py: add_middleware вставляет в начало списка,
|
||||
# значит зарегистрированный последним оказывается самым внешним.
|
||||
app.add_middleware(m.MetricsMiddleware)
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _count(route: str, status: str, method: str = "GET") -> float:
|
||||
value = REGISTRY.get_sample_value(
|
||||
"http_requests_total", {"method": method, "route": route, "status": status}
|
||||
)
|
||||
return value or 0.0
|
||||
|
||||
|
||||
def test_route_label_is_template_not_path(client: TestClient) -> None:
|
||||
"""Три разные заявки дают ОДИН ряд, а не три."""
|
||||
before = _count(_ROUTE, "200")
|
||||
|
||||
for item in ("lead-1001", "lead-1002", "lead-1003"):
|
||||
assert client.get(f"/__metrics_probe__/{item}").status_code == 200
|
||||
|
||||
assert _count(_ROUTE, "200") - before == 3.0
|
||||
|
||||
body = client.get("/metrics").text
|
||||
for item in ("lead-1001", "lead-1002", "lead-1003"):
|
||||
assert item not in body, f"идентификатор утёк в метку: {item}"
|
||||
|
||||
|
||||
def test_unmatched_paths_collapse_into_one_series(client: TestClient) -> None:
|
||||
"""Сканер, перебирающий адреса, не должен плодить ряды."""
|
||||
before = _count(m.UNMATCHED, "404")
|
||||
|
||||
assert client.get("/wp-admin/setup-config.php").status_code == 404
|
||||
assert client.get("/.env").status_code == 404
|
||||
assert client.get("/явно-нет-такого-пути").status_code == 404
|
||||
|
||||
assert _count(m.UNMATCHED, "404") - before == 3.0
|
||||
assert "wp-admin" not in client.get("/metrics").text
|
||||
|
||||
|
||||
def test_exception_is_counted_as_500(client: TestClient) -> None:
|
||||
"""Исключение проходит сквозь слой наружу — без finally запрос бы потерялся."""
|
||||
before = _count(_BOOM, "500")
|
||||
assert client.get(_BOOM).status_code == 500
|
||||
assert _count(_BOOM, "500") - before == 1.0
|
||||
|
||||
|
||||
def test_in_progress_returns_to_baseline(client: TestClient) -> None:
|
||||
"""inc/dec сходятся, в том числе на упавшем запросе.
|
||||
|
||||
Значение 1 — это сам скрейп, который в момент выгрузки ещё в обработке.
|
||||
Разъехавшийся счётчик выглядел бы как вечно растущая линия «запросов в
|
||||
работе» при простаивающем сервисе.
|
||||
"""
|
||||
client.get("/__metrics_probe__/x")
|
||||
client.get(_BOOM)
|
||||
assert "http_requests_in_progress 1.0" in client.get("/metrics").text
|
||||
|
||||
|
||||
def test_exposition_carries_histogram_and_build_info(client: TestClient) -> None:
|
||||
client.get("/__metrics_probe__/x")
|
||||
body = client.get("/metrics").text
|
||||
assert "http_request_duration_seconds_bucket{" in body
|
||||
assert "http_request_duration_seconds_count{" in body
|
||||
# Версия — из app/core/version.py, единственного источника правды; отдельного
|
||||
# хардкода здесь быть не должно.
|
||||
assert 'app_build_info{app="mera"' in body
|
||||
|
||||
|
||||
def test_metrics_path_is_public_for_the_in_network_agent() -> None:
|
||||
"""Без этой записи скрейп получал бы 401 и метрик не было бы вовсе.
|
||||
|
||||
Наружу путь при этом не открыт: у `gendsgn.ru` бэкенду «Меры» отдаётся
|
||||
только `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих
|
||||
блоках на `/metrics` стоит явный `respond 404`.
|
||||
"""
|
||||
from app.core.rbac import _PUBLIC_PATHS
|
||||
|
||||
assert "/metrics" in _PUBLIC_PATHS
|
||||
|
||||
|
||||
def test_metrics_survives_the_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Скрейп не должен ловить 429.
|
||||
|
||||
Агент ходит раз в 30 секунд бесконечно. Попади `/metrics` под общий лимитер —
|
||||
метрики начали бы пропадать пачками именно под нагрузкой, то есть ровно
|
||||
тогда, когда нужны. Сегодня спасает то, что `ratelimit.py` смотрит только на
|
||||
пути под `/api/`; тест сторожит это свойство, а не переписывает его.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.core.ratelimit import RateLimitMiddleware
|
||||
|
||||
monkeypatch.setattr(settings, "rate_limit", 2, raising=False)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(m.router)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
probe = TestClient(app)
|
||||
|
||||
statuses = [probe.get("/metrics").status_code for _ in range(6)]
|
||||
assert statuses == [200] * 6, statuses
|
||||
11
tradein-mvp/uv.lock
generated
11
tradein-mvp/uv.lock
generated
|
|
@ -1091,6 +1091,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.4"
|
||||
|
|
@ -1685,6 +1694,7 @@ dependencies = [
|
|||
{ name = "matplotlib" },
|
||||
{ name = "pillow" },
|
||||
{ name = "playwright" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
|
|
@ -1721,6 +1731,7 @@ requires-dist = [
|
|||
{ name = "matplotlib", specifier = ">=3.9.0" },
|
||||
{ name = "pillow", specifier = ">=10.3.0" },
|
||||
{ name = "playwright", specifier = ">=1.45" },
|
||||
{ name = "prometheus-client", specifier = ">=0.21.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.3.0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue