| """harness/telemetry.py β usage telemetry v0 (OM-3; principle 7: the improvement loop is |
| structural). Append-only JSONL of what people actually do β pages opened, explore queries run, |
| Analyst questions asked β the observe-half of just-in-time modeling: what's used gets promoted |
| into the governed model; what's ignored gets pruned. |
| |
| Fail-silent by design: telemetry must never break a page or a query. Local file v0 |
| (data/store/usage.jsonl, tenant data, git-ignored); mined in-session for now, store-backed later. |
| """ |
| import json |
| import sys |
| import time |
| import traceback |
| from pathlib import Path |
|
|
| PATH = Path(__file__).resolve().parents[1] / "data" / "store" / "usage.jsonl" |
| ERR_PATH = PATH.parent / "errors.jsonl" |
|
|
|
|
| def log(kind, user=None, tenant=None, **fields): |
| """Append one usage event. Never raises. |
| |
| β’ WAVE 19 (R4): `user` and `tenant` are FIRST-CLASS PARAMETERS, not just two more `**fields`. |
| They already worked as free-form fields β the point of naming them is that attribution stops |
| being a convention each call site remembers or forgets. The Analyst's token line is the first |
| caller to pass them (`harness/analyst.py`), which is what makes "who spent these tokens, for |
| which customer?" answerable AT ALL: before this wave the log recorded a token count attached |
| to nobody. |
| |
| Both are OPTIONAL and omitted from the line when absent, so a system-initiated run (the eval |
| ladder, `harness/routines.py` β neither runs on behalf of a person) writes exactly the record |
| it wrote before. A missing key is honest; `"user": null` would invite a reader to count it. |
| |
| β HONEST LIMIT β this file is CONTAINER-LOCAL JSONL (`data/store/usage.jsonl`, git-ignored, |
| lost on a Space restart). Attribution starts accruing now; it is not yet a durable, queryable |
| usage ledger, which is why the per-user token PANEL stays deferred (DEBT D-8) rather than |
| being built against a log that a redeploy erases. |
| """ |
| try: |
| PATH.parent.mkdir(parents=True, exist_ok=True) |
| rec = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "kind": kind} |
| if user: |
| rec["user"] = str(user) |
| if tenant: |
| rec["tenant"] = str(tenant) |
| rec.update(fields) |
| with PATH.open("a", encoding="utf-8") as f: |
| f.write(json.dumps(rec, default=str) + "\n") |
| except Exception: |
| pass |
|
|
|
|
| def error(where, exc=None, **fields): |
| """THE central error sink (owner rule 2026-07-23: ALL errors are logged). One JSONL line per |
| caught error (data/store/errors.jsonl β per-deployment, git-ignored) + a mirror line on |
| stderr so it also lands in the Space container logs. Call it from EVERY except block that |
| swallows or downgrades an exception β a failure with nowhere to land is how the app breaks |
| silently (the 2026-07-23 drawer fire). Never raises.""" |
| try: |
| rec = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "where": str(where)[:120], **fields} |
| if exc is not None: |
| rec["type"] = type(exc).__name__ |
| rec["msg"] = str(exc)[:400] |
| try: |
| tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__, |
| limit=8)) |
| if tb.strip(): |
| rec["trace"] = tb[-1800:] |
| except Exception: |
| pass |
| ERR_PATH.parent.mkdir(parents=True, exist_ok=True) |
| with ERR_PATH.open("a", encoding="utf-8") as f: |
| f.write(json.dumps(rec, default=str) + "\n") |
| print(f"[app-error] {rec['ts']} {rec['where']}: " |
| f"{rec.get('type', '')} {rec.get('msg', '')}", file=sys.stderr, flush=True) |
| except Exception: |
| pass |
|
|
|
|
| def read_errors(limit=500): |
| """Tail of the error log (newest last) β the Data Health admin section reads this.""" |
| if not ERR_PATH.exists(): |
| return [] |
| out = [] |
| try: |
| with ERR_PATH.open(encoding="utf-8") as f: |
| for line in f: |
| try: |
| out.append(json.loads(line)) |
| except json.JSONDecodeError: |
| continue |
| except Exception: |
| return out |
| return out[-limit:] |
|
|
|
|
| def read(limit=5000): |
| if not PATH.exists(): |
| return [] |
| out = [] |
| with PATH.open(encoding="utf-8") as f: |
| for line in f: |
| try: |
| out.append(json.loads(line)) |
| except json.JSONDecodeError: |
| continue |
| return out[-limit:] |
|
|
|
|
| def summary(): |
| """Counts by kind + the most-touched pages/topics β the promote-or-prune shortlist.""" |
| from collections import Counter |
| ev = read() |
| kinds = Counter(e.get("kind") for e in ev) |
| pages = Counter(e.get("page") for e in ev if e.get("kind") == "page") |
| topics = Counter(e.get("topic") for e in ev if e.get("topic")) |
| return {"events": len(ev), "by_kind": dict(kinds), |
| "top_pages": pages.most_common(10), "top_topics": topics.most_common(10)} |
|
|