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
|
||||
};
|
||||
@@ -17,6 +17,11 @@ The tracker appends one JSON object per session-stop to
|
||||
session**, so to total spend you take the **latest row per `session_id`** and
|
||||
sum across sessions — summing every row multiply-counts.
|
||||
|
||||
ECC also maintains internal per-session files under
|
||||
`~/.claude/metrics/cost-snapshots/` so runtime hooks can read the current
|
||||
session total without rescanning all history. Treat those files as a
|
||||
rebuildable cache; reports and exports should continue to use `costs.jsonl`.
|
||||
|
||||
Row schema:
|
||||
|
||||
| Field | Meaning |
|
||||
|
||||
@@ -9,6 +9,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { getCostSnapshotPath } = require('../../scripts/lib/session-cost-snapshot');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js');
|
||||
|
||||
@@ -115,6 +116,32 @@ function runTests() {
|
||||
assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input');
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('keeps JSONL authoritative when the snapshot path cannot be published', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const blockedSnapshotPath = path.join(
|
||||
metricsDir,
|
||||
'cost-snapshots',
|
||||
'snapshot-failure.json'
|
||||
);
|
||||
fs.mkdirSync(blockedSnapshotPath, { recursive: true });
|
||||
|
||||
try {
|
||||
const result = runScript(
|
||||
{ session_id: 'snapshot-failure' },
|
||||
withTempHome(tmpHome)
|
||||
);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const rows = fs.readFileSync(path.join(metricsDir, 'costs.jsonl'), 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => JSON.parse(line));
|
||||
assert.strictEqual(rows.at(-1).session_id, 'snapshot-failure');
|
||||
} finally {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 2. Creates metrics file when given transcript usage data
|
||||
(test('creates metrics file when given transcript usage data', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
@@ -154,6 +181,7 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
const metricsDir = path.dirname(metricsFile);
|
||||
assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`);
|
||||
|
||||
const content = fs.readFileSync(metricsFile, 'utf8').trim();
|
||||
@@ -169,6 +197,12 @@ function runTests() {
|
||||
assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number');
|
||||
assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive');
|
||||
|
||||
const snapshotFile = getCostSnapshotPath(metricsDir, 'session-from-hook');
|
||||
assert.ok(fs.existsSync(snapshotFile), 'Expected an O(1) per-session cost snapshot');
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
||||
assert.strictEqual(snapshot.schema_version, 'ecc.cost-snapshot.v1');
|
||||
assert.deepStrictEqual(snapshot.row, row, 'Snapshot must mirror the appended cumulative row');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge');
|
||||
const {
|
||||
getCostSnapshotPath,
|
||||
publishAppendedSessionCostSnapshot
|
||||
} = require('../../scripts/lib/session-cost-snapshot');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
@@ -233,6 +237,252 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost uses the per-session snapshot without scanning historical JSONL', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const snapshotsDir = path.join(metricsDir, 'cost-snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
const snapshotRow = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 0.75,
|
||||
input_tokens: 750,
|
||||
output_tokens: 375
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${JSON.stringify(snapshotRow)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(
|
||||
publishAppendedSessionCostSnapshot(metricsDir, 'S1', snapshotRow),
|
||||
true
|
||||
);
|
||||
|
||||
fs.readFileSync = function guardedRead(filePath, ...args) {
|
||||
if (path.basename(String(filePath)) === 'costs.jsonl') {
|
||||
throw new Error('historical JSONL scan should be bypassed on a snapshot hit');
|
||||
}
|
||||
return originalReadFileSync.call(this, filePath, ...args);
|
||||
};
|
||||
|
||||
const result = readSessionCost('S1');
|
||||
assert.deepStrictEqual(result, { totalCost: 0.75, totalIn: 750, totalOut: 375 });
|
||||
} finally {
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost ignores a stale snapshot after costs.jsonl advances', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
fs.mkdirSync(metricsDir, { recursive: true });
|
||||
const first = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 1,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
};
|
||||
const latest = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 2,
|
||||
input_tokens: 200,
|
||||
output_tokens: 100
|
||||
};
|
||||
const costsPath = path.join(metricsDir, 'costs.jsonl');
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(first)}\n`, 'utf8');
|
||||
publishAppendedSessionCostSnapshot(metricsDir, 'S1', first);
|
||||
fs.appendFileSync(costsPath, `${JSON.stringify(latest)}\n`, 'utf8');
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 2,
|
||||
totalIn: 200,
|
||||
totalOut: 100
|
||||
});
|
||||
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
fs.readFileSync = function guardedRead(filePath, ...args) {
|
||||
if (path.basename(String(filePath)) === 'costs.jsonl') {
|
||||
throw new Error('fallback should repair the session snapshot');
|
||||
}
|
||||
return originalReadFileSync.call(this, filePath, ...args);
|
||||
};
|
||||
try {
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 2,
|
||||
totalIn: 200,
|
||||
totalOut: 100
|
||||
});
|
||||
} finally {
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
}
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost falls back to JSONL when the session snapshot is malformed', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const snapshotsDir = path.join(metricsDir, 'cost-snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
fs.writeFileSync(getCostSnapshotPath(metricsDir, 'S1'), '{broken', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${JSON.stringify({
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 1.5,
|
||||
input_tokens: 1500,
|
||||
output_tokens: 750
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 1.5,
|
||||
totalIn: 1500,
|
||||
totalOut: 750
|
||||
});
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost falls back when a snapshot row has invalid numeric totals', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const snapshotsDir = path.join(metricsDir, 'cost-snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
const valid = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 2,
|
||||
input_tokens: 200,
|
||||
output_tokens: 100
|
||||
};
|
||||
const costsPath = path.join(metricsDir, 'costs.jsonl');
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(valid)}\n`, 'utf8');
|
||||
const stat = fs.statSync(costsPath);
|
||||
fs.writeFileSync(
|
||||
getCostSnapshotPath(metricsDir, 'S1'),
|
||||
JSON.stringify({
|
||||
schema_version: 'ecc.cost-snapshot.v1',
|
||||
source: { size_bytes: stat.size, mtime_ms: stat.mtimeMs },
|
||||
row: { session_id: 'S1' }
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 2,
|
||||
totalIn: 200,
|
||||
totalOut: 100
|
||||
});
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost skips invalid cumulative JSONL rows after the last valid total', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
let captured = '';
|
||||
process.stderr.write = chunk => {
|
||||
captured += String(chunk);
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
fs.mkdirSync(metricsDir, { recursive: true });
|
||||
const rows = [
|
||||
{ session_id: 'S1', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 },
|
||||
{ session_id: 'S1', estimated_cost_usd: -999, input_tokens: 'invalid', output_tokens: -5 },
|
||||
{ session_id: 'S1', input_tokens: 300, output_tokens: 150 },
|
||||
{ session_id: 'S1', estimated_cost_usd: null, input_tokens: 400, output_tokens: 200 },
|
||||
{ session_id: 'OTHER', estimated_cost_usd: -1, input_tokens: -1, output_tokens: -1 }
|
||||
];
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${rows.map(row => JSON.stringify(row)).join('\n')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 2,
|
||||
totalIn: 200,
|
||||
totalOut: 100
|
||||
});
|
||||
assert.match(captured, /skipped 3 invalid cumulative row\(s\) for S1/);
|
||||
} finally {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost finds session row beyond the old 8 KiB tail boundary', () => {
|
||||
// The previous implementation read only the trailing 8 KiB of
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
COST_SNAPSHOT_SCHEMA_VERSION,
|
||||
getCostSnapshotPath,
|
||||
publishAppendedSessionCostSnapshot,
|
||||
readSessionCostSnapshot,
|
||||
} = require('../../scripts/lib/session-cost-snapshot');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-snapshot-'));
|
||||
const costLogPath = path.join(root, 'costs.jsonl');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
if (test('round-trips a versioned cumulative row atomically', () => {
|
||||
const row = {
|
||||
session_id: 'session-1',
|
||||
estimated_cost_usd: 1.25,
|
||||
input_tokens: 10,
|
||||
output_tokens: 20
|
||||
};
|
||||
fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8');
|
||||
assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-1', row), true);
|
||||
const filePath = getCostSnapshotPath(root, 'session-1');
|
||||
assert.strictEqual(filePath, getCostSnapshotPath(root, 'session-1'));
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1'), row);
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(path.dirname(filePath)).filter(name => name.endsWith('.tmp')),
|
||||
[]
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('replaces the previous cumulative row for the same session', () => {
|
||||
const first = {
|
||||
session_id: 'session-update',
|
||||
estimated_cost_usd: 1,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
};
|
||||
fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8');
|
||||
assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', first), true);
|
||||
const latest = {
|
||||
session_id: 'session-update',
|
||||
estimated_cost_usd: 2,
|
||||
input_tokens: 200,
|
||||
output_tokens: 100
|
||||
};
|
||||
fs.appendFileSync(costLogPath, `${JSON.stringify(latest)}\n`, 'utf8');
|
||||
assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', latest), true);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-update'), latest);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('invalidates a snapshot when the append-only cost log advances', () => {
|
||||
const first = {
|
||||
session_id: 'session-stale',
|
||||
estimated_cost_usd: 1,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
};
|
||||
fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8');
|
||||
assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-stale', first), true);
|
||||
fs.appendFileSync(
|
||||
costLogPath,
|
||||
`${JSON.stringify({ session_id: 'session-stale', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 })}\n`,
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(readSessionCostSnapshot(root, 'session-stale'), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('a delayed older writer cannot overwrite the newest session row', () => {
|
||||
const older = { session_id: 'session-race', estimated_cost_usd: 1, input_tokens: 100, output_tokens: 50 };
|
||||
const newer = { session_id: 'session-race', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 };
|
||||
fs.writeFileSync(costLogPath, `${JSON.stringify(older)}\n`, 'utf8');
|
||||
fs.appendFileSync(costLogPath, `${JSON.stringify(newer)}\n`, 'utf8');
|
||||
assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', newer), true);
|
||||
assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', older), false);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-race'), newer);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unsafe session IDs instead of escaping the snapshot directory', () => {
|
||||
const unsafeId = '../outside';
|
||||
const row = { session_id: unsafeId, estimated_cost_usd: 9, input_tokens: 9, output_tokens: 9 };
|
||||
fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8');
|
||||
assert.throws(
|
||||
() => publishAppendedSessionCostSnapshot(root, unsafeId, row),
|
||||
/safe session ID/
|
||||
);
|
||||
|
||||
const escapedPath = path.join(root, 'outside.json');
|
||||
fs.writeFileSync(
|
||||
escapedPath,
|
||||
JSON.stringify({ schema_version: COST_SNAPSHOT_SCHEMA_VERSION, row }),
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(readSessionCostSnapshot(root, unsafeId), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prefixes Windows reserved device names with a safe basename', () => {
|
||||
assert.strictEqual(path.basename(getCostSnapshotPath(root, 'CON')), 'session-CON.json');
|
||||
assert.strictEqual(path.basename(getCostSnapshotPath(root, 'nul')), 'session-nul.json');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a snapshot whose row is bound to another session', () => {
|
||||
const filePath = getCostSnapshotPath(root, 'session-2');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
schema_version: COST_SNAPSHOT_SCHEMA_VERSION,
|
||||
row: { session_id: 'session-3', estimated_cost_usd: 3 }
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(readSessionCostSnapshot(root, 'session-2'), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects rows with missing, non-numeric, or negative totals', () => {
|
||||
const invalidRows = [
|
||||
{ session_id: 'invalid-row', input_tokens: 1, output_tokens: 1 },
|
||||
{ session_id: 'invalid-row', estimated_cost_usd: '1', input_tokens: 1, output_tokens: 1 },
|
||||
{ session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: -1, output_tokens: 1 },
|
||||
{ session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: 1, output_tokens: Infinity }
|
||||
];
|
||||
for (const row of invalidRows) {
|
||||
fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8');
|
||||
assert.throws(
|
||||
() => publishAppendedSessionCostSnapshot(root, 'invalid-row', row),
|
||||
/valid non-negative numeric totals/
|
||||
);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown schemas and malformed JSON', () => {
|
||||
const filePath = getCostSnapshotPath(root, 'session-4');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
schema_version: 'ecc.cost-snapshot.v999',
|
||||
row: { session_id: 'session-4' }
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null);
|
||||
fs.writeFileSync(filePath, '{broken', 'utf8');
|
||||
assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null);
|
||||
})) passed++; else failed++;
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user