UNPKG

claude-usage-tracker

Version:

Advanced analytics for Claude Code usage with cost optimization, conversation length analysis, and rate limit tracking

520 lines (514 loc) 20.7 kB
import { writeFileSync, statSync } from "node:fs"; import { calculateCost } from "./analyzer.js"; import { ConversationLengthAnalyzer } from "./conversation-length-analytics.js"; import { OptimizationAnalyzer } from "./optimization-analytics.js"; export class ExportManager { entries; constructor(entries) { this.entries = entries; } export(options) { try { // Validate inputs this.validateExportOptions(options); const filteredEntries = this.filterEntries(options); if (filteredEntries.length === 0) { throw new Error("No data found matching the specified criteria"); } const filename = this.generateFilename(options); switch (options.format) { case "csv": return this.exportCSV(filteredEntries, filename, options); case "json": return this.exportJSON(filteredEntries, filename, options); case "summary": return this.exportSummary(filteredEntries, filename, options); default: throw new Error(`Unsupported export format: ${options.format}`); } } catch (error) { if (error instanceof Error) { throw new Error(`Export failed: ${error.message}`); } throw new Error("Export failed: Unknown error occurred"); } } validateExportOptions(options) { // Validate format const validFormats = ["csv", "json", "summary"]; if (!validFormats.includes(options.format)) { throw new Error(`Invalid format '${options.format}'. Supported formats: ${validFormats.join(", ")}`); } // Validate template if (options.template) { const validTemplates = ["billing", "efficiency", "analytics", "raw", "detailed"]; if (!validTemplates.includes(options.template)) { throw new Error(`Invalid template '${options.template}'. Supported templates: ${validTemplates.join(", ")}`); } } // Validate dates if (options.startDate && !this.isValidDate(options.startDate)) { throw new Error(`Invalid start date '${options.startDate}'. Use YYYY-MM-DD format`); } if (options.endDate && !this.isValidDate(options.endDate)) { throw new Error(`Invalid end date '${options.endDate}'. Use YYYY-MM-DD format`); } if (options.startDate && options.endDate) { const start = new Date(options.startDate); const end = new Date(options.endDate); if (start > end) { throw new Error("Start date cannot be after end date"); } } // Validate groupBy if (options.groupBy) { const validGroupBy = ["day", "week", "month", "project", "conversation"]; if (!validGroupBy.includes(options.groupBy)) { throw new Error(`Invalid groupBy '${options.groupBy}'. Supported options: ${validGroupBy.join(", ")}`); } } } isValidDate(dateString) { const regex = /^\d{4}-\d{2}-\d{2}$/; if (!regex.test(dateString)) { return false; } const date = new Date(dateString); return date instanceof Date && !isNaN(date.getTime()); } filterEntries(options) { let filtered = [...this.entries]; // Date filtering if (options.startDate) { const startDate = new Date(options.startDate); filtered = filtered.filter(entry => new Date(entry.timestamp) >= startDate); } if (options.endDate) { const endDate = new Date(options.endDate); filtered = filtered.filter(entry => new Date(entry.timestamp) <= endDate); } // Project filtering if (options.project) { filtered = filtered.filter(entry => entry.instanceId?.toLowerCase().includes(options.project.toLowerCase())); } return filtered.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); } generateFilename(options) { const timestamp = new Date().toISOString().split('T')[0]; const template = options.template || 'export'; const format = options.format; if (options.output) { return options.output.endsWith(`.${format}`) ? options.output : `${options.output}.${format}`; } return `claude-usage-${template}-${timestamp}.${format}`; } exportCSV(entries, filename, options) { try { const csvData = this.generateCSVData(entries, options.template || "raw"); writeFileSync(filename, csvData, 'utf8'); return this.createSummary(entries, filename); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to write CSV file: ${error.message}`); } throw new Error("Failed to write CSV file: Unknown error"); } } exportJSON(entries, filename, options) { try { const jsonData = this.generateJSONData(entries, options); writeFileSync(filename, JSON.stringify(jsonData, null, 2), 'utf8'); return this.createSummary(entries, filename); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to write JSON file: ${error.message}`); } throw new Error("Failed to write JSON file: Unknown error"); } } exportSummary(entries, filename, options) { try { const summaryData = this.generateSummaryReport(entries); writeFileSync(filename, summaryData, 'utf8'); return this.createSummary(entries, filename); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to write summary file: ${error.message}`); } throw new Error("Failed to write summary file: Unknown error"); } } generateCSVData(entries, template) { switch (template) { case "billing": return this.generateBillingCSV(entries); case "efficiency": return this.generateEfficiencyCSV(entries); case "analytics": return this.generateAnalyticsCSV(entries); default: return this.generateRawCSV(entries); } } generateBillingCSV(entries) { const headers = [ "Date", "Project", "Conversation ID", "Model", "Input Tokens", "Output Tokens", "Total Tokens", "Cost (USD)", "Duration (minutes)" ]; const rows = entries.map(entry => { const cost = calculateCost(entry); const totalTokens = (entry.prompt_tokens || 0) + (entry.completion_tokens || 0); // Calculate duration (simplified) const duration = entry.total_tokens ? Math.round(entry.total_tokens / 100) : 1; return [ new Date(entry.timestamp).toISOString().split('T')[0], entry.instanceId || "unknown", entry.conversationId, entry.model || "unknown", entry.prompt_tokens || 0, entry.completion_tokens || 0, totalTokens, cost.toFixed(6), duration ].join(","); }); return [headers.join(","), ...rows].join("\n"); } generateEfficiencyCSV(entries) { const headers = [ "Date", "Project", "Conversation ID", "Messages in Conversation", "Tokens per Message", "Cost per Message", "Efficiency Score", "Model" ]; // Group by conversation for efficiency metrics const conversations = new Map(); for (const entry of entries) { if (!conversations.has(entry.conversationId)) { conversations.set(entry.conversationId, []); } conversations.get(entry.conversationId).push(entry); } const rows = []; for (const [convId, convEntries] of conversations) { const totalTokens = convEntries.reduce((sum, e) => sum + (e.prompt_tokens || 0) + (e.completion_tokens || 0), 0); const totalCost = convEntries.reduce((sum, e) => sum + calculateCost(e), 0); const messageCount = convEntries.length; const tokensPerMessage = messageCount > 0 ? totalTokens / messageCount : 0; const costPerMessage = messageCount > 0 ? totalCost / messageCount : 0; const efficiencyScore = totalCost > 0 ? totalTokens / totalCost : 0; const firstEntry = convEntries[0]; rows.push([ new Date(firstEntry.timestamp).toISOString().split('T')[0], firstEntry.instanceId || "unknown", convId, messageCount, tokensPerMessage.toFixed(2), costPerMessage.toFixed(6), efficiencyScore.toFixed(2), firstEntry.model || "unknown" ].join(",")); } return [headers.join(","), ...rows].join("\n"); } generateAnalyticsCSV(entries) { const headers = [ "Date", "Hour", "Project", "Model", "Conversation Count", "Total Tokens", "Total Cost", "Avg Tokens per Conversation", "Avg Cost per Conversation", "Peak Usage Indicator" ]; // Group by date, hour, project, and model const groups = new Map(); for (const entry of entries) { const date = new Date(entry.timestamp); const key = `${date.toISOString().split('T')[0]}-${date.getHours()}-${entry.instanceId || 'unknown'}-${entry.model || 'unknown'}`; if (!groups.has(key)) { groups.set(key, { conversations: new Set(), totalTokens: 0, totalCost: 0, entries: [] }); } const group = groups.get(key); group.conversations.add(entry.conversationId); group.totalTokens += (entry.prompt_tokens || 0) + (entry.completion_tokens || 0); group.totalCost += calculateCost(entry); group.entries.push(entry); } const rows = []; for (const [key, group] of groups) { const [date, hour, project, model] = key.split('-'); const conversationCount = group.conversations.size; const avgTokens = conversationCount > 0 ? group.totalTokens / conversationCount : 0; const avgCost = conversationCount > 0 ? group.totalCost / conversationCount : 0; // Simple peak detection (> 10 conversations in an hour) const isPeak = conversationCount > 10 ? "Yes" : "No"; rows.push([ date, hour, project, model, conversationCount, group.totalTokens, group.totalCost.toFixed(6), avgTokens.toFixed(2), avgCost.toFixed(6), isPeak ].join(",")); } return [headers.join(","), ...rows].join("\n"); } generateRawCSV(entries) { const headers = [ "Timestamp", "Conversation ID", "Instance ID", "Model", "Prompt Tokens", "Completion Tokens", "Total Tokens", "Cost USD", "Cost", "Request Type" ]; const rows = entries.map(entry => [ entry.timestamp, entry.conversationId, entry.instanceId || "", entry.model || "", entry.prompt_tokens || 0, entry.completion_tokens || 0, entry.total_tokens || 0, entry.costUSD || 0, entry.cost || 0, "" ].join(",")); return [headers.join(","), ...rows].join("\n"); } generateJSONData(entries, options) { const baseData = { metadata: { exportedAt: new Date().toISOString(), totalEntries: entries.length, dateRange: this.getDateRange(entries), filters: { startDate: options.startDate, endDate: options.endDate, project: options.project, template: options.template } } }; switch (options.template) { case "billing": return { ...baseData, billing: this.generateBillingData(entries) }; case "analytics": return { ...baseData, analytics: this.generateAnalyticsData(entries) }; default: return { ...baseData, entries: entries }; } } generateBillingData(entries) { const dailySummary = new Map(); for (const entry of entries) { const date = new Date(entry.timestamp).toISOString().split('T')[0]; if (!dailySummary.has(date)) { dailySummary.set(date, { date, totalCost: 0, totalTokens: 0, conversationCount: 0, projects: new Set() }); } const summary = dailySummary.get(date); summary.totalCost += calculateCost(entry); summary.totalTokens += (entry.prompt_tokens || 0) + (entry.completion_tokens || 0); summary.projects.add(entry.instanceId || "unknown"); } // Count unique conversations per day const conversationsByDate = new Map(); for (const entry of entries) { const date = new Date(entry.timestamp).toISOString().split('T')[0]; if (!conversationsByDate.has(date)) { conversationsByDate.set(date, new Set()); } conversationsByDate.get(date).add(entry.conversationId); } return Array.from(dailySummary.values()).map(summary => ({ ...summary, conversationCount: conversationsByDate.get(summary.date)?.size || 0, projects: Array.from(summary.projects) })); } generateAnalyticsData(entries) { if (entries.length === 0) return null; const analyzer = new ConversationLengthAnalyzer(); analyzer.loadConversations(entries); const lengthAnalysis = analyzer.analyzeConversationLengths(); const optimizationAnalyzer = new OptimizationAnalyzer(); const clusters = optimizationAnalyzer.clusterConversations(entries); return { conversationLengthAnalysis: lengthAnalysis, optimizationOpportunities: clusters.slice(0, 5) }; } generateSummaryReport(entries) { if (entries.length === 0) { return "No data found for the specified criteria.\n"; } const totalCost = entries.reduce((sum, e) => sum + calculateCost(e), 0); const totalTokens = entries.reduce((sum, e) => sum + (e.prompt_tokens || 0) + (e.completion_tokens || 0), 0); const dateRange = this.getDateRange(entries); const projects = new Set(entries.map(e => e.instanceId || "unknown")); const conversations = new Set(entries.map(e => e.conversationId)); const models = new Set(entries.map(e => e.model || "unknown")); const avgCostPerConversation = conversations.size > 0 ? totalCost / conversations.size : 0; const avgTokensPerMessage = entries.length > 0 ? totalTokens / entries.length : 0; return ` CLAUDE USAGE SUMMARY REPORT =========================== Export Date: ${new Date().toISOString()} Data Period: ${dateRange.start} to ${dateRange.end} OVERVIEW -------- Total Messages: ${entries.length.toLocaleString()} Total Conversations: ${conversations.size.toLocaleString()} Total Cost: $${totalCost.toFixed(4)} Total Tokens: ${totalTokens.toLocaleString()} AVERAGES -------- Cost per Conversation: $${avgCostPerConversation.toFixed(4)} Tokens per Message: ${avgTokensPerMessage.toFixed(0)} Messages per Conversation: ${(entries.length / conversations.size).toFixed(1)} BREAKDOWN --------- Projects: ${projects.size} (${Array.from(projects).join(", ")}) Models Used: ${Array.from(models).join(", ")} DAILY BREAKDOWN --------------- ${this.generateDailyBreakdown(entries)} TOP CONVERSATIONS BY COST -------------------------- ${this.generateTopConversations(entries)} `; } generateDailyBreakdown(entries) { const daily = new Map(); for (const entry of entries) { const date = new Date(entry.timestamp).toISOString().split('T')[0]; if (!daily.has(date)) { daily.set(date, { cost: 0, messages: 0, conversations: new Set() }); } const day = daily.get(date); day.cost += calculateCost(entry); day.messages++; day.conversations.add(entry.conversationId); } return Array.from(daily.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, data]) => `${date}: $${data.cost.toFixed(4)} (${data.messages} messages, ${data.conversations.size} conversations)`) .join('\n'); } generateTopConversations(entries) { const conversations = new Map(); for (const entry of entries) { if (!conversations.has(entry.conversationId)) { conversations.set(entry.conversationId, { cost: 0, messages: 0, project: entry.instanceId || "unknown" }); } const conv = conversations.get(entry.conversationId); conv.cost += calculateCost(entry); conv.messages++; } return Array.from(conversations.entries()) .sort(([, a], [, b]) => b.cost - a.cost) .slice(0, 10) .map(([id, data], index) => `${index + 1}. ${id.substring(0, 8)}... - $${data.cost.toFixed(4)} (${data.messages} messages, ${data.project})`) .join('\n'); } getDateRange(entries) { if (entries.length === 0) { const today = new Date().toISOString().split('T')[0]; return { start: today, end: today }; } let minTime = Number.MAX_SAFE_INTEGER; let maxTime = Number.MIN_SAFE_INTEGER; for (const entry of entries) { const time = new Date(entry.timestamp).getTime(); if (time < minTime) minTime = time; if (time > maxTime) maxTime = time; } const start = new Date(minTime); const end = new Date(maxTime); return { start: start.toISOString().split('T')[0], end: end.toISOString().split('T')[0] }; } createSummary(entries, filename) { const stats = statSync(filename); const totalCost = entries.reduce((sum, e) => sum + calculateCost(e), 0); const totalTokens = entries.reduce((sum, e) => sum + (e.prompt_tokens || 0) + (e.completion_tokens || 0), 0); const projects = Array.from(new Set(entries.map(e => e.instanceId || "unknown"))); const dateRange = this.getDateRange(entries); return { totalEntries: entries.length, dateRange, totalCost, totalTokens, projects, exportedAt: new Date().toISOString(), fileSize: this.formatFileSize(stats.size) }; } formatFileSize(bytes) { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } } //# sourceMappingURL=export-manager.js.map