pounce/backend/app/api/telemetry.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

366 lines
12 KiB
Python

"""
Telemetry KPIs (4A.2).
Admin-only endpoint to compute funnel KPIs from telemetry_events.
"""
from __future__ import annotations
import json
import statistics
from datetime import datetime, timedelta
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.models.telemetry import TelemetryEvent
from app.models.user import User
from app.schemas.referrals import ReferralKpiWindow, ReferralKpisResponse, ReferralReferrerRow
from app.schemas.telemetry import (
DealFunnelKpis,
TelemetryKpiWindow,
TelemetryKpisResponse,
YieldFunnelKpis,
)
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
def _require_admin(user: User) -> None:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
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 {}
def _median(values: list[float]) -> Optional[float]:
if not values:
return None
return float(statistics.median(values))
@router.get("/kpis", response_model=TelemetryKpisResponse)
async def get_kpis(
days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
end = datetime.utcnow()
start = end - timedelta(days=days)
event_names = [
# Deal funnel
"listing_view",
"inquiry_created",
"inquiry_status_changed",
"message_sent",
"listing_marked_sold",
# Yield funnel
"yield_connected",
"yield_click",
"yield_conversion",
"payout_paid",
]
rows = (
await db.execute(
select(
TelemetryEvent.event_name,
TelemetryEvent.created_at,
TelemetryEvent.listing_id,
TelemetryEvent.inquiry_id,
TelemetryEvent.yield_domain_id,
TelemetryEvent.click_id,
TelemetryEvent.metadata_json,
).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name.in_(event_names),
)
)
)
).all()
# -----------------------------
# Deal KPIs
# -----------------------------
listing_views = 0
inquiries_created = 0
inquiry_created_at: dict[int, datetime] = {}
first_seller_reply_at: dict[int, datetime] = {}
listings_with_inquiries: set[int] = set()
sold_listings: set[int] = set()
sold_at_by_listing: dict[int, datetime] = {}
first_inquiry_at_by_listing: dict[int, datetime] = {}
# -----------------------------
# Yield KPIs
# -----------------------------
connected_domains = 0
clicks = 0
conversions = 0
payouts_paid = 0
payouts_paid_amount_total = 0.0
for event_name, created_at, listing_id, inquiry_id, yield_domain_id, click_id, metadata_json in rows:
created_at = created_at # already datetime
if event_name == "listing_view":
listing_views += 1
continue
if event_name == "inquiry_created":
inquiries_created += 1
if inquiry_id:
inquiry_created_at[inquiry_id] = created_at
if listing_id:
listings_with_inquiries.add(listing_id)
prev = first_inquiry_at_by_listing.get(listing_id)
if prev is None or created_at < prev:
first_inquiry_at_by_listing[listing_id] = created_at
continue
if event_name == "message_sent":
if not inquiry_id:
continue
meta = _safe_json(metadata_json)
if meta.get("role") == "seller":
prev = first_seller_reply_at.get(inquiry_id)
if prev is None or created_at < prev:
first_seller_reply_at[inquiry_id] = created_at
continue
if event_name == "listing_marked_sold":
if listing_id:
sold_listings.add(listing_id)
sold_at_by_listing[listing_id] = created_at
continue
if event_name == "yield_connected":
connected_domains += 1
continue
if event_name == "yield_click":
clicks += 1
continue
if event_name == "yield_conversion":
conversions += 1
continue
if event_name == "payout_paid":
payouts_paid += 1
meta = _safe_json(metadata_json)
amount = meta.get("amount")
if isinstance(amount, (int, float)):
payouts_paid_amount_total += float(amount)
continue
seller_replied_inquiries = len(first_seller_reply_at.keys())
inquiry_reply_rate = (seller_replied_inquiries / inquiries_created) if inquiries_created else 0.0
# Inquiry → Sold rate (on listing-level intersection)
sold_from_inquiry = sold_listings.intersection(listings_with_inquiries)
inquiry_to_sold_listing_rate = (len(sold_from_inquiry) / len(listings_with_inquiries)) if listings_with_inquiries else 0.0
# Median reply time (seconds): inquiry_created → first seller message
reply_deltas: list[float] = []
for inq_id, created in inquiry_created_at.items():
replied = first_seller_reply_at.get(inq_id)
if replied:
reply_deltas.append((replied - created).total_seconds())
# Median time-to-sold (seconds): first inquiry on listing → listing sold
sold_deltas: list[float] = []
for listing in sold_from_inquiry:
inq_at = first_inquiry_at_by_listing.get(listing)
sold_at = sold_at_by_listing.get(listing)
if inq_at and sold_at and sold_at >= inq_at:
sold_deltas.append((sold_at - inq_at).total_seconds())
deal = DealFunnelKpis(
listing_views=listing_views,
inquiries_created=inquiries_created,
seller_replied_inquiries=seller_replied_inquiries,
inquiry_reply_rate=float(inquiry_reply_rate),
listings_with_inquiries=len(listings_with_inquiries),
listings_sold=len(sold_listings),
inquiry_to_sold_listing_rate=float(inquiry_to_sold_listing_rate),
median_reply_seconds=_median(reply_deltas),
median_time_to_sold_seconds=_median(sold_deltas),
)
yield_kpis = YieldFunnelKpis(
connected_domains=connected_domains,
clicks=clicks,
conversions=conversions,
conversion_rate=float(conversions / clicks) if clicks else 0.0,
payouts_paid=payouts_paid,
payouts_paid_amount_total=float(payouts_paid_amount_total),
)
return TelemetryKpisResponse(
window=TelemetryKpiWindow(days=days, start=start, end=end),
deal=deal,
yield_=yield_kpis,
)
@router.get("/referrals", response_model=ReferralKpisResponse)
async def get_referral_kpis(
days: int = Query(30, ge=1, le=365),
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Admin-only referral KPIs for the viral loop (3C.2).
This is intentionally user-based (users.referred_by_user_id) + telemetry-based (referral_link_viewed),
so it stays robust even if ref codes evolve.
"""
_require_admin(current_user)
end = datetime.utcnow()
start = end - timedelta(days=days)
# Referred user counts per referrer (all-time + window)
referred_counts_subq = (
select(
User.referred_by_user_id.label("referrer_user_id"),
func.count(User.id).label("referred_users_total"),
func.coalesce(
func.sum(case((User.created_at >= start, 1), else_=0)),
0,
).label("referred_users_window"),
)
.where(User.referred_by_user_id.isnot(None))
.group_by(User.referred_by_user_id)
.subquery()
)
# Referral link views in window (telemetry)
link_views_subq = (
select(
TelemetryEvent.user_id.label("referrer_user_id"),
func.count(TelemetryEvent.id).label("referral_link_views_window"),
)
.where(
and_(
TelemetryEvent.event_name == "referral_link_viewed",
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.user_id.isnot(None),
)
)
.group_by(TelemetryEvent.user_id)
.subquery()
)
# Referrers: anyone with an invite_code (we still show even if counts are zero)
rows = (
await db.execute(
select(
User.id,
User.email,
User.invite_code,
User.created_at,
func.coalesce(referred_counts_subq.c.referred_users_total, 0),
func.coalesce(referred_counts_subq.c.referred_users_window, 0),
func.coalesce(link_views_subq.c.referral_link_views_window, 0),
)
.where(User.invite_code.isnot(None))
.outerjoin(referred_counts_subq, referred_counts_subq.c.referrer_user_id == User.id)
.outerjoin(link_views_subq, link_views_subq.c.referrer_user_id == User.id)
.order_by(
func.coalesce(referred_counts_subq.c.referred_users_window, 0).desc(),
func.coalesce(referred_counts_subq.c.referred_users_total, 0).desc(),
User.created_at.desc(),
)
.offset(offset)
.limit(limit)
)
).all()
referrers = [
ReferralReferrerRow(
user_id=int(user_id),
email=str(email),
invite_code=str(invite_code) if invite_code else None,
created_at=created_at,
referred_users_total=int(referred_total or 0),
referred_users_window=int(referred_window or 0),
referral_link_views_window=int(link_views or 0),
)
for user_id, email, invite_code, created_at, referred_total, referred_window, link_views in rows
]
totals = {}
totals["referrers_with_invite_code"] = int(
(
await db.execute(
select(func.count(User.id)).where(User.invite_code.isnot(None))
)
).scalar()
or 0
)
totals["referred_users_total"] = int(
(
await db.execute(
select(func.count(User.id)).where(User.referred_by_user_id.isnot(None))
)
).scalar()
or 0
)
totals["referred_users_window"] = int(
(
await db.execute(
select(func.count(User.id)).where(
and_(
User.referred_by_user_id.isnot(None),
User.created_at >= start,
User.created_at <= end,
)
)
)
).scalar()
or 0
)
totals["referral_link_views_window"] = int(
(
await db.execute(
select(func.count(TelemetryEvent.id)).where(
and_(
TelemetryEvent.event_name == "referral_link_viewed",
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
)
)
)
).scalar()
or 0
)
return ReferralKpisResponse(
window=ReferralKpiWindow(days=days, start=start, end=end),
totals=totals,
referrers=referrers,
)