mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-19 23:33:07 +08:00
fix(hooks): persist metrics warning dedup
This commit is contained in:
committed by
Affaan Mustafa
parent
4cafdb8304
commit
9b1d891870
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||||
const { getClaudeDir } = require('../lib/utils');
|
const { getClaudeDir } = require('../lib/utils');
|
||||||
@@ -19,7 +20,7 @@ const MAX_STDIN = 1024 * 1024;
|
|||||||
const MAX_FILES_TRACKED = 200;
|
const MAX_FILES_TRACKED = 200;
|
||||||
const RECENT_TOOLS_SIZE = 5;
|
const RECENT_TOOLS_SIZE = 5;
|
||||||
const HASH_INPUT_LIMIT = 2048;
|
const HASH_INPUT_LIMIT = 2048;
|
||||||
const costWarningSignatures = new Map();
|
const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-';
|
||||||
|
|
||||||
function toNumber(value) {
|
function toNumber(value) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
@@ -77,11 +78,34 @@ function extractFilePaths(toolName, toolInput) {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCostWarningOnce(kind, costsPath, signature, message) {
|
function getCostWarningCachePath(costsPath) {
|
||||||
const key = `${kind}:${costsPath}`;
|
const hash = crypto.createHash('sha256').update(costsPath).digest('hex').slice(0, 16);
|
||||||
if (costWarningSignatures.get(key) === signature) return;
|
return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${hash}.json`);
|
||||||
costWarningSignatures.set(key, signature);
|
}
|
||||||
|
|
||||||
|
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);
|
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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,8 +126,9 @@ function writeCostWarningOnce(kind, costsPath, signature, message) {
|
|||||||
* even cheaper.
|
* even cheaper.
|
||||||
*/
|
*/
|
||||||
function readSessionCost(sessionId) {
|
function readSessionCost(sessionId) {
|
||||||
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
let costsPath = path.join('metrics', 'costs.jsonl');
|
||||||
try {
|
try {
|
||||||
|
costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||||
const content = fs.readFileSync(costsPath, 'utf8');
|
const content = fs.readFileSync(costsPath, 'utf8');
|
||||||
const lines = content.split('\n').filter(Boolean);
|
const lines = content.split('\n').filter(Boolean);
|
||||||
|
|
||||||
@@ -111,6 +136,7 @@ function readSessionCost(sessionId) {
|
|||||||
let totalIn = 0;
|
let totalIn = 0;
|
||||||
let totalOut = 0;
|
let totalOut = 0;
|
||||||
let malformed = 0;
|
let malformed = 0;
|
||||||
|
const malformedHasher = crypto.createHash('sha256');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const row = JSON.parse(line);
|
const row = JSON.parse(line);
|
||||||
@@ -121,17 +147,18 @@ function readSessionCost(sessionId) {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
malformed += 1;
|
malformed += 1;
|
||||||
|
malformedHasher.update(line).update('\0');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// One aggregated breadcrumb per call rather than one per bad row, so a
|
// One aggregated breadcrumb per call rather than one per bad row, so a
|
||||||
// log-flooded costs.jsonl stays diagnosable without overwhelming stderr.
|
// log-flooded costs.jsonl stays diagnosable without overwhelming stderr.
|
||||||
// Suppress repeats for the same malformed-line count; this hook runs after
|
// Suppress repeats for the same malformed-line signature across hook
|
||||||
// every tool invocation, so a persistent bad row should not spam stderr.
|
// subprocesses, so a persistent bad row should not spam stderr.
|
||||||
if (malformed > 0) {
|
if (malformed > 0) {
|
||||||
writeCostWarningOnce(
|
writeCostWarningIfChanged(
|
||||||
'malformed',
|
'malformed',
|
||||||
costsPath,
|
costsPath,
|
||||||
String(malformed),
|
`${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`,
|
||||||
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
|
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,10 +169,10 @@ function readSessionCost(sessionId) {
|
|||||||
// (permission, EISDIR, malformed read) deserves a breadcrumb because
|
// (permission, EISDIR, malformed read) deserves a breadcrumb because
|
||||||
// the bridge will silently report zero cost otherwise.
|
// the bridge will silently report zero cost otherwise.
|
||||||
if (err && err.code !== 'ENOENT') {
|
if (err && err.code !== 'ENOENT') {
|
||||||
writeCostWarningOnce(
|
writeCostWarningIfChanged(
|
||||||
'read-error',
|
'read-error',
|
||||||
costsPath,
|
costsPath,
|
||||||
err.code || err.name || 'error',
|
`${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`
|
`[ecc-metrics-bridge] failing open after ${err.name || 'error'} reading ${costsPath}: ${err.message || String(err)}\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -277,6 +278,48 @@ function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
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 (
|
if (
|
||||||
test('readSessionCost stays silent when costs.jsonl does not exist (ENOENT)', () => {
|
test('readSessionCost stays silent when costs.jsonl does not exist (ENOENT)', () => {
|
||||||
// ENOENT is the common case before any Stop event has fired — it is
|
// ENOENT is the common case before any Stop event has fired — it is
|
||||||
|
|||||||
Reference in New Issue
Block a user