File size: 5,285 Bytes
fd0bd3b
 
e6a4103
fd0bd3b
 
 
 
e6a4103
 
 
 
2f1129f
 
e6a4103
 
fd0bd3b
2f1129f
 
 
 
 
 
 
 
fd0bd3b
 
 
 
 
 
 
 
e6a4103
fd0bd3b
 
 
e6a4103
 
 
 
 
 
 
 
fd0bd3b
 
e6a4103
 
 
 
 
 
fd0bd3b
 
 
e6a4103
 
 
 
 
 
 
 
 
 
fd0bd3b
 
 
e6a4103
 
 
 
 
2f1129f
 
 
 
fd0bd3b
 
 
 
 
 
e6a4103
 
2f1129f
 
 
 
e6a4103
2f1129f
 
 
e6a4103
2f1129f
e6a4103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd0bd3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6a4103
 
 
 
fd0bd3b
 
 
 
2f1129f
 
 
 
fd0bd3b
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""
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)}