mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
Compare commits
6 Commits
pr-2052
...
fix/metric
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d7067ff5 | ||
|
|
ef13690fc3 | ||
|
|
8cb9dac8ec | ||
|
|
cd176504d3 | ||
|
|
44e13541fa | ||
|
|
e61bb043ed |
@@ -11,6 +11,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
const { getClaudeDir } = require('../lib/utils');
|
||||
@@ -19,6 +20,7 @@ const MAX_STDIN = 1024 * 1024;
|
||||
const MAX_FILES_TRACKED = 200;
|
||||
const RECENT_TOOLS_SIZE = 5;
|
||||
const HASH_INPUT_LIMIT = 2048;
|
||||
const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-';
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
@@ -76,41 +78,104 @@ function extractFilePaths(toolName, toolInput) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
function getCostWarningCachePath(costsPath) {
|
||||
const hash = crypto.createHash('sha256').update(costsPath).digest('hex').slice(0, 16);
|
||||
return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${hash}.json`);
|
||||
}
|
||||
|
||||
function readCostWarningCache(cachePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeCostWarningIfChanged(kind, costsPath, signature, message) {
|
||||
const cachePath = getCostWarningCachePath(costsPath);
|
||||
const cache = readCostWarningCache(cachePath);
|
||||
if (cache[kind] === signature) return;
|
||||
|
||||
process.stderr.write(message);
|
||||
try {
|
||||
const next = { ...cache, [kind]: signature };
|
||||
const tmp = `${cachePath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(next), 'utf8');
|
||||
fs.renameSync(tmp, cachePath);
|
||||
} catch {
|
||||
// Warning-cache persistence is best effort; never block hook execution.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cumulative cost for a session from the tail of costs.jsonl.
|
||||
* Reads last 8KB to avoid scanning entire file.
|
||||
* Read cumulative cost for a session from costs.jsonl.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
function readSessionCost(sessionId) {
|
||||
let costsPath = path.join('metrics', 'costs.jsonl');
|
||||
try {
|
||||
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const stat = fs.statSync(costsPath);
|
||||
const readSize = Math.min(stat.size, 8192);
|
||||
const fd = fs.openSync(costsPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
||||
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
||||
costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const content = fs.readFileSync(costsPath, 'utf8');
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed lines */
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
let malformed = 0;
|
||||
const malformedHasher = 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);
|
||||
}
|
||||
} catch {
|
||||
malformed += 1;
|
||||
malformedHasher.update(line).update('\0');
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
// One aggregated breadcrumb per call rather than one per bad row, so a
|
||||
// log-flooded costs.jsonl stays diagnosable without overwhelming stderr.
|
||||
// Suppress repeats for the same malformed-line signature across hook
|
||||
// subprocesses, so a persistent bad row should not spam stderr.
|
||||
if (malformed > 0) {
|
||||
writeCostWarningIfChanged(
|
||||
'malformed',
|
||||
costsPath,
|
||||
`${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`,
|
||||
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
|
||||
);
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
} catch (err) {
|
||||
// ENOENT is the common case (no Stop event has fired yet this session)
|
||||
// and is not actually a failure — stay silent on it. Anything else
|
||||
// (permission, EISDIR, malformed read) deserves a breadcrumb because
|
||||
// the bridge will silently report zero cost otherwise.
|
||||
if (err && err.code !== 'ENOENT') {
|
||||
writeCostWarningIfChanged(
|
||||
'read-error',
|
||||
costsPath,
|
||||
`${err.code || err.name || 'error'}:${err.message || String(err)}`,
|
||||
`[ecc-metrics-bridge] failing open after ${err.name || 'error'} reading ${costsPath}: ${err.message || String(err)}\n`
|
||||
);
|
||||
}
|
||||
return { totalCost: 0, totalIn: 0, totalOut: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
@@ -145,6 +146,216 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost returns the LAST cumulative row, not the sum (cost-tracker contract)', () => {
|
||||
// cost-tracker.js writes one row per Stop event; each row is already
|
||||
// a cumulative session total ("To get per-session cost, take the
|
||||
// last row per session_id."). Summing across rows over-counts:
|
||||
// 0.01 + 0.02 + 0.03 = 0.06, but the correct answer is 0.03.
|
||||
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 });
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
[
|
||||
JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.01, input_tokens: 333, output_tokens: 166 }),
|
||||
JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.02, input_tokens: 666, output_tokens: 333 }),
|
||||
JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.03, input_tokens: 1000, output_tokens: 500 })
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
const result = readSessionCost('S1');
|
||||
assert.strictEqual(result.totalCost, 0.03, `expected last-row 0.03, got ${result.totalCost} (was the bug: 0.06)`);
|
||||
assert.strictEqual(result.totalIn, 1000);
|
||||
assert.strictEqual(result.totalOut, 500);
|
||||
} 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 finds session row beyond the old 8 KiB tail boundary', () => {
|
||||
// The previous implementation read only the trailing 8 KiB of
|
||||
// costs.jsonl. A long-running deployment where the target session's
|
||||
// most recent cumulative row sat further back than that — e.g.
|
||||
// pushed past by many rows from OTHER sessions — silently saw
|
||||
// cost=0. This test wedges the S1 row at the file start, fills
|
||||
// ~16 KiB of OTHER-session noise after it, and asserts the S1 row
|
||||
// is still found.
|
||||
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 otherRow = JSON.stringify({ session_id: 'OTHER', estimated_cost_usd: 1, input_tokens: 100, output_tokens: 50 });
|
||||
const s1Row = JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.5, input_tokens: 500, output_tokens: 250 });
|
||||
const rows = [s1Row, ...Array(200).fill(otherRow)];
|
||||
fs.writeFileSync(path.join(metricsDir, 'costs.jsonl'), rows.join('\n') + '\n', 'utf8');
|
||||
// Confirm we're actually past the old 8 KiB ceiling so the test
|
||||
// would have failed under the previous implementation.
|
||||
const size = fs.statSync(path.join(metricsDir, 'costs.jsonl')).size;
|
||||
assert.ok(size > 8192, `setup: expected costs.jsonl > 8 KiB, got ${size} bytes`);
|
||||
const result = readSessionCost('S1');
|
||||
assert.strictEqual(result.totalCost, 0.5);
|
||||
assert.strictEqual(result.totalIn, 500);
|
||||
assert.strictEqual(result.totalOut, 250);
|
||||
} 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 writes one stderr breadcrumb when malformed lines persist across calls', () => {
|
||||
// Reviewer (coderabbitai) asked for diagnosability when the inner
|
||||
// catch silently skips malformed JSON rows. Verify the aggregated
|
||||
// "skipped N malformed line(s)" breadcrumb appears on stderr while
|
||||
// the function still recovers the last valid matching row. Because
|
||||
// this hook runs after every tool invocation, the same bad rows should
|
||||
// not emit the same warning on every call.
|
||||
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 });
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
[
|
||||
JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.5, input_tokens: 500, output_tokens: 250 }),
|
||||
'NOT_JSON',
|
||||
'{"truncated":',
|
||||
JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.7, input_tokens: 700, output_tokens: 350 }),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
const result = readSessionCost('S1');
|
||||
assert.strictEqual(result.totalCost, 0.7, 'last valid row should still win');
|
||||
const secondResult = readSessionCost('S1');
|
||||
assert.deepStrictEqual(secondResult, result);
|
||||
const matches = captured.match(/skipped 2 malformed line\(s\)/g) || [];
|
||||
assert.strictEqual(matches.length, 1,
|
||||
`expected one aggregated malformed-line breadcrumb on stderr, got: ${captured}`);
|
||||
} 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 suppresses repeated malformed breadcrumbs across hook subprocesses', () => {
|
||||
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 });
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
[
|
||||
JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.7, input_tokens: 700, output_tokens: 350 }),
|
||||
'NOT_JSON',
|
||||
'{"truncated":'
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const bridgePath = path.resolve(__dirname, '../../scripts/hooks/ecc-metrics-bridge');
|
||||
const code = "const { readSessionCost } = require(process.argv[1]); readSessionCost('S1');";
|
||||
const env = { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome };
|
||||
const first = spawnSync(process.execPath, ['-e', code, bridgePath], { env, encoding: 'utf8' });
|
||||
const second = spawnSync(process.execPath, ['-e', code, bridgePath], { env, encoding: 'utf8' });
|
||||
|
||||
assert.strictEqual(first.status, 0, first.stderr || first.stdout);
|
||||
assert.strictEqual(second.status, 0, second.stderr || second.stdout);
|
||||
assert.match(first.stderr, /skipped 2 malformed line\(s\)/);
|
||||
assert.strictEqual(second.stderr, '', `expected repeat subprocess warning suppression, got: ${second.stderr}`);
|
||||
} 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 stays silent when costs.jsonl does not exist (ENOENT)', () => {
|
||||
// ENOENT is the common case before any Stop event has fired — it is
|
||||
// not a failure and should not produce stderr noise. Other errors
|
||||
// (permission, EISDIR, etc.) DO produce a breadcrumb, covered by the
|
||||
// malformed-line test above's surrounding harness.
|
||||
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;
|
||||
// Do NOT create the metrics dir or file — readSessionCost should
|
||||
// hit ENOENT and return zeros silently.
|
||||
const result = readSessionCost('S1');
|
||||
assert.deepStrictEqual(result, { totalCost: 0, totalIn: 0, totalOut: 0 });
|
||||
assert.strictEqual(captured, '', `expected no stderr on ENOENT, got: ${captured}`);
|
||||
} 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 does not include unrelated default-session rows', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
|
||||
Reference in New Issue
Block a user