feat(dashboard): per-session dollar-weighted savings panel with bucket + passthrough breakdown

This commit is contained in:
teamchong
2026-05-24 13:59:57 -04:00
parent 0cf2680d62
commit fb13de7c9b
8 changed files with 495 additions and 37 deletions
+36 -36
View File
File diff suppressed because one or more lines are too long
+162 -1
View File
@@ -142,6 +142,31 @@ export interface RecentRow {
* output×5 in input-token-equivalents. The dashboard headline matches it
* so a "20% saved" number means weekly-limit consumption dropped by 20%,
* not "20% off the slice we touched while the other half stayed full." */
/**
* Per-session aggregate. Same dollar-weighted savings math as the global
* `Totals` block, but partitioned by `info.firstUserSha8` so the dashboard
* can show "what's happening RIGHT NOW" instead of stale lifetime numbers.
* Field names mirror the JSON wire shape served by `serveCurrentSessionJson`.
*/
interface SessionTotals {
sessionId: string;
firstSeen: number; // unix seconds
lastSeen: number; // unix seconds
requests: number;
// Dollar-weighted accumulators. baseline/actual are the SAME math as the
// global `Totals.allBaselineEquivalentWeighted` / `allActualInputWeighted`
// + `allOutputWeighted`, but scoped to a single session.
baselineInputWeighted: number;
actualInputWeighted: number;
outputWeighted: number;
// Per-bucket char attribution from ev.info.bucketChars.
bucketChars: { [bucket: string]: number };
// Passthrough reason histogram from ev.info.passthroughReasons.
passthroughReasons: Record<string, number>;
passthroughRequests: number; // count of requests where !ev.info.compressed
compressedRequests: number;
}
interface Totals {
requests: number;
compressedRequests: number;
@@ -258,10 +283,24 @@ const OUTPUT_TOKEN_RATE = 5.0;
* actual target model; do not trust hardcoded chars-per-token or
* tokens-per-image constants on 4.7 without verifying against the
* upstream probe. */
const ASSUMED_INPUT_USD_PER_MTOK = 5.0;
export const ASSUMED_INPUT_USD_PER_MTOK = 5.0;
export class DashboardState {
private recent: RecentRow[] = [];
/** Per-session dollar-weighted totals, keyed by `info.firstUserSha8`. The
* dashboard surfaces ONLY the most-recently-active session via the
* `serveCurrentSessionJson` endpoint — older sessions linger in the Map
* so a tab refresh during a brief lull still finds the previous session,
* but get evicted at `SESSION_CAP` to bound memory in long-running hosts. */
private sessions: Map<string, SessionTotals> = new Map();
/** sha8 of the most-recently-active session id. null when no events have
* ever carried a `firstUserSha8` (e.g. a cold start with only passthrough
* hits that the upstream probe never tagged). */
private currentSessionId: string | null = null;
/** Hard cap on `sessions` Map entries. Keeps memory bounded in
* long-running deployments. 50 sessions × ~13 numeric fields each is
* comfortably under a MB even with fat bucket/passthrough histograms. */
private static readonly SESSION_CAP = 50;
private totals: Totals = {
requests: 0,
compressedRequests: 0,
@@ -460,6 +499,75 @@ export class DashboardState {
}
}
// Per-session aggregation. Uses the SAME baseline/actual/output math as
// the global accumulators above, partitioned by `info.firstUserSha8`
// so the dashboard's "current session" panel can show what's happening
// RIGHT NOW instead of stale lifetime numbers. Untagged events (no
// firstUserSha8 — cold start, passthrough probe failures) are skipped
// rather than bucketed into a synthetic "unknown" session.
const sid = info?.firstUserSha8;
if (typeof sid === 'string' && sid.length > 0) {
this.currentSessionId = sid;
let s = this.sessions.get(sid);
if (!s) {
s = {
sessionId: sid,
firstSeen: Date.now() / 1000,
lastSeen: Date.now() / 1000,
requests: 0,
baselineInputWeighted: 0,
actualInputWeighted: 0,
outputWeighted: 0,
bucketChars: {},
passthroughReasons: {},
passthroughRequests: 0,
compressedRequests: 0,
};
this.sessions.set(sid, s);
// Cap memory — drop the oldest session by lastSeen when over budget.
if (this.sessions.size > DashboardState.SESSION_CAP) {
const oldest = [...this.sessions.values()].sort(
(a, b) => a.lastSeen - b.lastSeen,
)[0];
if (oldest) this.sessions.delete(oldest.sessionId);
}
}
s.lastSeen = Date.now() / 1000;
s.requests += 1;
// Reuse the same haveUsage / haveBaseline guards + the
// baselineInputEff / actualInputEff / outputEquiv locals computed
// earlier in update() so the lifetime totals block (above) and the
// per-session block (here) read the same values. Re-deriving them
// here would duplicate the cache-aware-baseline math and invite drift.
if (haveBaseline && haveUsage) {
s.baselineInputWeighted += baselineInputEff;
s.actualInputWeighted += actualInputEff;
s.outputWeighted += outputEquiv;
}
if (compressed) s.compressedRequests += 1;
else s.passthroughRequests += 1;
// Per-bucket char attribution from `info.bucketChars` (flat
// `Partial<Record<BucketName, number>>` — see src/core/tracker.ts).
// Matches the shape the Svelte panel iterates over.
const bc = info?.bucketChars;
if (bc && typeof bc === 'object') {
for (const [key, val] of Object.entries(bc) as [string, unknown][]) {
if (typeof val === 'number' && Number.isFinite(val)) {
s.bucketChars[key] = (s.bucketChars[key] ?? 0) + val;
}
}
}
// Passthrough reason histogram from `info.passthroughReasons`.
const pr = info?.passthroughReasons;
if (pr && typeof pr === 'object') {
for (const [reason, count] of Object.entries(pr as Record<string, unknown>)) {
if (typeof count === 'number' && Number.isFinite(count) && count > 0) {
s.passthroughReasons[reason] = (s.passthroughReasons[reason] ?? 0) + count;
}
}
}
}
// Measurement totals are independent of usage/baseline gating — they
// accumulate whenever the scanner produced numbers. The scanner sets
// measurement to undefined on 5xx (no body to scan) and on unknown
@@ -561,6 +669,57 @@ export class DashboardState {
// ---- HTTP handlers ------------------------------------------------------
/**
* Per-session "what's happening right now" payload backing the
* `SessionSummary` panel. Scopes the dollar-weighted savings ratio + the
* per-bucket char attribution + the passthrough-reason histogram to the
* most-recently-active session (tracked via `info.firstUserSha8`) so the
* top-of-dashboard headline reflects the live session rather than stale
* lifetime aggregates from a previous run.
*
* Returns `{ sessionId: null, message: 'no active session yet' }` when no
* events have been received yet (or the first events were all untagged —
* cold start, probe failures) — `update()` only sets `currentSessionId`
* when a `firstUserSha8`-tagged event lands. The client renders a stale
* panel rather than zeroes when a session goes idle (NO `lastSeen >
* threshold` check here; see comment in `update()` for the rationale).
*/
serveCurrentSessionJson(): Response {
if (!this.currentSessionId) {
return jsonResponse({
sessionId: null,
message: 'no active session yet',
});
}
const s = this.sessions.get(this.currentSessionId);
if (!s) {
return jsonResponse({ sessionId: null, message: 'no active session yet' });
}
// Dollar-weighted savings ratio. Same math as the global `saved_pct`
// (compressed AND passthrough requests — passthrough has baseline=actual,
// so it correctly drags the ratio down when we leak). Honest denominator:
// includes both compressed and uncompressed flows that the proxy saw.
const baselineUsd = (s.baselineInputWeighted * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
const actualUsd = (s.actualInputWeighted * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
const savedUsd = baselineUsd - actualUsd;
const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;
return jsonResponse({
sessionId: s.sessionId,
firstSeen: s.firstSeen,
lastSeen: s.lastSeen,
uptimeSec: Math.max(0, s.lastSeen - s.firstSeen),
requests: s.requests,
compressedRequests: s.compressedRequests,
passthroughRequests: s.passthroughRequests,
baselineUsd: round4(baselineUsd),
actualUsd: round4(actualUsd),
savedUsd: round4(savedUsd),
savedPct: round1(savedPct),
bucketChars: s.bucketChars,
passthroughReasons: s.passthroughReasons,
});
}
serveStats(): Response {
// Two headline numbers, derived from the same per-event accumulators:
//
@@ -836,6 +995,7 @@ export type DashboardRoute =
| { kind: 'png' } // /proxy-latest-png
| { kind: 'api-sessions' } // /api/sessions.json
| { kind: 'api-stats' } // /api/stats.json
| { kind: 'current-session' } // /api/current-session.json
| { kind: 'api-compression' }; // /api/compression (POST {enabled}) — runtime kill switch
/** Match dashboard paths (handle query strings on /proxy-latest-png). */
@@ -846,6 +1006,7 @@ export function dashboardPath(pathname: string): DashboardRoute | null {
if (pathname === '/proxy-latest-png') return { kind: 'png' };
if (pathname === '/api/sessions.json') return { kind: 'api-sessions' };
if (pathname === '/api/stats.json') return { kind: 'api-stats' };
if (pathname === '/api/current-session.json') return { kind: 'current-session' };
if (pathname === '/api/compression') return { kind: 'api-compression' };
return null;
}
+3
View File
@@ -10,6 +10,7 @@
import Sessions from './components/Sessions.svelte';
import StatsTable from './components/StatsTable.svelte';
import CompressionToggle from './components/CompressionToggle.svelte';
import SessionSummary from './components/SessionSummary.svelte';
import ToastTray from './components/ToastTray.svelte';
import { stats } from './stores/index.js';
@@ -34,6 +35,8 @@
<CompressionToggle />
<SessionSummary />
<StatsHeader />
<div class="row">
@@ -0,0 +1,263 @@
<script lang="ts">
// Top-of-dashboard headline. Dollar-weighted savings ratio scoped to the
// most-recently-active session, with per-bucket breakdown of where the
// savings came from and a list of passthrough reasons for requests we
// didn't compress. Polls 2s via the `currentSession` store.
import { currentSession } from '../stores/index.js';
$: data = $currentSession.data;
$: err = $currentSession.error;
$: hasSession = data && data.sessionId != null;
function fmtUsd(n: number): string {
return '$' + n.toFixed(2);
}
function fmtPct(n: number): string {
return n.toFixed(1) + '%';
}
function fmtDuration(sec: number): string {
if (sec < 60) return Math.round(sec) + 's';
const m = Math.floor(sec / 60);
if (m < 60) return m + 'm';
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
}
function fmtChars(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
return (n / 1_000_000).toFixed(1) + 'M';
}
$: bucketTotal = data?.bucketChars
? Object.values(data.bucketChars).reduce((a, b) => a + b, 0)
: 0;
$: bucketEntries = data?.bucketChars
? (Object.entries(data.bucketChars) as [string, number][])
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1])
: [];
$: passthroughEntries = data?.passthroughReasons
? (Object.entries(data.passthroughReasons) as [string, number][])
.sort((a, b) => b[1] - a[1])
: [];
// Static key→label / key→color maps. The Record type matches the spec's
// `bucketChars` shape so any new bucket added in tracker.ts will show up
// in legend order. Hex codes chosen to match the legacy panel palette.
const BUCKET_LABELS: Record<string, string> = {
static_slab: 'slab',
reminder: 'reminder',
tool_result: 'tool_result',
history: 'history',
billing: 'billing',
dynamic: 'dynamic',
};
const BUCKET_COLORS: Record<string, string> = {
static_slab: '#b9be0',
reminder: '#85c5f6',
tool_result: '#10b981',
history: '#f59e0b',
billing: '#8e7681',
dynamic: '#ef4444',
};
</script>
<div class="panel session-summary">
{#if err}
<div class="error">error: {err}</div>
{:else if !data}
<div class="loading">loading…</div>
{:else if !hasSession}
<h2>This session</h2>
<div class="empty">{data.message ?? 'no active session yet'}</div>
{:else}
<div class="header">
<h2>This session</h2>
<div class="sub">
started {fmtDuration(data.uptimeSec ?? 0)} ago · {data.requests} requests
</div>
</div>
<div class="headline">
<div class="big">
Saved <span class="pct">{fmtPct(data.savedPct ?? 0)}</span>
</div>
<div class="usd">
{fmtUsd(data.savedUsd ?? 0)} of {fmtUsd(data.baselineUsd ?? 0)} baseline
</div>
</div>
<div class="bar">
<div class="bar-fill" style="width: {Math.max(0, Math.min(100, data.savedPct ?? 0))}%"></div>
</div>
{#if bucketEntries.length > 0}
<div class="section">
<div class="section-label">Where it came from</div>
<div class="bucket-bar">
{#each bucketEntries as [key, val]}
{@const w = bucketTotal > 0 ? (val / bucketTotal) * 100 : 0}
<div
class="bucket-seg"
style="width: {w}%; background: {BUCKET_COLORS[key] ?? '#8e7681'}"
title="{BUCKET_LABELS[key] ?? key}: {fmtChars(val)} chars ({w.toFixed(1)}%)"
></div>
{/each}
</div>
<div class="bucket-legend">
{#each bucketEntries as [key, val]}
{@const w = bucketTotal > 0 ? (val / bucketTotal) * 100 : 0}
<span class="legend-item">
<span class="swatch" style="background: {BUCKET_COLORS[key] ?? '#8e7681'}"></span>
{BUCKET_LABELS[key] ?? key} {w.toFixed(0)}%
</span>
{/each}
</div>
</div>
{/if}
{#if passthroughEntries.length > 0}
<div class="section">
<div class="section-label">
Where it didn't · {data.passthroughRequests ?? 0} of {data.requests} requests passed through
</div>
<ul class="passthrough-list">
{#each passthroughEntries as [reason, count]}
<li><span class="reason">{reason}</span> <span class="count">{count}</span></li>
{/each}
</ul>
</div>
{:else if (data.passthroughRequests ?? 0) === 0}
<div class="section">
<div class="section-label-good">✓ Every request compressed</div>
</div>
{/if}
{/if}
</div>
<style>
.session-summary {
margin-bottom: 22px;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8e7681;
}
.sub {
font-size: 12px;
color: #8e7681;
}
.headline {
margin-bottom: 10px;
}
.big {
font-size: 18px;
font-weight: 600;
color: #c9d1d9;
}
.pct {
color: #3fb950;
font-variant-numeric: tabular-nums;
}
.usd {
font-size: 12px;
color: #8e7681;
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
.bar {
margin-top: 8px;
height: 6px;
background: #21262d;
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #3fb950;
transition: width 1s ease;
}
.section {
margin-top: 16px;
}
.section-label {
font-size: 11px;
color: #8e7681;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.section-label-good {
color: #3fb950;
text-transform: none;
letter-spacing: 0;
font-size: 12px;
}
.bucket-bar {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
background: #21262d;
}
.bucket-seg {
height: 100%;
transition: width 1s ease;
}
.bucket-legend {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 11px;
color: #c9d1d9;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.swatch {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
}
.passthrough-list {
margin: 0;
padding: 0;
list-style: none;
font-size: 12px;
color: #c9d1d9;
}
.passthrough-list li {
display: flex;
justify-content: space-between;
padding: 2px 0;
font-variant-numeric: tabular-nums;
}
.reason {
color: #8e7681;
}
.count {
color: #8e7681;
}
.loading, .empty, .error {
color: #8e7681;
font-size: 12px;
}
.error {
color: #f85149;
}
</style>
+1
View File
@@ -14,6 +14,7 @@ export const API = {
latestPng: '/proxy-latest-png',
sessions: '/api/sessions.json',
fullStats: '/api/stats.json',
currentSession: '/api/current-session.json',
compressionToggle: '/api/compression',
} as const;
+2
View File
@@ -11,6 +11,7 @@ import type {
RecentPayload,
SessionsPayload,
FullStatsPayload,
CurrentSessionPayload,
} from '../types.js';
// Live counters + recent table (legacy poll cadence: 2s).
@@ -20,6 +21,7 @@ export const recent = pollJson<RecentPayload>('/proxy-recent', 2000);
// Slower endpoints (legacy: 5s).
export const sessions = pollJson<SessionsPayload>('/api/sessions.json', 5000);
export const fullStats = pollJson<FullStatsPayload>('/api/stats.json', 5000);
export const currentSession = pollJson<CurrentSessionPayload>('/api/current-session.json', 2000);
// when null the image viewer follows the latest render; when set it pins that image id. ui-only state.
export const selectedImageId = writable<number | null>(null);
+25
View File
@@ -152,3 +152,28 @@ export interface FullStatsSummary {
export interface CompressionToggleResponse {
compression_enabled: boolean;
}
/** /api/current-session.json payload — per-session aggregates for the most-recently-active Claude Code session. */
export interface CurrentSessionPayload {
sessionId: string | null;
message?: string;
firstSeen?: number;
lastSeen?: number;
uptimeSec?: number;
requests?: number;
compressedRequests?: number;
passthroughRequests?: number;
baselineUsd?: number;
actualUsd?: number;
savedUsd?: number;
savedPct?: number;
bucketChars?: {
static_slab: number;
reminder: number;
tool_result: number;
history: number;
billing: number;
dynamic: number;
};
passthroughReasons?: Record<string, number>;
}
+3
View File
@@ -202,6 +202,9 @@ async function dispatchDashboard(
case 'api-stats':
if (method !== 'GET') return undefined;
return dashboard.serveApiStats();
case 'current-session':
if (method !== 'GET') return undefined;
return dashboard.serveCurrentSessionJson();
case 'api-compression': {
if (method !== 'POST') {
return new Response(