#!/usr/bin/env python3 """What every org actually called, what it cost, and what failed — straight from the prod DB. uv run --frozen python scripts/usage_report.py [--days 1] [--top 25] [--html PATH] [--json] Run it daily. It prints a summary to stdout and writes a self-contained HTML dashboard. WHY THIS READS THE DATABASE AND NOT THE ADMIN API ------------------------------------------------- `/admin/calls` caps at 1,000 rows with no paging — about five hours at current volume — so a daily job against it would silently miss most of the day. The three `/admin/reconcile/*` reports aggregate over 30 days server-side but only answer their own three questions, and none of them can group by endpoint for a provider that doesn't report its own cost. Two columns this report is built on, `credential_tier` and `refused_by`, are exposed by NO admin route at all: `/orgs/{id}/calls` carries them but is scoped to the caller's own org. So the database is the only source that can answer "what is everyone doing", and this script is the sanctioned way to ask. WHAT IT DOES TO PRODUCTION -------------------------- The prod Postgres keeps an EMPTY `ipAllowList`, which drops every external connection. This script opens a hole for this machine's /32, reads, and closes it again in a `finally` — then re-reads the resource to PROVE it closed. If you see "allowlist NOT closed" in the output, close it by hand (Render dashboard -> treg-db -> Access Control) before doing anything else. Nothing here writes: every statement is a SELECT, and the connection is opened read-only. It deliberately uses RAW SQL rather than the ORM. Importing `treg.models` binds the query to whatever branch is checked out, and a branch carrying an unmigrated column makes every SELECT fail against prod with `UndefinedColumnError` — a trap that has cost real time before. Raw SQL against named columns is immune, so this script runs correctly from any branch. DEFINITIONS (they are not interchangeable, and conflating them is how "our error rate" gets misread) ---------------------------------------------------------------------------------------------------- refused `refused_by IS NOT NULL` — TREG said no before a byte went upstream: no balance, a daily cap, a bad token, an unknown endpoint, a malformed request. Costs nothing and is not a provider failure. `balance` refusals are the paywall, i.e. demand we didn't serve. failed `status_code >= 400 AND refused_by IS NULL` — the call went upstream and the PROVIDER answered badly. This is the number that means something is broken. ok everything else. spend `SUM(cost_charged_micro)` — what actually hit the org's balance. NOT the estimate: a released or refunded call still carries an estimate and would over-report spend. """ from __future__ import annotations import argparse import asyncio import datetime as dt import html import json import os import sys import urllib.request from pathlib import Path REPO = Path(__file__).resolve().parent.parent DB_ID = os.environ.get("TREG_PROD_DB_ID", "dpg-d94fp3d7vvec73di5rqg-a") RENDER_API = "https://api.render.com/v1" # ---- environment ------------------------------------------------------------------------------- def env(key: str) -> str: """Read `key` from the process env, falling back to the repo's .env (same file the deploy uses).""" if os.environ.get(key): return os.environ[key] envfile = REPO / ".env" if envfile.exists(): for line in envfile.read_text().splitlines(): line = line.strip() if line.startswith(key + "="): return line.split("=", 1)[1].strip().strip('"').strip("'") raise SystemExit(f"{key} is not set (looked in the environment and {envfile})") def render_api(method: str, path: str, body: dict | None = None): """One Render API call. PATCH, never PUT — PUT answers with a non-JSON body on this resource.""" req = urllib.request.Request( RENDER_API + path, method=method, data=json.dumps(body).encode() if body is not None else None, headers={"Authorization": "Bearer " + env("RENDER_API_KEY"), "Content-Type": "application/json", "Accept": "application/json"}, ) with urllib.request.urlopen(req, timeout=45) as resp: raw = resp.read() return json.loads(raw) if raw.strip() else None def my_ip() -> str: return urllib.request.urlopen("https://api.ipify.org", timeout=20).read().decode().strip() # ---- the queries ------------------------------------------------------------------------------- # One CTE defines the window and the three outcome classes once, so every panel below counts the # same way. `endpoint_id` is NULL for a call to a team's own registered tool; `tool_name` names it, # so the report coalesces to keep own-tool traffic visible instead of bucketing it all as "unknown". BASE = """ with c as ( select coalesce(endpoint_id, tool_name) as ep, coalesce(provider, split_part(tool_name,'.',1)) as prov, -- The split that decides which of the two endpoint tables a row lands in. A non-NULL -- endpoint_id means the call RESOLVED to a catalog endpoint; NULL means it was either a tool -- the team registered themselves or a shape treg could not resolve at all. (endpoint_id is not null) as cataloged, id, method, org_id, user_email, status_code, refused_by, credential_tier, path, {evidence_cols} nullif(client,'') as client, coalesce(cost_charged_micro,0) as spend, duration_ms, created_at, (refused_by is not null) as refused, (status_code >= 400 and refused_by is null) as failed, (status_code < 400) as ok from callrecord where created_at >= $1 ) """ QUERIES: dict[str, str] = { "summary": BASE + """ select count(*) calls, count(distinct org_id) orgs, count(distinct ep) endpoints, count(distinct prov) providers, sum(spend) spend, sum(case when ok then 1 else 0 end) ok, sum(case when failed then 1 else 0 end) failed, sum(case when refused then 1 else 0 end) refused, percentile_disc(0.5) within group (order by duration_ms) p50_ms, percentile_disc(0.95) within group (order by duration_ms) p95_ms from c""", # {unit} is filled from a two-value whitelist in `collect` — a short window wants the day's # hourly shape, a long one wants days. Never interpolate anything user-supplied here. "daily": BASE + """ select date_trunc('{unit}', created_at) d, count(*) calls, sum(case when ok then 1 else 0 end) ok, sum(case when failed then 1 else 0 end) failed, sum(case when refused then 1 else 0 end) refused, sum(spend) spend, count(distinct org_id) orgs from c group by 1 order by 1""", # Every endpoint, with each failure class broken out as its own column so a row says WHERE to # improve without opening anything: `req` is a request treg rejected, `res` is something it # could not find or serve, `failed` is the provider answering badly. Three different fixes. "endpoints": BASE + """ select ep, prov, cataloged, count(*) calls, count(distinct org_id) orgs, sum(spend) spend, sum(case when ok then 1 else 0 end) ok, sum(case when failed then 1 else 0 end) failed, sum(case when refused_by = 'request' then 1 else 0 end) req, sum(case when refused_by = 'resolution' then 1 else 0 end) res, sum(case when refused_by = 'balance' then 1 else 0 end) bal, sum(case when refused_by = 'cap' then 1 else 0 end) cap, sum(case when refused_by = 'auth' then 1 else 0 end) auth, sum(case when refused_by = 'policy' then 1 else 0 end) pol, percentile_disc(0.5) within group (order by duration_ms) p50_ms from c group by 1,2,3 order by count(*) desc""", # The per-endpoint drill-down behind each expandable row: one line per (reason, status), with a # sample path so a 400 can be reproduced. Only non-2xx rows, so it stays small. # Keyed by (ep, cataloged), NOT by ep alone: the same name can appear in BOTH endpoint tables — # e.g. a tikhub id that resolved 3,428 times and was also sent 159 times in an unresolvable # shape. Keying on the name alone would show each row the other one's errors. # `evidence` and `sent` are the provider's own error message and the caller's request, captured # for failed PLATFORM calls only (see models.CallRecord.error_response). They are NULL for an # own-key call by design, and for anything that failed before the capture shipped — so a blank # column here means "not captured", never "no error". # # Both come from ONE row, via the same ORDER BY inside array_agg. They used to be independent # `max()`es, which silently paired one call's request with a DIFFERENT call's response — a # plausible, readable, wrong diagnosis, which is worse than showing nothing. Ordering prefers a # row that actually has evidence, then the newest, and `id` makes it a total order so the two # aggregates cannot disagree. # # `reason` distinguishes three owners, not two: treg refused it, treg could not reach the # provider (a relay 502 — the provider never answered, and calling that "provider answered" is # the same false-diagnosis class), or the provider answered badly. "errdetail": BASE + """ select ep, cataloged, -- Four cases, not three. Deriving "who failed" from the evidence TEXT is only sound -- while the text is there: before migration (A35) deploys it is NULL, and after 14 days -- it is ''. Both used to fall through to "provider answered", which quietly -- re-attributed treg's own 502s to the provider — the same false-diagnosis class this -- report was just fixed for, reappearing whenever evidence is absent. When we cannot -- tell, say so. case when refused_by is not null then refused_by when error_response like 'treg:%' then '(treg never reached it)' when error_response is null or error_response = '' then '(evidence not captured)' else '(provider answered)' end reason, status_code, count(*) n, count(distinct org_id) orgs, left(min(path), 170) sample, string_agg(distinct method, '/') methods, {evidence_agg} from c where status_code >= 400 group by 1,2,3,4 order by 1, 5 desc""", "providers": BASE + """ select prov, count(*) calls, count(distinct org_id) orgs, sum(spend) spend, sum(case when failed then 1 else 0 end) failed, sum(case when refused then 1 else 0 end) refused from c group by 1 order by count(*) desc""", "statuses": BASE + """ select status_code, count(*) n, sum(case when refused then 1 else 0 end) refused from c where status_code >= 400 group by 1 order by 2 desc""", "refusals": BASE + """ select refused_by, count(*) n, count(distinct org_id) orgs from c where refused_by is not null group by 1 order by 2 desc""", "failures": BASE + """ select ep, prov, status_code, count(*) n, count(distinct org_id) orgs from c where failed group by 1,2,3 order by 4 desc limit 40""", # credential_tier is the rung of the ladder that served the call (api.py `_marketplace_call`). # NULL is NOT a fourth rung — it means the call was never a catalog endpoint at all, i.e. a plain # proxy call to a tool the team registered themselves. Labelling that "(own tool)" next to the # real "tool" tier is how you end up with two buckets that read identically and mean different # things, so each row is spelled out here instead. "tiers": BASE + """ select case credential_tier when 'platform' then 'platform — treg''s key, metered' when 'tool' then 'tool — org''s own key, catalog endpoint' when 'credential' then 'credential — org''s own secret, no tool registered' else 'not a catalog call — org''s own registered tool' end tier, count(*) n, sum(spend) spend, count(distinct org_id) orgs from c group by 1 order by 2 desc""", "clients": BASE + """ select coalesce(client,'(unreported)') client, count(*) n, count(distinct org_id) orgs from c group by 1 order by 2 desc""", # "The catalog doesn't have X" — filed from the web, the CLI, or by an agent over MCP. # NOT windowed like everything else, and deliberately: a request is a BACKLOG ITEM, not a time # series. A `--days 1` run that showed only today's would report an empty queue while a month of # unanswered asks sat in the table. So every open row is listed, and `in_window` marks the new # ones. `$1` is referenced only for that flag — collect() passes `since` to every query, and a # statement that ignores its parameter is a protocol error, not a no-op. "requests": """ select id, org_id, user_email, capability, query, note, contact, source, status, created_at, (created_at >= $1) as in_window from toolrequest where status = 'open' or created_at >= $1 order by created_at desc limit 300""", # Catalog searches that matched NOTHING, grouped by query — the demand signal one step before a # tool request (most agents that miss never file one; the query text is all they leave behind). # Windowed like the call panels: a miss is a time-series event, not a backlog item — the same # query missing last month and this month should read as two spikes, not one eternal row. "search_misses": """ select query, count(*) n, string_agg(distinct source, '/') sources, min(created_at) first_seen, max(created_at) last_seen from searchmiss where created_at >= $1 group by 1 order by count(*) desc, max(created_at) desc limit 200""", "orgs": BASE + """ select c.org_id, o.slug, count(*) calls, sum(c.spend) spend, sum(case when c.failed then 1 else 0 end) failed, sum(case when c.refused then 1 else 0 end) refused, count(distinct c.ep) endpoints, max(c.created_at) last_seen from c left join org o on o.id = c.org_id group by 1,2 order by count(*) desc""", } async def collect(since: dt.datetime, unit: str = "day") -> dict: """Open prod to this IP, run every query, close prod again. Closing is not optional.""" import asyncpg if unit not in ("hour", "day"): # the only values that ever reach the SQL string raise ValueError(unit) ip = my_ip() print(f"opening prod allowlist for {ip}/32 ...", file=sys.stderr) render_api("PATCH", f"/postgres/{DB_ID}", {"ipAllowList": [{"cidrBlock": f"{ip}/32", "description": "usage_report.py"}]}) try: dsn = render_api("GET", f"/postgres/{DB_ID}/connection-info")["externalConnectionString"] # Retry the connect. Two transient failures are common here and neither means anything is # wrong: the allowlist PATCH takes a moment to take effect, and Render's Postgres hostname # intermittently SERVFAILs (observed 2026-08-17). An unattended daily run must ride both out # rather than exit — the `finally` below still closes the allowlist if every attempt fails. conn = None for attempt, pause in enumerate((2, 5, 10, 0), start=1): try: conn = await asyncpg.connect(dsn, ssl="require", timeout=45, server_settings={"default_transaction_read_only": "on"}) break except (OSError, asyncpg.PostgresError) as exc: if not pause: raise print(f" connect attempt {attempt} failed ({type(exc).__name__}: {exc}); " f"retrying in {pause}s", file=sys.stderr) await asyncio.sleep(pause) try: # The failure-evidence columns arrive with migration (A35). Until that deploys, prod does # not have them — so probe rather than assume, or this script dies on # UndefinedColumnError against exactly the database it exists to report on. Degrading to # "no evidence available" keeps every other panel working through the deploy window. has_evidence = bool(await conn.fetchval( "select 1 from information_schema.columns" " where table_name = 'callrecord' and column_name = 'error_response'")) if not has_evidence: print(" note: callrecord has no error_request/error_response yet — evidence panels " "will be empty until migration (A35) deploys", file=sys.stderr) ev_cols = ("error_request, error_response," if has_evidence else "null::text error_request, null::text error_response,") # `nullif(..., '')` so an aged-out row reads as "no evidence" rather than as # evidence whose content is the word `` — otherwise the drawer prints # "said " and the exemplar picker prefers an expired row over a newer real one. ev_agg = ("""left((array_agg(nullif(error_response, '') order by (nullif(error_response, '') is not null) desc, id desc))[1], 400) evidence, left((array_agg(nullif(error_request, '') order by (nullif(error_response, '') is not null) desc, id desc))[1], 220) sent""" if has_evidence else "null::text evidence, null::text sent") # Same probe-don't-assume dance as the evidence columns: `searchmiss` arrives with the # zero-result search logging, and until that deploys the table does not exist on prod — # this script must keep working from any branch against exactly that database. has_misses = bool(await conn.fetchval( "select 1 from information_schema.tables where table_name = 'searchmiss'")) if not has_misses: print(" note: no searchmiss table yet — the empty-search panel will be empty " "until the search-miss logging deploys", file=sys.stderr) out = {} for name, sql in QUERIES.items(): if name == "search_misses" and not has_misses: out[name] = [] continue sql = (sql.replace("{unit}", unit) .replace("{evidence_cols}", ev_cols) .replace("{evidence_agg}", ev_agg)) rows = [dict(r) for r in await conn.fetch(sql, since)] out[name] = rows print(f" {name}: {len(rows)} rows", file=sys.stderr) return out finally: await conn.close() finally: # Runs on success, on error, and on Ctrl-C. The verify is the point: a PATCH that 200s but # leaves the list populated would quietly expose prod until someone noticed. try: render_api("PATCH", f"/postgres/{DB_ID}", {"ipAllowList": []}) still = render_api("GET", f"/postgres/{DB_ID}").get("ipAllowList") if still: print(f"!! allowlist NOT closed — still {still}. Close it by hand NOW.", file=sys.stderr) else: print("prod allowlist closed and verified.", file=sys.stderr) except Exception as exc: # noqa: BLE001 — never let a reporting bug leave prod open silently print(f"!! could not close the allowlist ({exc}). Close it by hand NOW.", file=sys.stderr) # ---- formatting helpers ------------------------------------------------------------------------ def usd(micro) -> str: return f"${(micro or 0) / 1_000_000:,.2f}" def pct(n, d) -> str: return f"{(n / d * 100):.1f}%" if d else "—" def short(s: str, n: int) -> str: s = s or "—" return s if len(s) <= n else s[: n - 1] + "…" # ---- terminal ------------------------------------------------------------------------------------ def print_summary(data: dict, days: int, top: int) -> None: s = data["summary"][0] calls = s["calls"] or 0 print(f"\n\033[1mtreg usage — last {days}d\033[0m ({calls:,} calls · {s['orgs']} orgs · " f"{s['endpoints']} endpoints · {s['providers']} providers)") print(f" ok {s['ok']:,} ({pct(s['ok'], calls)}) " f"provider-failed {s['failed']:,} ({pct(s['failed'], calls)}) " f"treg-refused {s['refused']:,} ({pct(s['refused'], calls)})") print(f" spend {usd(s['spend'])} latency p50 {s['p50_ms'] or 0}ms / p95 {s['p95_ms'] or 0}ms") if data["refusals"]: print("\n\033[1mwhat treg stopped before sending\033[0m (costs nothing)") for r in data["refusals"]: # An `auth` refusal has no org — the token never resolved to one — so 0 here is correct. where = f"across {r['orgs']} orgs" if r["orgs"] else "no org resolved" plain, gloss = PLAIN.get(r["refused_by"], (r["refused_by"], "")) print(f" {r['n']:>6} {plain:<14} {where:<20} {gloss}") cat = [e for e in data["endpoints"] if e["cataloged"]] non = [e for e in data["endpoints"] if not e["cataloged"]] print(f"\n\033[1mtop {top} endpoints\033[0m ({len(cat)} catalog + {len(non)} non-catalog; " f"the HTML lists every one)") print(f" {'calls':>7} {'prov':>5} {'req':>5} {'reso':>5} {'spend':>9} {'orgs':>5} endpoint") for e in data["endpoints"][:top]: okrate = (e["ok"] or 0) / e["calls"] if e["calls"] else 1 flag = "\033[31m!\033[0m" if e["calls"] >= 20 and okrate < 0.85 else " " print(f" {e['calls']:>7} {e['failed'] or '':>5} {e['req'] or '':>5} {e['res'] or '':>5} " f"{usd(e['spend']):>9} {e['orgs']:>5} {flag}{short(e['ep'], 54)}") fails = data["failures"] if fails: print("\n\033[1mtop provider failures\033[0m (the provider answered badly — treg's refusals are excluded)") for f in fails[:12]: print(f" {f['n']:>5} {f['status_code']} {short(f['ep'], 62)} ({f['orgs']} orgs)") reqs = data.get("requests") or [] if reqs: new = sum(1 for r in reqs if r["in_window"]) # A row with neither an email nor a contact is unanswerable — the request endpoint is # deliberately anonymous-friendly, so this is the cost of that choice, stated out loud. anon = sum(1 for r in reqs if not (r["user_email"] or r["contact"])) print(f"\n\033[1mtool requests\033[0m ({len(reqs)} open · {new} filed in this window · " f"{anon} with no way to reply)") for r in reqs[:12]: who = r["user_email"] or r["contact"] or "anonymous" print(f" {r['created_at']:%m-%d %H:%M} {r['source']:<4} {short(who, 24):<24} " f"{short(r['capability'], 58)}") if len(reqs) > 12: print(f" … {len(reqs) - 12} more (the HTML lists every one)") misses = data.get("search_misses") or [] if misses: hits = sum(m["n"] for m in misses) print(f"\n\033[1msearches that matched nothing\033[0m ({len(misses)} distinct queries · " f"{hits} misses in this window)") for m in misses[:12]: print(f" {m['n']:>5} {m['sources']:<8} {short(m['query'], 64)}") if len(misses) > 12: print(f" … {len(misses) - 12} more (the HTML lists every one)") print("\n\033[1mtop orgs\033[0m") for o in data["orgs"][:10]: print(f" {o['calls']:>6} calls {usd(o['spend']):>9} {(o['slug'] or '?'):<24} " f"{o['endpoints']} endpoints, {o['failed']} failed, {o['refused']} refused") # ---- HTML ---------------------------------------------------------------------------------------- CSS = """ :root{ --bg:#eaeeee; --surface:#fff; --surface2:#f4f7f7; --ink:#121a1a; --muted:#5c6d6d; --line:#d2dcdc; --line-soft:#e3eaea; --accent:#0d6e68; --accent-soft:#0d6e6822; --ok:#2c7a52; --warn:#a86a14; --crit:#ab332c; --shadow:0 1px 2px #0f26261a; } @media (prefers-color-scheme:dark){ :root:not([data-theme="light"]){ --bg:#0d1313; --surface:#141d1d; --surface2:#101818; --ink:#e4eded; --muted:#8fa3a3; --line:#24312f; --line-soft:#1c2726; --accent:#4ec9be; --accent-soft:#4ec9be22; --ok:#48a877; --warn:#cf9440; --crit:#d9645c; --shadow:0 1px 2px #0006; } } :root[data-theme="dark"]{ --bg:#0d1313; --surface:#141d1d; --surface2:#101818; --ink:#e4eded; --muted:#8fa3a3; --line:#24312f; --line-soft:#1c2726; --accent:#4ec9be; --accent-soft:#4ec9be22; --ok:#48a877; --warn:#cf9440; --crit:#d9645c; --shadow:0 1px 2px #0006; } *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink); font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; -webkit-font-smoothing:antialiased} .wrap{max-width:1180px;margin:0 auto;padding:32px 20px 64px;display:flex;flex-direction:column;gap:24px} .num{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums} header{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:12px; border-bottom:1px solid var(--line);padding-bottom:16px} h1{margin:0;font-size:20px;font-weight:650;letter-spacing:-.01em} h2{margin:0 0 12px;font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.09em;color:var(--muted)} .sub{color:var(--muted);font-size:13px} .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px} .kpi{background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:14px 16px; box-shadow:var(--shadow);display:flex;flex-direction:column;gap:4px} .kpi .k{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)} .kpi .v{font-size:24px;font-weight:600;letter-spacing:-.02em} .kpi .d{font-size:12px;color:var(--muted)} .kpi.ok .v{color:var(--ok)} .kpi.warn .v{color:var(--warn)} .kpi.crit .v{color:var(--crit)} .card{background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:18px; box-shadow:var(--shadow);overflow:hidden} .cols{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start} @media (max-width:820px){.cols{grid-template-columns:1fr}} .scroll{overflow-x:auto} table{border-collapse:collapse;width:100%;font-size:13px} th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted); font-weight:600;padding:0 8px 7px;border-bottom:1px solid var(--line);white-space:nowrap} td{padding:6px 8px;border-bottom:1px solid var(--line-soft);white-space:nowrap} tr:last-child td{border-bottom:0} td.r,th.r{text-align:right} td.name{white-space:normal;word-break:break-word;min-width:220px} /* A request filed inside the report window. The left rule carries the meaning; the tint is only a hint, so this still reads on a monochrome print and in both themes. */ tr.fresh td{background:var(--accent-soft)} tr.fresh td:first-child{box-shadow:inset 2px 0 0 var(--accent)} .bar{position:relative;display:block;height:3px;background:var(--accent-soft);border-radius:2px;margin-top:4px} .bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:2px} .pill{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600; border:1px solid currentColor} .pill.ok{color:var(--ok)} .pill.warn{color:var(--warn)} .pill.crit{color:var(--crit)} .dim{color:var(--muted)} .legend{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:10px} .legend b{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px} footer{color:var(--muted);font-size:12px;border-top:1px solid var(--line);padding-top:14px;line-height:1.7} code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px; background:var(--surface2);padding:1px 5px;border-radius:3px} /* --- the two big expandable endpoint tables ------------------------------------------------ */ .toolbar{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0 0 14px} .toolbar input[type=search]{flex:1 1 220px;min-width:180px;padding:6px 10px;font:inherit; color:var(--ink);background:var(--surface2);border:1px solid var(--line);border-radius:4px} .toolbar label{display:flex;gap:6px;align-items:center;font-size:12px;color:var(--muted); cursor:pointer;user-select:none} .toolbar .count{font-size:12px;color:var(--muted)} :is(input,button,tr).exp:focus-visible,tr.exp:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} table.big{min-width:900px} table.big thead th{position:sticky;top:0;z-index:1;background:var(--surface)} tr.exp{cursor:pointer} tr.exp:hover td{background:var(--surface2)} .caret{display:inline-block;width:13px;color:var(--accent);font-size:10px; transition:transform .12s ease;transform-origin:center} tr.exp[aria-expanded="true"] .caret{transform:rotate(90deg)} @media (prefers-reduced-motion:reduce){.caret{transition:none}} tr.detail>td{background:var(--surface2);padding:0 8px 10px 26px} table.inner{width:auto;min-width:520px;font-size:12px;margin-top:2px} table.inner th{padding-top:8px;border-bottom:1px solid var(--line)} table.inner td{border-bottom:1px solid var(--line-soft);padding:4px 10px 4px 0} table.inner code{font-size:11px;background:transparent;padding:0} /* The captured evidence: what the provider said, and what the caller sent. Wraps, because these are sentences — a message clipped to a column width is the problem this feature exists to fix. */ tr.evrow td{padding:0 10px 8px 0;border-bottom:1px solid var(--line-soft)} .ev{display:flex;gap:8px;align-items:baseline;margin-top:2px;font-size:12px} .ev b{flex:0 0 auto;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)} .ev code{white-space:pre-wrap;word-break:break-word;color:var(--ink);font-size:11.5px} """ # Row expansion + filtering. Vanilla, inline, no network: the page must work from a file:// URL. JS = """ for (const t of document.querySelectorAll('table.big')) { t.addEventListener('click', e => { const tr = e.target.closest('tr.exp'); if (!tr || !t.contains(tr)) return; const d = tr.nextElementSibling; if (!d || !d.classList.contains('detail')) return; const open = d.hidden; d.hidden = !open; tr.setAttribute('aria-expanded', open); }); t.addEventListener('keydown', e => { if (e.key !== 'Enter' && e.key !== ' ') return; const tr = e.target.closest('tr.exp'); if (!tr) return; e.preventDefault(); tr.click(); }); } for (const box of document.querySelectorAll('[data-filters]')) { const table = document.querySelector('#' + box.dataset.filters); const q = box.querySelector('input[type=search]'); const badOnly = box.querySelector('input[type=checkbox]'); const out = box.querySelector('.count'); const apply = () => { const needle = q.value.trim().toLowerCase(); let shown = 0, total = 0; for (const tr of table.tBodies[0].rows) { if (tr.classList.contains('detail')) continue; total++; const hit = (!needle || tr.dataset.ep.toLowerCase().includes(needle)) && (!badOnly.checked || tr.dataset.bad === '1'); tr.hidden = !hit; if (hit) shown++; const d = tr.nextElementSibling; if (d && d.classList.contains('detail') && !hit) { d.hidden = true; tr.removeAttribute('aria-expanded'); } } out.textContent = shown === total ? total + ' rows' : shown + ' of ' + total + ' rows'; }; q.addEventListener('input', apply); badOnly.addEventListener('change', apply); apply(); } """ def svg_stacked(daily: list[dict], unit: str = "day") -> str: """Volume as ok / provider-failed / treg-refused. Hand-built bars — no library, no CDN.""" if not daily: return '

