gendesign/tradein-mvp/backend/tests/test_3471_product_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

94 lines
4.5 KiB
Python
Raw Permalink 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.

"""Продуктовые счётчики (#3471): числа бизнеса рядом с техническими метриками.
Проверяется то же, ради чего вообще заведён `test_metrics.py` — не «метрика
существует», а что она не может взорвать кардинальность. Источник счётчиков —
реальные `event_type` из `user_events` (миграция 184) плюс две ручки без
собственного audit-события (suggest, PDF). Метки везде — фиксированный литерал
из места вызова (outcome/found/channel/result), никогда значение из запроса
(username, адрес, estimate_id), поэтому тест фиксирует именно НАБОР меток, а не
факт роста счётчика на единицу — рост уже проверен паттерном `test_metrics.py`.
"""
from __future__ import annotations
from prometheus_client import REGISTRY, generate_latest
from app.observability import metrics as m
def _labelnames(counter: object) -> tuple[str, ...]:
return tuple(counter._labelnames) # type: ignore[attr-defined]
def test_estimates_counter_has_bounded_outcome_label() -> None:
"""Только исход расчёта, никогда адрес/estimate_id — те дали бы ряд на заявку."""
assert _labelnames(m.ESTIMATES) == ("outcome",)
def test_address_suggestions_counter_has_bounded_found_label() -> None:
"""found — да/нет, не сам адрес и не количество результатов (unbounded)."""
assert _labelnames(m.ADDRESS_SUGGESTIONS) == ("found",)
def test_reports_exported_counter_has_no_labels() -> None:
"""У «Меры» один формат отчёта (PDF оценки) — без лейбла, дублировать нечего."""
assert _labelnames(m.REPORTS_EXPORTED) == ()
def test_leads_counter_has_no_labels() -> None:
assert _labelnames(m.LEADS) == ()
def test_support_messages_counter_has_bounded_channel_label() -> None:
"""channel — ровно два значения (web/anon), не thread_id и не username."""
assert _labelnames(m.SUPPORT_MESSAGES) == ("channel",)
def test_logins_counter_has_bounded_result_label() -> None:
"""result — исход попытки, не username (иначе ряд на каждый аккаунт)."""
assert _labelnames(m.LOGINS) == ("result",)
def test_product_counters_survive_a_realistic_sequence() -> None:
"""Инкременты по реальным меткам видны в экспозиции и не мешают друг другу.
Значения сравниваются приращением: реестр `prometheus_client` глобален на
процесс, и абсолютное число зависит от порядка запуска тестов.
"""
def _value(name: str, labels: dict[str, str]) -> float:
return REGISTRY.get_sample_value(name, labels) or 0.0
before_ok = _value("mera_estimates_total", {"outcome": "ok"})
before_insufficient = _value("mera_estimates_total", {"outcome": "insufficient_data"})
before_found = _value("mera_address_suggestions_total", {"found": "yes"})
before_web = _value("mera_support_messages_total", {"channel": "web"})
before_login_ok = _value("mera_logins_total", {"result": "success"})
m.ESTIMATES.labels(outcome="ok").inc()
m.ESTIMATES.labels(outcome="insufficient_data").inc()
m.ADDRESS_SUGGESTIONS.labels(found="yes").inc()
m.SUPPORT_MESSAGES.labels(channel="web").inc()
m.LOGINS.labels(result="success").inc()
m.LEADS.inc()
m.REPORTS_EXPORTED.inc()
assert _value("mera_estimates_total", {"outcome": "ok"}) - before_ok == 1.0
assert (
_value("mera_estimates_total", {"outcome": "insufficient_data"}) - before_insufficient
== 1.0
)
assert _value("mera_address_suggestions_total", {"found": "yes"}) - before_found == 1.0
assert _value("mera_support_messages_total", {"channel": "web"}) - before_web == 1.0
assert _value("mera_logins_total", {"result": "success"}) - before_login_ok == 1.0
body = generate_latest(REGISTRY).decode()
for metric in (
"mera_estimates_total",
"mera_address_suggestions_total",
"mera_reports_exported_total",
"mera_leads_total",
"mera_support_messages_total",
"mera_logins_total",
):
assert metric in body