mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
perf(metrics): cache cumulative session costs
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user