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;
+147
View File
@@ -0,0 +1,147 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { writeFileAtomic } = require('./atomic-write');
const { sanitizeSessionId } = require('./session-bridge');
const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1';
const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots';
const COST_LOG_FILENAME = 'costs.jsonl';
function assertSafeSessionId(sessionId) {
if (sanitizeSessionId(sessionId) !== sessionId) {
throw new Error('Cost snapshot requires a safe session ID');
}
}
function getCostSnapshotPath(metricsDir, sessionId) {
assertSafeSessionId(sessionId);
// Prefix the filename so Windows device names such as CON/NUL/COM1 never
// become the basename, even when they are otherwise valid session IDs.
return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY, `session-${sessionId}.json`);
}
function getCostLogSignature(metricsDir) {
const stat = fs.statSync(path.join(metricsDir, COST_LOG_FILENAME));
return {
size_bytes: stat.size,
mtime_ms: stat.mtimeMs
};
}
function signaturesMatch(left, right) {
return left?.size_bytes === right?.size_bytes
&& left?.mtime_ms === right?.mtime_ms;
}
function isValidCostRow(row, sessionId) {
return row?.session_id === sessionId
&& typeof row.estimated_cost_usd === 'number'
&& Number.isFinite(row.estimated_cost_usd)
&& row.estimated_cost_usd >= 0
&& typeof row.input_tokens === 'number'
&& Number.isFinite(row.input_tokens)
&& row.input_tokens >= 0
&& typeof row.output_tokens === 'number'
&& Number.isFinite(row.output_tokens)
&& row.output_tokens >= 0;
}
function assertCostLogSignature(source) {
if (!Number.isSafeInteger(source?.size_bytes) || source.size_bytes < 0) {
throw new Error('Cost snapshot requires a valid source size');
}
if (!Number.isFinite(source?.mtime_ms) || source.mtime_ms < 0) {
throw new Error('Cost snapshot requires a valid source mtime');
}
}
function writeSnapshotForSource(metricsDir, sessionId, row, source) {
assertSafeSessionId(sessionId);
if (!isValidCostRow(row, sessionId)) {
throw new Error('Cost snapshot requires valid non-negative numeric totals for its session');
}
const snapshotPath = getCostSnapshotPath(metricsDir, sessionId);
assertCostLogSignature(source);
return writeFileAtomic(
snapshotPath,
JSON.stringify({
schema_version: COST_SNAPSHOT_SCHEMA_VERSION,
source,
row
}),
{
beforeRename() {
if (!signaturesMatch(source, getCostLogSignature(metricsDir))) {
throw new Error('Cost log changed while publishing its session snapshot');
}
}
}
);
}
function costLogEndsWithRow(metricsDir, row) {
const expected = Buffer.from(`${JSON.stringify(row)}\n`, 'utf8');
const descriptor = fs.openSync(path.join(metricsDir, COST_LOG_FILENAME), 'r');
try {
const stat = fs.fstatSync(descriptor);
if (stat.size < expected.length) return false;
const actual = Buffer.allocUnsafe(expected.length);
const bytesRead = fs.readSync(
descriptor,
actual,
0,
expected.length,
stat.size - expected.length
);
return bytesRead === expected.length && actual.equals(expected);
} finally {
fs.closeSync(descriptor);
}
}
function publishAppendedSessionCostSnapshot(metricsDir, sessionId, row) {
assertSafeSessionId(sessionId);
if (!isValidCostRow(row, sessionId)) {
throw new Error('Cost snapshot requires valid non-negative numeric totals for its session');
}
const sourceBefore = getCostLogSignature(metricsDir);
if (!costLogEndsWithRow(metricsDir, row)) return false;
const sourceAfter = getCostLogSignature(metricsDir);
if (!signaturesMatch(sourceBefore, sourceAfter)) return false;
writeSnapshotForSource(metricsDir, sessionId, row, sourceAfter);
return true;
}
function repairSessionCostSnapshot(metricsDir, sessionId, row, source) {
writeSnapshotForSource(metricsDir, sessionId, row, source);
}
function readSessionCostSnapshot(metricsDir, sessionId) {
try {
const snapshot = JSON.parse(
fs.readFileSync(getCostSnapshotPath(metricsDir, sessionId), 'utf8')
);
if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null;
if (!isValidCostRow(snapshot.row, sessionId)) return null;
if (!signaturesMatch(snapshot.source, getCostLogSignature(metricsDir))) return null;
return snapshot.row;
} catch {
return null;
}
}
module.exports = {
COST_SNAPSHOT_SCHEMA_VERSION,
COST_SNAPSHOT_DIRECTORY,
COST_LOG_FILENAME,
getCostSnapshotPath,
getCostLogSignature,
signaturesMatch,
isValidCostRow,
publishAppendedSessionCostSnapshot,
repairSessionCostSnapshot,
readSessionCostSnapshot
};