UNPKG

claude-code-web

Version:

Web-based interface for Claude Code CLI accessible via browser

494 lines (418 loc) 14.4 kB
const EventEmitter = require('events'); class UsageAnalytics extends EventEmitter { constructor(options = {}) { super(); // Configuration this.sessionDurationHours = options.sessionDurationHours || 5; this.confidenceThreshold = options.confidenceThreshold || 0.95; this.burnRateWindow = options.burnRateWindow || 60; // minutes for burn rate calculation this.updateInterval = options.updateInterval || 10000; // 10 seconds // Plan limits (v3.0.0 updated) this.planLimits = { 'pro': { tokens: 19000, cost: 18.00, messages: 250, algorithm: 'fixed' }, 'claude-pro': { // Keep for backwards compatibility tokens: 19000, cost: 18.00, messages: 250, algorithm: 'fixed' }, 'max5': { tokens: 88000, cost: 35.00, messages: 1000, algorithm: 'fixed' }, 'claude-max5': { // Keep for backwards compatibility tokens: 88000, cost: 35.00, messages: 1000, algorithm: 'fixed' }, 'max20': { tokens: 220000, cost: 140.00, messages: 2000, algorithm: 'fixed' }, 'claude-max20': { // Keep for backwards compatibility tokens: 220000, cost: 140.00, messages: 2000, algorithm: 'fixed' }, 'custom': { tokens: null, // Calculated via P90 cost: options.customCostLimit || 76.89, // Default based on typical usage messages: 1019, // Higher message limit for custom plans algorithm: 'p90' } }; // Current plan (can be set by user) this.currentPlan = options.plan || 'custom'; // Session tracking this.activeSessions = new Map(); // sessionId -> session data this.sessionHistory = []; this.rollingWindows = new Map(); // Track multiple overlapping windows // Usage data this.recentUsage = []; // Array of {timestamp, tokens, cost, model} this.historicalData = []; this.p90Limit = null; // Burn rate tracking this.burnRateHistory = []; this.currentBurnRate = 0; this.velocityTrend = 'stable'; // 'increasing', 'decreasing', 'stable' // Predictions this.depletionTime = null; this.depletionConfidence = 0; } /** * Process new usage data point */ addUsageData(data) { const entry = { timestamp: new Date(), tokens: (data.inputTokens || 0) + (data.outputTokens || 0), // Only input + output tokens inputTokens: data.inputTokens || 0, outputTokens: data.outputTokens || 0, cacheCreationTokens: data.cacheCreationTokens || 0, cacheReadTokens: data.cacheReadTokens || 0, cost: data.cost || 0, model: data.model || 'unknown', sessionId: data.sessionId }; this.recentUsage.push(entry); // Keep only recent data for burn rate (last hour) const cutoff = new Date(Date.now() - this.burnRateWindow * 60 * 1000); this.recentUsage = this.recentUsage.filter(e => e.timestamp > cutoff); // Update burn rate this.calculateBurnRate(); // Update predictions this.updatePredictions(); this.emit('usage-update', entry); } /** * Start or update a session */ startSession(sessionId, startTime = new Date()) { const session = { id: sessionId, startTime: startTime, endTime: new Date(startTime.getTime() + this.sessionDurationHours * 60 * 60 * 1000), tokens: 0, cost: 0, messages: 0, isActive: true, window: 'current' }; this.activeSessions.set(sessionId, session); this.updateRollingWindows(); this.emit('session-started', session); return session; } /** * Update rolling windows for overlapping sessions */ updateRollingWindows() { const now = new Date(); this.rollingWindows.clear(); // Find all sessions that could be active const fiveHoursAgo = new Date(now - this.sessionDurationHours * 60 * 60 * 1000); for (const [id, session] of this.activeSessions) { if (session.startTime > fiveHoursAgo) { const windowId = `window_${session.startTime.getTime()}`; if (!this.rollingWindows.has(windowId)) { this.rollingWindows.set(windowId, { startTime: session.startTime, endTime: session.endTime, sessions: [], totalTokens: 0, totalCost: 0, remainingTokens: this.getTokenLimit(), burnRate: 0 }); } const window = this.rollingWindows.get(windowId); window.sessions.push(id); } } this.emit('windows-updated', Array.from(this.rollingWindows.values())); } /** * Calculate burn rate with sophisticated analysis */ calculateBurnRate() { if (this.recentUsage.length < 2) { this.currentBurnRate = 0; return; } // Sort by timestamp const sorted = [...this.recentUsage].sort((a, b) => a.timestamp - b.timestamp); // Calculate rates over different time windows const rates = []; const windows = [5, 10, 15, 30, 60]; // minutes for (const window of windows) { const cutoff = new Date(Date.now() - window * 60 * 1000); const windowData = sorted.filter(e => e.timestamp > cutoff); if (windowData.length >= 2) { const duration = (windowData[windowData.length - 1].timestamp - windowData[0].timestamp) / 1000 / 60; // minutes // Only count input + output tokens for burn rate, not cache tokens const totalTokens = windowData.reduce((sum, e) => sum + e.inputTokens + e.outputTokens, 0); if (duration > 0) { rates.push({ window: window, rate: totalTokens / duration, weight: Math.min(windowData.length / 10, 1) // Weight by data points }); } } } if (rates.length === 0) { this.currentBurnRate = 0; return; } // Weighted average of rates const totalWeight = rates.reduce((sum, r) => sum + r.weight, 0); this.currentBurnRate = rates.reduce((sum, r) => sum + r.rate * r.weight, 0) / totalWeight; // Track burn rate history for trend analysis this.burnRateHistory.push({ timestamp: new Date(), rate: this.currentBurnRate }); // Keep only last hour of history const histCutoff = new Date(Date.now() - 60 * 60 * 1000); this.burnRateHistory = this.burnRateHistory.filter(e => e.timestamp > histCutoff); // Analyze trend this.analyzeTrend(); this.emit('burn-rate-updated', { rate: this.currentBurnRate, trend: this.velocityTrend, confidence: this.calculateConfidence() }); } /** * Analyze velocity trend */ analyzeTrend() { if (this.burnRateHistory.length < 5) { this.velocityTrend = 'stable'; return; } // Compare recent rates to older rates const mid = Math.floor(this.burnRateHistory.length / 2); const oldRates = this.burnRateHistory.slice(0, mid); const newRates = this.burnRateHistory.slice(mid); const oldAvg = oldRates.reduce((sum, e) => sum + e.rate, 0) / oldRates.length; const newAvg = newRates.reduce((sum, e) => sum + e.rate, 0) / newRates.length; const change = (newAvg - oldAvg) / oldAvg; if (change > 0.15) { this.velocityTrend = 'increasing'; } else if (change < -0.15) { this.velocityTrend = 'decreasing'; } else { this.velocityTrend = 'stable'; } } /** * Update predictions for token depletion */ updatePredictions() { const currentSession = this.getCurrentSession(); if (!currentSession || this.currentBurnRate === 0) { this.depletionTime = null; this.depletionConfidence = 0; return; } const limit = this.getTokenLimit(); const used = this.getSessionTokens(currentSession.id); const remaining = limit - used; if (remaining <= 0) { this.depletionTime = new Date(); this.depletionConfidence = 1; return; } // Calculate time to depletion const minutesToDepletion = remaining / this.currentBurnRate; this.depletionTime = new Date(Date.now() + minutesToDepletion * 60 * 1000); // Calculate confidence based on data quality this.depletionConfidence = this.calculateConfidence(); // Adjust for trend if (this.velocityTrend === 'increasing') { // Depletion might happen sooner const adjustment = 0.9; // 10% sooner const adjustedTime = Date.now() + (this.depletionTime - Date.now()) * adjustment; this.depletionTime = new Date(adjustedTime); } else if (this.velocityTrend === 'decreasing') { // Depletion might happen later const adjustment = 1.1; // 10% later const adjustedTime = Date.now() + (this.depletionTime - Date.now()) * adjustment; this.depletionTime = new Date(adjustedTime); } this.emit('prediction-updated', { depletionTime: this.depletionTime, confidence: this.depletionConfidence, remaining: remaining, burnRate: this.currentBurnRate }); } /** * Calculate confidence score for predictions */ calculateConfidence() { let confidence = 0; let factors = 0; // Factor 1: Amount of recent data if (this.recentUsage.length > 0) { const dataScore = Math.min(this.recentUsage.length / 20, 1); confidence += dataScore * 0.3; factors++; } // Factor 2: Consistency of burn rate if (this.burnRateHistory.length > 3) { const rates = this.burnRateHistory.map(e => e.rate); const mean = rates.reduce((a, b) => a + b, 0) / rates.length; const variance = rates.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / rates.length; const cv = mean > 0 ? Math.sqrt(variance) / mean : 1; // Coefficient of variation const consistencyScore = Math.max(0, 1 - cv); confidence += consistencyScore * 0.4; factors++; } // Factor 3: Trend stability const trendScore = this.velocityTrend === 'stable' ? 1 : 0.7; confidence += trendScore * 0.3; factors++; return factors > 0 ? confidence / factors : 0; } /** * Get current active session */ getCurrentSession() { const now = new Date(); for (const [id, session] of this.activeSessions) { if (session.startTime <= now && session.endTime > now) { return session; } } return null; } /** * Get token limit based on plan */ getTokenLimit() { const plan = this.planLimits[this.currentPlan]; if (plan.algorithm === 'fixed') { return plan.tokens; } else if (plan.algorithm === 'p90') { // Use P90 if calculated, otherwise use a reasonable default // Default of 188,026 based on typical P90 calculations return this.p90Limit || 188026; } return 188026; // Default fallback based on typical P90 } /** * Calculate P90 limit from historical data */ calculateP90Limit(historicalSessions) { if (!historicalSessions || historicalSessions.length < 10) { return null; } // Extract token counts from sessions const tokenCounts = historicalSessions .map(s => s.totalTokens) .filter(t => t > 0) .sort((a, b) => a - b); if (tokenCounts.length === 0) { return null; } // Calculate P90 const p90Index = Math.floor(tokenCounts.length * 0.9); this.p90Limit = tokenCounts[p90Index]; this.emit('p90-calculated', { limit: this.p90Limit, sampleSize: tokenCounts.length, confidence: Math.min(tokenCounts.length / 100, 1) }); return this.p90Limit; } /** * Get tokens used in a session */ getSessionTokens(sessionId) { const session = this.activeSessions.get(sessionId); if (!session) return 0; // Sum tokens from usage data for this session const sessionData = this.recentUsage.filter(e => e.sessionId === sessionId); return sessionData.reduce((sum, e) => sum + e.tokens, 0); } /** * Get comprehensive analytics data */ getAnalytics() { const currentSession = this.getCurrentSession(); return { currentSession: currentSession ? { id: currentSession.id, startTime: currentSession.startTime, endTime: currentSession.endTime, tokens: this.getSessionTokens(currentSession.id), remaining: this.getTokenLimit() - this.getSessionTokens(currentSession.id), percentUsed: (this.getSessionTokens(currentSession.id) / this.getTokenLimit()) * 100 } : null, burnRate: { current: this.currentBurnRate, trend: this.velocityTrend, history: this.burnRateHistory.slice(-10) // Last 10 data points }, predictions: { depletionTime: this.depletionTime, confidence: this.depletionConfidence, minutesRemaining: this.depletionTime ? Math.max(0, (this.depletionTime - Date.now()) / 1000 / 60) : null }, plan: { type: this.currentPlan, limits: this.planLimits[this.currentPlan], p90Limit: this.p90Limit }, windows: Array.from(this.rollingWindows.values()), activeSessions: Array.from(this.activeSessions.values()).map(s => ({ id: s.id, startTime: s.startTime, endTime: s.endTime, isActive: s.isActive, tokens: this.getSessionTokens(s.id) })) }; } /** * Set user's plan type */ setPlan(planType) { if (this.planLimits[planType]) { this.currentPlan = planType; this.updatePredictions(); this.emit('plan-changed', planType); } } /** * Clean up old data */ cleanup() { const now = new Date(); // Remove expired sessions for (const [id, session] of this.activeSessions) { if (session.endTime < now) { this.sessionHistory.push(session); this.activeSessions.delete(id); } } // Keep only last 24 hours of history const cutoff = new Date(now - 24 * 60 * 60 * 1000); this.sessionHistory = this.sessionHistory.filter(s => s.endTime > cutoff); } } module.exports = UsageAnalytics;