From 62ebc28e3f45af4945929c33b07f3ffd22f1a223 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 14:38:16 +0200 Subject: [PATCH] feat(stats): aggregate token/cost totals in cache-analyzer --- lib/stats/cache-analyzer.mjs | 24 ++++++++++ tests/lib/cache-analyzer.test.mjs | 78 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tests/lib/cache-analyzer.test.mjs diff --git a/lib/stats/cache-analyzer.mjs b/lib/stats/cache-analyzer.mjs index 609ce72..2e5c7f4 100644 --- a/lib/stats/cache-analyzer.mjs +++ b/lib/stats/cache-analyzer.mjs @@ -43,6 +43,16 @@ export function summarize(lines) { unique_event_names: [], oldest_event_iso: null, newest_event_iso: null, + // SKAL-2 token/cost aggregation (additive; zero when no token records). + // With upsert semantics (one record per session_id), summing across lines + // = correct cross-session aggregate. cost_usd is summed only when finite; + // a null (refuse-to-estimate) record still counts in sessions_with_tokens. + total_tokens_input: 0, + total_tokens_output: 0, + total_tokens_cache_creation: 0, + total_tokens_cache_read: 0, + total_cost_usd: 0, + sessions_with_tokens: 0, }; const durations = []; @@ -71,6 +81,20 @@ export function summarize(lines) { if (newestMs === null || t > newestMs) newestMs = t; } } + + // SKAL-2: aggregate token/cost from token-bearing records (token-usage + // schema). Detect by presence of any numeric token field. + const tokenKeys = ['tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read']; + const hasTokens = tokenKeys.some(k => typeof obj[k] === 'number' && Number.isFinite(obj[k])); + if (hasTokens) { + summary.sessions_with_tokens++; + if (Number.isFinite(obj.tokens_input)) summary.total_tokens_input += obj.tokens_input; + if (Number.isFinite(obj.tokens_output)) summary.total_tokens_output += obj.tokens_output; + if (Number.isFinite(obj.tokens_cache_creation)) summary.total_tokens_cache_creation += obj.tokens_cache_creation; + if (Number.isFinite(obj.tokens_cache_read)) summary.total_tokens_cache_read += obj.tokens_cache_read; + // cost_usd may be null (refuse-to-estimate) — sum finite values only. + if (Number.isFinite(obj.cost_usd)) summary.total_cost_usd += obj.cost_usd; + } } if (durations.length > 0) { diff --git a/tests/lib/cache-analyzer.test.mjs b/tests/lib/cache-analyzer.test.mjs new file mode 100644 index 0000000..17b4292 --- /dev/null +++ b/tests/lib/cache-analyzer.test.mjs @@ -0,0 +1,78 @@ +// tests/lib/cache-analyzer.test.mjs +// SKAL-2 Step 3 — token/cost aggregation in cache-analyzer's summarize(). +// Also the first test coverage for cache-analyzer (previously zero-coverage): +// includes a regression guard that the pre-existing duration/event-name fields +// still compute correctly on mixed input. +// +// Hermetic: no LLM, network, time, or randomness. Idiom: +// tests/lib/token-usage.test.mjs (pure-module node:test style). + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { summarize } from '../../lib/stats/cache-analyzer.mjs'; + +const jsonl = (objs) => objs.map(o => JSON.stringify(o)); + +// ---- token/cost aggregation ------------------------------------------------ + +test('summarize — two token-bearing records → summed totals + cost', () => { + const s = summarize(jsonl([ + { ts: '2026-06-26T12:00:00Z', session_id: 'S1', scope: 'main-context', tokens_input: 1000, tokens_output: 200, tokens_cache_creation: 400, tokens_cache_read: 5000, cost_usd: 0.03675 }, + { ts: '2026-06-26T13:00:00Z', session_id: 'S2', scope: 'main-context', tokens_input: 2000, tokens_output: 300, tokens_cache_creation: 600, tokens_cache_read: 1000, cost_usd: 0.05 }, + ])); + assert.equal(s.total_tokens_input, 3000); + assert.equal(s.total_tokens_output, 500); + assert.equal(s.total_tokens_cache_creation, 1000); + assert.equal(s.total_tokens_cache_read, 6000); + assert.equal(s.sessions_with_tokens, 2); + assert.ok(Math.abs(s.total_cost_usd - 0.08675) < 1e-9, `cost ${s.total_cost_usd}`); +}); + +test('summarize — cost_usd:null counts the session but is excluded from total_cost_usd', () => { + const s = summarize(jsonl([ + { session_id: 'S1', tokens_input: 100, cost_usd: 0.01 }, + { session_id: 'S2', tokens_input: 200, cost_usd: null, is_estimate: true }, + ])); + assert.equal(s.sessions_with_tokens, 2, 'null-cost session must still be counted'); + assert.equal(s.total_tokens_input, 300); + assert.ok(Math.abs(s.total_cost_usd - 0.01) < 1e-9, `cost ${s.total_cost_usd}`); +}); + +test('summarize — no token records → token totals stay zero (additive default)', () => { + const s = summarize(jsonl([ + { event: 'main-merge-gate', duration_ms: 100 }, + ])); + assert.equal(s.total_tokens_input, 0); + assert.equal(s.total_cost_usd, 0); + assert.equal(s.sessions_with_tokens, 0); +}); + +// ---- regression guard: pre-existing fields still computed ------------------ + +test('summarize — existing duration/event-name fields still correct on mixed input', () => { + const s = summarize(jsonl([ + { event: 'main-merge-gate', duration_ms: 100, ts: '2026-06-26T10:00:00Z' }, + { event: 'main-merge-approved', duration_ms: 200, ts: '2026-06-26T11:00:00Z' }, + { session_id: 'S1', tokens_input: 1000, cost_usd: 0.04, ts: '2026-06-26T12:00:00Z' }, + ])); + // Pre-existing behavior (regression guard) + assert.equal(s.total_events, 3); + assert.equal(s.events_with_duration, 2); + assert.equal(s.wall_time_ms_max, 200); + assert.deepEqual(s.unique_event_names, ['main-merge-approved', 'main-merge-gate']); + // New behavior coexists + assert.equal(s.total_tokens_input, 1000); + assert.equal(s.sessions_with_tokens, 1); + assert.ok(Math.abs(s.total_cost_usd - 0.04) < 1e-9); +}); + +test('summarize — malformed/empty lines tolerated alongside token records', () => { + const s = summarize([ + '', + 'not json', + JSON.stringify({ session_id: 'S1', tokens_input: 50, cost_usd: 0.001 }), + ]); + assert.equal(s.total_events, 1); + assert.equal(s.sessions_with_tokens, 1); + assert.equal(s.total_tokens_input, 50); +});