feat(stats): aggregate token/cost totals in cache-analyzer
This commit is contained in:
parent
a9c442c201
commit
62ebc28e3f
2 changed files with 102 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
78
tests/lib/cache-analyzer.test.mjs
Normal file
78
tests/lib/cache-analyzer.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue