File size: 5,014 Bytes
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14ceee
 
ea7b176
 
 
 
 
 
c14ceee
ea7b176
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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)}