""" 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)