claude-git-hooks 2.8.0 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +6 -0
- package/bin/claude-hooks +78 -3
- package/lib/config.js +1 -0
- package/lib/hooks/pre-commit.js +21 -2
- package/lib/hooks/prepare-commit-msg.js +13 -1
- package/lib/utils/claude-client.js +103 -20
- package/lib/utils/prompt-builder.js +10 -9
- package/lib/utils/resolution-prompt.js +2 -2
- package/lib/utils/telemetry.js +507 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ Todos los cambios notables en este proyecto se documentarán en este archivo.
|
|
|
5
5
|
El formato está basado en [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.9.1] - 2025-12-30
|
|
9
|
+
|
|
10
|
+
### 🐛 Fixed
|
|
11
|
+
|
|
12
|
+
- **Template path mismatch after v2.8.0 migration** - Fixed critical bug where templates were not found after config migration (#51)
|
|
13
|
+
- **What was broken**: After v2.8.0 moved templates from `.claude/` to `.claude/prompts/`, the code still looked in the old location
|
|
14
|
+
- **Root cause**: `prompt-builder.js` and `resolution-prompt.js` had hardcoded `.claude/` paths instead of `.claude/prompts/`
|
|
15
|
+
- **Symptom**: `ENOENT: no such file or directory` errors for `CLAUDE_ANALYSIS_PROMPT.md` and `CLAUDE_RESOLUTION_PROMPT.md`
|
|
16
|
+
- **Fix**: Updated default paths in template loading functions to `.claude/prompts/`
|
|
17
|
+
- **Files changed**:
|
|
18
|
+
- `lib/utils/prompt-builder.js:45,133,223,234-235,244` - Updated `loadTemplate`, `loadPrompt`, `buildAnalysisPrompt` defaults
|
|
19
|
+
- `lib/utils/resolution-prompt.js:183` - Updated `generateResolutionPrompt` template path
|
|
20
|
+
- **Impact**: Pre-commit analysis and resolution prompt generation now work correctly after v2.8.0+ installation
|
|
21
|
+
|
|
22
|
+
### 🎯 User Experience
|
|
23
|
+
|
|
24
|
+
- **Before**: Users upgrading from v2.6.x to v2.8.0+ experienced "Template not found" errors even after successful installation
|
|
25
|
+
- **After**: Templates are found in correct `.claude/prompts/` directory as intended by v2.8.0 changes
|
|
26
|
+
|
|
27
|
+
## [2.9.0] - 2025-12-26
|
|
28
|
+
|
|
29
|
+
### ✨ Added
|
|
30
|
+
|
|
31
|
+
- **Local telemetry system with per-retry tracking** - Enabled by default for debugging JSON parsing failures (#50)
|
|
32
|
+
- **What it does**: Tracks JSON parsing failures and successes with full context, recording EVERY retry attempt individually
|
|
33
|
+
- **Key features**:
|
|
34
|
+
- **Granular tracking**: Records each retry attempt separately (not just final result)
|
|
35
|
+
- **Unique IDs**: Each event has unique ID (timestamp-counter-random) for deduplication
|
|
36
|
+
- **Success flag**: Boolean indicating whether attempt succeeded (true) or failed (false)
|
|
37
|
+
- **Retry metadata**: `retryAttempt` (0-3) and `totalRetries` (max configured)
|
|
38
|
+
- **Error types**: Tracks EXECUTION_ERROR, JSON_PARSE_ERROR, etc.
|
|
39
|
+
- **Hook coverage**: pre-commit, prepare-commit-msg, analyze-diff, create-pr
|
|
40
|
+
- **Privacy-first**: Local-only (`.claude/telemetry/`), no data leaves user's machine, no external transmission
|
|
41
|
+
- **Storage**: JSON lines format with automatic rotation (keeps last 30 days)
|
|
42
|
+
- **CLI commands**:
|
|
43
|
+
- `claude-hooks telemetry show` - Display statistics (failure rate, failures by batch size/model/hook, retry patterns)
|
|
44
|
+
- `claude-hooks telemetry clear` - Reset telemetry data
|
|
45
|
+
- **Enabled by default**: To disable, add `"system": { "telemetry": false }` to `.claude/config.json`
|
|
46
|
+
- **Files added**:
|
|
47
|
+
- `lib/utils/telemetry.js` - Telemetry collection with ID generation, retry tracking, and statistics
|
|
48
|
+
- **Files changed**:
|
|
49
|
+
- `lib/utils/claude-client.js:678-780,788-798` - Modified withRetry() to record telemetry on each attempt
|
|
50
|
+
- `lib/utils/claude-client.js:609-618,845-856` - Removed duplicate telemetry recordings
|
|
51
|
+
- `lib/hooks/pre-commit.js` - Pass telemetry context for analysis
|
|
52
|
+
- `lib/hooks/prepare-commit-msg.js` - Pass telemetry context for commit messages
|
|
53
|
+
- `lib/config.js` - Add telemetry config option (default: true)
|
|
54
|
+
- `bin/claude-hooks:1076-1091,1354-1366` - Add telemetry context to analyze-diff and create-pr
|
|
55
|
+
- `bin/claude-hooks` - Add telemetry CLI commands
|
|
56
|
+
- **Impact**: Enables diagnosis of retry patterns, identifies which retry attempts succeed, tracks transient vs persistent errors, helps optimize batch sizes and models
|
|
57
|
+
|
|
8
58
|
## [2.8.0] - 2025-12-24
|
|
9
59
|
|
|
10
60
|
### 🎨 Changed
|
package/README.md
CHANGED
|
@@ -65,6 +65,12 @@ claude-hooks --debug true
|
|
|
65
65
|
|
|
66
66
|
# Ver estado de debug
|
|
67
67
|
claude-hooks --debug status
|
|
68
|
+
|
|
69
|
+
# Ver estadísticas de telemetría (v2.9.0+, habilitada por defecto)
|
|
70
|
+
claude-hooks telemetry show
|
|
71
|
+
|
|
72
|
+
# Limpiar datos de telemetría (v2.9.0+)
|
|
73
|
+
claude-hooks telemetry clear
|
|
68
74
|
```
|
|
69
75
|
|
|
70
76
|
### 📦 Instalación y Gestión
|
package/bin/claude-hooks
CHANGED
|
@@ -16,6 +16,7 @@ import { getOrPromptTaskId, formatWithTaskId } from '../lib/utils/task-id.js';
|
|
|
16
16
|
import { createPullRequest, getReviewersForFiles, parseGitHubRepo, setupGitHubMcp, getGitHubMcpStatus } from '../lib/utils/github-client.js';
|
|
17
17
|
import { showPRPreview, promptConfirmation, promptMenu, showSuccess, showError, showInfo, showWarning, showSpinner, promptEditField } from '../lib/utils/interactive-ui.js';
|
|
18
18
|
import { setupGitHubMCP } from '../lib/utils/mcp-setup.js';
|
|
19
|
+
import { displayStatistics as showTelemetryStats, clearTelemetry as clearTelemetryData } from '../lib/utils/telemetry.js';
|
|
19
20
|
import logger from '../lib/utils/logger.js';
|
|
20
21
|
|
|
21
22
|
// Why: ES6 modules don't have __dirname, need to recreate it
|
|
@@ -1072,9 +1073,22 @@ async function analyzeDiff(args) {
|
|
|
1072
1073
|
info('Sending to Claude for analysis...');
|
|
1073
1074
|
const startTime = Date.now();
|
|
1074
1075
|
|
|
1076
|
+
// Prepare telemetry context
|
|
1077
|
+
const filesChanged = diffFiles.split('\n').length;
|
|
1078
|
+
const telemetryContext = {
|
|
1079
|
+
fileCount: filesChanged,
|
|
1080
|
+
batchSize: filesChanged,
|
|
1081
|
+
totalBatches: 1,
|
|
1082
|
+
model: subagentModel || 'sonnet',
|
|
1083
|
+
hook: 'analyze-diff'
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1075
1086
|
try {
|
|
1076
|
-
// Use cross-platform executeClaudeWithRetry from claude-client.js
|
|
1077
|
-
const response = await executeClaudeWithRetry(prompt, {
|
|
1087
|
+
// Use cross-platform executeClaudeWithRetry from claude-client.js with telemetry
|
|
1088
|
+
const response = await executeClaudeWithRetry(prompt, {
|
|
1089
|
+
timeout: 180000, // 3 minutes for diff analysis
|
|
1090
|
+
telemetryContext
|
|
1091
|
+
});
|
|
1078
1092
|
|
|
1079
1093
|
// Extract JSON from response using claude-client utility
|
|
1080
1094
|
const result = extractJSON(response);
|
|
@@ -1336,7 +1350,20 @@ async function createPr(args) {
|
|
|
1336
1350
|
|
|
1337
1351
|
showInfo('Generating PR metadata with Claude...');
|
|
1338
1352
|
logger.debug('create-pr', 'Calling Claude with prompt', { promptLength: prompt.length });
|
|
1339
|
-
|
|
1353
|
+
|
|
1354
|
+
// Prepare telemetry context for create-pr
|
|
1355
|
+
const telemetryContext = {
|
|
1356
|
+
fileCount: filesArray.length,
|
|
1357
|
+
batchSize: filesArray.length,
|
|
1358
|
+
totalBatches: 1,
|
|
1359
|
+
model: 'sonnet', // create-pr always uses main model
|
|
1360
|
+
hook: 'create-pr'
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
const response = await executeClaudeWithRetry(prompt, {
|
|
1364
|
+
timeout: 180000,
|
|
1365
|
+
telemetryContext
|
|
1366
|
+
});
|
|
1340
1367
|
logger.debug('create-pr', 'Claude response received', { responseLength: response.length });
|
|
1341
1368
|
|
|
1342
1369
|
const analysisResult = extractJSON(response);
|
|
@@ -1703,6 +1730,7 @@ Commands:
|
|
|
1703
1730
|
presets List all available presets
|
|
1704
1731
|
--set-preset <name> Set the active preset
|
|
1705
1732
|
preset current Show the current active preset
|
|
1733
|
+
telemetry [action] Telemetry management (show or clear)
|
|
1706
1734
|
--debug <value> Set debug mode (true, false, or status)
|
|
1707
1735
|
--version, -v Show the current version
|
|
1708
1736
|
help Show this help
|
|
@@ -1725,6 +1753,8 @@ Examples:
|
|
|
1725
1753
|
claude-hooks presets # List available presets
|
|
1726
1754
|
claude-hooks --set-preset backend # Set backend preset
|
|
1727
1755
|
claude-hooks preset current # Show current preset
|
|
1756
|
+
claude-hooks telemetry show # Show telemetry statistics
|
|
1757
|
+
claude-hooks telemetry clear # Clear telemetry data
|
|
1728
1758
|
claude-hooks --debug true # Enable debug mode
|
|
1729
1759
|
claude-hooks --debug status # Check debug status
|
|
1730
1760
|
|
|
@@ -2171,6 +2201,41 @@ async function setDebug(value) {
|
|
|
2171
2201
|
}
|
|
2172
2202
|
|
|
2173
2203
|
// Main
|
|
2204
|
+
/**
|
|
2205
|
+
* Show telemetry statistics
|
|
2206
|
+
* Why: Help users understand JSON parsing patterns and batch performance
|
|
2207
|
+
*/
|
|
2208
|
+
async function showTelemetry() {
|
|
2209
|
+
await showTelemetryStats();
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* Clear telemetry data
|
|
2214
|
+
* Why: Allow users to reset telemetry
|
|
2215
|
+
*/
|
|
2216
|
+
async function clearTelemetry() {
|
|
2217
|
+
const config = await getConfig();
|
|
2218
|
+
|
|
2219
|
+
if (!config.system?.telemetry && config.system?.telemetry !== undefined) {
|
|
2220
|
+
console.log('\n⚠️ Telemetry is currently disabled.\n');
|
|
2221
|
+
console.log('To re-enable (default), remove or set to true in .claude/config.json:');
|
|
2222
|
+
console.log('{');
|
|
2223
|
+
console.log(' "system": {');
|
|
2224
|
+
console.log(' "telemetry": true');
|
|
2225
|
+
console.log(' }');
|
|
2226
|
+
console.log('}\n');
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const confirmed = await promptConfirmation('Are you sure you want to clear all telemetry data?');
|
|
2231
|
+
if (confirmed) {
|
|
2232
|
+
await clearTelemetryData();
|
|
2233
|
+
success('Telemetry data cleared successfully');
|
|
2234
|
+
} else {
|
|
2235
|
+
info('Telemetry data was not cleared');
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2174
2239
|
async function main() {
|
|
2175
2240
|
const args = process.argv.slice(2);
|
|
2176
2241
|
const command = args[0];
|
|
@@ -2223,6 +2288,16 @@ async function main() {
|
|
|
2223
2288
|
case 'migrate-config':
|
|
2224
2289
|
await migrateConfig();
|
|
2225
2290
|
break;
|
|
2291
|
+
case 'telemetry':
|
|
2292
|
+
// Handle subcommands: telemetry show, telemetry clear
|
|
2293
|
+
if (args[1] === 'show' || args[1] === undefined) {
|
|
2294
|
+
await showTelemetry();
|
|
2295
|
+
} else if (args[1] === 'clear') {
|
|
2296
|
+
await clearTelemetry();
|
|
2297
|
+
} else {
|
|
2298
|
+
error(`Unknown telemetry subcommand: ${args[1]}`);
|
|
2299
|
+
}
|
|
2300
|
+
break;
|
|
2226
2301
|
case '--debug':
|
|
2227
2302
|
await setDebug(args[1]);
|
|
2228
2303
|
break;
|
package/lib/config.js
CHANGED
package/lib/hooks/pre-commit.js
CHANGED
|
@@ -364,10 +364,19 @@ const main = async () => {
|
|
|
364
364
|
})
|
|
365
365
|
);
|
|
366
366
|
|
|
367
|
+
// Build telemetry context
|
|
368
|
+
const telemetryContext = {
|
|
369
|
+
fileCount: filesData.length,
|
|
370
|
+
batchSize: batchSize,
|
|
371
|
+
model: subagentModel,
|
|
372
|
+
hook: 'pre-commit'
|
|
373
|
+
};
|
|
374
|
+
|
|
367
375
|
// Execute in parallel
|
|
368
376
|
const results = await analyzeCodeParallel(prompts, {
|
|
369
377
|
timeout: config.analysis.timeout,
|
|
370
|
-
saveDebug: false // Don't save debug for individual batches
|
|
378
|
+
saveDebug: false, // Don't save debug for individual batches
|
|
379
|
+
telemetryContext
|
|
371
380
|
});
|
|
372
381
|
|
|
373
382
|
// Simple consolidation: merge all results
|
|
@@ -403,9 +412,19 @@ const main = async () => {
|
|
|
403
412
|
{ promptLength: prompt.length }
|
|
404
413
|
);
|
|
405
414
|
|
|
415
|
+
// Build telemetry context for single execution
|
|
416
|
+
const telemetryContext = {
|
|
417
|
+
fileCount: filesData.length,
|
|
418
|
+
batchSize: filesData.length, // Single batch = all files
|
|
419
|
+
totalBatches: 1,
|
|
420
|
+
model: config.subagents?.model || 'haiku',
|
|
421
|
+
hook: 'pre-commit'
|
|
422
|
+
};
|
|
423
|
+
|
|
406
424
|
result = await analyzeCode(prompt, {
|
|
407
425
|
timeout: config.analysis.timeout,
|
|
408
|
-
saveDebug: config.system.debug
|
|
426
|
+
saveDebug: config.system.debug,
|
|
427
|
+
telemetryContext
|
|
409
428
|
});
|
|
410
429
|
}
|
|
411
430
|
|
|
@@ -265,9 +265,21 @@ const main = async () => {
|
|
|
265
265
|
// Generate message with Claude
|
|
266
266
|
logger.info('Sending to Claude...');
|
|
267
267
|
|
|
268
|
+
// Build telemetry context
|
|
269
|
+
const telemetryContext = {
|
|
270
|
+
fileCount: filesData.length,
|
|
271
|
+
batchSize: config.subagents?.batchSize || 3,
|
|
272
|
+
totalBatches: subagentsEnabled && filesData.length >= 3
|
|
273
|
+
? Math.ceil(filesData.length / (config.subagents?.batchSize || 3))
|
|
274
|
+
: 1,
|
|
275
|
+
model: subagentModel,
|
|
276
|
+
hook: 'prepare-commit-msg'
|
|
277
|
+
};
|
|
278
|
+
|
|
268
279
|
const response = await analyzeCode(prompt, {
|
|
269
280
|
timeout: config.commitMessage.timeout,
|
|
270
|
-
saveDebug: config.system.debug
|
|
281
|
+
saveDebug: config.system.debug,
|
|
282
|
+
telemetryContext
|
|
271
283
|
});
|
|
272
284
|
|
|
273
285
|
logger.debug(
|
|
@@ -23,6 +23,7 @@ import logger from './logger.js';
|
|
|
23
23
|
import config from '../config.js';
|
|
24
24
|
import { detectClaudeError, formatClaudeError, ClaudeErrorType, isRecoverableError } from './claude-diagnostics.js';
|
|
25
25
|
import { which } from './which-command.js';
|
|
26
|
+
import { recordJsonParseFailure, recordBatchSuccess, rotateTelemetry } from './telemetry.js';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Custom error for Claude client failures
|
|
@@ -526,10 +527,11 @@ const executeClaudeInteractive = (prompt, { timeout = 300000 } = {}) => new Prom
|
|
|
526
527
|
* Why: Claude may include markdown formatting or explanatory text around JSON
|
|
527
528
|
*
|
|
528
529
|
* @param {string} response - Raw response from Claude
|
|
530
|
+
* @param {Object} telemetryContext - Context for telemetry recording (optional)
|
|
529
531
|
* @returns {Object} Parsed JSON object
|
|
530
532
|
* @throws {ClaudeClientError} If no valid JSON found
|
|
531
533
|
*/
|
|
532
|
-
const extractJSON = (response) => {
|
|
534
|
+
const extractJSON = (response, telemetryContext = {}) => {
|
|
533
535
|
logger.debug(
|
|
534
536
|
'claude-client - extractJSON',
|
|
535
537
|
'Extracting JSON from response',
|
|
@@ -594,16 +596,24 @@ const extractJSON = (response) => {
|
|
|
594
596
|
}
|
|
595
597
|
|
|
596
598
|
// No valid JSON found
|
|
599
|
+
const responsePreview = response.substring(0, 500);
|
|
600
|
+
|
|
597
601
|
logger.error(
|
|
598
602
|
'claude-client - extractJSON',
|
|
599
603
|
'No valid JSON found in response',
|
|
600
604
|
new ClaudeClientError('No valid JSON in response', {
|
|
601
|
-
output:
|
|
605
|
+
output: responsePreview
|
|
602
606
|
})
|
|
603
607
|
);
|
|
604
608
|
|
|
609
|
+
// Telemetry is now recorded by withRetry wrapper
|
|
605
610
|
throw new ClaudeClientError('No valid JSON found in Claude response', {
|
|
606
|
-
|
|
611
|
+
context: {
|
|
612
|
+
response: response,
|
|
613
|
+
errorInfo: {
|
|
614
|
+
type: 'JSON_PARSE_ERROR'
|
|
615
|
+
}
|
|
616
|
+
}
|
|
607
617
|
});
|
|
608
618
|
};
|
|
609
619
|
|
|
@@ -661,8 +671,8 @@ const saveDebugResponse = async (prompt, response, filename = config.output.debu
|
|
|
661
671
|
};
|
|
662
672
|
|
|
663
673
|
/**
|
|
664
|
-
* Wraps an async function with retry logic for recoverable errors
|
|
665
|
-
* Why: Reusable retry logic for Claude CLI operations
|
|
674
|
+
* Wraps an async function with retry logic for recoverable errors and telemetry tracking
|
|
675
|
+
* Why: Reusable retry logic for Claude CLI operations with per-attempt telemetry
|
|
666
676
|
*
|
|
667
677
|
* @param {Function} fn - Async function to execute with retry
|
|
668
678
|
* @param {Object} options - Retry options
|
|
@@ -670,15 +680,50 @@ const saveDebugResponse = async (prompt, response, filename = config.output.debu
|
|
|
670
680
|
* @param {number} options.baseRetryDelay - Base delay in ms (default: 2000)
|
|
671
681
|
* @param {number} options.retryCount - Current retry attempt (internal, default: 0)
|
|
672
682
|
* @param {string} options.operationName - Name for logging (default: 'operation')
|
|
683
|
+
* @param {Object} options.telemetryContext - Context for telemetry recording (optional)
|
|
673
684
|
* @returns {Promise<any>} Result from fn
|
|
674
685
|
* @throws {Error} If fn fails after all retries
|
|
675
686
|
*/
|
|
676
|
-
const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation' } = {}) => {
|
|
687
|
+
const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation', telemetryContext = null } = {}) => {
|
|
677
688
|
const retryDelay = baseRetryDelay * Math.pow(2, retryCount);
|
|
689
|
+
const startTime = Date.now();
|
|
678
690
|
|
|
679
691
|
try {
|
|
680
|
-
|
|
692
|
+
const result = await fn();
|
|
693
|
+
|
|
694
|
+
// Record success telemetry if context provided
|
|
695
|
+
if (telemetryContext) {
|
|
696
|
+
const duration = Date.now() - startTime;
|
|
697
|
+
await recordBatchSuccess({
|
|
698
|
+
...telemetryContext,
|
|
699
|
+
duration,
|
|
700
|
+
retryAttempt: retryCount,
|
|
701
|
+
totalRetries: maxRetries
|
|
702
|
+
}).catch(err => {
|
|
703
|
+
logger.debug('claude-client - withRetry', 'Failed to record success telemetry', err);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return result;
|
|
681
708
|
} catch (error) {
|
|
709
|
+
// Record failure telemetry if context provided (before retry decision)
|
|
710
|
+
if (telemetryContext) {
|
|
711
|
+
const errorType = error.context?.errorInfo?.type || error.name || 'UNKNOWN_ERROR';
|
|
712
|
+
const errorMessage = error.message || 'Unknown error';
|
|
713
|
+
|
|
714
|
+
await recordJsonParseFailure({
|
|
715
|
+
...telemetryContext,
|
|
716
|
+
errorType,
|
|
717
|
+
errorMessage,
|
|
718
|
+
retryAttempt: retryCount,
|
|
719
|
+
totalRetries: maxRetries,
|
|
720
|
+
responseLength: error.context?.response?.length || 0,
|
|
721
|
+
responsePreview: error.context?.response?.substring(0, 100) || ''
|
|
722
|
+
}).catch(err => {
|
|
723
|
+
logger.debug('claude-client - withRetry', 'Failed to record failure telemetry', err);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
682
727
|
// Check if error is recoverable and we haven't exceeded retry limit
|
|
683
728
|
const hasContext = !!error.context;
|
|
684
729
|
const hasErrorInfo = !!error.context?.errorInfo;
|
|
@@ -714,8 +759,8 @@ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount
|
|
|
714
759
|
// Wait before retry
|
|
715
760
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
716
761
|
|
|
717
|
-
// Retry with incremented count
|
|
718
|
-
return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName });
|
|
762
|
+
// Retry with incremented count and same telemetry context
|
|
763
|
+
return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName, telemetryContext });
|
|
719
764
|
}
|
|
720
765
|
|
|
721
766
|
// Add retry attempt to error context if not already present
|
|
@@ -730,17 +775,25 @@ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount
|
|
|
730
775
|
};
|
|
731
776
|
|
|
732
777
|
/**
|
|
733
|
-
* Executes Claude CLI with retry logic
|
|
734
|
-
* Why: Provides retry capability for executeClaude calls
|
|
778
|
+
* Executes Claude CLI with retry logic and telemetry tracking
|
|
779
|
+
* Why: Provides retry capability for executeClaude calls with per-attempt telemetry
|
|
735
780
|
*
|
|
736
781
|
* @param {string} prompt - Prompt text
|
|
737
|
-
* @param {Object} options - Execution options
|
|
782
|
+
* @param {Object} options - Execution options
|
|
783
|
+
* @param {number} options.timeout - Timeout in milliseconds
|
|
784
|
+
* @param {Array<string>} options.allowedTools - Allowed tools for Claude
|
|
785
|
+
* @param {Object} options.telemetryContext - Context for telemetry (fileCount, hook, etc.)
|
|
738
786
|
* @returns {Promise<string>} Claude's response
|
|
739
787
|
*/
|
|
740
788
|
const executeClaudeWithRetry = async (prompt, options = {}) => {
|
|
789
|
+
const { telemetryContext = {}, ...executeOptions } = options;
|
|
790
|
+
|
|
741
791
|
return withRetry(
|
|
742
|
-
() => executeClaude(prompt,
|
|
743
|
-
{
|
|
792
|
+
() => executeClaude(prompt, executeOptions),
|
|
793
|
+
{
|
|
794
|
+
operationName: 'executeClaude',
|
|
795
|
+
telemetryContext: telemetryContext
|
|
796
|
+
}
|
|
744
797
|
);
|
|
745
798
|
};
|
|
746
799
|
|
|
@@ -752,17 +805,25 @@ const executeClaudeWithRetry = async (prompt, options = {}) => {
|
|
|
752
805
|
* @param {Object} options - Analysis options
|
|
753
806
|
* @param {number} options.timeout - Timeout in milliseconds
|
|
754
807
|
* @param {boolean} options.saveDebug - Save response to debug file (default: from config)
|
|
808
|
+
* @param {Object} options.telemetryContext - Context for telemetry (fileCount, batchSize, etc.)
|
|
755
809
|
* @returns {Promise<Object>} Parsed analysis result
|
|
756
810
|
* @throws {ClaudeClientError} If analysis fails
|
|
757
811
|
*/
|
|
758
|
-
const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug } = {}) => {
|
|
812
|
+
const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug, telemetryContext = {} } = {}) => {
|
|
813
|
+
const startTime = Date.now();
|
|
814
|
+
|
|
759
815
|
logger.debug(
|
|
760
816
|
'claude-client - analyzeCode',
|
|
761
817
|
'Starting code analysis',
|
|
762
818
|
{ promptLength: prompt.length, timeout, saveDebug }
|
|
763
819
|
);
|
|
764
820
|
|
|
765
|
-
//
|
|
821
|
+
// Rotate telemetry files periodically
|
|
822
|
+
rotateTelemetry().catch(err => {
|
|
823
|
+
logger.debug('claude-client - analyzeCode', 'Failed to rotate telemetry', err);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// Use withRetry to wrap the entire analysis flow (with telemetry tracking)
|
|
766
827
|
return withRetry(
|
|
767
828
|
async () => {
|
|
768
829
|
// Execute Claude CLI
|
|
@@ -774,7 +835,9 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system
|
|
|
774
835
|
}
|
|
775
836
|
|
|
776
837
|
// Extract and parse JSON
|
|
777
|
-
const result = extractJSON(response);
|
|
838
|
+
const result = extractJSON(response, telemetryContext);
|
|
839
|
+
|
|
840
|
+
const duration = Date.now() - startTime;
|
|
778
841
|
|
|
779
842
|
logger.debug(
|
|
780
843
|
'claude-client - analyzeCode',
|
|
@@ -782,13 +845,21 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system
|
|
|
782
845
|
{
|
|
783
846
|
hasApproved: 'approved' in result,
|
|
784
847
|
hasQualityGate: 'QUALITY_GATE' in result,
|
|
785
|
-
blockingIssuesCount: result.blockingIssues?.length ?? 0
|
|
848
|
+
blockingIssuesCount: result.blockingIssues?.length ?? 0,
|
|
849
|
+
duration
|
|
786
850
|
}
|
|
787
851
|
);
|
|
788
852
|
|
|
853
|
+
// Telemetry is now recorded by withRetry wrapper
|
|
789
854
|
return result;
|
|
790
855
|
},
|
|
791
|
-
{
|
|
856
|
+
{
|
|
857
|
+
operationName: 'analyzeCode',
|
|
858
|
+
telemetryContext: {
|
|
859
|
+
responseLength: 0, // Will be updated in withRetry
|
|
860
|
+
...telemetryContext
|
|
861
|
+
}
|
|
862
|
+
}
|
|
792
863
|
);
|
|
793
864
|
};
|
|
794
865
|
|
|
@@ -810,6 +881,7 @@ const chunkArray = (array, size) => {
|
|
|
810
881
|
* Runs multiple analyzeCode calls in parallel
|
|
811
882
|
* @param {Array<string>} prompts - Array of prompts to analyze
|
|
812
883
|
* @param {Object} options - Same options as analyzeCode
|
|
884
|
+
* @param {Object} options.telemetryContext - Base telemetry context (will be augmented per batch)
|
|
813
885
|
* @returns {Promise<Array<Object>>} Array of results
|
|
814
886
|
*/
|
|
815
887
|
const analyzeCodeParallel = async (prompts, options = {}) => {
|
|
@@ -824,7 +896,18 @@ const analyzeCodeParallel = async (prompts, options = {}) => {
|
|
|
824
896
|
const promises = prompts.map((prompt, index) => {
|
|
825
897
|
console.log(` ⚡ Launching batch ${index + 1}/${prompts.length}...`);
|
|
826
898
|
logger.debug('claude-client - analyzeCodeParallel', `Starting batch ${index + 1}`);
|
|
827
|
-
|
|
899
|
+
|
|
900
|
+
// Augment telemetry context with batch info
|
|
901
|
+
const batchTelemetryContext = {
|
|
902
|
+
...(options.telemetryContext || {}),
|
|
903
|
+
batchIndex: index,
|
|
904
|
+
totalBatches: prompts.length
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
return analyzeCode(prompt, {
|
|
908
|
+
...options,
|
|
909
|
+
telemetryContext: batchTelemetryContext
|
|
910
|
+
});
|
|
828
911
|
});
|
|
829
912
|
|
|
830
913
|
console.log(' ⏳ Waiting for all batches to complete...\n');
|
|
@@ -38,11 +38,11 @@ class PromptBuilderError extends Error {
|
|
|
38
38
|
* Why absolute path: Ensures templates are found regardless of cwd (cross-platform)
|
|
39
39
|
*
|
|
40
40
|
* @param {string} templateName - Name of template file
|
|
41
|
-
* @param {string} baseDir - Base directory (default:
|
|
41
|
+
* @param {string} baseDir - Base directory (default: .claude/prompts, fallback to templates)
|
|
42
42
|
* @returns {Promise<string>} Template content
|
|
43
43
|
* @throws {PromptBuilderError} If template not found
|
|
44
44
|
*/
|
|
45
|
-
const loadTemplate = async (templateName, baseDir = '.claude') => {
|
|
45
|
+
const loadTemplate = async (templateName, baseDir = '.claude/prompts') => {
|
|
46
46
|
// Why: Use repo root for absolute path (works on Windows/PowerShell/Git Bash)
|
|
47
47
|
const repoRoot = getRepoRoot();
|
|
48
48
|
let templatePath = path.join(repoRoot, baseDir, templateName);
|
|
@@ -118,7 +118,7 @@ const replaceTemplate = (template, variables) => {
|
|
|
118
118
|
*
|
|
119
119
|
* @param {string} templateName - Name of template file
|
|
120
120
|
* @param {Object} variables - Variables to replace in template
|
|
121
|
-
* @param {string} baseDir - Base directory (default: .claude)
|
|
121
|
+
* @param {string} baseDir - Base directory (default: .claude/prompts)
|
|
122
122
|
* @returns {Promise<string>} Prompt with replaced variables
|
|
123
123
|
* @throws {PromptBuilderError} If template not found
|
|
124
124
|
*
|
|
@@ -130,7 +130,7 @@ const replaceTemplate = (template, variables) => {
|
|
|
130
130
|
* DELETIONS: 5
|
|
131
131
|
* });
|
|
132
132
|
*/
|
|
133
|
-
const loadPrompt = async (templateName, variables = {}, baseDir = '.claude') => {
|
|
133
|
+
const loadPrompt = async (templateName, variables = {}, baseDir = '.claude/prompts') => {
|
|
134
134
|
logger.debug(
|
|
135
135
|
'prompt-builder - loadPrompt',
|
|
136
136
|
'Loading prompt with variables',
|
|
@@ -219,19 +219,20 @@ const buildAnalysisPrompt = async ({
|
|
|
219
219
|
guidelinesName = 'CLAUDE_PRE_COMMIT.md',
|
|
220
220
|
files = [],
|
|
221
221
|
metadata = {},
|
|
222
|
-
subagentConfig = null
|
|
222
|
+
subagentConfig = null,
|
|
223
|
+
baseDir = '.claude/prompts'
|
|
223
224
|
} = {}) => {
|
|
224
225
|
logger.debug(
|
|
225
226
|
'prompt-builder - buildAnalysisPrompt',
|
|
226
227
|
'Building analysis prompt',
|
|
227
|
-
{ templateName, guidelinesName, fileCount: files.length, subagentsEnabled: subagentConfig?.enabled }
|
|
228
|
+
{ templateName, guidelinesName, fileCount: files.length, subagentsEnabled: subagentConfig?.enabled, baseDir }
|
|
228
229
|
);
|
|
229
230
|
|
|
230
231
|
try {
|
|
231
232
|
// Load template and guidelines
|
|
232
233
|
const [template, guidelines] = await Promise.all([
|
|
233
|
-
loadTemplate(templateName),
|
|
234
|
-
loadTemplate(guidelinesName)
|
|
234
|
+
loadTemplate(templateName, baseDir),
|
|
235
|
+
loadTemplate(guidelinesName, baseDir)
|
|
235
236
|
]);
|
|
236
237
|
|
|
237
238
|
// Start with template
|
|
@@ -240,7 +241,7 @@ const buildAnalysisPrompt = async ({
|
|
|
240
241
|
// Add subagent instruction if enabled and 3+ files
|
|
241
242
|
if (subagentConfig?.enabled && files.length >= 3) {
|
|
242
243
|
try {
|
|
243
|
-
const subagentInstruction = await loadTemplate('SUBAGENT_INSTRUCTION.md');
|
|
244
|
+
const subagentInstruction = await loadTemplate('SUBAGENT_INSTRUCTION.md', baseDir);
|
|
244
245
|
const subagentVariables = {
|
|
245
246
|
BATCH_SIZE: subagentConfig.batchSize || 3,
|
|
246
247
|
MODEL: subagentConfig.model || 'haiku'
|
|
@@ -171,7 +171,7 @@ ${content}
|
|
|
171
171
|
* @param {Array<Object>} analysisResult.blockingIssues - Array of blocking issues
|
|
172
172
|
* @param {Object} options - Generation options
|
|
173
173
|
* @param {string} options.outputPath - Output file path (default: 'claude_resolution_prompt.md')
|
|
174
|
-
* @param {string} options.templatePath - Template path (default: '.claude/CLAUDE_RESOLUTION_PROMPT.md')
|
|
174
|
+
* @param {string} options.templatePath - Template path (default: '.claude/prompts/CLAUDE_RESOLUTION_PROMPT.md')
|
|
175
175
|
* @param {number} options.fileCount - Number of files analyzed
|
|
176
176
|
* @returns {Promise<string>} Path to generated resolution prompt
|
|
177
177
|
* @throws {ResolutionPromptError} If generation fails
|
|
@@ -180,7 +180,7 @@ const generateResolutionPrompt = async (
|
|
|
180
180
|
analysisResult,
|
|
181
181
|
{
|
|
182
182
|
outputPath = null,
|
|
183
|
-
templatePath = '.claude/CLAUDE_RESOLUTION_PROMPT.md',
|
|
183
|
+
templatePath = '.claude/prompts/CLAUDE_RESOLUTION_PROMPT.md',
|
|
184
184
|
fileCount = 0
|
|
185
185
|
} = {}
|
|
186
186
|
) => {
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: telemetry.js
|
|
3
|
+
* Purpose: Local telemetry collection for debugging and optimization
|
|
4
|
+
*
|
|
5
|
+
* Design principles:
|
|
6
|
+
* - OPT-IN ONLY: Requires explicit config.system.telemetry = true
|
|
7
|
+
* - LOCAL ONLY: Data never leaves user's machine
|
|
8
|
+
* - PRIVACY-FIRST: No PII, no code content, just metrics
|
|
9
|
+
* - STRUCTURED LOGS: JSON lines format for easy analysis
|
|
10
|
+
* - AUTO-ROTATION: Limits file size to prevent unbounded growth
|
|
11
|
+
*
|
|
12
|
+
* Key responsibilities:
|
|
13
|
+
* - Track JSON parsing failures with context
|
|
14
|
+
* - Record batch analysis metrics
|
|
15
|
+
* - Provide local statistics via CLI
|
|
16
|
+
* - Auto-rotate log files
|
|
17
|
+
*
|
|
18
|
+
* Dependencies:
|
|
19
|
+
* - fs/promises: File operations
|
|
20
|
+
* - logger: Debug logging
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'fs/promises';
|
|
24
|
+
import fsSync from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import logger from './logger.js';
|
|
27
|
+
import config from '../config.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Telemetry event structure
|
|
31
|
+
* @typedef {Object} TelemetryEvent
|
|
32
|
+
* @property {string} id - Unique event ID (timestamp-counter-random)
|
|
33
|
+
* @property {string} timestamp - ISO timestamp
|
|
34
|
+
* @property {string} type - Event type
|
|
35
|
+
* @property {boolean} success - Whether the operation succeeded
|
|
36
|
+
* @property {number} retryAttempt - Current retry attempt (0-based)
|
|
37
|
+
* @property {number} totalRetries - Total retry attempts configured
|
|
38
|
+
* @property {Object} data - Event data
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Counter for generating unique IDs within the same millisecond
|
|
43
|
+
*/
|
|
44
|
+
let eventCounter = 0;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate unique event ID
|
|
48
|
+
* Why: Ensure each telemetry event has a unique identifier
|
|
49
|
+
* Format: timestamp-counter-random (e.g., 1703612345678-001-a3f)
|
|
50
|
+
*
|
|
51
|
+
* @returns {string} Unique event ID
|
|
52
|
+
*/
|
|
53
|
+
const generateEventId = () => {
|
|
54
|
+
const timestamp = Date.now();
|
|
55
|
+
const counter = String(eventCounter++).padStart(3, '0');
|
|
56
|
+
const random = Math.random().toString(36).substring(2, 5);
|
|
57
|
+
|
|
58
|
+
// Reset counter if it exceeds 999
|
|
59
|
+
if (eventCounter > 999) {
|
|
60
|
+
eventCounter = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return `${timestamp}-${counter}-${random}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get telemetry directory path
|
|
68
|
+
* Why: Store in .claude/telemetry (gitignored) per-repo
|
|
69
|
+
*
|
|
70
|
+
* @returns {string} Telemetry directory path
|
|
71
|
+
*/
|
|
72
|
+
const getTelemetryDir = () => {
|
|
73
|
+
return path.join(process.cwd(), '.claude', 'telemetry');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get current telemetry log file path
|
|
78
|
+
* Why: Use date-based naming for natural rotation
|
|
79
|
+
*
|
|
80
|
+
* @returns {string} Current log file path
|
|
81
|
+
*/
|
|
82
|
+
const getCurrentLogFile = () => {
|
|
83
|
+
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
84
|
+
return path.join(getTelemetryDir(), `telemetry-${date}.jsonl`);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if telemetry is enabled
|
|
89
|
+
* Why: Enabled by default, users must explicitly disable
|
|
90
|
+
*
|
|
91
|
+
* @returns {boolean} True if telemetry is enabled (default: true)
|
|
92
|
+
*/
|
|
93
|
+
const isTelemetryEnabled = () => {
|
|
94
|
+
// Enabled by default - only disabled if explicitly set to false
|
|
95
|
+
return config.system?.telemetry !== false;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ensure telemetry directory exists
|
|
100
|
+
* Why: Create on first use
|
|
101
|
+
*/
|
|
102
|
+
const ensureTelemetryDir = async () => {
|
|
103
|
+
try {
|
|
104
|
+
const dir = getTelemetryDir();
|
|
105
|
+
await fs.mkdir(dir, { recursive: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logger.debug('telemetry - ensureTelemetryDir', 'Failed to create directory', error);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Append event to telemetry log
|
|
113
|
+
* Why: JSON lines format for efficient append and parsing
|
|
114
|
+
*
|
|
115
|
+
* @param {TelemetryEvent} event - Event to log
|
|
116
|
+
*/
|
|
117
|
+
const appendEvent = async (event) => {
|
|
118
|
+
try {
|
|
119
|
+
const logFile = getCurrentLogFile();
|
|
120
|
+
const line = JSON.stringify(event) + '\n';
|
|
121
|
+
|
|
122
|
+
// Append to file (create if doesn't exist)
|
|
123
|
+
await fs.appendFile(logFile, line, 'utf8');
|
|
124
|
+
|
|
125
|
+
logger.debug('telemetry - appendEvent', `Event logged: ${event.type}`);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Don't fail on telemetry errors
|
|
128
|
+
logger.debug('telemetry - appendEvent', 'Failed to append event', error);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Record a telemetry event
|
|
134
|
+
* Why: Centralized event recording with opt-in check
|
|
135
|
+
*
|
|
136
|
+
* @param {string} eventType - Type of event
|
|
137
|
+
* @param {Object} eventData - Event-specific data (no PII, no code content)
|
|
138
|
+
* @param {Object} metadata - Event metadata (success, retryAttempt, totalRetries)
|
|
139
|
+
*/
|
|
140
|
+
export const recordEvent = async (eventType, eventData = {}, metadata = {}) => {
|
|
141
|
+
// Check opt-in
|
|
142
|
+
if (!isTelemetryEnabled()) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Ensure directory exists
|
|
148
|
+
await ensureTelemetryDir();
|
|
149
|
+
|
|
150
|
+
// Create event with all metadata
|
|
151
|
+
const event = {
|
|
152
|
+
id: generateEventId(),
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
type: eventType,
|
|
155
|
+
success: metadata.success ?? false,
|
|
156
|
+
retryAttempt: metadata.retryAttempt ?? 0,
|
|
157
|
+
totalRetries: metadata.totalRetries ?? 0,
|
|
158
|
+
data: eventData
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Append to log
|
|
162
|
+
await appendEvent(event);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Don't fail on telemetry errors
|
|
165
|
+
logger.debug('telemetry - recordEvent', 'Failed to record event', error);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Record JSON parsing failure or API execution error
|
|
171
|
+
* Why: Track failures with batch context and retry information for debugging
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} options - Failure context
|
|
174
|
+
* @param {number} options.retryAttempt - Current retry attempt (0-based)
|
|
175
|
+
* @param {number} options.totalRetries - Total retry attempts configured
|
|
176
|
+
*/
|
|
177
|
+
export const recordJsonParseFailure = async (options) => {
|
|
178
|
+
await recordEvent(
|
|
179
|
+
'json_parse_failure',
|
|
180
|
+
{
|
|
181
|
+
fileCount: options.fileCount || 0,
|
|
182
|
+
batchSize: options.batchSize || 0,
|
|
183
|
+
batchIndex: options.batchIndex ?? -1,
|
|
184
|
+
totalBatches: options.totalBatches || 0,
|
|
185
|
+
model: options.model || 'unknown',
|
|
186
|
+
responseLength: options.responseLength || 0,
|
|
187
|
+
// Only include first 100 chars to avoid storing large responses
|
|
188
|
+
responsePreview: (options.responsePreview || '').substring(0, 100),
|
|
189
|
+
errorMessage: options.errorMessage || '',
|
|
190
|
+
errorType: options.errorType || 'unknown',
|
|
191
|
+
parallelMode: (options.totalBatches || 0) > 1,
|
|
192
|
+
hook: options.hook || 'unknown' // pre-commit, prepare-commit-msg, analyze-diff, create-pr
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
success: false,
|
|
196
|
+
retryAttempt: options.retryAttempt ?? 0,
|
|
197
|
+
totalRetries: options.totalRetries ?? 0
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Record successful batch analysis
|
|
204
|
+
* Why: Track successes with retry information to compare against failures
|
|
205
|
+
*
|
|
206
|
+
* @param {Object} options - Success context
|
|
207
|
+
* @param {number} options.retryAttempt - Current retry attempt (0-based)
|
|
208
|
+
* @param {number} options.totalRetries - Total retry attempts configured
|
|
209
|
+
*/
|
|
210
|
+
export const recordBatchSuccess = async (options) => {
|
|
211
|
+
await recordEvent(
|
|
212
|
+
'batch_success',
|
|
213
|
+
{
|
|
214
|
+
fileCount: options.fileCount || 0,
|
|
215
|
+
batchSize: options.batchSize || 0,
|
|
216
|
+
batchIndex: options.batchIndex ?? -1,
|
|
217
|
+
totalBatches: options.totalBatches || 0,
|
|
218
|
+
model: options.model || 'unknown',
|
|
219
|
+
duration: options.duration || 0,
|
|
220
|
+
responseLength: options.responseLength || 0,
|
|
221
|
+
parallelMode: (options.totalBatches || 0) > 1,
|
|
222
|
+
hook: options.hook || 'unknown'
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
success: true,
|
|
226
|
+
retryAttempt: options.retryAttempt ?? 0,
|
|
227
|
+
totalRetries: options.totalRetries ?? 0
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read all telemetry events from log files
|
|
234
|
+
* Why: Aggregate data for statistics
|
|
235
|
+
*
|
|
236
|
+
* @param {number} maxDays - Maximum days to read (default: 7)
|
|
237
|
+
* @returns {Promise<Array<TelemetryEvent>>} Array of events
|
|
238
|
+
*/
|
|
239
|
+
const readTelemetryEvents = async (maxDays = 7) => {
|
|
240
|
+
try {
|
|
241
|
+
const dir = getTelemetryDir();
|
|
242
|
+
const files = await fs.readdir(dir);
|
|
243
|
+
|
|
244
|
+
// Filter to .jsonl files only
|
|
245
|
+
const logFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
246
|
+
|
|
247
|
+
// Sort by date (newest first)
|
|
248
|
+
logFiles.sort().reverse();
|
|
249
|
+
|
|
250
|
+
// Limit to maxDays
|
|
251
|
+
const limitedFiles = logFiles.slice(0, maxDays);
|
|
252
|
+
|
|
253
|
+
// Read all events
|
|
254
|
+
const events = [];
|
|
255
|
+
for (const file of limitedFiles) {
|
|
256
|
+
const filePath = path.join(dir, file);
|
|
257
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
258
|
+
|
|
259
|
+
// Parse JSON lines
|
|
260
|
+
const lines = content.trim().split('\n');
|
|
261
|
+
for (const line of lines) {
|
|
262
|
+
if (line.trim()) {
|
|
263
|
+
try {
|
|
264
|
+
events.push(JSON.parse(line));
|
|
265
|
+
} catch (parseError) {
|
|
266
|
+
// Skip invalid lines
|
|
267
|
+
logger.debug('telemetry - readTelemetryEvents', 'Invalid JSON line', parseError);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return events;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
logger.debug('telemetry - readTelemetryEvents', 'Failed to read events', error);
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get telemetry statistics
|
|
282
|
+
* Why: Provide insights for debugging and optimization
|
|
283
|
+
*
|
|
284
|
+
* @param {number} maxDays - Maximum days to analyze (default: 7)
|
|
285
|
+
* @returns {Promise<Object>} Statistics object
|
|
286
|
+
*/
|
|
287
|
+
export const getStatistics = async (maxDays = 7) => {
|
|
288
|
+
if (!isTelemetryEnabled()) {
|
|
289
|
+
return {
|
|
290
|
+
enabled: false,
|
|
291
|
+
message: 'Telemetry is disabled. To enable (default), remove or set "system.telemetry: true" in .claude/config.json'
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const events = await readTelemetryEvents(maxDays);
|
|
297
|
+
|
|
298
|
+
const stats = {
|
|
299
|
+
enabled: true,
|
|
300
|
+
period: `Last ${maxDays} days`,
|
|
301
|
+
totalEvents: events.length,
|
|
302
|
+
jsonParseFailures: 0,
|
|
303
|
+
batchSuccesses: 0,
|
|
304
|
+
failuresByBatchSize: {},
|
|
305
|
+
failuresByModel: {},
|
|
306
|
+
failuresByHook: {},
|
|
307
|
+
successesByHook: {},
|
|
308
|
+
avgFilesPerFailure: 0,
|
|
309
|
+
avgFilesPerSuccess: 0,
|
|
310
|
+
failureRate: 0
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
let totalFilesInFailures = 0;
|
|
314
|
+
let totalFilesInSuccesses = 0;
|
|
315
|
+
|
|
316
|
+
events.forEach(event => {
|
|
317
|
+
if (event.type === 'json_parse_failure') {
|
|
318
|
+
stats.jsonParseFailures++;
|
|
319
|
+
totalFilesInFailures += event.data.fileCount || 0;
|
|
320
|
+
|
|
321
|
+
// Group by batch size
|
|
322
|
+
const batchSize = event.data.batchSize || 0;
|
|
323
|
+
stats.failuresByBatchSize[batchSize] = (stats.failuresByBatchSize[batchSize] || 0) + 1;
|
|
324
|
+
|
|
325
|
+
// Group by model
|
|
326
|
+
const model = event.data.model || 'unknown';
|
|
327
|
+
stats.failuresByModel[model] = (stats.failuresByModel[model] || 0) + 1;
|
|
328
|
+
|
|
329
|
+
// Group by hook
|
|
330
|
+
const hook = event.data.hook || 'unknown';
|
|
331
|
+
stats.failuresByHook[hook] = (stats.failuresByHook[hook] || 0) + 1;
|
|
332
|
+
|
|
333
|
+
} else if (event.type === 'batch_success') {
|
|
334
|
+
stats.batchSuccesses++;
|
|
335
|
+
totalFilesInSuccesses += event.data.fileCount || 0;
|
|
336
|
+
|
|
337
|
+
// Group by hook
|
|
338
|
+
const hook = event.data.hook || 'unknown';
|
|
339
|
+
stats.successesByHook[hook] = (stats.successesByHook[hook] || 0) + 1;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Calculate averages
|
|
344
|
+
if (stats.jsonParseFailures > 0) {
|
|
345
|
+
stats.avgFilesPerFailure = parseFloat((totalFilesInFailures / stats.jsonParseFailures).toFixed(2));
|
|
346
|
+
}
|
|
347
|
+
if (stats.batchSuccesses > 0) {
|
|
348
|
+
stats.avgFilesPerSuccess = parseFloat((totalFilesInSuccesses / stats.batchSuccesses).toFixed(2));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Calculate failure rate
|
|
352
|
+
const totalAnalyses = stats.jsonParseFailures + stats.batchSuccesses;
|
|
353
|
+
if (totalAnalyses > 0) {
|
|
354
|
+
stats.failureRate = parseFloat((stats.jsonParseFailures / totalAnalyses * 100).toFixed(2));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return stats;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
logger.debug('telemetry - getStatistics', 'Failed to calculate statistics', error);
|
|
360
|
+
return {
|
|
361
|
+
enabled: true,
|
|
362
|
+
error: 'Failed to calculate statistics',
|
|
363
|
+
details: error.message
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Rotate old telemetry files
|
|
370
|
+
* Why: Prevent unbounded growth
|
|
371
|
+
*
|
|
372
|
+
* @param {number} maxDays - Keep files newer than this many days (default: 30)
|
|
373
|
+
*/
|
|
374
|
+
export const rotateTelemetry = async (maxDays = 30) => {
|
|
375
|
+
if (!isTelemetryEnabled()) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const dir = getTelemetryDir();
|
|
381
|
+
const files = await fs.readdir(dir);
|
|
382
|
+
|
|
383
|
+
// Filter to .jsonl files
|
|
384
|
+
const logFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
385
|
+
|
|
386
|
+
// Calculate cutoff date
|
|
387
|
+
const cutoffDate = new Date();
|
|
388
|
+
cutoffDate.setDate(cutoffDate.getDate() - maxDays);
|
|
389
|
+
const cutoffStr = cutoffDate.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
390
|
+
|
|
391
|
+
// Delete old files
|
|
392
|
+
for (const file of logFiles) {
|
|
393
|
+
// Extract date from filename (telemetry-YYYY-MM-DD.jsonl)
|
|
394
|
+
const match = file.match(/telemetry-(\d{4}-\d{2}-\d{2})\.jsonl/);
|
|
395
|
+
if (match) {
|
|
396
|
+
const fileDate = match[1];
|
|
397
|
+
if (fileDate < cutoffStr) {
|
|
398
|
+
const filePath = path.join(dir, file);
|
|
399
|
+
await fs.unlink(filePath);
|
|
400
|
+
logger.debug('telemetry - rotateTelemetry', `Deleted old telemetry file: ${file}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
logger.debug('telemetry - rotateTelemetry', 'Failed to rotate telemetry', error);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Clear all telemetry data
|
|
411
|
+
* Why: Allow users to reset
|
|
412
|
+
*/
|
|
413
|
+
export const clearTelemetry = async () => {
|
|
414
|
+
try {
|
|
415
|
+
const dir = getTelemetryDir();
|
|
416
|
+
|
|
417
|
+
// Check if directory exists
|
|
418
|
+
if (!fsSync.existsSync(dir)) {
|
|
419
|
+
logger.info('No telemetry data found');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Delete all .jsonl files
|
|
424
|
+
const files = await fs.readdir(dir);
|
|
425
|
+
const logFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
426
|
+
|
|
427
|
+
for (const file of logFiles) {
|
|
428
|
+
const filePath = path.join(dir, file);
|
|
429
|
+
await fs.unlink(filePath);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
logger.info(`Cleared ${logFiles.length} telemetry files`);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
logger.error('telemetry - clearTelemetry', 'Failed to clear telemetry', error);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Display telemetry statistics to console
|
|
440
|
+
* Why: User-friendly stats display for CLI
|
|
441
|
+
*/
|
|
442
|
+
export const displayStatistics = async () => {
|
|
443
|
+
const stats = await getStatistics();
|
|
444
|
+
|
|
445
|
+
console.log('\n╔════════════════════════════════════════════════════════════════════╗');
|
|
446
|
+
console.log('║ TELEMETRY STATISTICS ║');
|
|
447
|
+
console.log('╚════════════════════════════════════════════════════════════════════╝\n');
|
|
448
|
+
|
|
449
|
+
if (!stats.enabled) {
|
|
450
|
+
console.log(stats.message);
|
|
451
|
+
console.log('\nTelemetry is disabled in your configuration.');
|
|
452
|
+
console.log('To re-enable (default), remove or set to true in .claude/config.json:');
|
|
453
|
+
console.log('{');
|
|
454
|
+
console.log(' "system": {');
|
|
455
|
+
console.log(' "telemetry": true');
|
|
456
|
+
console.log(' }');
|
|
457
|
+
console.log('}\n');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (stats.error) {
|
|
462
|
+
console.log(`Error: ${stats.error}`);
|
|
463
|
+
console.log(`Details: ${stats.details}\n`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log(`Period: ${stats.period}`);
|
|
468
|
+
console.log(`Total events: ${stats.totalEvents}\n`);
|
|
469
|
+
|
|
470
|
+
console.log('━━━ ANALYSIS RESULTS ━━━');
|
|
471
|
+
console.log(`✅ Successful analyses: ${stats.batchSuccesses}`);
|
|
472
|
+
console.log(`❌ JSON parse failures: ${stats.jsonParseFailures}`);
|
|
473
|
+
console.log(`📊 Failure rate: ${stats.failureRate}%\n`);
|
|
474
|
+
|
|
475
|
+
if (stats.jsonParseFailures > 0) {
|
|
476
|
+
console.log('━━━ FAILURES BY BATCH SIZE ━━━');
|
|
477
|
+
Object.entries(stats.failuresByBatchSize)
|
|
478
|
+
.sort((a, b) => b[1] - a[1])
|
|
479
|
+
.forEach(([size, count]) => {
|
|
480
|
+
console.log(` Batch size ${size}: ${count} failures`);
|
|
481
|
+
});
|
|
482
|
+
console.log();
|
|
483
|
+
|
|
484
|
+
console.log('━━━ FAILURES BY MODEL ━━━');
|
|
485
|
+
Object.entries(stats.failuresByModel)
|
|
486
|
+
.sort((a, b) => b[1] - a[1])
|
|
487
|
+
.forEach(([model, count]) => {
|
|
488
|
+
console.log(` ${model}: ${count} failures`);
|
|
489
|
+
});
|
|
490
|
+
console.log();
|
|
491
|
+
|
|
492
|
+
console.log('━━━ FAILURES BY HOOK ━━━');
|
|
493
|
+
Object.entries(stats.failuresByHook)
|
|
494
|
+
.sort((a, b) => b[1] - a[1])
|
|
495
|
+
.forEach(([hook, count]) => {
|
|
496
|
+
console.log(` ${hook}: ${count} failures`);
|
|
497
|
+
});
|
|
498
|
+
console.log();
|
|
499
|
+
|
|
500
|
+
console.log('━━━ AVERAGES ━━━');
|
|
501
|
+
console.log(` Avg files per failure: ${stats.avgFilesPerFailure}`);
|
|
502
|
+
console.log(` Avg files per success: ${stats.avgFilesPerSuccess}\n`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
console.log('📂 Telemetry location: .claude/telemetry/');
|
|
506
|
+
console.log('💡 Tip: Share telemetry logs when reporting issues for faster debugging\n');
|
|
507
|
+
};
|