Продуктовые счётчики и дашборд воронки #3491
11 changed files with 371 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.observability.metrics import REPORTS_EXPORTED
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
AnalysisRunDetail,
|
AnalysisRunDetail,
|
||||||
AnalysisRunListResponse,
|
AnalysisRunListResponse,
|
||||||
|
|
@ -1612,6 +1613,11 @@ def export_parcel_forecast(
|
||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
||||||
|
|
||||||
|
# #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано
|
||||||
|
# (до самого рендера), зато один раз на весь запрос и без риска разъехаться
|
||||||
|
# с новой веткой формата, если её когда-нибудь добавят.
|
||||||
|
REPORTS_EXPORTED.labels(format=format).inc()
|
||||||
|
|
||||||
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
||||||
if format == "tg":
|
if format == "tg":
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -4911,6 +4917,7 @@ async def get_parcel_best_layouts_pdf(
|
||||||
today = _dt.date.today().strftime("%Y-%m-%d")
|
today = _dt.date.today().strftime("%Y-%m-%d")
|
||||||
cad_safe = cad_num.replace(":", "-")
|
cad_safe = cad_num.replace(":", "-")
|
||||||
filename = f"tz-layout-{cad_safe}-{today}.pdf"
|
filename = f"tz-layout-{cad_safe}-{today}.pdf"
|
||||||
|
REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
|
||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,19 @@ BUILD_INFO.labels(
|
||||||
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
|
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
|
||||||
).set(1)
|
).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:
|
def route_label(scope: Scope) -> str:
|
||||||
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
||||||
|
|
|
||||||
36
backend/tests/test_3471_product_metrics.py
Normal file
36
backend/tests/test_3471_product_metrics.py
Normal file
|
|
@ -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
|
||||||
161
ops/metrics/grafana/dashboards/product.json
Normal file
161
ops/metrics/grafana/dashboards/product.json
Normal file
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,7 @@ from app.core.password import (
|
||||||
verify_slots_saturated,
|
verify_slots_saturated,
|
||||||
)
|
)
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
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.auth_session import create_session, get_user_by_username, revoke_session
|
||||||
from app.services.identity_store import AccessState, get_identity_db
|
from app.services.identity_store import AccessState, get_identity_db
|
||||||
from app.services.user_events import schedule_event
|
from app.services.user_events import schedule_event
|
||||||
|
|
@ -324,6 +325,7 @@ async def _reject_invalid_credentials(
|
||||||
fails = _USERNAME_FAIL_LIMITER.record(username)
|
fails = _USERNAME_FAIL_LIMITER.record(username)
|
||||||
delay_s = _throttle_delay_s(fails)
|
delay_s = _throttle_delay_s(fails)
|
||||||
|
|
||||||
|
LOGINS.labels(result="failed").inc()
|
||||||
schedule_event(
|
schedule_event(
|
||||||
event_type="login_failed",
|
event_type="login_failed",
|
||||||
username=username,
|
username=username,
|
||||||
|
|
@ -452,6 +454,7 @@ async def login(
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LOGINS.labels(result="success").inc()
|
||||||
schedule_event(
|
schedule_event(
|
||||||
event_type="login_success",
|
event_type="login_success",
|
||||||
username=user["username"],
|
username=user["username"],
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db, run_db_thread
|
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.estimator import _lookup_house_facts
|
||||||
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||||||
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
|
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
|
||||||
|
|
@ -132,6 +133,7 @@ async def suggest_addresses(
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
|
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
|
||||||
return SuggestResponse(
|
return SuggestResponse(
|
||||||
items=[
|
items=[
|
||||||
SuggestItem(
|
SuggestItem(
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.api.v1.trade_in import _assert_estimate_access
|
from app.api.v1.trade_in import _assert_estimate_access
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.observability.metrics import LEADS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -173,6 +174,7 @@ async def create_trade_in_lead(
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
LEADS.inc()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
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 import web_support_storage as storage
|
||||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||||
from app.services.tgbot.client import TelegramError
|
from app.services.tgbot.client import TelegramError
|
||||||
|
|
@ -406,6 +407,7 @@ async def send_support_message(
|
||||||
|
|
||||||
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||||
_send_limiter.record(username)
|
_send_limiter.record(username)
|
||||||
|
SUPPORT_MESSAGES.labels(channel="web").inc()
|
||||||
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
|
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
|
||||||
_send_failure_limiter.reset(username)
|
_send_failure_limiter.reset(username)
|
||||||
|
|
||||||
|
|
@ -631,6 +633,7 @@ async def send_anon_support_message(
|
||||||
|
|
||||||
_send_limiter.record(thread_key)
|
_send_limiter.record(thread_key)
|
||||||
_anon_ip_limiter.record(ip)
|
_anon_ip_limiter.record(ip)
|
||||||
|
SUPPORT_MESSAGES.labels(channel="anon").inc()
|
||||||
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
|
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
|
||||||
_send_failure_limiter.reset(thread_key)
|
_send_failure_limiter.reset(thread_key)
|
||||||
_anon_ip_failure_limiter.reset(ip)
|
_anon_ip_failure_limiter.reset(ip)
|
||||||
|
|
|
||||||
|
|
@ -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.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||||
|
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
|
|
@ -560,6 +561,13 @@ async def estimate(
|
||||||
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
|
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
|
||||||
# сериализация ответа ниже дёшевы и слот держать не должны.
|
# сериализация ответа ниже дёшевы и слот держать не должны.
|
||||||
_estimate_slots.release()
|
_estimate_slots.release()
|
||||||
|
|
||||||
|
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
|
||||||
|
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
|
||||||
|
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
|
||||||
|
# потому что аналогов не было, это отдельный, а не ошибочный исход.
|
||||||
|
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
|
||||||
|
|
||||||
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
||||||
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
||||||
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
||||||
|
|
@ -1013,6 +1021,7 @@ def estimate_pdf(
|
||||||
brand_obj = _resolve_brand(owner_brand_slug, db)
|
brand_obj = _resolve_brand(owner_brand_slug, db)
|
||||||
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
|
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
|
||||||
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
|
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
|
||||||
|
REPORTS_EXPORTED.inc()
|
||||||
logger.info(
|
logger.info(
|
||||||
"PDF generated estimate_id=%s brand=%s size=%d",
|
"PDF generated estimate_id=%s brand=%s size=%d",
|
||||||
estimate_id,
|
estimate_id,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,47 @@ BUILD_INFO = Gauge(
|
||||||
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
|
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
|
||||||
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
|
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:
|
def route_label(scope: Scope) -> str:
|
||||||
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
||||||
|
|
|
||||||
94
tradein-mvp/backend/tests/test_3471_product_metrics.py
Normal file
94
tradein-mvp/backend/tests/test_3471_product_metrics.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue