pounce/backend/app/observability/business_metrics.py
Yves Gugger bb7ce97330
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Deploy: referral rewards antifraud + legal contact updates
2025-12-15 13:56:43 +01:00

305 lines
12 KiB
Python

"""
Business KPIs exported as Prometheus metrics (4B Ops).
These KPIs are derived from real telemetry events in the database.
We cache computations to avoid putting load on the DB on every scrape.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Optional
from sqlalchemy import and_, func, select
from app.config import get_settings
from app.database import AsyncSessionLocal
from app.models.telemetry import TelemetryEvent
settings = get_settings()
try:
from prometheus_client import Gauge
except Exception: # pragma: no cover
Gauge = None # type: ignore
@dataclass(frozen=True)
class TelemetryWindowKpis:
window_days: int
start: datetime
end: datetime
# Deal
listing_views: int
inquiries_created: int
seller_replied_inquiries: int
inquiry_reply_rate: float
listings_with_inquiries: int
listings_sold: int
inquiry_to_sold_listing_rate: float
# Yield
connected_domains: int
clicks: int
conversions: int
conversion_rate: float
payouts_paid: int
payouts_paid_amount_total: float
_cache_until_by_days: dict[int, datetime] = {}
_cache_value_by_days: dict[int, TelemetryWindowKpis] = {}
def _safe_json(metadata_json: Optional[str]) -> dict[str, Any]:
if not metadata_json:
return {}
try:
value = json.loads(metadata_json)
return value if isinstance(value, dict) else {}
except Exception:
return {}
async def _compute_window_kpis(days: int) -> TelemetryWindowKpis:
end = datetime.utcnow()
start = end - timedelta(days=days)
async with AsyncSessionLocal() as db:
# Fast path: grouped counts for pure counter events
count_events = [
"listing_view",
"inquiry_created",
"yield_connected",
"yield_click",
"yield_conversion",
"payout_paid",
]
grouped = (
await db.execute(
select(TelemetryEvent.event_name, func.count(TelemetryEvent.id))
.where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name.in_(count_events),
)
)
.group_by(TelemetryEvent.event_name)
)
).all()
counts = {name: int(cnt) for name, cnt in grouped}
listing_views = counts.get("listing_view", 0)
inquiries_created = counts.get("inquiry_created", 0)
connected_domains = counts.get("yield_connected", 0)
clicks = counts.get("yield_click", 0)
conversions = counts.get("yield_conversion", 0)
payouts_paid = counts.get("payout_paid", 0)
# Distinct listing counts (deal)
listings_with_inquiries = (
await db.execute(
select(func.count(func.distinct(TelemetryEvent.listing_id))).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "inquiry_created",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalar() or 0
listings_sold = (
await db.execute(
select(func.count(func.distinct(TelemetryEvent.listing_id))).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "listing_marked_sold",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalar() or 0
# For rates we need intersections/uniques; keep it exact via minimal event fetch
inquiry_listing_ids = (
await db.execute(
select(func.distinct(TelemetryEvent.listing_id)).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "inquiry_created",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalars().all()
sold_listing_ids = (
await db.execute(
select(func.distinct(TelemetryEvent.listing_id)).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "listing_marked_sold",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalars().all()
inquiry_set = {int(x) for x in inquiry_listing_ids if x is not None}
sold_set = {int(x) for x in sold_listing_ids if x is not None}
sold_from_inquiry = inquiry_set.intersection(sold_set)
inquiry_to_sold_listing_rate = (len(sold_from_inquiry) / len(inquiry_set)) if inquiry_set else 0.0
# Seller reply rate: unique inquiries with at least one seller message
msg_rows = (
await db.execute(
select(TelemetryEvent.inquiry_id, TelemetryEvent.metadata_json).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "message_sent",
TelemetryEvent.inquiry_id.isnot(None),
)
)
)
).all()
seller_replied_inquiries_set: set[int] = set()
for inquiry_id, metadata_json in msg_rows:
if inquiry_id is None:
continue
meta = _safe_json(metadata_json)
if meta.get("role") == "seller":
seller_replied_inquiries_set.add(int(inquiry_id))
seller_replied_inquiries = len(seller_replied_inquiries_set)
inquiry_reply_rate = (seller_replied_inquiries / inquiries_created) if inquiries_created else 0.0
# Payout amounts (sum of metadata amounts)
payout_rows = (
await db.execute(
select(TelemetryEvent.metadata_json).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "payout_paid",
TelemetryEvent.metadata_json.isnot(None),
)
)
)
).scalars().all()
payouts_paid_amount_total = 0.0
for metadata_json in payout_rows:
meta = _safe_json(metadata_json)
amount = meta.get("amount")
if isinstance(amount, (int, float)):
payouts_paid_amount_total += float(amount)
conversion_rate = (conversions / clicks) if clicks else 0.0
return TelemetryWindowKpis(
window_days=days,
start=start,
end=end,
listing_views=int(listing_views),
inquiries_created=int(inquiries_created),
seller_replied_inquiries=int(seller_replied_inquiries),
inquiry_reply_rate=float(inquiry_reply_rate),
listings_with_inquiries=int(listings_with_inquiries),
listings_sold=int(listings_sold),
inquiry_to_sold_listing_rate=float(inquiry_to_sold_listing_rate),
connected_domains=int(connected_domains),
clicks=int(clicks),
conversions=int(conversions),
conversion_rate=float(conversion_rate),
payouts_paid=int(payouts_paid),
payouts_paid_amount_total=float(payouts_paid_amount_total),
)
async def get_cached_window_kpis(days: int) -> Optional[TelemetryWindowKpis]:
"""Return cached KPIs for a window (recompute if TTL expired)."""
if not settings.enable_business_metrics:
return None
now = datetime.utcnow()
until = _cache_until_by_days.get(days)
cached = _cache_value_by_days.get(days)
if until is not None and cached is not None and now < until:
return cached
value = await _compute_window_kpis(int(days))
ttl_seconds = max(5, int(settings.business_metrics_cache_seconds))
_cache_until_by_days[int(days)] = now + timedelta(seconds=ttl_seconds)
_cache_value_by_days[int(days)] = value
return value
# -----------------------------
# Prometheus Gauges
# -----------------------------
if Gauge is not None:
_g = {
"deal_listing_views": Gauge("pounce_deal_listing_views", "Deal: listing views in window", ["window_days"]),
"deal_inquiries_created": Gauge("pounce_deal_inquiries_created", "Deal: inquiries created in window", ["window_days"]),
"deal_seller_replied_inquiries": Gauge(
"pounce_deal_seller_replied_inquiries", "Deal: inquiries with seller reply in window", ["window_days"]
),
"deal_inquiry_reply_rate": Gauge("pounce_deal_inquiry_reply_rate", "Deal: inquiry reply rate in window", ["window_days"]),
"deal_listings_with_inquiries": Gauge(
"pounce_deal_listings_with_inquiries", "Deal: distinct listings with inquiries in window", ["window_days"]
),
"deal_listings_sold": Gauge("pounce_deal_listings_sold", "Deal: distinct listings marked sold in window", ["window_days"]),
"deal_inquiry_to_sold_listing_rate": Gauge(
"pounce_deal_inquiry_to_sold_listing_rate", "Deal: (listings with inquiry) -> sold rate in window", ["window_days"]
),
"yield_connected_domains": Gauge("pounce_yield_connected_domains", "Yield: connected domains in window", ["window_days"]),
"yield_clicks": Gauge("pounce_yield_clicks", "Yield: clicks in window", ["window_days"]),
"yield_conversions": Gauge("pounce_yield_conversions", "Yield: conversions in window", ["window_days"]),
"yield_conversion_rate": Gauge("pounce_yield_conversion_rate", "Yield: conversion rate in window", ["window_days"]),
"yield_payouts_paid": Gauge("pounce_yield_payouts_paid", "Yield: payouts paid in window", ["window_days"]),
"yield_payouts_paid_amount_total": Gauge(
"pounce_yield_payouts_paid_amount_total", "Yield: total amount paid out in window", ["window_days"]
),
}
else: # pragma: no cover
_g = {}
async def update_prometheus_business_metrics() -> None:
"""Compute KPIs and set Prometheus gauges (no-op when disabled)."""
if Gauge is None or not _g:
return
if not settings.enable_business_metrics:
return
windows = {1, int(settings.business_metrics_days)}
for days in sorted(windows):
kpis = await get_cached_window_kpis(days)
if kpis is None:
continue
w = str(int(kpis.window_days))
_g["deal_listing_views"].labels(window_days=w).set(kpis.listing_views)
_g["deal_inquiries_created"].labels(window_days=w).set(kpis.inquiries_created)
_g["deal_seller_replied_inquiries"].labels(window_days=w).set(kpis.seller_replied_inquiries)
_g["deal_inquiry_reply_rate"].labels(window_days=w).set(kpis.inquiry_reply_rate)
_g["deal_listings_with_inquiries"].labels(window_days=w).set(kpis.listings_with_inquiries)
_g["deal_listings_sold"].labels(window_days=w).set(kpis.listings_sold)
_g["deal_inquiry_to_sold_listing_rate"].labels(window_days=w).set(kpis.inquiry_to_sold_listing_rate)
_g["yield_connected_domains"].labels(window_days=w).set(kpis.connected_domains)
_g["yield_clicks"].labels(window_days=w).set(kpis.clicks)
_g["yield_conversions"].labels(window_days=w).set(kpis.conversions)
_g["yield_conversion_rate"].labels(window_days=w).set(kpis.conversion_rate)
_g["yield_payouts_paid"].labels(window_days=w).set(kpis.payouts_paid)
_g["yield_payouts_paid_amount_total"].labels(window_days=w).set(kpis.payouts_paid_amount_total)