"""How well /t sessions went: the page's own report, logged and judged. The page decides every half second which problem, if any, the visitor sees (static/transcribe2/index.html, `qualityNow`), and adds up the seconds spent in each. It sends that tally on page hide and every 5 minutes; each report is the whole visit so far, so the last one per `sid` is the visit. Nothing said is in a report: no text, no audio. The client is the same keyed hash the usage log uses (stt_budget.client_id), never an address. """ import json import logging import threading from collections import Counter, defaultdict from datetime import datetime, timezone from pathlib import Path from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StringConstraints LOG_FILE = Path("data") / "stt_sessions.jsonl" # What qualityNow can show; "ok" is time with nothing shown. Code = Literal["ok", "connect", "off", "mic", "stuck", "slow", "noise", "far", "lang", "music"] RED = ("off", "mic", "stuck") # nothing is transcribed, or speech is being lost AMBER = ("slow", "noise", "far", "lang") # text comes, but too late or unreliable # Histogram edges, as the page bins them (LAG_EDGES, CONF_EDGES); the last bin is above LAG_EDGES_MS = (500, 1000, 2000, 3000, 5000, 10000) CONF_EDGES = (0.5, 0.6, 0.7, 0.75, 0.8, 0.9) # Verdict cutoffs. Assumption, not measured: a visit that spent over a fifth # of its time in a state is described by it. Tune once real visits are in. SHARE = 0.2 MIN_ACTIVE_S = 15.0 # shorter visits are page checks, kept out of the rates… # …unless a problem showed this long (3× the page's 1 s hold, Q_HOLD_MS): a visitor # who leaves over slow or missing text is what the rates are for. PROBLEM_MIN_S = 3.0 FIRST_CONNECT_S = 10.0 # the page gives a first connect this long (connectWs) _YEAR_S = 366 * 24 * 3600.0 # a sanity bound: an always-on tab must not hit it Seconds = Annotated[float, Field(ge=0, le=_YEAR_S)] Count = Annotated[int, Field(ge=0, le=1_000_000)] Short = Annotated[str, StringConstraints(max_length=40)] _log = logging.getLogger(__name__) _write_lock = threading.Lock() class SessionReport(BaseModel): """One /t page visit so far, as the page reports it.""" model_config = ConfigDict(extra="forbid") sid: Annotated[str, StringConstraints(pattern=r"^[a-z0-9]{8,32}$")] secs: dict[Code, Seconds] = Field(max_length=10) episodes: dict[Code, Count] = Field(max_length=10) off_why: dict[Short, Seconds] = Field(default_factory=dict, max_length=12) words: Count = 0 conf: Annotated[float, Field(ge=0, le=1)] | None = None start_ms: Annotated[int, Field(ge=0, le=600_000)] | None = None drops: Count = 0 route: Literal["EU", "US", ""] = "" model: Short = "" lang: Short = "" env: Short = "" denoise: bool = True display: Literal["cursor", "ball", "off"] = "cursor" final: bool = False # raw numbers to tune the cutoffs on missed_s: Seconds = 0 # mic audio no socket took: drops, restarts, lost queues lag_hist: list[Count] = Field(default_factory=lambda: [0] * 7, min_length=7, max_length=7) conf_hist: list[Count] = Field(default_factory=lambda: [0] * 7, min_length=7, max_length=7) queue_max: Annotated[int, Field(ge=0, le=10**9)] = 0 # bytes stuck in the phone, at worst def record(report: SessionReport, client: str, vid: str, os_name: str, browser: str) -> None: """Append one report. Never raises: a lost report must not break /t. `vid` is the visits log's id for the visitor ("" without a captcha token).""" line = json.dumps({ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "client": client, "vid": vid, "os": os_name, "browser": browser, **report.model_dump(), }) try: LOG_FILE.parent.mkdir(parents=True, exist_ok=True) with _write_lock, LOG_FILE.open("a", encoding="utf-8") as file: file.write(line + "\n") except OSError as error: _log.warning("STT session not recorded: %s", error) def load() -> list[dict]: """The last report of each visit, newest visits last.""" visits: dict[str, dict] = {} try: with LOG_FILE.open(encoding="utf-8") as file: for line in file: try: entry = json.loads(line) visits[entry["sid"]] = entry except (ValueError, KeyError, TypeError): continue # a half-written last line, or junk except FileNotFoundError: return [] return list(visits.values()) def verdict(visit: dict) -> tuple[str, str]: """(verdict, main reason) for one visit. failed: Deepgram never answered. broken: over SHARE of the time not listening. degraded: over SHARE of it late or unreliable. too_short: under MIN_ACTIVE_S with under PROBLEM_MIN_S of problems. The reason is the longest problem of the kind that decided the verdict; for ok and too_short, the longest problem of any kind. """ secs = visit.get("secs") or {} total = sum(secs.values()) def longest(codes: tuple[str, ...]) -> str: found = {c: secs[c] for c in codes if secs.get(c)} return max(found, key=found.__getitem__) if found else "" if visit.get("start_ms") is None: # left before the page's own first-connect timeout: not yet a failure if total < FIRST_CONNECT_S: return "too_short", "" return "failed", longest(RED) or "off" if total < MIN_ACTIVE_S and sum(secs.get(c, 0) for c in RED + AMBER) < PROBLEM_MIN_S: return "too_short", longest(RED + AMBER) if sum(secs.get(c, 0) for c in RED) > SHARE * total: return "broken", longest(RED) if sum(secs.get(c, 0) for c in AMBER) > SHARE * total: return "degraded", longest(AMBER) return "ok", longest(RED + AMBER) def summarize(visits: list[dict]) -> dict: """What /stats shows: verdicts, why, and where problems cluster.""" verdicts: Counter[str] = Counter() reasons: Counter[str] = Counter() problem_secs: Counter[str] = Counter() total_secs = 0.0 off_why: Counter[str] = Counter() lag_hist, conf_hist = [0] * 7, [0] * 7 missed = 0.0 for visit in visits: name, reason = verdict(visit) verdicts[name] += 1 if name in ("failed", "broken", "degraded"): reasons[reason] += 1 secs = visit.get("secs") or {} total_secs += sum(secs.values()) problem_secs.update({c: s for c, s in secs.items() if c != "ok"}) off_why.update(visit.get("off_why") or {}) missed += visit.get("missed_s") or 0 for total, counts in ((lag_hist, visit.get("lag_hist")), (conf_hist, visit.get("conf_hist"))): for i, n in enumerate((counts or [])[:7]): total[i] += n return { "visits": len(visits), "verdicts": dict(verdicts), "reasons": dict(reasons.most_common()), "problem_secs": {c: round(s) for c, s in problem_secs.most_common()}, "total_s": round(total_secs), "off_why": {why: round(s) for why, s in off_why.most_common()}, "missed_s": round(missed), "lag_hist": lag_hist, "lag_edges_ms": LAG_EDGES_MS, "conf_hist": conf_hist, "conf_edges": CONF_EDGES, } def times(visit: dict) -> tuple[str, str]: """(start, end) of a visit, ISO UTC, "" when unknown. The page ends the sid with its start time (Date.now() in base 36); the end is the last report.""" end = visit.get("ts", "") try: start = datetime.fromtimestamp(int(visit["sid"][-8:], 36) / 1000, timezone.utc) except (KeyError, ValueError, OverflowError, OSError): return "", end if start.year < 2025: # not a time: a sid from before it carried one return "", end start_iso = start.isoformat(timespec="seconds") # the page's clock, not ours: never after the report that closed the visit return (min(start_iso, end) if end else start_iso), end def breakdown(visits: list[dict], numbers: dict[str, int]) -> dict: """summarize for all visits, plus "by_os" and "by_visitor": the same per group. OSes: most /t time first. Visitors: those with a judged (not too_short) visit, most visits first, each with its "sessions" (one per visit, newest first, with "start" and "end"). `numbers` maps a visits log id to its "Visitor N" on the Visitors tab.""" # Reports from before "vid" was logged borrow it from a later one of the same client. vid_of = {v["client"]: v["vid"] for v in visits if v.get("vid")} by_os: dict[str, list[dict]] = defaultdict(list) by_who: dict[str, list[dict]] = defaultdict(list) for v in visits: client = v.get("client", "") by_os[v.get("os") or "Unknown"].append(v) by_who[v.get("vid") or vid_of.get(client) or client].append(v) oses = [{"name": name, **summarize(group)} for name, group in by_os.items()] people = [{"name": f"Visitor {numbers[key]}" if key in numbers else f"id {key[:6]}", "device": f"{group[-1].get('os', '')} {group[-1].get('browser', '')}".strip(), **summarize(group), "sessions": [dict(zip(("start", "end"), times(v))) | summarize([v]) for v in reversed(group)]} for key, group in by_who.items()] oses.sort(key=lambda s: -s["total_s"]) people = sorted((p for p in people if p["visits"] > p["verdicts"].get("too_short", 0)), key=lambda s: (-s["visits"], -s["total_s"])) return summarize(visits) | {"by_os": oses, "by_visitor": people}