""" Lightweight SQLite-based analytics logger. Tracks request counts, IPs, selected parameters, and end-to-end durations. """ import sqlite3 from datetime import datetime from app.config import ( ANALYTICS_DB_PATH, TIMING_DEFAULT_ARCHIVE_SEC, TIMING_DEFAULT_CURRENT_SEC, TIMING_DEFAULT_DISPATCH_ARCHIVE_SEC, TIMING_DEFAULT_DISPATCH_CURRENT_SEC, TIMING_MIN_SAMPLES, ) # Default timing (seconds) keyed by source_type string _TIMING_DEFAULTS: dict[str, int] = { "current": TIMING_DEFAULT_CURRENT_SEC, "archive": TIMING_DEFAULT_ARCHIVE_SEC, "dispatch_current": TIMING_DEFAULT_DISPATCH_CURRENT_SEC, "dispatch_archive": TIMING_DEFAULT_DISPATCH_ARCHIVE_SEC, } def _get_conn() -> sqlite3.Connection: conn = sqlite3.connect(ANALYTICS_DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db() -> None: """Create the analytics table and migrate any missing columns.""" with _get_conn() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, ip TEXT, duid TEXT, date TEXT, action TEXT, duration_ms INTEGER, source_type TEXT ) """) # Migrate pre-existing tables that lack the new columns. existing = {row[1] for row in conn.execute("PRAGMA table_info(requests)")} if "duration_ms" not in existing: conn.execute("ALTER TABLE requests ADD COLUMN duration_ms INTEGER") if "source_type" not in existing: conn.execute("ALTER TABLE requests ADD COLUMN source_type TEXT") conn.commit() def log_request( ip: str, duid: str, date: str, action: str, *, duration_ms: int | None = None, source_type: str | None = None, ) -> None: """Log a single request, optionally with timing and data-source type.""" try: with _get_conn() as conn: conn.execute( """ INSERT INTO requests (timestamp, ip, duid, date, action, duration_ms, source_type) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( datetime.utcnow().isoformat(), ip, duid, date, action, duration_ms, source_type, ), ) conn.commit() except Exception: pass # Never let analytics failures break the main flow def get_timing_estimate(source_type: str) -> dict: """ Return a p75 wait-time estimate in seconds for the given source_type. source_type is one of: 'current', 'archive', 'dispatch_current', 'dispatch_archive'. Derived from the last 100 successful view requests that recorded a duration for this source type. Falls back to the hardcoded default until at least TIMING_MIN_SAMPLES records are available. """ default_sec = _TIMING_DEFAULTS.get(source_type, TIMING_DEFAULT_CURRENT_SEC) try: with _get_conn() as conn: rows = conn.execute( """ SELECT duration_ms FROM requests WHERE source_type = ? AND duration_ms IS NOT NULL AND action = 'view' ORDER BY id DESC LIMIT 100 """, (source_type,), ).fetchall() durations = sorted(row[0] for row in rows) n = len(durations) if n < TIMING_MIN_SAMPLES: return {"seconds": default_sec, "sample_count": n, "is_default": True} p75_idx = int(0.75 * (n - 1)) p75_sec = round(durations[p75_idx] / 1000) return {"seconds": p75_sec, "sample_count": n, "is_default": False} except Exception: return {"seconds": default_sec, "sample_count": 0, "is_default": True} def get_stats() -> dict: """Return summary analytics.""" try: with _get_conn() as conn: total = conn.execute("SELECT COUNT(*) FROM requests").fetchone()[0] by_action = conn.execute( "SELECT action, COUNT(*) as cnt FROM requests GROUP BY action ORDER BY cnt DESC" ).fetchall() by_duid = conn.execute( "SELECT duid, COUNT(*) as cnt FROM requests GROUP BY duid ORDER BY cnt DESC LIMIT 20" ).fetchall() by_ip = conn.execute( "SELECT ip, COUNT(*) as cnt FROM requests GROUP BY ip ORDER BY cnt DESC LIMIT 50" ).fetchall() recent = conn.execute( """ SELECT timestamp, ip, duid, date, action, duration_ms, source_type FROM requests ORDER BY id DESC LIMIT 100 """ ).fetchall() return { "total_requests": total, "by_action": [dict(r) for r in by_action], "by_duid": [dict(r) for r in by_duid], "by_ip": [dict(r) for r in by_ip], "recent": [dict(r) for r in recent], } except Exception as e: return {"error": str(e)}