perf(metrics): cache cumulative session costs

This commit is contained in:
wellkilo
2026-09-13 01:52:26 +08:00
parent 8321021c54
commit e1805d7deb
7 changed files with 672 additions and 19 deletions
+10 -1
View File
@@ -4,7 +4,9 @@
*
* Reads transcript_path from Stop hook stdin, sums usage across all
* assistant turns in the session JSONL, and appends one row to
* ~/.claude/metrics/costs.jsonl.
* ~/.claude/metrics/costs.jsonl. It also atomically publishes the latest
* cumulative row under metrics/cost-snapshots/ so frequent PostToolUse
* hooks do not need to rescan the unbounded history.
*
* Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... }
* The Stop payload does NOT include `usage` or `model` directly. The previous
@@ -42,6 +44,7 @@ const os = require('os');
const path = require('path');
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
const { sanitizeSessionId } = require('../lib/session-bridge');
const { publishAppendedSessionCostSnapshot } = require('../lib/session-cost-snapshot');
const HARNESS_COST_MAX_AGE_SECONDS = 300;
@@ -243,6 +246,12 @@ process.stdin.on('end', () => {
};
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
try {
publishAppendedSessionCostSnapshot(metricsDir, sessionId, row);
} catch {
// The append-only log remains authoritative. A later bridge read falls
// back to it when an atomic snapshot cannot be published.
}
} catch {
// Non-blocking — never fail the Stop hook.
}
+56 -18
View File
@@ -14,6 +14,13 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
const {
getCostLogSignature,
isValidCostRow,
readSessionCostSnapshot,
repairSessionCostSnapshot,
signaturesMatch,
} = require('../lib/session-cost-snapshot');
const { getClaudeDir } = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
@@ -134,41 +141,55 @@ function writeCostWarningIfChanged(kind, costsPath, signature, message) {
}
/**
* Read cumulative cost for a session from costs.jsonl.
* Read cumulative cost for a session.
*
* Scans the full file because each row is a cumulative session total
* (see cost-tracker.js docblock) and the row we need is the last one
* matching `sessionId`. The previous implementation read only the
* trailing 8 KiB; any session whose latest cumulative row was pushed
* past that window by newer rows from other sessions silently dropped
* to zero — the opposite sign of the double-count bug fixed in the
* previous commit.
* The Stop hook publishes an atomic per-session snapshot, so the normal
* PostToolUse path reads O(1) data instead of reparsing unbounded history.
* Older ECC installations and damaged/missing snapshots remain compatible:
* they fall back to scanning costs.jsonl for the last cumulative row.
*
* costs.jsonl is append-only and unbounded today (no rotation in
* cost-tracker.js). At a typical ~150 bytes per row, even 100k rows
* is ~15 MB and a single sync read on every PostToolUse hook is in
* the low milliseconds. If rotation lands later, this scan becomes
* even cheaper.
* The fallback deliberately scans the whole file. A fixed tail window loses
* sessions whose newest row has been pushed back by other sessions.
*/
function readSessionCost(sessionId) {
let costsPath = path.join('metrics', 'costs.jsonl');
try {
costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
const metricsDir = path.join(getClaudeDir(), 'metrics');
const snapshot = readSessionCostSnapshot(metricsDir, sessionId);
if (snapshot) {
return {
totalCost: toNumber(snapshot.estimated_cost_usd),
totalIn: toNumber(snapshot.input_tokens),
totalOut: toNumber(snapshot.output_tokens)
};
}
costsPath = path.join(metricsDir, 'costs.jsonl');
const sourceBefore = getCostLogSignature(metricsDir);
const content = fs.readFileSync(costsPath, 'utf8');
const lines = content.split('\n').filter(Boolean);
let totalCost = 0;
let totalIn = 0;
let totalOut = 0;
let latestRow = null;
let malformed = 0;
let invalid = 0;
const malformedHasher = crypto.createHash('sha256');
const invalidHasher = crypto.createHash('sha256');
for (const line of lines) {
try {
const row = JSON.parse(line);
if (row.session_id === sessionId) {
totalCost = toNumber(row.estimated_cost_usd);
totalIn = toNumber(row.input_tokens);
totalOut = toNumber(row.output_tokens);
if (isValidCostRow(row, sessionId)) {
latestRow = row;
totalCost = row.estimated_cost_usd;
totalIn = row.input_tokens;
totalOut = row.output_tokens;
} else {
invalid += 1;
invalidHasher.update(line).update('\0');
}
}
} catch {
malformed += 1;
@@ -187,6 +208,23 @@ function readSessionCost(sessionId) {
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
);
}
if (invalid > 0) {
writeCostWarningIfChanged(
'invalid-row',
costsPath,
`${invalid}:${invalidHasher.digest('hex').slice(0, 16)}`,
`[ecc-metrics-bridge] skipped ${invalid} invalid cumulative row(s) for ${sessionId} in ${costsPath}\n`
);
}
const sourceAfter = getCostLogSignature(metricsDir);
if (latestRow && signaturesMatch(sourceBefore, sourceAfter)) {
try {
repairSessionCostSnapshot(metricsDir, sessionId, latestRow, sourceAfter);
} catch {
// Snapshot repair is best effort; the JSONL result remains valid.
}
}
return { totalCost, totalIn, totalOut };
} catch (err) {
// ENOENT is the common case (no Stop event has fired yet this session)
@@ -259,7 +297,7 @@ function run(rawInput) {
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
bridge.recent_tools = recent;
// Update cost from costs.jsonl tail
// Use the O(1) session snapshot, with JSONL compatibility fallback.
const costs = readSessionCost(sessionId);
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
bridge.total_input_tokens = costs.totalIn;