From 690f1ef5d22ec4ee131051a31d26767e1f3e0873 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:22:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(metrics):=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=B8=20Prometheus=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=9C=D0=B5=D1=80=D1=8B=20=D0=B8=20=D0=9F=D1=82=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20+=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец попросил вывести продукт в Графану — до этого там были только технические панели (запросы/латентность/память). Список счётчиков взят из реально пишущихся событий, а не выдуман: Мера (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 --- backend/app/api/v1/parcels.py | 7 + backend/app/observability/metrics.py | 13 ++ backend/tests/test_3471_product_metrics.py | 36 ++++ ops/metrics/grafana/dashboards/product.json | 161 ++++++++++++++++++ tradein-mvp/backend/app/api/v1/auth.py | 3 + tradein-mvp/backend/app/api/v1/geocode.py | 2 + tradein-mvp/backend/app/api/v1/lead.py | 2 + tradein-mvp/backend/app/api/v1/support.py | 3 + tradein-mvp/backend/app/api/v1/trade_in.py | 9 + .../backend/app/observability/metrics.py | 41 +++++ .../tests/test_3471_product_metrics.py | 94 ++++++++++ 11 files changed, 371 insertions(+) create mode 100644 backend/tests/test_3471_product_metrics.py create mode 100644 ops/metrics/grafana/dashboards/product.json create mode 100644 tradein-mvp/backend/tests/test_3471_product_metrics.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 5540e7ee..28eea285 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db +from app.observability.metrics import REPORTS_EXPORTED from app.schemas.parcel import ( AnalysisRunDetail, AnalysisRunListResponse, @@ -1612,6 +1613,11 @@ def export_parcel_forecast( if run is None: raise HTTPException(status_code=404, detail="прогноз ещё не посчитан") + # #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано + # (до самого рендера), зато один раз на весь запрос и без риска разъехаться + # с новой веткой формата, если её когда-нибудь добавят. + REPORTS_EXPORTED.labels(format=format).inc() + # tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment. if format == "tg": return Response( @@ -4911,6 +4917,7 @@ async def get_parcel_best_layouts_pdf( today = _dt.date.today().strftime("%Y-%m-%d") cad_safe = cad_num.replace(":", "-") filename = f"tz-layout-{cad_safe}-{today}.pdf" + REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc() return Response( content=pdf_bytes, media_type="application/pdf", diff --git a/backend/app/observability/metrics.py b/backend/app/observability/metrics.py index d8005281..1e9cf0bf 100644 --- a/backend/app/observability/metrics.py +++ b/backend/app/observability/metrics.py @@ -100,6 +100,19 @@ BUILD_INFO.labels( 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__``. diff --git a/backend/tests/test_3471_product_metrics.py b/backend/tests/test_3471_product_metrics.py new file mode 100644 index 00000000..ee028ac2 --- /dev/null +++ b/backend/tests/test_3471_product_metrics.py @@ -0,0 +1,36 @@ +"""Продуктовый счётчик экспорта отчётов (#3471): выгрузок §22-форсайта / ТЗ. + +Мера кардинальности та же, что в `test_metrics.py`: единственный лейбл — +`format`, фиксированный литерал из `Literal[...]` сигнатуры эндпоинта +(`export_parcel_forecast`) плюс одно статичное значение `best_layouts_pdf` +(ТЗ на проектирование) — не кадастровый номер и не идентификатор пользователя. +""" + +from __future__ import annotations + +from prometheus_client import REGISTRY, generate_latest + +from app.observability import metrics as m + + +def test_reports_exported_counter_has_bounded_format_label() -> None: + assert tuple(m.REPORTS_EXPORTED._labelnames) == ("format",) # type: ignore[attr-defined] + + +def test_reports_exported_counter_increments_per_format() -> None: + def _value(fmt: str) -> float: + return ( + REGISTRY.get_sample_value("sitefinder_reports_exported_total", {"format": fmt}) or 0.0 + ) + + before_pdf = _value("pdf") + before_layouts = _value("best_layouts_pdf") + + m.REPORTS_EXPORTED.labels(format="pdf").inc() + m.REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc() + + assert _value("pdf") - before_pdf == 1.0 + assert _value("best_layouts_pdf") - before_layouts == 1.0 + + body = generate_latest(REGISTRY).decode() + assert "sitefinder_reports_exported_total" in body diff --git a/ops/metrics/grafana/dashboards/product.json b/ops/metrics/grafana/dashboards/product.json new file mode 100644 index 00000000..025cd467 --- /dev/null +++ b/ops/metrics/grafana/dashboards/product.json @@ -0,0 +1,161 @@ +{ + "uid": "gendesign-product", + "title": "Продуктовые метрики", + "description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.", + "tags": ["gendesign", "product"], + "timezone": "browser", + "editable": false, + "schemaVersion": 39, + "refresh": "5m", + "time": { "from": "now-24h", "to": "now" }, + "panels": [ + { "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } }, + + { + "type": "timeseries", + "title": "Оценок в час, по исходу", + "description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (outcome) (increase(mera_estimates_total[1h]))", + "legendFormat": "{{outcome}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }, + { "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Подсказки адреса в час, нашёлся ли результат", + "description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))", + "legendFormat": "найден: {{found}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Лиды и скачанные отчёты в час", + "description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 }, + "targets": [ + { "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" }, + { "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [] + } + }, + + { + "type": "timeseries", + "title": "Входы в час, по исходу", + "description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (result) (increase(mera_logins_total[1h]))", + "legendFormat": "{{result}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }, + { "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Обращения в поддержку в час, по каналу", + "description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (channel) (increase(mera_support_messages_total[1h]))", + "legendFormat": "{{channel}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] } + ] + } + }, + + { "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } }, + + { + "type": "timeseries", + "title": "Экспортов отчётов по участку в час, по формату", + "description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))", + "legendFormat": "{{format}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } + }, + "overrides": [] + } + } + ] +} diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index af346cde..7c1d63d6 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -73,6 +73,7 @@ from app.core.password import ( verify_slots_saturated, ) from app.core.ratelimit import SlidingWindowLimiter, _client_ip +from app.observability.metrics import LOGINS from app.services.auth_session import create_session, get_user_by_username, revoke_session from app.services.identity_store import AccessState, get_identity_db from app.services.user_events import schedule_event @@ -324,6 +325,7 @@ async def _reject_invalid_credentials( fails = _USERNAME_FAIL_LIMITER.record(username) delay_s = _throttle_delay_s(fails) + LOGINS.labels(result="failed").inc() schedule_event( event_type="login_failed", username=username, @@ -452,6 +454,7 @@ async def login( path="/", ) + LOGINS.labels(result="success").inc() schedule_event( event_type="login_success", username=user["username"], diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index 3d3c4e98..de1bd07a 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -11,6 +11,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db, run_db_thread +from app.observability.metrics import ADDRESS_SUGGESTIONS from app.services.estimator import _lookup_house_facts from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest from app.services.regions import DEFAULT_REGION_CODE, region_by_city @@ -132,6 +133,7 @@ async def suggest_addresses( except ValueError as exc: # Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента. raise HTTPException(status_code=422, detail=str(exc)) from exc + ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc() return SuggestResponse( items=[ SuggestItem( diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 653fd141..f4738c45 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -33,6 +33,7 @@ from sqlalchemy.orm import Session from app.api.v1.trade_in import _assert_estimate_access from app.core.config import settings from app.core.db import get_db +from app.observability.metrics import LEADS logger = logging.getLogger(__name__) @@ -173,6 +174,7 @@ async def create_trade_in_lead( ) db.commit() + LEADS.inc() logger.info( "trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s", diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py index 555b5cce..bdaf5268 100644 --- a/tradein-mvp/backend/app/api/v1/support.py +++ b/tradein-mvp/backend/app/api/v1/support.py @@ -73,6 +73,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db from app.core.ratelimit import SlidingWindowLimiter, _client_ip +from app.observability.metrics import SUPPORT_MESSAGES from app.services.tgbot import web_support_storage as storage from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT from app.services.tgbot.client import TelegramError @@ -406,6 +407,7 @@ async def send_support_message( # Отправка удалась — теперь и только теперь расходуем rate-limit бюджет. _send_limiter.record(username) + SUPPORT_MESSAGES.labels(channel="web").inc() # Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы. _send_failure_limiter.reset(username) @@ -631,6 +633,7 @@ async def send_anon_support_message( _send_limiter.record(thread_key) _anon_ip_limiter.record(ip) + SUPPORT_MESSAGES.labels(channel="anon").inc() # Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы. _send_failure_limiter.reset(thread_key) _anon_ip_failure_limiter.reset(ip) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 56db97ae..e011040a 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -22,6 +22,7 @@ from app.core.anon_session import get_or_create_anon_session_id from app.core.config import settings from app.core.db import get_db from app.core.ratelimit import SlidingWindowLimiter, _client_ip +from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, @@ -560,6 +561,13 @@ async def estimate( # #3082: слот возвращаем сразу после дорогой части — инкремент квоты и # сериализация ответа ниже дёшевы и слот держать не должны. _estimate_slots.release() + + # #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429 + # ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data — + # тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а + # потому что аналогов не было, это отдельный, а не ошибочный исход. + ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc() + # #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise # выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение # тут: при гонке двух /estimate на used=lim-1 второй получит False. @@ -1013,6 +1021,7 @@ def estimate_pdf( brand_obj = _resolve_brand(owner_brand_slug, db) pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj) filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf" + REPORTS_EXPORTED.inc() logger.info( "PDF generated estimate_id=%s brand=%s size=%d", estimate_id, diff --git a/tradein-mvp/backend/app/observability/metrics.py b/tradein-mvp/backend/app/observability/metrics.py index e9343d4d..bb144d5b 100644 --- a/tradein-mvp/backend/app/observability/metrics.py +++ b/tradein-mvp/backend/app/observability/metrics.py @@ -99,6 +99,47 @@ BUILD_INFO = Gauge( # смысл метки в том, чтобы «что было задеплоено в 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__``. diff --git a/tradein-mvp/backend/tests/test_3471_product_metrics.py b/tradein-mvp/backend/tests/test_3471_product_metrics.py new file mode 100644 index 00000000..d9efaf85 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3471_product_metrics.py @@ -0,0 +1,94 @@ +"""Продуктовые счётчики (#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