feat(dash): first-class persistent observability dashboard - #38
feat(dash): first-class persistent observability dashboard#38OsherElhadad wants to merge 3 commits into
Conversation
…he honest-metrics API
context-guru's entire observability surface was `GET /stats`: one in-memory Aggregator
snapshot, gone on restart. A user could not answer the question the product exists to
answer -- what value is context-guru providing? -- and nothing could show history,
per-request detail, or what compaction actually removed.
This adds the storage, capture and API layer. The UI lands next.
Capture is off the hot path, and that is the load-bearing property. The handler builds
one struct from values the request path already computed and hands it to a buffered
channel with a `default:` branch; a full queue DROPS the event and increments a counter
the dashboard itself displays. Measured cost on the request goroutine is ~175ns
(BenchmarkRecord), with a regression test that fails above 50us -- so moving I/O onto
the capture path breaks CI. An observability layer that can add latency to, or fail, a
request has no business existing in a tool that sells latency awareness.
One writer goroutine owns the database, batching inserts in a transaction and fanning
summaries to SSE. The existing Aggregator is untouched and remains the fast in-process
counter behind /stats.
Storage is SQLite via modernc.org/sqlite (pure Go -- no C toolchain beyond the one
tree-sitter already forces), WAL mode, four tables: requests, request_components,
request_content (gzip, size-capped, skippable) and the ingested bench_runs/bench_tasks.
Timestamps are epoch MILLISECONDS everywhere; a formatted locale string cannot be
range-queried, sorted portably, or bucketed. Retention is bounded by age AND size --
age alone cannot bound a burst, size alone silently erases a quiet week. A schema
version mismatch renames the old file aside and starts fresh: a dashboard is a derived
view, so discarding history beats refusing to boot and renaming beats deleting.
An unopenable path degrades to in-memory with a warning; the proxy's job is to proxy.
No rollup tables. Series are bucketed in SQL at query time (ts/bucket*bucket GROUP BY 1),
so any bucket width works with no migration. Pagination is KEYSET, not OFFSET, so page
500 of a busy proxy's history costs the same as page 1 and cannot skip or duplicate a row
while new requests arrive. p95 is computed exactly with ORDER BY + OFFSET -- answerable
at all, unlike a deployment with no histogram.
The metric semantics are the point, not the plumbing:
* Four labelled savings denominators, each carrying its own prose description in the
payload. A whole-request ratio recounts the transcript every turn, so a 200-turn
session reads ~0% however well compaction performs; a compressible-only ratio
flatters by excluding what we chose not to touch. Both are true. Neither is "the"
number, so each ships with the divisor named.
* The new-input ratio is guarded on the BILLED figure, not the sum: with no provider
usage data the denominator would be `saved` alone and the ratio would read ~100%.
It reports unavailable instead.
* Gross, unique and adjusted are separate columns and overcount_ratio is surfaced.
* The cost of our own safety mechanisms sits beside their benefit: tokens frozen for
cache safety, restorations after a premature offload, reverted runs, and
context-guru's own latency and LLM spend.
* token_accounting per row (complete|partial|missing). A request is priced only when
the provider reported all four tiers AND the model's rates are known; otherwise cost
reads unknown, never zero and never exact.
* Cache-miss attribution with cold_start as a NON-failure -- the first request of a
session, or the first for a model, has nothing to hit -- and TTL expiry winning ties
against a changed prefix, because a prefix that changed after the entry had already
expired was not the cause.
* "Why didn't you compact this?" as a first-class reason bucket rather than an absence
of data.
Redaction happens BEFORE the database, never on read: headers are blanket-redacted by
key against a short allowlist (a denylist fails the moment a gateway invents a new auth
header), config keys are allowlisted with credential-named keys always withheld, and
captured content is pattern-scrubbed then size-capped -- in that order, so a secret at
the truncation boundary cannot survive by being cut into an unmatchable prefix. A test
writes a canary through the real capture path and reads every stored column back.
Two subtleties the allowlist got wrong on the first pass, both fixed with tests:
`components` is keyed by user-chosen component names and cannot be allowlisted, so
redacting the subtree wholesale made the config view show nothing; and a substring match
on "token" also swallows max_tokens/min_tokens, redacting every threshold in that same
view. Safety that destroys the feature is not safety.
Cost is computed at WRITE time so history does not reprice when a published rate changes;
percentages are derived at READ time so a filter change needs no rebuild.
Supporting changes:
* apply.BodyTrace: BodyFull now delegates to it, so the rewrite is byte-identical
whether or not anyone is looking. The Trace carries the resolved session, the
RunReport, the cache-awareness facts (AttemptedTokens/FrozenTokens -- the honest
denominator and the cost of cache safety) and each rewritten message's before/after
text: the same material CONTEXT_GURU_DUMP writes to a file, handed to a caller.
* internal/modelinfo gains per-token pricing (four tiers), and a real bug is fixed:
the LiteLLM map was decoded into one typed map[string]struct, which fails on the
document's prose-filled `sample_spec` entry and on the handful of models that spell
an integer as a float -- yielding NOTHING. Window lookups have therefore silently
never resolved in production. Per-entry decode keeps the ~2,900 good rows; the
resolved rates match the benchmark harness's published price vector exactly.
* metrics.Snapshot gains the four billed token tiers plus attempted/frozen tokens and
the two ratios derived from them. Additive only -- deploy/harbor parses this payload.
Assisted-By: Claude Opus 5
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Serves the dashboard at /dashboard/ and mounts the API at /api/*, both only when
--dashboard is passed. Without the flag the route table is byte-identical to before and
every dashboard path 404s, verified by a test.
The UI is three files -- index.html, style.css, app.js -- embedded with go:embed. No npm,
no bundler, no framework, and deliberately NO CDN: context-guru ships into VPC and
air-gapped contexts, so a script tag pointing at unpkg is a shipping bug rather than a
nit. A test fetches each asset and fails on any external origin, and the assets are served
under `default-src 'self'` so a CDN tag added later breaks loudly in the browser instead of
silently breaking the offline install.
Charts are hand-drawn SVG rather than a vendored chart library. SVG path/rect/text is a
native browser feature, the series are small, and 45KB of dependency would buy a tooltip
that is fifteen lines here.
Design tokens are CSS custom properties from line one, with dark mode redefining only the
tokens -- both under prefers-color-scheme and under an explicit [data-theme], so the toggle
wins in both directions. (The reference implementation we studied hardcoded hex and had to
bolt on ~25 light-mode overrides afterwards.)
Views: Overview (20 stat tiles, the four labelled denominators with their prose
descriptions, the baseline-vs-actual cumulative cost chart with the saved area shaded, the
honest savings waterfall, safety-mechanism costs, cache-miss attribution, uncompressed
reasons, accounting confidence, five time series, and an SSE live feed), Components,
Sessions, Requests with the request drawer, Benchmarks with a cost-vs-reward scatter and
per-task drill-down, and Config.
The diff view is the headline feature -- the view both reference implementations carry the
data for and neither built. It is an LCS diff with common head/tail trimmed first (agent
transcripts share long identical stretches), rendered Git-style with line numbers and
collapsed unchanged runs, plus side-by-side and after-only modes. Blocks are ordered
biggest-saving first and the largest opens, because leading with an unchanged block buries
the answer to "what did context-guru actually remove?".
Every stat tile and panel carries a data-testid, asserted by a Go test so a rename fails
before the browser checks and the docs screenshots silently go stale.
Nothing renders through innerHTML. A tool output in a transcript is attacker-influenced
text, so every value goes through textContent or the el() helper, which throws on a raw
`html` prop. The same reasoning keeps the strict style-src CSP: inline style ATTRIBUTES
are blocked, so el() routes styles through the CSSOM, which is exempt and equally
expressive.
Proxy wiring:
* proxy/usage.go reads the four billed token tiers from a response in either dialect.
Anthropic's input_tokens already excludes the cached tiers; OpenAI's prompt_tokens
INCLUDES its cached_tokens, so fresh is the difference -- getting that backwards
double-counts the whole transcript every turn, which is exactly the kind of error a
savings number conceals.
* Streamed responses are sniffed through a BOUNDED head+tail window as the bytes go by,
never buffered: head because Anthropic reports the input tiers in the first SSE event
and the output count in the last, tail because OpenAI reports everything in a final
chunk. A newline separates the halves so a truncated line cannot fuse two events into
one unparseable one.
* The capture is finished in a defer AFTER the client's response is complete, so
redaction, gzip and the insert are all off the critical path.
* context-guru's own LLM spend is attributed per request as the delta of the cheap-model
counters across it.
Two real bugs the tests caught, both in the usage path and both silent under-reporting:
an output-only usage block (Anthropic's streaming message_delta, which carries the FINAL
completion count) parsed as "no usage at all"; and the sniffer dropped its tail window
whenever both halves saturated, losing usage for every large streamed response.
A golden test pins the exact key set of /stats and of each per-component object, in both
directions -- a lost field fails, and a NEW field fails until it is recorded in the golden
list. deploy/harbor/*.py parses that payload to produce every published benchmark result,
so a rename would invalidate the reproduction path silently: the harness would keep
running and report zeros, which is worse than a build break.
Also guards a class of bug that shipped a blank dashboard during development: a dropped
closing paren in app.js. Go compiled, every Go test passed, the HTML served 200, and the
page rendered nothing. A test now parses app.js with node when present and falls back to a
bracket-balance scan (strings, template literals and comments skipped) otherwise.
Assisted-By: Claude Opus 5
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…cture note New docs/dashboard.md: what each view shows, the five honest-metrics rules, the capture/store architecture with the measured overhead, the storage and retention model, the access-gating table, the flags and the API -- with screenshots taken from the real app driven by browser automation against live captured traffic, not fixtures. docs/how-to/measure-savings.md is rewritten around the dashboard and around one question it previously ducked: WHICH savings number should you quote? It now leads with a table mapping the question you are actually asking to the denominator that answers it, explains why a whole-request ratio trends to ~0% on a long session (the denominator grows quadratically as the agent re-sends its history), and explains why cost and token savings diverge -- a cache write bills ~11.5x a read, so removing unique tokens moves 0.02-0.13% of the billed total. /stats keeps its own section as the scriptable snapshot, with the stability contract stated. docs/design.md gains "Observability: the dashboard store", framed as five decisions and what each one refuses. docs/reference/routes.md documents every new route, every filter parameter and the access gating. docs/reference/config.md documents the flags, including why there is deliberately no "disable observability in production" switch: for a tool whose value IS observability, that would be backwards. Screenshots are cropped and JPEG-encoded to keep the whole set to ~2.5MB rather than the 10MB the raw 2x captures came to, and each one was scanned -- byte-level against the live credential values and pattern-wise, then again with OCR -- before being committed. `mkdocs build --strict` is clean. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
OsherElhadad
left a comment
There was a problem hiding this comment.
Independent review — verdict: changes needed (2 blockers), and the modelinfo fix should be split out
I reviewed this from my own worktree at origin/feat/i30-dashboard (e650817) and ran everything below myself rather than taking the PR body on trust. A lot of this holds up extremely well — see "what I verified and confirmed" at the end. Two findings are blockers.
🔴 BLOCKER 1 — net_dollars_saved is inflated by the overcount ratio (13.1× on my run). This is the exact defect the issue calls "the worst possible defect in an observability tool".
dash/event.go:235:
e.BaselineCostUSD = e.CostUSD + float64(e.Saved())*p.CacheWritee.Saved() is gross — tokens_before - tokens_after, which recounts the same compaction every turn the agent re-sends the transcript. The PR is careful and correct about this everywhere in token space (saved_gross / saved_unique / overcount_ratio are separate columns, denominators() uses SavedUnique for the honest ratios). Then the dollar figure — the single number a user actually reads — silently uses gross.
Measured on 63 real replayed requests through my own build:
saved_gross 3,718,890 saved_unique 283,479 overcount_ratio 13.12x
reported compaction saving $7.0244 (= gross x cache_write)
unique-based saving $0.5354 (= unique x cache_write)
reported net_saved_usd $7.0027
DOLLAR SAVINGS OVERSTATED 13.1x
The overview renders "NET DOLLARS SAVED $7.00" in the tile row immediately next to "OVERCOUNT RATIO 13.1×". The dashboard is displaying the correction factor for its own headline number and not applying it. Same bug propagates to SessionRow.SavedUSD (dash/query.go), the waterfall's compaction step (dash/overview.go:waterfall), and the cumulative baseline-vs-actual chart — i.e. the issue's "the important one".
There is a second, smaller error inside the same line: even the unique tokens are all priced at CacheWrite. Re-sent (non-unique) removed tokens would have been served from cache, so at most they'd have been billed at CacheRead (~1/12th). Pricing them at the write rate is what produces the 11.5x-amplified inflation.
Fix: price unique savings at CacheWrite, and if you want to keep a gross line at all, price (gross − unique) at CacheRead and label it separately. Concretely:
// unique content genuinely never reached the provider as new input
e.BaselineCostUSD = e.CostUSD + float64(uniqueSaved)*p.CacheWrite +
float64(e.Saved()-uniqueSaved)*p.CacheReadThen add the regression test that would have caught this: build a window where overcount_ratio == 10, assert net_saved_usd is ~1/10th of the gross-priced figure. Right now no test asserts a dollar figure against a known-overcounted token figure, which is why an otherwise very well-tested PR shipped this.
🔴 BLOCKER 2 — content redaction runs on the request goroutine and costs 53 ms/request, not 175 ns. The headline overhead claim measures the wrong thing.
proxy/dashcapture.go:152-161 runs dash.RedactContent (9 regexes) over up to ContentMaxPerRequest (24) blobs × before+after = 48 × 16 KiB — inside capture.finish, which is called from serve's defer (proxy/proxy.go:472). A deferred call in the handler runs before the handler returns, so the connection is not released and (for keep-alive clients, i.e. every real agent) the next request on that connection waits behind it.
I measured this against three of my own builds on unique ports, same fake upstream, same 380 KB realistic transcript, median of 10:
dashboard OFF median 214 ms
dash ON, content OFF median 215 ms <- +1 ms (the 175 ns claim is fine HERE)
dash ON, content ON median 268 ms <- +53 ms
So: the capture channel send really is ~175 ns as claimed and BenchmarkRecord is honest about what it measures — but it is not the dashboard's per-request cost, because redaction was placed on the caller's goroutine. docs/dashboard.md and the PR body both state the dashboard costs ~0.000002% of a request; with the default --dashboard-content=true it costs ~25%.
The dominant cost is one regex, dash/redact.go:161:
#7 3.449 ms/blob (?i)\b([A-Z0-9_]*(?:API_?KEY|AUTH_?TOKEN|SECRET|...)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"',}]{8,}
(full per-regex timings over a 17 KB log-shaped blob: #7 3.4 ms, #8 930 µs, #6 612 µs, #3 552 µs, #2 551 µs, #5 520 µs, #4 457 µs, #0 90 µs, #9 50 µs → ~7 ms per blob, ~310 ms for a full 48-blob request in the worst case.)
Fix (either is fine, first is smaller): move redaction to the writer goroutine. It is already the right place — capture.go:run() owns the event, redaction is idempotent, and nothing downstream needs it earlier. The comment at dashcapture.go:156 says redaction "runs on the writer's caller — this goroutine, after the response is out", which is the bug in one sentence: after the response body is out, but still on the request goroutine and still before the handler returns. Additionally, cheap wins: gate the whole regex pass behind a strings.ContainsAny(s, ":=")-style prefilter, and merge #0/#2/#3/#5 into one alternation.
Also note TestCaptureOverheadIsNegligible cannot catch this, because it calls Record directly and never goes through finish. A regression test at the finish level (or an end-to-end handler latency assertion with content capture on) is what's missing.
🟠 Should the modelinfo fix be in this PR? No — split it out. But it is correct.
I reproduced both sides independently against the live LiteLLM document:
origin/main: claude-sonnet-4-5 -> window=0 ok=false (every lookup, all four models)
this branch: byKey=3664 priceBy=3378
claude-sonnet-4-5 window=200000 price={3e-06 1.5e-05 3e-07 3.75e-06} ok=true
aws/claude-sonnet-5 window=1000000 price={2e-06 1e-05 2e-07 2.5e-06} ok=true
The fix is correct and genuinely per-entry (json.RawMessage map, continue on a bad entry), and the float64-then-int() handling of float-spelled integers is right. It's a real, severe, pre-existing bug — matching #39.
But it does not belong here:
- It changes compaction behaviour, not observability.
components/trigger.goresolves fraction thresholds againstCtx.CtxWindow; today that is always 0 so fractions are ignored and only absolutes apply. After this fix, every fraction-configured deployment starts compacting differently — on the first request, silently, with no flag. That is a behaviour change hidden inside a +9,254-line dashboard PR, and it is un-bisectable if it regresses a benchmark. - It is a prerequisite for the dashboard's pricing, not a consequence of it. Land it first as its own small PR (fixing #39), with the before/after resolution evidence in that PR's description, then rebase this one on top. The
Price/Priceradditions can come with the dashboard; the per-entry decode fix should not. - The stated blast radius deserves its own review: no preset currently ships a fraction (
config.gopresetConfigs use absolutemin_tokens/min_request_tokensonly), so the practical risk today is low — but that is an argument for it being an easy separate PR, not for burying it.
The other three bug fixes are appropriately in scope (all three are on the usage-parsing path this PR introduces) and each has a test. usage.go's output-only-block branch and the sniffer head+tail fix both look right, and the app.js bracket-balance test is a reasonable answer to "no Go test could catch this".
🟡 Medium
Open(":memory:") uses cache=shared, so every in-memory DB in the process is the same database. dash/store.go:37:
return openDSN("file::memory:?cache=shared", "")My test:
a, _ := Open(":memory:"); b, _ := Open(":memory:")
a.insertBatch([]*Event{{TS:1, SessionID:"only-in-a"}})
// b sees 1 row -> LEAKProduction only opens one, so this is not a live bug today — but it makes :memory: tests leak state into each other (a latent source of the flakiest possible failure), and it means the unwritable-path fallback of two proxies in one process would silently merge. Use file:dashN?mode=memory&cache=private with a per-instance name, or just drop cache=shared (a single *sql.DB connection pool needs it only if you rely on multiple conns seeing the same memory DB — which, with one writer goroutine, you don't... verify against sql.DB's pooling before dropping, since a private in-memory DB is per-connection).
Benchmark ingestion counted 42 runs but only 17 have data — and the UI shows 25 empty rows. I pointed a real proxy at /tmp/cg-runs,/tmp/tb-runs:
proxy log: "ingested benchmark runs runs=17 tasks=652"
/api/benchmarks: 42 rows, 25 with NO arms (e.g. smoke-hd, rerun2, dbg1, final50-v5)
IngestBenchDir inserts a bench_runs row before it knows whether any rows-*.json parsed, then returns tasks=0 — so IngestBenchRoots doesn't count the run, but the row is already committed. The result is a Benchmarks tab padded with 25 contentless entries and a log line that disagrees with the UI. The PR body's "42 historical runs ingested" is really "17 ingested, 25 empty shells". Fix: only commit the run row if tasks > 0 (or len(rowFiles) > 0), and make the counter and the table agree.
On the flagged basename-collision concern: I checked, and there are no basename collisions across those two roots today, so it's a latent issue rather than a live one. Keying on the full path (or root+basename) is a two-line fix; not a blocker.
Redaction is a denylist for content, and it rots — as predicted. Header and config redaction are properly allowlist-based (correct, and RedactHeaders is genuinely blanket-by-key). But contentSecrets is a pattern denylist, and it is the one place secrets from arbitrary agent output land. I threw 22 realistic credential shapes at RedactContent; 11 got through:
LEAK Authorization: Bearer SECRET… -> "Authorization: «redacted» SECRET…" (!! the value survives)
LEAK {"api_key": "SECRET…"} -> unchanged (JSON form: name is quoted, #7 needs \b[A-Z0-9_]*)
LEAK {"apiKey":"SECRET…"} -> unchanged
LEAK https://user:SECRET@host/path -> unchanged (basic-auth URL)
LEAK postgres://admin:SECRET@db/x -> unchanged
LEAK {"private_key_id":"SECRET…"} -> unchanged (GCP service-account JSON)
LEAK DefaultEndpointsProtocol=…;AccountKey=SECRET… -> unchanged (Azure)
LEAK glpat-SECRET… / sk_live_… / hf_… -> unchanged (GitLab PAT, Stripe, HuggingFace)
The Authorization: Bearer one is the most alarming: regex #8 matches authorization\s*:\s*\S+ and \S+ stops at the space before the token, so the replacement redacts the word "Bearer" and leaves the credential. Please add a test for that exact string.
The JSON-form misses are one character each — #7's leading \b[A-Z0-9_]* won't cross the ". Suggest: also match ["']?[A-Za-z0-9_]*(api_?key|secret|token|password|private_key)[A-Za-z0-9_]*["']?\s*[:=], add a ://user:pass@ URL rule, and add the well-known prefixes (glpat-, sk_live_, hf_, AIza, ya29.) to the prefix alternation. Note the config path correctly refuses anthropic_upstream: https://user:SECRET@… — no wait, it doesn't: my nested-config test showed that one leaking too, because the key is allowlisted and only the key name is checked, never the value. A credential-in-URL value check on allowlisted string values would close both.
To be fair on what redaction does get right: the DB canary test (redact_test.go:262) is real, end-to-end, and asserts on every stored column; I confirmed my sk-ant-…/Bearer … request headers do not reach the database at all (strings dash.db | grep SECRETCANARY → 0 hits), because headers are never captured in the first place. Redaction is on the write path, as required. The gap is specifically arbitrary-content patterns.
Options.Mode and Options.Preset are set in main.go:98 and never read. proxy/dashcapture.go:55 takes the preset from h.opts.Preset instead, and mode comes from Event.FromTrace. Dead config surface — delete both fields, or read them.
🟢 Minor / non-blocking
GET /api/benchmarks?refresh=1performs a filesystem scan and DB writes on an unauthenticated GET. Aggregates being open is the documented design, but a mutating GET behind no gate is worth putting behinda.trusted(r)(and arguably POST).Prune's size rule runsVACUUMinside the writer goroutine'sselectloop, up to 8 times.VACUUMrewrites the whole file and blocks; on a 512 MiB DB that stalls capture for seconds and starts dropping events. Capture won't add request latency (good), but the drop counter will spike for reasons the UI attributes to load. Considerincremental_vacuumor accepting a looser size bound.- The overview's time-series charts degenerate to a single plotted point when all data falls in one bucket (my whole replay was inside one minute). Not wrong, but a 1-point line chart with no fallback message reads as broken; consider a "not enough time range to chart" empty state.
percentile()interpolates nothing and filterscol > 0, so p95 latency silently excludes zero-latency requests. Fine, but it means p95 is over a different population than the mean beside it.cg_latency_msrecorded 876,759 ms for my 400 KB-blob request. That's a real (pre-existing) pipeline cost, not a dashboard bug — but the dashboard is now the thing that surfaces it, and the request-detail view renders it as "876759 ms" rather than "14.6 min".dur()handles hours; the drawer isn't using it for this field.- On the flagged per-request LLM-cost attribution via a process-global counter delta: not a blocker. It is documented, it's approximate only under concurrency, and
cg_llm_cost_usdis ~0.3% of the figures involved. Same for clear-on-overflow vs LRU (markedponytail:, honest failure mode) and the 1,500-line diff fallback (marked, with a stated ceiling).observemode being plumbed-but-unused is correctly attributed to #31.
Scope
54 files is large but mostly coherent — dash/ is 16 files of one new package and the UI is 3. The genuinely separable pieces are: (a) the modelinfo per-entry decode fix → its own PR, per above; (b) benchmark ingestion (dash/bench.go + the Benchmarks tab, ~600 lines) is independent of per-request capture and would have been a clean third PR, as the issue itself suggested. I would not ask you to re-split the storage/API/UI at this point — the commits are already readable in that order and re-splitting now costs more than it buys.
What I verified myself (vs took on trust)
Verified by running it:
BodyFullbyte-identity — the highest-risk change: PASSES. I compiled the same test into bothorigin/mainand this branch, ran 96 configurations (6 presets × cache auto/on/off × bypass on/off × 4 conversation turns with a stateful store), and diffed SHA-256 of every output: byte-identical, zero differences. Also checked every caller (proxy.chat,proxy.compact,adapters/bifrost) — all route throughBodyFull→BodyTrace→bodyFulland the trace is genuinely write-only.attemptedTokensdoes run unconditionally even with the dashboard off, but it'stokens.Counton already-memoized strings; I benchmarkedBodyFullon a 1.4 MB body across both branches (30 iters × 3): 891/891/902 ms vs 929/897/900 ms — no regression.go test -race ./...: 20/20 green.go test -race -count=5 ./dash/: green, 135 s, no races.- Writer lifecycle, my own tests:
Close()does flush the tail batch (7 events with a 1-hour flush interval all landed);RecordafterCloseneither panics nor blocks (5,000 late calls, instant); an unwritable path does degrade to:memory:instead of failing the proxy. Drop counter isatomic.Int64. SSEPublishis non-blocking with per-client buffer + evict, so a hung client cannot stall the writer. All correct. /statsbackward compat: golden test is genuinely bidirectional (loses-a-field fails AND gains-a-field fails). I independently grepped everydeploy/harbor/*.pyreader —measure.py,replay.py,replay2.py,swebench.py,sweep.pyread 16 distinct keys, all present, none renamed. Snapshot changes are additive only. Verified safe.mkdocs build --strict: clean. All 13 screenshots referenced, none orphaned, every--dashboard*flag andDASHBOARD_*env var documented, all 12 documented API routes return 200. Docs describe implemented behaviour — except the overhead claim (Blocker 2).- All 18 committed screenshots scanned byte-wise (
strings) forsk-ant|sk-…|ghp_|AKIA|Bearer|api_key: 0 hits, and no EXIF/comment metadata. Clean. - Model resolution before/after, live, against the real LiteLLM doc (see above).
UI, driven with Playwright against my own build on port 4190 (40 real requests replayed from /tmp/cg-runs/capture-swebench.jsonl, read-only, + 42 bench dirs ingested):
- All six views render with real data; 0 console errors, 0 page errors, 0 off-origin requests across every probe.
data-testidon every tile as the issue required. - CSP verified live:
default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'— and nothing violates it (thesetStyle-via-CSSOM trick is a nice touch). - XSS attempt fully blocked. I sent a request with session id
<img src=x onerror=window.__XSS=1>"><script>window.__XSS2=1</script>and a<script>in tool_result content, then opened the row in the drawer:__XSS/__XSS2/__XSS3allfalse, 1 script tag (the app's own), 0 injected imgs, and the markup renders as literal text.innerHTMLappears exactly once inapp.js— in a comment saying not to use it.el()throws on anhtmlprop. Genuinely good. - Request detail + before/after diff is the best thing in this PR — real Git-style hunks with line numbers over actual replayed content, all three modes (Git diff / side-by-side / after-only) working, per-component table with outcomes (
skipped/acted/mutated only). - Dark mode:
rgb(15,18,22), correct. 390 px viewport:scrollWidth == clientWidth == 390, no horizontal overflow. Long session ids (300 chars) don't break layout. Empty result set renders "0 / — / unknown" with "no priced requests", not zeros pretending to be data.
Took on trust: the "124 browser checks" count (I ran my own ~30 instead), the exact BenchmarkRecord numbers (I reproduced the conclusion for the channel send, not the digits), and that make lint is clean.
This is genuinely high-quality work — the metric semantics are thought through far past what the issue asked for, the honesty machinery (token_accounting, overcount_ratio, uncompressed_reason, cold-start-is-not-a-failure, four labelled denominators each carrying its own prose description, SafetyCost beside its benefit) is all present and correct, and the byte-identity of the rewrite path holds under 96 configurations. Fix the dollar denominator and get redaction off the request goroutine, and I'd merge it.
|
Superseded by #57. Same work, rebased onto Blockers, each with the regression test that would have caught it:
Also: unique Thanks for the review — the verification work in it (96-configuration byte-identity, the per-regex timings, the 22 shapes, the Playwright pass) is what made these findable, and every one of them reproduced exactly as described. |
Closes #30
context-guru had no dashboard: the entire observability surface was
GET /stats, onein-memory
Aggregatorsnapshot that vanished on restart. A user could not answer thequestion the product exists to answer — what value is context-guru providing?
This adds a persistent dashboard: durable per-request storage, an off-hot-path capture
pipeline, a filterable JSON/SSE API, and an embedded single-page UI.
--dashboardturns iton; without the flag the route table is byte-identical to before and
/statsis unchanged.What is here
Storage — SQLite via
modernc.org/sqlite(pure Go, no extra C toolchain), WAL mode.requests·request_components·request_content(gzip, size-capped, skippable) ·bench_runs/bench_tasks. Epoch-ms timestamps throughout; a formatted locale stringcannot be range-queried, sorted portably, or bucketed. Retention bounded by age and
size. Schema versioned, with a mismatch renaming the old file aside rather than refusing to
boot.
--dashboard-db :memory:for no-persistence, which is also the automatic fallbackwhen the path is unwritable.
Capture off the hot path — the handler builds one struct from values the request path
already computed and does a channel send with a
default:branch. A full queue drops andcounts; the drop count is surfaced in the UI, because a dashboard that hides its own
coverage gaps cannot be trusted about anything else. One writer goroutine batches inserts
and fans summaries to SSE.
API —
/api/stats,/api/series?bucket=,/api/requests(server-side filters +keyset pagination),
/api/requests/{id},/api/sessions,/api/components,/api/facets,/api/config,/api/benchmarks[/{id}/tasks],/api/capture,/api/events(SSE honoring
Last-Event-ID). No rollup tables — series are bucketed in SQL at query time.UI — three files via
go:embed. No npm, no bundler, no framework, and no CDN:context-guru ships into VPC and air-gapped contexts, so a test fails on any external origin
and the assets are served under
default-src 'self'. Charts are hand-drawn SVG. Designtokens are CSS custom properties from line one, so dark mode redefines only tokens.
Metric semantics — the actual point
overcount_ratiosurfacedtoken_accountingper rowcomplete|partial|missing. An unpriceable request reads unknown — never zero, never exacthit · cold_start · ttl_expiry · prefix_change · unknown; cold start is not a failure, TTL wins tiesbypassed · below_trigger · cache_frozen · found_nothing · reverted · no_messagesCost is computed at write time so history does not reprice; percentages at read time
so a filter change needs no rebuild.
The headline feature: the before/after diff
Both reference implementations carry this data and neither renders it.
LCS diff with common head/tail trimmed first, Git-style hunks with line numbers and
collapsed unchanged runs, plus side-by-side and after-only modes. Blocks are ordered
biggest-saving first and the largest opens — leading with an unchanged block buries the
answer.
Per-component economics
On the real traffic above the verdicts do their job:
extractis earning its place,cacheinjectmutates, saves no content (its win is provider-side), and a component thatburned wall time for nothing reads costly and inert.
Benchmarks
Ingested straight from the harness artifacts (
summary.json+rows-<arm>.json) — no newexport format, so 42 historical runs from
/tmp/tb-runsand/tmp/cg-runsingested onfirst boot (652 task rows). Per arm: solve rate, mean reward, total/mean cost, cost per
solve, cache hit rate, exceptions, with a cost-vs-reward scatter and per-task drill-down.
Re-ingesting replaces a run rather than duplicating it.
Sessions, requests, config, dark mode
Measured capture overhead
The issue asks for this explicitly, since a dashboard that costs request latency is a
regression in a tool that sells latency awareness.
~175 ns per request on the request goroutine — against a measured 8.4 s mean upstream
round trip on this workload, that is ~0.000002%.
BenchmarkRecord's 7.5 µs includes thebenchmark's own
&Event{}allocation, which the handler does regardless. The full-queue(drop) path is 388 ns, so an overloaded dashboard degrades instead of becoming a latency
incident. A regression test fails above 50 µs, so moving I/O onto the capture path breaks CI.
Security
Redaction happens before the database, never on read — a secret on disk is a secret
forever, and a redact-on-read filter is one forgotten code path from leaking it. Headers are
blanket-redacted by key against a short allowlist (a denylist fails the moment a gateway
invents a new auth header); config keys are allowlisted with credential-named keys always
withheld; content is pattern-scrubbed then capped, so a secret at the truncation
boundary cannot survive by being cut into an unmatchable prefix.
Per-request content and the effective config are loopback/trusted-CIDR only; aggregates stay
open, because a proxy bound to
0.0.0.0should still report its own numbers. There isdeliberately no "disable observability in production" switch.
Real-data validation (not fixtures)
Everything above is rendered from real traffic through this proxy on port 4107:
claude-codetranscripts, against the live provider — so all four billed token tiers, the prompt-cache
structure, cold starts and TTL expiries are real, not synthesised.
claudeCLI session through the proxy.drill-down verified.
Observed: 4 sessions · 120k → 106k content tokens · 14k gross / 2.0k unique saved
(overcount 7.0×) · 328k cache reads vs 144k writes · $0.46 baseline vs $0.44 actual ·
accounting 32
complete/ 9partial· cache misses attributed 26 hit / 8 unknown /4 cold_start / 3 ttl_expiry.
Tests
dash: schema migration + version-mismatch preservation, retention by age and size(with cascade), keyset pagination covering every row exactly once, every filter
dimension, query-time bucketing, redaction (incl. a canary written through the real
capture path and read back from every column), the capture channel dropping rather than
blocking with the drop counted, SSE fan-out with a hung client evicted,
Last-Event-IDbackfill, cache attribution, benchmark ingest from real harness-shaped artifacts.
proxy: golden/statsshape in both directions (a lost field fails; a new fieldfails until recorded), usage parsing in both dialects, the streaming sniffer, and
end-to-end capture through a real pipeline.
go test -race ./...— 20/20 packages green, including the capture path and SSE hub.make lintclean;mkdocs build --strictclean.Browser verification: 124 checks, 0 failures against the live app — every stat tile's
data-testid, chart rendering and tooltips, filters, pagination, the drawer, all three diffmodes, benchmark drill-down, dark/light, a 390 px viewport, and empty states. Every
screenshot was scanned for credentials byte-wise and by OCR before being committed.
Bugs found and fixed along the way
Four real defects, each with a regression test:
internal/modelinfonever resolved anything in production. The LiteLLM prices mapwas decoded into one typed
map[string]struct{…}, which fails on the document'sprose-filled
sample_specentry and on models that spell an integer as a float —yielding nothing, so every context-window lookup silently returned "unknown" and
fraction-based triggers have never fired. Per-entry decode keeps the ~2,900 good rows;
the resolved rates now match the harness's published price vector exactly. (Pre-existing,
unrelated to the dashboard.)
message_delta, which carries the final completion count — parsed as "no usage".usage for every large streamed response.
app.jsrendered a blank dashboard. Go compiled, every Go testpassed, the HTML served 200. There is now a test that parses
app.js(node when present,a bracket-balance scan otherwise).
Plus two redaction over-reaches caught by the browser checks: redacting the
componentssubtree wholesale made the config view show nothing, and a substring match on
tokenalsoswallowed
max_tokens/min_tokens. Safety that destroys the feature is not safety.Notes for review
apply.BodyFullnow delegates toapply.BodyTrace, so the rewrite is byte-identicalwhether or not anyone is looking. No behaviour change on the compaction path.
metrics.Snapshotgains fields only — never renames or removals — guarded by the goldentest, because
deploy/harbor/*.pyparses that payload by name.modernc.org/sqlite(pure Go).Open concerns
process-global cheap-model counter, so under concurrency a request can be charged a
neighbour's call. Correct in aggregate, approximate per row. Fixing it properly needs the
Modelinterface to carry a per-request label.Recorder.Observe/MarkUniqueclear their maps on overflow rather than evicting LRU(marked with
ponytail:comments). A reset re-reports a cold start, which is honest butloses attribution precision; it needs a real LRU only if session churn gets large.
is O(n·m). A real Myers O(nd) would remove the ceiling.
under different roots collide.
modecolumn and filter are in place, but onlyactive/bypassoccur today —observeneeds feat(proxy): three operating modes — sync, async (cache-safe deferred compaction), and observe (measure without enforcing) #31.