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
139 lines
6.5 KiB
Python
139 lines
6.5 KiB
Python
"""Слой метрик: метка маршрута не должна взрывать кардинальность (#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
|