UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

508 lines (447 loc) 17.1 kB
/** * File: telemetry.js * Purpose: Local telemetry collection for debugging and optimization * * Design principles: * - OPT-IN ONLY: Requires explicit config.system.telemetry = true * - LOCAL ONLY: Data never leaves user's machine * - PRIVACY-FIRST: No PII, no code content, just metrics * - STRUCTURED LOGS: JSON lines format for easy analysis * - AUTO-ROTATION: Limits file size to prevent unbounded growth * * Key responsibilities: * - Track JSON parsing failures with context * - Record batch analysis metrics * - Provide local statistics via CLI * - Auto-rotate log files * * Dependencies: * - fs/promises: File operations * - logger: Debug logging */ import fs from 'fs/promises'; import fsSync from 'fs'; import path from 'path'; import logger from './logger.js'; import config from '../config.js'; /** * Telemetry event structure * @typedef {Object} TelemetryEvent * @property {string} id - Unique event ID (timestamp-counter-random) * @property {string} timestamp - ISO timestamp * @property {string} type - Event type * @property {boolean} success - Whether the operation succeeded * @property {number} retryAttempt - Current retry attempt (0-based) * @property {number} totalRetries - Total retry attempts configured * @property {Object} data - Event data */ /** * Counter for generating unique IDs within the same millisecond */ let eventCounter = 0; /** * Generate unique event ID * Why: Ensure each telemetry event has a unique identifier * Format: timestamp-counter-random (e.g., 1703612345678-001-a3f) * * @returns {string} Unique event ID */ const generateEventId = () => { const timestamp = Date.now(); const counter = String(eventCounter++).padStart(3, '0'); const random = Math.random().toString(36).substring(2, 5); // Reset counter if it exceeds 999 if (eventCounter > 999) { eventCounter = 0; } return `${timestamp}-${counter}-${random}`; }; /** * Get telemetry directory path * Why: Store in .claude/telemetry (gitignored) per-repo * * @returns {string} Telemetry directory path */ const getTelemetryDir = () => { return path.join(process.cwd(), '.claude', 'telemetry'); }; /** * Get current telemetry log file path * Why: Use date-based naming for natural rotation * * @returns {string} Current log file path */ const getCurrentLogFile = () => { const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD return path.join(getTelemetryDir(), `telemetry-${date}.jsonl`); }; /** * Check if telemetry is enabled * Why: Enabled by default, users must explicitly disable * * @returns {boolean} True if telemetry is enabled (default: true) */ const isTelemetryEnabled = () => { // Enabled by default - only disabled if explicitly set to false return config.system?.telemetry !== false; }; /** * Ensure telemetry directory exists * Why: Create on first use */ const ensureTelemetryDir = async () => { try { const dir = getTelemetryDir(); await fs.mkdir(dir, { recursive: true }); } catch (error) { logger.debug('telemetry - ensureTelemetryDir', 'Failed to create directory', error); } }; /** * Append event to telemetry log * Why: JSON lines format for efficient append and parsing * * @param {TelemetryEvent} event - Event to log */ const appendEvent = async (event) => { try { const logFile = getCurrentLogFile(); const line = JSON.stringify(event) + '\n'; // Append to file (create if doesn't exist) await fs.appendFile(logFile, line, 'utf8'); logger.debug('telemetry - appendEvent', `Event logged: ${event.type}`); } catch (error) { // Don't fail on telemetry errors logger.debug('telemetry - appendEvent', 'Failed to append event', error); } }; /** * Record a telemetry event * Why: Centralized event recording with opt-in check * * @param {string} eventType - Type of event * @param {Object} eventData - Event-specific data (no PII, no code content) * @param {Object} metadata - Event metadata (success, retryAttempt, totalRetries) */ export const recordEvent = async (eventType, eventData = {}, metadata = {}) => { // Check opt-in if (!isTelemetryEnabled()) { return; } try { // Ensure directory exists await ensureTelemetryDir(); // Create event with all metadata const event = { id: generateEventId(), timestamp: new Date().toISOString(), type: eventType, success: metadata.success ?? false, retryAttempt: metadata.retryAttempt ?? 0, totalRetries: metadata.totalRetries ?? 0, data: eventData }; // Append to log await appendEvent(event); } catch (error) { // Don't fail on telemetry errors logger.debug('telemetry - recordEvent', 'Failed to record event', error); } }; /** * Record JSON parsing failure or API execution error * Why: Track failures with batch context and retry information for debugging * * @param {Object} options - Failure context * @param {number} options.retryAttempt - Current retry attempt (0-based) * @param {number} options.totalRetries - Total retry attempts configured */ export const recordJsonParseFailure = async (options) => { await recordEvent( 'json_parse_failure', { fileCount: options.fileCount || 0, batchSize: options.batchSize || 0, batchIndex: options.batchIndex ?? -1, totalBatches: options.totalBatches || 0, model: options.model || 'unknown', responseLength: options.responseLength || 0, // Only include first 100 chars to avoid storing large responses responsePreview: (options.responsePreview || '').substring(0, 100), errorMessage: options.errorMessage || '', errorType: options.errorType || 'unknown', parallelMode: (options.totalBatches || 0) > 1, hook: options.hook || 'unknown' // pre-commit, prepare-commit-msg, analyze-diff, create-pr }, { success: false, retryAttempt: options.retryAttempt ?? 0, totalRetries: options.totalRetries ?? 0 } ); }; /** * Record successful batch analysis * Why: Track successes with retry information to compare against failures * * @param {Object} options - Success context * @param {number} options.retryAttempt - Current retry attempt (0-based) * @param {number} options.totalRetries - Total retry attempts configured */ export const recordBatchSuccess = async (options) => { await recordEvent( 'batch_success', { fileCount: options.fileCount || 0, batchSize: options.batchSize || 0, batchIndex: options.batchIndex ?? -1, totalBatches: options.totalBatches || 0, model: options.model || 'unknown', duration: options.duration || 0, responseLength: options.responseLength || 0, parallelMode: (options.totalBatches || 0) > 1, hook: options.hook || 'unknown' }, { success: true, retryAttempt: options.retryAttempt ?? 0, totalRetries: options.totalRetries ?? 0 } ); }; /** * Read all telemetry events from log files * Why: Aggregate data for statistics * * @param {number} maxDays - Maximum days to read (default: 7) * @returns {Promise<Array<TelemetryEvent>>} Array of events */ const readTelemetryEvents = async (maxDays = 7) => { try { const dir = getTelemetryDir(); const files = await fs.readdir(dir); // Filter to .jsonl files only const logFiles = files.filter(f => f.endsWith('.jsonl')); // Sort by date (newest first) logFiles.sort().reverse(); // Limit to maxDays const limitedFiles = logFiles.slice(0, maxDays); // Read all events const events = []; for (const file of limitedFiles) { const filePath = path.join(dir, file); const content = await fs.readFile(filePath, 'utf8'); // Parse JSON lines const lines = content.trim().split('\n'); for (const line of lines) { if (line.trim()) { try { events.push(JSON.parse(line)); } catch (parseError) { // Skip invalid lines logger.debug('telemetry - readTelemetryEvents', 'Invalid JSON line', parseError); } } } } return events; } catch (error) { logger.debug('telemetry - readTelemetryEvents', 'Failed to read events', error); return []; } }; /** * Get telemetry statistics * Why: Provide insights for debugging and optimization * * @param {number} maxDays - Maximum days to analyze (default: 7) * @returns {Promise<Object>} Statistics object */ export const getStatistics = async (maxDays = 7) => { if (!isTelemetryEnabled()) { return { enabled: false, message: 'Telemetry is disabled. To enable (default), remove or set "system.telemetry: true" in .claude/config.json' }; } try { const events = await readTelemetryEvents(maxDays); const stats = { enabled: true, period: `Last ${maxDays} days`, totalEvents: events.length, jsonParseFailures: 0, batchSuccesses: 0, failuresByBatchSize: {}, failuresByModel: {}, failuresByHook: {}, successesByHook: {}, avgFilesPerFailure: 0, avgFilesPerSuccess: 0, failureRate: 0 }; let totalFilesInFailures = 0; let totalFilesInSuccesses = 0; events.forEach(event => { if (event.type === 'json_parse_failure') { stats.jsonParseFailures++; totalFilesInFailures += event.data.fileCount || 0; // Group by batch size const batchSize = event.data.batchSize || 0; stats.failuresByBatchSize[batchSize] = (stats.failuresByBatchSize[batchSize] || 0) + 1; // Group by model const model = event.data.model || 'unknown'; stats.failuresByModel[model] = (stats.failuresByModel[model] || 0) + 1; // Group by hook const hook = event.data.hook || 'unknown'; stats.failuresByHook[hook] = (stats.failuresByHook[hook] || 0) + 1; } else if (event.type === 'batch_success') { stats.batchSuccesses++; totalFilesInSuccesses += event.data.fileCount || 0; // Group by hook const hook = event.data.hook || 'unknown'; stats.successesByHook[hook] = (stats.successesByHook[hook] || 0) + 1; } }); // Calculate averages if (stats.jsonParseFailures > 0) { stats.avgFilesPerFailure = parseFloat((totalFilesInFailures / stats.jsonParseFailures).toFixed(2)); } if (stats.batchSuccesses > 0) { stats.avgFilesPerSuccess = parseFloat((totalFilesInSuccesses / stats.batchSuccesses).toFixed(2)); } // Calculate failure rate const totalAnalyses = stats.jsonParseFailures + stats.batchSuccesses; if (totalAnalyses > 0) { stats.failureRate = parseFloat((stats.jsonParseFailures / totalAnalyses * 100).toFixed(2)); } return stats; } catch (error) { logger.debug('telemetry - getStatistics', 'Failed to calculate statistics', error); return { enabled: true, error: 'Failed to calculate statistics', details: error.message }; } }; /** * Rotate old telemetry files * Why: Prevent unbounded growth * * @param {number} maxDays - Keep files newer than this many days (default: 30) */ export const rotateTelemetry = async (maxDays = 30) => { if (!isTelemetryEnabled()) { return; } try { const dir = getTelemetryDir(); const files = await fs.readdir(dir); // Filter to .jsonl files const logFiles = files.filter(f => f.endsWith('.jsonl')); // Calculate cutoff date const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - maxDays); const cutoffStr = cutoffDate.toISOString().split('T')[0]; // YYYY-MM-DD // Delete old files for (const file of logFiles) { // Extract date from filename (telemetry-YYYY-MM-DD.jsonl) const match = file.match(/telemetry-(\d{4}-\d{2}-\d{2})\.jsonl/); if (match) { const fileDate = match[1]; if (fileDate < cutoffStr) { const filePath = path.join(dir, file); await fs.unlink(filePath); logger.debug('telemetry - rotateTelemetry', `Deleted old telemetry file: ${file}`); } } } } catch (error) { logger.debug('telemetry - rotateTelemetry', 'Failed to rotate telemetry', error); } }; /** * Clear all telemetry data * Why: Allow users to reset */ export const clearTelemetry = async () => { try { const dir = getTelemetryDir(); // Check if directory exists if (!fsSync.existsSync(dir)) { logger.info('No telemetry data found'); return; } // Delete all .jsonl files const files = await fs.readdir(dir); const logFiles = files.filter(f => f.endsWith('.jsonl')); for (const file of logFiles) { const filePath = path.join(dir, file); await fs.unlink(filePath); } logger.info(`Cleared ${logFiles.length} telemetry files`); } catch (error) { logger.error('telemetry - clearTelemetry', 'Failed to clear telemetry', error); } }; /** * Display telemetry statistics to console * Why: User-friendly stats display for CLI */ export const displayStatistics = async () => { const stats = await getStatistics(); console.log('\n╔════════════════════════════════════════════════════════════════════╗'); console.log('║ TELEMETRY STATISTICS ║'); console.log('╚════════════════════════════════════════════════════════════════════╝\n'); if (!stats.enabled) { console.log(stats.message); console.log('\nTelemetry is disabled in your configuration.'); console.log('To re-enable (default), remove or set to true in .claude/config.json:'); console.log('{'); console.log(' "system": {'); console.log(' "telemetry": true'); console.log(' }'); console.log('}\n'); return; } if (stats.error) { console.log(`Error: ${stats.error}`); console.log(`Details: ${stats.details}\n`); return; } console.log(`Period: ${stats.period}`); console.log(`Total events: ${stats.totalEvents}\n`); console.log('━━━ ANALYSIS RESULTS ━━━'); console.log(`✅ Successful analyses: ${stats.batchSuccesses}`); console.log(`❌ JSON parse failures: ${stats.jsonParseFailures}`); console.log(`📊 Failure rate: ${stats.failureRate}%\n`); if (stats.jsonParseFailures > 0) { console.log('━━━ FAILURES BY BATCH SIZE ━━━'); Object.entries(stats.failuresByBatchSize) .sort((a, b) => b[1] - a[1]) .forEach(([size, count]) => { console.log(` Batch size ${size}: ${count} failures`); }); console.log(); console.log('━━━ FAILURES BY MODEL ━━━'); Object.entries(stats.failuresByModel) .sort((a, b) => b[1] - a[1]) .forEach(([model, count]) => { console.log(` ${model}: ${count} failures`); }); console.log(); console.log('━━━ FAILURES BY HOOK ━━━'); Object.entries(stats.failuresByHook) .sort((a, b) => b[1] - a[1]) .forEach(([hook, count]) => { console.log(` ${hook}: ${count} failures`); }); console.log(); console.log('━━━ AVERAGES ━━━'); console.log(` Avg files per failure: ${stats.avgFilesPerFailure}`); console.log(` Avg files per success: ${stats.avgFilesPerSuccess}\n`); } console.log('📂 Telemetry location: .claude/telemetry/'); console.log('💡 Tip: Share telemetry logs when reporting issues for faster debugging\n'); };