No calls in this window.

' fmt = "%Hh" if unit == "hour" else "%d" W, H, PAD = 1100, 190, 26 peak = max(d["calls"] for d in daily) or 1 n = len(daily) slot = (W - PAD * 2) / n bw = max(3.0, min(slot * 0.66, 46)) parts = [] for i, d in enumerate(daily): x = PAD + slot * i + (slot - bw) / 2 y = H - PAD for key, var in (("refused", "var(--warn)"), ("failed", "var(--crit)"), ("ok", "var(--ok)")): v = d[key] or 0 if not v: continue h = (v / peak) * (H - PAD * 2) y -= h parts.append(f'') every = max(1, n // 14) # thin the axis labels so hourly buckets don't collide if i % every == 0: parts.append( f'{d["d"].strftime(fmt)}') parts.append(f'{d["d"]}: {d["calls"]:,} calls, {d["failed"]} failed, {d["refused"]} refused') grid = "".join( f'' for f in (0.5, 1.0)) return (f'{grid}{"".join(parts)}' f'' '
' 'ok' 'provider failed' 'treg refused
') # The database stores terse gate names. Nobody should have to learn them to read this report, so # every surface prints plain English and keeps the raw value alongside for grepping the DB. PLAIN = { "request": ("bad request", "wrong method, or a missing/invalid parameter"), "resolution": ("not found", "no such endpoint, or treg has no key to call it with"), "balance": ("out of credit", "the org's prepaid balance could not cover it"), # NOT "the org hit its own spending cap": every 429 maps to `cap`, and that covers a member # call-count cap, a tag call or spend cap, the platform ceiling, a trial allowance and a demo-IP # limit. Naming one of them was a confident wrong answer for the other five. "cap": ("hit a limit", "some cap or quota — member, tag, org, platform or trial"), "auth": ("bad token", "the caller's token was missing, wrong or expired"), "policy": ("blocked", "an ACL, deny rule or suspension refused it"), "(treg never reached it)": ("treg could not reach the provider", "timeout, reset, failed injection or SSRF refusal — no answer came"), } def _detail_rows(detail: list[dict], cols: int) -> str: """The drill-down revealed when a row is expanded. The first column answers WHO produced the status, which is the whole point of the drawer: the identical 400 can be the provider rejecting a relayed call or treg refusing to relay it at all, and those have different owners. An earlier version put both in one column headed "Refused by", which made every provider error read as "refused by: the provider answered" — a contradiction. """ rows = [] for d in detail: upstream = d["reason"] == "(provider answered)" who = ('provider' if upstream else 'treg') plain, gloss = PLAIN.get(d["reason"], (d["reason"], "")) why = ('the provider rejected it' if upstream else f'{html.escape(plain)} — {html.escape(gloss)}') # The method is already on every row and costs nothing to show. It IS the diagnosis for a # whole failure class: 47 of apollo.people.enrich's failures were a GET at a POST endpoint, # and without this column the drawer only ever said "bad request". meth = html.escape(d.get("methods") or "") rows.append( f'{who}{why}{meth} {d["status_code"]}' f'{d["n"]:,}{d["orgs"]}' f'{html.escape(d["sample"] or "")}') # The captured evidence, when there is any. Its own full-width row rather than another # column: a provider's message is a sentence, and squeezing it into a table cell is how it # ends up truncated to uselessness — which was the whole problem this feature solves. said, sent = d.get("evidence"), d.get("sent") if said or sent: bits = [] if said: bits.append(f'
said {html.escape(said)}
') if sent: bits.append(f'
sent {html.escape(sent)}
') rows.append(f'{"".join(bits)}') elif not d.get("cataloged"): # Say WHY there is nothing, rather than letting a blank read as "no detail exists". # Evidence is captured for platform calls only, so a team's own registered tool — which # is the single largest failure group, google-ads at 283 — never has any here. rows.append('
' 'not captured — own registered tool, so the request and the provider\'s ' 'answer are the team\'s to inspect, not treg\'s
') return (f'' f'
' f'' f'' f'' f'{"".join(rows)}
Stopped byReasonStatusCallsOrgsExample request — a full URL went upstream; ' f'a bare id never left treg
') def endpoint_table(eps: list[dict], details: dict[tuple[str, bool], list[dict]], *, first_col: str, table_id: str) -> str: """Every endpoint, one row each, expandable into its error breakdown. No truncation.""" if not eps: return '

Nothing in this bucket.

' peak = max(e["calls"] for e in eps) or 1 head = (f'{first_col}CallsOrgsOK' 'Provider
said noBad
request' 'Not
foundOut of credit
/ capped' 'Spendp50') cols = 10 out = [] for e in eps: calls, ep = e["calls"], e["ep"] or "—" other = (e["bal"] or 0) + (e["cap"] or 0) + (e["auth"] or 0) + (e["pol"] or 0) bad = (e["failed"] or 0) + (e["req"] or 0) + (e["res"] or 0) + other okrate = (e["ok"] or 0) / calls if calls else 0 cls = "crit" if okrate < 0.6 else "warn" if okrate < 0.95 else "ok" det = details.get((ep, e["cataloged"])) # Only a row that HAS failures is expandable — a clickable row with an empty drawer is a lie. out.append( f'' f'{"▸" if det else "·"}' f'{html.escape(ep)}' f'' f'{calls:,}{e["orgs"]}' f'{okrate * 100:.0f}%' f'{e["failed"] or ""}{e["req"] or ""}' f'{e["res"] or ""}{other or ""}' f'{usd(e["spend"])}' f'{e["p50_ms"] or 0}ms') if det: out.append(_detail_rows(det, cols)) return (f'
{head}' f'{"".join(out)}
') def build_html(data: dict, days: int, top: int, since: dt.datetime, unit: str = "day") -> str: s = data["summary"][0] calls = s["calls"] or 0 fail_rate = (s["failed"] or 0) / calls if calls else 0 kpi_cls = "crit" if fail_rate > 0.10 else "warn" if fail_rate > 0.05 else "ok" bal = next((r["n"] for r in data["refusals"] if r["refused_by"] == "balance"), 0) kpis = [ ("Calls", f"{calls:,}", f"{s['orgs']} orgs · {s['endpoints']} endpoints", ""), ("Spend", usd(s["spend"]), f"{usd((s['spend'] or 0) / max(days, 1))}/day", ""), ("Provider failures", f"{pct(s['failed'], calls)}", f"{s['failed']:,} calls", kpi_cls), ("Refused by treg", f"{pct(s['refused'], calls)}", f"{s['refused']:,} calls", "warn" if s["refused"] else ""), ("Hit the paywall", f"{bal:,}", "balance refusals", "warn" if bal else ""), ("Latency p95", f"{s['p95_ms'] or 0}ms", f"p50 {s['p50_ms'] or 0}ms", ""), ] kpi_html = "".join( f'
{k}{v}' f'{d}
' for k, v, d, c in kpis) def table(head: str, body: str) -> str: return f'
{head}{body}
' refusals = table( 'ReasonCallsOrgs', "".join(f'{html.escape(r["refused_by"])}' f'{r["n"]:,}{r["orgs"]}' for r in data["refusals"]) or 'Nothing refused.') failures = table( 'EndpointStatusCalls', "".join(f'{html.escape(f["ep"] or "—")}' f'{f["status_code"]}{f["n"]:,}' for f in data["failures"][:14]) or 'No provider failures.') providers = table( 'ProviderCallsOrgsFailed' 'RefusedSpend', "".join(f'{html.escape(p["prov"] or "—")}{p["calls"]:,}' f'{p["orgs"]}{p["failed"] or ""}' f'{p["refused"] or ""}{usd(p["spend"])}' for p in data["providers"][:20])) orgs = table( 'OrgCallsEndpointsFailed' 'RefusedSpend', "".join(f'{html.escape(o["slug"] or ("org " + str(o["org_id"])))}' f'{o["calls"]:,}{o["endpoints"]}' f'{o["failed"] or ""}{o["refused"] or ""}' f'{usd(o["spend"])}' for o in data["orgs"][:15])) tiers = table( 'CredentialCallsOrgsSpend', "".join(f'{html.escape(t["tier"])}{t["n"]:,}' f'{t["orgs"]}{usd(t["spend"])}' for t in data["tiers"])) clients = table( 'AgentCallsOrgs', "".join(f'{html.escape(c["client"])}{c["n"]:,}' f'{c["orgs"]}' for c in data["clients"][:10])) reqs = data.get("requests") or [] req_new = sum(1 for r in reqs if r["in_window"]) req_new_label = f" · {req_new} new" if req_new else "" req_anon = sum(1 for r in reqs if not (r["user_email"] or r["contact"])) def request_row(r: dict) -> str: who = r["user_email"] or r["contact"] who_cell = html.escape(who) if who else 'anonymous' detail = r["note"] or r["query"] detail_html = f'
{html.escape(short(detail, 260))}
' if detail else "" return (f'' f'{r["created_at"]:%Y-%m-%d %H:%M}' f'{html.escape(r["source"] or "?")}' f'{who_cell}' f'{html.escape(r["capability"] or "—")}{detail_html}') requests_html = table( 'FiledSourceWhoAsked for', "".join(request_row(r) for r in reqs) or 'No open requests.') misses = data.get("search_misses") or [] miss_total = sum(m["n"] for m in misses) misses_html = table( 'MissesSourceQueryLast seen', "".join(f'{m["n"]:,}{html.escape(m["sources"])}' f'{html.escape(m["query"])}' f'{m["last_seen"]:%Y-%m-%d %H:%M}' for m in misses) or 'No empty searches in this window ' '(or the search-miss logging has not deployed yet).') details: dict[tuple[str, bool], list[dict]] = {} for d in data["errdetail"]: details.setdefault((d["ep"], d["cataloged"]), []).append(d) cataloged = [e for e in data["endpoints"] if e["cataloged"]] other = [e for e in data["endpoints"] if not e["cataloged"]] endpoints = endpoint_table(cataloged, details, first_col="Endpoint", table_id="t-cat") noncatalog = endpoint_table(other, details, table_id="t-non", first_col="Tool name / what the caller sent") def toolbar(target: str, n: int) -> str: return (f'
' f'' f'' f'
') generated = dt.datetime.now().strftime("%Y-%m-%d %H:%M") # The DOCTYPE is load-bearing, not boilerplate: without it Chrome renders in QUIRKS MODE, where # does not inherit `color` from its ancestors. Every table cell then falls back to the # light-theme ink and the whole report is invisible dark-on-dark for a dark-mode reader. Verified # in a browser — the bug is silent in light mode, so it survives any amount of source review. return f"""treg Call Ledger

treg call ledger

{since:%Y-%m-%d %H:%M} → now · {days} day{"s" if days != 1 else ""}
generated {generated}
{kpi_html}

Volume by {unit}

{svg_stacked(data["daily"], unit)}

Tool requests — all {len(reqs)} open{req_new_label}

"The catalog doesn't have X", filed from the web page, the CLI, or by an agent mid-search over MCP. New rows in this window are highlighted. Every row here stays until someone flips its status by hand — this is a queue, not a feed, so it is listed in full regardless of the report window.
Read them against the catalog before building anything. A request for something treg already serves is a discovery failure wearing a coverage failure's clothes, and shipping the endpoint again would not have helped the person who asked.
{req_anon} of {len(reqs)} carry neither an email nor a contact — filing is deliberately anonymous-friendly, so those asks cannot be answered even when we build the thing.

{requests_html}

Searches that matched nothing — {len(misses)} distinct · {miss_total} misses

Every catalog search in this window that returned zero results, from the API/CLI route and from agents over MCP. This is the demand signal one step before the tool-request queue above — most agents that miss never file, so the query text is all they leave behind.
Read each against the catalog before adding anything: a miss for something treg already serves is a naming/discovery failure — fix the endpoint's words, not the coverage.

{misses_html}

Catalog endpoints — all {len(cataloged)}

Calls that resolved to a catalog endpoint. The three failure columns are separated because each has a different owner:
Provider said no — treg passed the call on and the provider rejected it. Their end.
Bad request — wrong HTTP method, or a missing/invalid parameter. treg stopped it; nothing was sent and nothing was charged. (stored as request)
Not found — no such endpoint, or it exists but treg has no key to call it with. Also stopped before sending. (stored as resolution)
Out of credit / capped — the org ran out of balance or hit its own cap. Not a bug; this is demand you did not serve.
Click any row with errors for the status codes and an example request.

{toolbar("t-cat", len(cataloged))}{endpoints}

Not a catalog endpoint — all {len(other)}

Everything absent from the table above, because no endpoint_id was attached. Two populations live here: tools a team registered itself (real hosts — google-ads, search-console, render, vercel), and calls treg could not make sense of — a bare catalog id sent where /call/ wants the full upstream URL, or https: written with one slash. A high Not found count is the second kind.

{toolbar("t-non", len(other))}{noncatalog}

Why treg said no

{refusals}

These never reached a provider and cost nothing. balance is demand that arrived and was turned away.

Provider failures

{failures}

Providers

{providers}

Whose credential paid

{tiers}

Which agent called

{clients}

Busiest orgs

{orgs}
Stopped by treg = refused before anything went upstream (bad request, not found, out of credit, capped, bad token, blocked) — costs nothing and is not a provider fault. Provider said no = the call went upstream and came back 4xx/5xx. spend = cost_charged_micro, what actually hit the org's balance — not the reserved estimate.
Generated by scripts/usage_report.py from the production database.
""" # ---- entry point --------------------------------------------------------------------------------- def main() -> None: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--days", type=int, default=1, help="window size in days (default 1)") ap.add_argument("--top", type=int, default=25, help="endpoints to list (default 25)") ap.add_argument("--html", metavar="PATH", help="write the dashboard here (default reports/usage-.html; '-' to skip)") ap.add_argument("--json", action="store_true", help="dump the raw aggregates to stdout instead") args = ap.parse_args() days = max(1, args.days) # Naive UTC — the convention every timestamp column in this app stores (see reconcile.window_start). since = dt.datetime.now(dt.UTC).replace(tzinfo=None) - dt.timedelta(days=days) unit = "hour" if days <= 2 else "day" # a one-day report wants the day's shape, not one bar data = asyncio.run(collect(since, unit)) if args.json: print(json.dumps(data, indent=2, default=str)) return print_summary(data, days, args.top) if args.html == "-": return # The window goes in the FILENAME. Without it a --days 1 and a --days 7 run on the same date # write to the same path, and the second silently replaces the first — two very different # reports, one name, no way to tell them apart afterwards. out = Path(args.html) if args.html else REPO / "reports" / f"usage-{dt.date.today()}-{days}d.html" out.parent.mkdir(parents=True, exist_ok=True) out.write_text(build_html(data, days, args.top, since, unit), encoding="utf-8") print(f"\nwrote {out}") if __name__ == "__main__": main()