diff --git a/src/web-server/data-aggregator.ts b/src/web-server/data-aggregator.ts index 0ebd882f..2d8c26b2 100644 --- a/src/web-server/data-aggregator.ts +++ b/src/web-server/data-aggregator.ts @@ -1,516 +1,7 @@ /** - * Data Aggregator for Claude Code Usage Analytics + * Data Aggregator - Re-export from modularized location * - * Aggregates raw JSONL entries into daily, monthly, and session summaries. - * Uses model-pricing.ts for cost calculations. + * @deprecated Import from './usage/data-aggregator' instead */ -import { type RawUsageEntry } from './jsonl-parser'; -import { calculateCost } from './model-pricing'; -import { - type ModelBreakdown, - type DailyUsage, - type HourlyUsage, - type MonthlyUsage, - type SessionUsage, -} from './usage-types'; - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** Extract YYYY-MM-DD from ISO timestamp */ -function extractDate(timestamp: string): string { - return timestamp.slice(0, 10); -} - -/** Extract YYYY-MM from ISO timestamp */ -function extractMonth(timestamp: string): string { - return timestamp.slice(0, 7); -} - -/** Extract YYYY-MM-DD HH:00 from ISO timestamp */ -function extractHour(timestamp: string): string { - const date = timestamp.slice(0, 10); - const hour = timestamp.slice(11, 13) || '00'; - return `${date} ${hour}:00`; -} - -/** Create model breakdown from accumulated data */ -function createModelBreakdown( - modelName: string, - inputTokens: number, - outputTokens: number, - cacheCreationTokens: number, - cacheReadTokens: number -): ModelBreakdown { - const cost = calculateCost( - { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }, - modelName - ); - - return { - modelName, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - cost, - }; -} - -/** Accumulator for per-model token counts */ -interface ModelAccumulator { - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; -} - -// ============================================================================ -// DAILY AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into daily usage summaries - * Groups by date (YYYY-MM-DD), calculates costs per model - */ -export function aggregateDailyUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): DailyUsage[] { - // Group entries by date - const byDate = new Map(); - - for (const entry of entries) { - const date = extractDate(entry.timestamp); - const existing = byDate.get(date) || []; - existing.push(entry); - byDate.set(date, existing); - } - - // Build daily summaries - const dailyUsage: DailyUsage[] = []; - - for (const [date, dateEntries] of byDate) { - // Aggregate by model - const modelMap = new Map(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - - for (const entry of dateEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - dailyUsage.push({ - date, - source, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - cost: totalCost, - totalCost, - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - }); - } - - // Sort by date descending (most recent first) - dailyUsage.sort((a, b) => b.date.localeCompare(a.date)); - - return dailyUsage; -} - -// ============================================================================ -// HOURLY AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into hourly usage summaries - * Groups by hour (YYYY-MM-DD HH:00), calculates costs per model - */ -export function aggregateHourlyUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): HourlyUsage[] { - // Group entries by hour - const byHour = new Map(); - - for (const entry of entries) { - const hour = extractHour(entry.timestamp); - const existing = byHour.get(hour) || []; - existing.push(entry); - byHour.set(hour, existing); - } - - // Build hourly summaries - const hourlyUsage: HourlyUsage[] = []; - - for (const [hour, hourEntries] of byHour) { - // Aggregate by model - const modelMap = new Map(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - - for (const entry of hourEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - hourlyUsage.push({ - hour, - source, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - cost: totalCost, - totalCost, - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - }); - } - - // Sort by hour descending (most recent first) - hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour)); - - return hourlyUsage; -} - -// ============================================================================ -// MONTHLY AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into monthly usage summaries - * Groups by month (YYYY-MM), calculates costs per model - */ -export function aggregateMonthlyUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): MonthlyUsage[] { - // Group entries by month - const byMonth = new Map(); - - for (const entry of entries) { - const month = extractMonth(entry.timestamp); - const existing = byMonth.get(month) || []; - existing.push(entry); - byMonth.set(month, existing); - } - - // Build monthly summaries - const monthlyUsage: MonthlyUsage[] = []; - - for (const [month, monthEntries] of byMonth) { - // Aggregate by model - const modelMap = new Map(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - - for (const entry of monthEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - monthlyUsage.push({ - month, - source, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - totalCost, - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - }); - } - - // Sort by month descending (most recent first) - monthlyUsage.sort((a, b) => b.month.localeCompare(a.month)); - - return monthlyUsage; -} - -// ============================================================================ -// SESSION AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into session usage summaries - * Groups by sessionId, tracks last activity and versions - */ -export function aggregateSessionUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): SessionUsage[] { - // Group entries by sessionId - const bySession = new Map(); - - for (const entry of entries) { - if (!entry.sessionId) continue; - const existing = bySession.get(entry.sessionId) || []; - existing.push(entry); - bySession.set(entry.sessionId, existing); - } - - // Build session summaries - const sessionUsage: SessionUsage[] = []; - - for (const [sessionId, sessionEntries] of bySession) { - // Aggregate by model - const modelMap = new Map(); - const versions = new Set(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - let lastActivity = ''; - let projectPath = ''; - - for (const entry of sessionEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - - // Track latest timestamp - if (entry.timestamp > lastActivity) { - lastActivity = entry.timestamp; - } - - // Track versions - if (entry.version) { - versions.add(entry.version); - } - - // Use project path from entry - if (entry.projectPath) { - projectPath = entry.projectPath; - } - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - sessionUsage.push({ - sessionId, - projectPath, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - cost: totalCost, - totalCost, - lastActivity, - versions: Array.from(versions), - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - source, - }); - } - - // Sort by last activity descending (most recent first) - sessionUsage.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); - - return sessionUsage; -} - -// ============================================================================ -// MAIN DATA LOADER (drop-in replacement for better-ccusage) -// ============================================================================ - -import { scanProjectsDirectory, type ParserOptions } from './jsonl-parser'; - -/** - * Load daily usage data (replaces better-ccusage loadDailyUsageData) - */ -export async function loadDailyUsageData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateDailyUsage(entries); -} - -/** - * Load hourly usage data for today's chart - */ -export async function loadHourlyUsageData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateHourlyUsage(entries); -} - -/** - * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) - */ -export async function loadMonthlyUsageData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateMonthlyUsage(entries); -} - -/** - * Load session data (replaces better-ccusage loadSessionData) - */ -export async function loadSessionData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateSessionUsage(entries); -} - -/** - * Load all usage data in a single pass (more efficient) - */ -export async function loadAllUsageData(options?: ParserOptions): Promise<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -}> { - const entries = await scanProjectsDirectory(options); - return { - daily: aggregateDailyUsage(entries), - hourly: aggregateHourlyUsage(entries), - monthly: aggregateMonthlyUsage(entries), - session: aggregateSessionUsage(entries), - }; -} +export * from './usage/data-aggregator'; diff --git a/src/web-server/services/usage-aggregator.ts b/src/web-server/services/usage-aggregator.ts index 33bd04e0..bda65d69 100644 --- a/src/web-server/services/usage-aggregator.ts +++ b/src/web-server/services/usage-aggregator.ts @@ -1,538 +1,7 @@ /** - * Usage Aggregator Service + * Usage Aggregator Service - Re-export from modularized location * - * Handles multi-instance usage data aggregation and caching. - * Combines data from default Claude config and all CCS instances. + * @deprecated Import from '../usage/aggregator' instead */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - loadDailyUsageData, - loadMonthlyUsageData, - loadSessionData, - loadAllUsageData, - loadHourlyUsageData, -} from '../data-aggregator'; -import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from '../usage-types'; -import { - readDiskCache, - writeDiskCache, - isDiskCacheFresh, - isDiskCacheStale, - clearDiskCache, - getCacheAge, -} from '../usage-disk-cache'; -import { ok, info, fail } from '../../utils/ui'; - -// ============================================================================ -// Multi-Instance Support - Aggregate usage from CCS profiles -// ============================================================================ - -/** Path to CCS instances directory */ -const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); - -/** - * Get list of CCS instance paths that have usage data - * Only returns instances with existing projects/ directory - */ -function getInstancePaths(): string[] { - if (!fs.existsSync(CCS_INSTANCES_DIR)) { - return []; - } - - try { - const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) - .filter((instancePath) => { - // Only include instances that have a projects directory - const projectsPath = path.join(instancePath, 'projects'); - return fs.existsSync(projectsPath); - }); - } catch { - console.error(fail('Failed to read CCS instances directory')); - return []; - } -} - -/** - * Load usage data from a specific instance - * Uses custom JSONL parser with instance's projects directory - */ -async function loadInstanceData(instancePath: string): Promise<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -}> { - try { - const projectsDir = path.join(instancePath, 'projects'); - const result = await loadAllUsageData({ projectsDir }); - return result; - } catch (_err) { - // Instance may have no usage data - that's OK - const instanceName = path.basename(instancePath); - console.log(info(`No usage data in instance: ${instanceName}`)); - return { daily: [], hourly: [], monthly: [], session: [] }; - } -} - -/** - * Merge daily usage data from multiple sources - * Combines entries with same date by aggregating tokens - */ -export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { - const dateMap = new Map(); - - for (const source of sources) { - for (const day of source) { - const existing = dateMap.get(day.date); - if (existing) { - // Aggregate tokens for same date - existing.inputTokens += day.inputTokens; - existing.outputTokens += day.outputTokens; - existing.cacheCreationTokens += day.cacheCreationTokens; - existing.cacheReadTokens += day.cacheReadTokens; - existing.totalCost += day.totalCost; - // Merge unique models - const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); - // Merge model breakdowns by aggregating same modelName - for (const breakdown of day.modelBreakdowns) { - const existingBreakdown = existing.modelBreakdowns.find( - (b) => b.modelName === breakdown.modelName - ); - if (existingBreakdown) { - existingBreakdown.inputTokens += breakdown.inputTokens; - existingBreakdown.outputTokens += breakdown.outputTokens; - existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; - existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; - existingBreakdown.cost += breakdown.cost; - } else { - existing.modelBreakdowns.push({ ...breakdown }); - } - } - } else { - // Clone to avoid mutating original - dateMap.set(day.date, { - ...day, - modelsUsed: [...day.modelsUsed], - modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })), - }); - } - } - } - - return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date)); -} - -/** - * Merge monthly usage data from multiple sources - */ -export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { - const monthMap = new Map(); - - for (const source of sources) { - for (const month of source) { - const existing = monthMap.get(month.month); - if (existing) { - existing.inputTokens += month.inputTokens; - existing.outputTokens += month.outputTokens; - existing.cacheCreationTokens += month.cacheCreationTokens; - existing.cacheReadTokens += month.cacheReadTokens; - existing.totalCost += month.totalCost; - const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); - } else { - monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); - } - } - } - - return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); -} - -/** - * Merge hourly usage data from multiple sources - * Combines entries with same hour by aggregating tokens - */ -export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { - const hourMap = new Map(); - - for (const source of sources) { - for (const hour of source) { - const existing = hourMap.get(hour.hour); - if (existing) { - existing.inputTokens += hour.inputTokens; - existing.outputTokens += hour.outputTokens; - existing.cacheCreationTokens += hour.cacheCreationTokens; - existing.cacheReadTokens += hour.cacheReadTokens; - existing.totalCost += hour.totalCost; - const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); - // Merge model breakdowns - for (const breakdown of hour.modelBreakdowns) { - const existingBreakdown = existing.modelBreakdowns.find( - (b) => b.modelName === breakdown.modelName - ); - if (existingBreakdown) { - existingBreakdown.inputTokens += breakdown.inputTokens; - existingBreakdown.outputTokens += breakdown.outputTokens; - existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; - existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; - existingBreakdown.cost += breakdown.cost; - } else { - existing.modelBreakdowns.push({ ...breakdown }); - } - } - } else { - hourMap.set(hour.hour, { - ...hour, - modelsUsed: [...hour.modelsUsed], - modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), - }); - } - } - } - - return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); -} - -/** - * Merge session data from multiple sources - * Deduplicates by sessionId (same session shouldn't appear in multiple instances) - */ -export function mergeSessionData(sources: SessionUsage[][]): SessionUsage[] { - const sessionMap = new Map(); - - for (const source of sources) { - for (const session of source) { - // Use sessionId as unique key - later entries overwrite earlier ones - sessionMap.set(session.sessionId, session); - } - } - - return Array.from(sessionMap.values()).sort( - (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() - ); -} - -// ============================================================================ -// Caching Layer - Reduces better-ccusage library calls -// ============================================================================ - -interface CacheEntry { - data: T; - timestamp: number; -} - -// Cache TTLs (milliseconds) -const CACHE_TTL = { - daily: 60 * 1000, // 1 minute - changes frequently - monthly: 5 * 60 * 1000, // 5 minutes - aggregated data - session: 60 * 1000, // 1 minute - user may refresh -}; - -/// Stale-while-revalidate: max age for stale data (7 days) -// We always show cached data to avoid blocking UI, refresh happens in background -const STALE_TTL = 7 * 24 * 60 * 60 * 1000; - -// Track when data was last fetched (for UI indicator) -let lastFetchTimestamp: number | null = null; - -/** Get timestamp of last successful data fetch */ -export function getLastFetchTimestamp(): number | null { - return lastFetchTimestamp; -} - -// In-memory cache -const cache = new Map>(); - -// Pending requests for coalescing (prevents duplicate concurrent calls) -const pendingRequests = new Map>(); - -// Track if disk cache has been loaded into memory -let diskCacheInitialized = false; - -// Track if background refresh is in progress -let isRefreshing = false; - -/** - * Persist cache to disk when we have enough data to be useful. - */ -function persistCacheIfComplete(): void { - const daily = cache.get('daily') as CacheEntry | undefined; - const hourly = cache.get('hourly') as CacheEntry | undefined; - const monthly = cache.get('monthly') as CacheEntry | undefined; - const session = cache.get('session') as CacheEntry | undefined; - - // Write if we have at least daily data (the most essential) - if (daily) { - writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []); - } -} - -/** - * Load fresh data and update both memory and disk caches - * Aggregates data from default ~/.claude/ AND all CCS instances - */ -async function refreshFromSource(): Promise<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -}> { - // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) - const defaultData = await loadAllUsageData(); - - // Load data from all CCS instances sequentially - const instancePaths = getInstancePaths(); - const instanceDataResults: Array<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; - }> = []; - - for (const instancePath of instancePaths) { - try { - const data = await loadInstanceData(instancePath); - instanceDataResults.push(data); - } catch (err) { - const instanceName = path.basename(instancePath); - console.error(fail(`Failed to load instance ${instanceName}: ${err}`)); - } - } - - // Collect successful instance data - const allDailySources: DailyUsage[][] = [defaultData.daily]; - const allHourlySources: HourlyUsage[][] = [defaultData.hourly]; - const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; - const allSessionSources: SessionUsage[][] = [defaultData.session]; - - for (const result of instanceDataResults) { - allDailySources.push(result.daily); - allHourlySources.push(result.hourly); - allMonthlySources.push(result.monthly); - allSessionSources.push(result.session); - } - - if (instanceDataResults.length > 0) { - console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); - } - - // Merge all data sources - const daily = mergeDailyData(allDailySources); - const hourly = mergeHourlyData(allHourlySources); - const monthly = mergeMonthlyData(allMonthlySources); - const session = mergeSessionData(allSessionSources); - - // Update in-memory cache - const now = Date.now(); - cache.set('daily', { data: daily, timestamp: now }); - cache.set('hourly', { data: hourly, timestamp: now }); - cache.set('monthly', { data: monthly, timestamp: now }); - cache.set('session', { data: session, timestamp: now }); - lastFetchTimestamp = now; - - // Persist to disk - writeDiskCache(daily, hourly, monthly, session); - - return { daily, hourly, monthly, session }; -} - -/** - * Initialize in-memory cache from disk cache (lazy - called on first API request). - */ -function ensureDiskCacheLoaded(): void { - if (diskCacheInitialized) return; - diskCacheInitialized = true; - - const diskCache = readDiskCache(); - if (!diskCache) return; - - // Load disk cache into memory (regardless of freshness) - cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); - cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); - cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); - cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); - lastFetchTimestamp = diskCache.timestamp; -} - -/** - * Get cached data or fetch from loader with TTL - * Implements stale-while-revalidate pattern for instant responses - */ -async function getCachedData(key: string, ttl: number, loader: () => Promise): Promise { - // Ensure disk cache is loaded on first request - ensureDiskCacheLoaded(); - - const cached = cache.get(key) as CacheEntry | undefined; - const now = Date.now(); - - // Fresh cache - return immediately - if (cached && now - cached.timestamp < ttl) { - return cached.data; - } - - // Stale cache - return immediately, refresh in background (SWR pattern) - if (cached && now - cached.timestamp < STALE_TTL) { - // Fire and forget background refresh if not already pending - if (!pendingRequests.has(key)) { - const promise = loader() - .then((data) => { - cache.set(key, { data, timestamp: Date.now() }); - lastFetchTimestamp = Date.now(); - persistCacheIfComplete(); - }) - .catch((err) => { - console.error(fail(`Background refresh failed for ${key}: ${err}`)); - }) - .finally(() => { - pendingRequests.delete(key); - }); - pendingRequests.set(key, promise); - } - return cached.data; - } - - // No usable cache - check if request is already pending (coalesce) - const pending = pendingRequests.get(key) as Promise | undefined; - if (pending) { - return pending; - } - - // Create new request - const promise = loader() - .then((data) => { - cache.set(key, { data, timestamp: Date.now() }); - lastFetchTimestamp = Date.now(); - persistCacheIfComplete(); - return data; - }) - .finally(() => { - pendingRequests.delete(key); - }); - - pendingRequests.set(key, promise); - return promise; -} - -/** Cached loader for daily usage data */ -export async function getCachedDailyData(): Promise { - return getCachedData('daily', CACHE_TTL.daily, async () => { - return await loadDailyUsageData(); - }); -} - -/** Cached loader for monthly usage data */ -export async function getCachedMonthlyData(): Promise { - return getCachedData('monthly', CACHE_TTL.monthly, async () => { - return await loadMonthlyUsageData(); - }); -} - -/** Cached loader for session data */ -export async function getCachedSessionData(): Promise { - return getCachedData('session', CACHE_TTL.session, async () => { - return await loadSessionData(); - }); -} - -/** Cached loader for hourly usage data */ -export async function getCachedHourlyData(): Promise { - return getCachedData('hourly', CACHE_TTL.daily, async () => { - return await loadHourlyUsageData(); - }); -} - -/** - * Clear all cached data (useful for manual refresh) - */ -export function clearUsageCache(): void { - cache.clear(); - clearDiskCache(); - // Reset so next API call will try to reload from disk/source - diskCacheInitialized = false; -} - -/** - * Pre-warm usage caches on server startup - * - * Strategy: - * 1. Check disk cache - if fresh, use it (instant startup) - * 2. If stale, use it immediately but trigger background refresh - * 3. If no cache, return immediately and let first request trigger load - */ -export async function prewarmUsageCache(): Promise<{ - timestamp: number; - elapsed: number; - source: string; -}> { - const start = Date.now(); - console.log(info('Pre-warming usage cache...')); - - try { - const diskCache = readDiskCache(); - - // Fresh disk cache - use it directly - if (diskCache && isDiskCacheFresh(diskCache)) { - const now = Date.now(); - cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); - cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); - cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); - cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); - lastFetchTimestamp = diskCache.timestamp; - - const elapsed = Date.now() - start; - console.log( - ok(`Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`) - ); - return { timestamp: now, elapsed, source: 'disk-fresh' }; - } - - // Stale disk cache - use it immediately, refresh in background - if (diskCache && isDiskCacheStale(diskCache)) { - const now = Date.now(); - cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); - cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); - cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); - cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); - lastFetchTimestamp = diskCache.timestamp; - - const elapsed = Date.now() - start; - console.log( - ok( - `Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)` - ) - ); - - // Background refresh - if (!isRefreshing) { - isRefreshing = true; - refreshFromSource() - .then(() => console.log(ok('Background refresh complete'))) - .catch((err) => console.error(fail(`Background refresh failed: ${err}`))) - .finally(() => { - isRefreshing = false; - }); - } - - return { timestamp: now, elapsed, source: 'disk-stale' }; - } - - // No usable disk cache - refresh from source (blocking for first startup only) - console.log(info('No disk cache, loading from source...')); - await refreshFromSource(); - - const elapsed = Date.now() - start; - console.log(ok(`Usage cache ready (${elapsed}ms)`)); - return { timestamp: Date.now(), elapsed, source: 'fresh' }; - } catch (err) { - console.error(fail(`Failed to prewarm usage cache: ${err}`)); - throw err; - } -} +export * from '../usage/aggregator'; diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index 0af47521..18d6db23 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -1,155 +1,7 @@ /** - * Persistent Disk Cache for Usage Data + * Usage Disk Cache - Re-export from modularized location * - * Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files - * on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate. - * - * Cache location: ~/.ccs/cache/usage.json - * Default TTL: 5 minutes (configurable) + * @deprecated Import from './usage/disk-cache' instead */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './usage-types'; -import { ok, info, warn } from '../utils/ui'; - -// Cache configuration -const CCS_DIR = path.join(os.homedir(), '.ccs'); -const CACHE_DIR = path.join(CCS_DIR, 'cache'); -const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes -const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) - -/** Structure of the disk cache file */ -export interface UsageDiskCache { - version: number; - timestamp: number; - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -} - -// Current cache version - increment to invalidate old caches -// v3: Added hourly data to cache -const CACHE_VERSION = 3; - -/** - * Ensure ~/.ccs/cache directory exists - */ -function ensureCacheDir(): void { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); - } -} - -/** - * Read usage data from disk cache - * Returns null if cache is missing, corrupted, or has incompatible version - * NOTE: Does NOT reject based on age - caller handles staleness via SWR pattern - */ -export function readDiskCache(): UsageDiskCache | null { - try { - if (!fs.existsSync(CACHE_FILE)) { - return null; - } - - const data = fs.readFileSync(CACHE_FILE, 'utf-8'); - const cache: UsageDiskCache = JSON.parse(data); - - // Version check - invalidate if schema changed - if (cache.version !== CACHE_VERSION) { - console.log(info('Cache version mismatch, will refresh')); - return null; - } - - // Always return cache regardless of age - SWR pattern handles staleness - return cache; - } catch (err) { - // Cache corrupted or unreadable - treat as miss - console.log(info('Cache read failed, will refresh:') + ` ${(err as Error).message}`); - return null; - } -} - -/** - * Check if disk cache is fresh (within TTL) - */ -export function isDiskCacheFresh(cache: UsageDiskCache | null): boolean { - if (!cache) return false; - const age = Date.now() - cache.timestamp; - return age < CACHE_TTL_MS; -} - -/** - * Check if disk cache is stale but usable (between TTL and STALE_TTL) - */ -export function isDiskCacheStale(cache: UsageDiskCache | null): boolean { - if (!cache) return false; - const age = Date.now() - cache.timestamp; - return age >= CACHE_TTL_MS && age < STALE_TTL_MS; -} - -/** - * Write usage data to disk cache - */ -export function writeDiskCache( - daily: DailyUsage[], - hourly: HourlyUsage[], - monthly: MonthlyUsage[], - session: SessionUsage[] -): void { - try { - ensureCacheDir(); - - const cache: UsageDiskCache = { - version: CACHE_VERSION, - timestamp: Date.now(), - daily, - hourly, - monthly, - session, - }; - - // Write atomically using temp file + rename - const tempFile = CACHE_FILE + '.tmp'; - fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); - fs.renameSync(tempFile, CACHE_FILE); - - console.log(ok('Disk cache updated')); - } catch (err) { - // Non-fatal - we can still serve from memory - console.log(warn('Failed to write disk cache:') + ` ${(err as Error).message}`); - } -} - -/** - * Get cache age in human-readable format - */ -export function getCacheAge(cache: UsageDiskCache | null): string { - if (!cache) return 'never'; - - const age = Date.now() - cache.timestamp; - const seconds = Math.floor(age / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) return `${hours}h ${minutes % 60}m ago`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s ago`; - return `${seconds}s ago`; -} - -/** - * Delete disk cache (for manual refresh) - */ -export function clearDiskCache(): void { - try { - if (fs.existsSync(CACHE_FILE)) { - fs.unlinkSync(CACHE_FILE); - console.log(ok('Disk cache cleared')); - } - } catch (err) { - console.log(warn('Failed to clear disk cache:') + ` ${(err as Error).message}`); - } -} +export * from './usage/disk-cache'; diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index f5de613a..73322caf 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -1,630 +1,12 @@ /** - * Usage Analytics API Routes + * Usage Analytics API Routes - Re-export from modularized location * - * Provides REST endpoints for Claude Code usage analytics. - * Supports daily, monthly, and session-based usage data aggregation. - * - * Data aggregation and caching logic is in services/usage-aggregator.ts + * @deprecated Import from './usage/routes' instead */ -import { Router, Request, Response } from 'express'; -import type { DailyUsage, Anomaly, AnomalySummary, TokenBreakdown } from './usage-types'; -import { getModelPricing } from './model-pricing'; -import { - getCachedDailyData, - getCachedMonthlyData, - getCachedSessionData, - getCachedHourlyData, - clearUsageCache, - getLastFetchTimestamp, -} from './services/usage-aggregator'; - export { + usageRoutes, prewarmUsageCache, clearUsageCache, getLastFetchTimestamp, -} from './services/usage-aggregator'; - -export const usageRoutes = Router(); - -/** Query parameters for usage endpoints */ -interface UsageQuery { - since?: string; // YYYYMMDD format - until?: string; // YYYYMMDD format - limit?: string; - offset?: string; -} - -// Constants for validation -const MAX_LIMIT = 1000; -const DEFAULT_LIMIT = 50; -const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format - -// ============================================================================ -// Validation Helpers -// ============================================================================ - -/** - * Validate date string in YYYYMMDD format - */ -function validateDate(dateString?: string): string | undefined { - if (!dateString) return undefined; - - if (!DATE_REGEX.test(dateString)) { - throw new Error('Invalid date format. Use YYYYMMDD'); - } - - const year = parseInt(dateString.substring(0, 4), 10); - const month = parseInt(dateString.substring(4, 6), 10); - const day = parseInt(dateString.substring(6, 8), 10); - - if (year < 2024 || year > 2100) throw new Error('Year out of valid range'); - if (month < 1 || month > 12) throw new Error('Month out of valid range'); - if (day < 1 || day > 31) throw new Error('Day out of valid range'); - - return dateString; -} - -function validateLimit(limit?: string): number { - if (!limit) return DEFAULT_LIMIT; - const num = parseInt(limit, 10); - if (isNaN(num) || num < 1 || num > MAX_LIMIT) { - throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`); - } - return num; -} - -function validateOffset(offset?: string): number { - if (!offset) return 0; - const num = parseInt(offset, 10); - if (isNaN(num) || num < 0) { - throw new Error('Offset must be a non-negative number'); - } - return num; -} - -function filterByDateRange( - data: T[] | undefined, - since?: string, - until?: string -): T[] { - if (!data || !Array.isArray(data)) return []; - if (!since && !until) return data; - - return data.filter((item) => { - const itemDate = - item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, ''); - if (!itemDate) return true; - - const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8); - if (since && normalizedDate < since) return false; - if (until && normalizedDate > until) return false; - return true; - }); -} - -function errorResponse(res: Response, error: unknown, defaultMessage: string): void { - console.error(defaultMessage + ':', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const isValidationError = - errorMessage.includes('Invalid') || - errorMessage.includes('format') || - errorMessage.includes('range') || - errorMessage.includes('must be'); - - res.status(isValidationError ? 400 : 500).json({ - success: false, - error: isValidationError ? errorMessage : defaultMessage, - }); -} - -function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown { - let inputTokens = 0, - outputTokens = 0, - cacheCreationTokens = 0, - cacheReadTokens = 0; - let inputCost = 0, - outputCost = 0, - cacheCreationCost = 0, - cacheReadCost = 0; - - for (const day of dailyData) { - for (const breakdown of day.modelBreakdowns) { - const pricing = getModelPricing(breakdown.modelName); - inputTokens += breakdown.inputTokens; - outputTokens += breakdown.outputTokens; - cacheCreationTokens += breakdown.cacheCreationTokens; - cacheReadTokens += breakdown.cacheReadTokens; - inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion; - outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion; - cacheCreationCost += - (breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; - cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; - } - } - - return { - input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, - cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, - cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, - }; -} - -// ============================================================================ -// Route Handlers -// ============================================================================ - -usageRoutes.get( - '/summary', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - let totalInputTokens = 0, - totalOutputTokens = 0; - let totalCacheCreationTokens = 0, - totalCacheReadTokens = 0, - totalCost = 0; - - for (const day of filtered) { - totalInputTokens += day.inputTokens; - totalOutputTokens += day.outputTokens; - totalCacheCreationTokens += day.cacheCreationTokens; - totalCacheReadTokens += day.cacheReadTokens; - totalCost += day.totalCost; - } - - const totalTokens = totalInputTokens + totalOutputTokens; - const tokenBreakdown = calculateTokenBreakdownCosts(filtered); - - res.json({ - success: true, - data: { - totalTokens, - totalInputTokens, - totalOutputTokens, - totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens, - totalCacheCreationTokens, - totalCacheReadTokens, - totalCost: Math.round(totalCost * 100) / 100, - tokenBreakdown, - totalDays: filtered.length, - averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, - averageCostPerDay: - filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0, - }, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch usage summary'); - } - } -); - -usageRoutes.get( - '/daily', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - const trends = filtered.map((day) => ({ - date: day.date, - tokens: day.inputTokens + day.outputTokens, - inputTokens: day.inputTokens, - outputTokens: day.outputTokens, - cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, - cost: Math.round(day.totalCost * 100) / 100, - modelsUsed: day.modelsUsed.length, - })); - - res.json({ success: true, data: trends }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch daily usage'); - } - } -); - -usageRoutes.get( - '/hourly', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const hourlyData = await getCachedHourlyData(); - - const filtered = (hourlyData || []).filter((h) => { - const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); - if (since && hourDate < since) return false; - if (until && hourDate > until) return false; - return true; - }); - - const trends = filtered.map((hour) => ({ - hour: hour.hour, - tokens: hour.inputTokens + hour.outputTokens, - inputTokens: hour.inputTokens, - outputTokens: hour.outputTokens, - cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, - cost: Math.round(hour.totalCost * 100) / 100, - modelsUsed: hour.modelsUsed.length, - requests: hour.modelBreakdowns.length, - })); - - const filledTrends = fillHourlyGaps(trends, since, until); - res.json({ success: true, data: filledTrends }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch hourly usage'); - } - } -); - -function fillHourlyGaps( - data: Array<{ - hour: string; - tokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - cost: number; - modelsUsed: number; - requests: number; - }>, - since?: string, - until?: string -): typeof data { - if (!since && !until) return data.sort((a, b) => a.hour.localeCompare(b.hour)); - - const hourMap = new Map(data.map((d) => [d.hour, d])); - const now = new Date(); - const startDate = since - ? new Date(Date.UTC(+since.slice(0, 4), +since.slice(4, 6) - 1, +since.slice(6, 8), 0, 0, 0)) - : new Date(now.getTime() - 24 * 60 * 60 * 1000); - const endDate = until - ? new Date(Date.UTC(+until.slice(0, 4), +until.slice(4, 6) - 1, +until.slice(6, 8), 23, 59, 59)) - : now; - const cappedEndDate = endDate > now ? now : endDate; - - const result: typeof data = []; - const current = new Date(startDate); - current.setMinutes(0, 0, 0); - - while (current <= cappedEndDate) { - const year = current.getUTCFullYear(); - const month = String(current.getUTCMonth() + 1).padStart(2, '0'); - const day = String(current.getUTCDate()).padStart(2, '0'); - const hour = String(current.getUTCHours()).padStart(2, '0'); - const hourKey = `${year}-${month}-${day} ${hour}:00`; - - result.push( - hourMap.get(hourKey) || { - hour: hourKey, - tokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - cost: 0, - modelsUsed: 0, - requests: 0, - } - ); - current.setTime(current.getTime() + 60 * 60 * 1000); - } - return result; -} - -usageRoutes.get( - '/models', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - const modelMap = new Map< - string, - { - model: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - } - >(); - - for (const day of filtered) { - for (const breakdown of day.modelBreakdowns) { - const existing = modelMap.get(breakdown.modelName) || { - model: breakdown.modelName, - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0, - }; - existing.inputTokens += breakdown.inputTokens; - existing.outputTokens += breakdown.outputTokens; - existing.cacheCreationTokens += breakdown.cacheCreationTokens; - existing.cacheReadTokens += breakdown.cacheReadTokens; - existing.cost += breakdown.cost; - modelMap.set(breakdown.modelName, existing); - } - } - - const models = Array.from(modelMap.values()); - const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); - - const result = models - .map((m) => { - const pricing = getModelPricing(m.model); - const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion; - const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion; - const cacheCreationCost = - (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; - const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; - const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; - - return { - model: m.model, - tokens: m.inputTokens + m.outputTokens, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - cacheCreationTokens: m.cacheCreationTokens, - cacheReadTokens: m.cacheReadTokens, - cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.cost * 100) / 100, - percentage: - totalTokens > 0 - ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 - : 0, - costBreakdown: { - input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, - cacheCreation: { - tokens: m.cacheCreationTokens, - cost: Math.round(cacheCreationCost * 100) / 100, - }, - cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, - }, - ioRatio: Math.round(ioRatio * 10) / 10, - }; - }) - .sort((a, b) => b.tokens - a.tokens); - - res.json({ success: true, data: result }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch model usage'); - } - } -); - -usageRoutes.get( - '/sessions', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const limit = validateLimit(req.query.limit); - const offset = validateOffset(req.query.offset); - - const sessionData = await getCachedSessionData(); - const filtered = filterByDateRange(sessionData, since, until); - const sorted = [...filtered].sort( - (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() - ); - const paginated = sorted.slice(offset, offset + limit); - - const sessions = paginated.map((s) => ({ - sessionId: s.sessionId, - projectPath: s.projectPath, - tokens: s.inputTokens + s.outputTokens, - inputTokens: s.inputTokens, - outputTokens: s.outputTokens, - cost: Math.round(s.totalCost * 100) / 100, - lastActivity: s.lastActivity, - modelsUsed: s.modelsUsed, - })); - - res.json({ - success: true, - data: { - sessions, - total: filtered.length, - limit, - offset, - hasMore: offset + limit < filtered.length, - }, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch sessions'); - } - } -); - -usageRoutes.get( - '/monthly', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const monthlyData = await getCachedMonthlyData(); - - const filtered = - since || until - ? monthlyData.filter((m) => { - const monthDate = m.month.replace('-', '') + '01'; - if (since && monthDate < since) return false; - if (until && monthDate > until) return false; - return true; - }) - : monthlyData; - - const result = filtered.map((m) => ({ - month: m.month, - tokens: m.inputTokens + m.outputTokens, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.totalCost * 100) / 100, - modelsUsed: m.modelsUsed.length, - })); - - res.json({ success: true, data: result.sort((a, b) => a.month.localeCompare(b.month)) }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch monthly usage'); - } - } -); - -usageRoutes.post('/refresh', (_req: Request, res: Response) => { - clearUsageCache(); - res.json({ success: true, message: 'Usage cache cleared' }); -}); - -usageRoutes.get('/status', (_req: Request, res: Response) => { - const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator - res.json({ - success: true, - data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size }, - }); -}); - -// ============================================================================ -// ANOMALY DETECTION -// ============================================================================ - -const ANOMALY_THRESHOLDS = { - HIGH_INPUT_TOKENS: 10_000_000, - HIGH_IO_RATIO: 100, - COST_SPIKE_MULTIPLIER: 2, - HIGH_CACHE_READ_TOKENS: 1_000_000_000, -}; - -function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] { - const anomalies: Anomaly[] = []; - const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0); - const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0; - const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER; - - for (const day of dailyData) { - if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) { - const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10; - anomalies.push({ - date: day.date, - type: 'cost_spike', - value: day.totalCost, - threshold: avgDailyCost, - message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`, - }); - } - - for (const breakdown of day.modelBreakdowns) { - if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) { - const multiplier = - Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10; - anomalies.push({ - date: day.date, - type: 'high_input', - model: breakdown.modelName, - value: breakdown.inputTokens, - threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS, - message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`, - }); - } - - if (breakdown.outputTokens > 0) { - const ioRatio = breakdown.inputTokens / breakdown.outputTokens; - if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) { - const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10; - anomalies.push({ - date: day.date, - type: 'high_io_ratio', - model: breakdown.modelName, - value: ioRatio, - threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO, - message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`, - }); - } - } - - if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) { - const multiplier = - Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) / - 10; - anomalies.push({ - date: day.date, - type: 'high_cache_read', - model: breakdown.modelName, - value: breakdown.cacheReadTokens, - threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS, - message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`, - }); - } - } - } - return anomalies.sort((a, b) => b.date.localeCompare(a.date)); -} - -function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`; - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; - return tokens.toString(); -} - -function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary { - const highInputDates = new Set(); - const highIoRatioDates = new Set(); - const costSpikeDates = new Set(); - const highCacheReadDates = new Set(); - - for (const anomaly of anomalies) { - switch (anomaly.type) { - case 'high_input': - highInputDates.add(anomaly.date); - break; - case 'high_io_ratio': - highIoRatioDates.add(anomaly.date); - break; - case 'cost_spike': - costSpikeDates.add(anomaly.date); - break; - case 'high_cache_read': - highCacheReadDates.add(anomaly.date); - break; - } - } - - return { - totalAnomalies: anomalies.length, - highInputDays: highInputDates.size, - highIoRatioDays: highIoRatioDates.size, - costSpikeDays: costSpikeDates.size, - highCacheReadDays: highCacheReadDates.size, - }; -} - -usageRoutes.get( - '/insights', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - const anomalies = detectAnomalies(filtered); - const summary = summarizeAnomalies(anomalies); - - res.json({ success: true, data: { anomalies, summary } }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch usage insights'); - } - } -); +} from './usage/routes'; diff --git a/src/web-server/usage-types.ts b/src/web-server/usage-types.ts index 820bfc53..0430433a 100644 --- a/src/web-server/usage-types.ts +++ b/src/web-server/usage-types.ts @@ -1,146 +1,7 @@ /** - * Usage Data Types + * Usage Types - Re-export from modularized location * - * Type definitions for aggregated usage data. - * Compatible with better-ccusage interfaces for drop-in replacement. + * @deprecated Import from './usage/types' instead */ -// ============================================================================ -// MODEL BREAKDOWN -// ============================================================================ - -/** Per-model token and cost breakdown */ -export interface ModelBreakdown { - modelName: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; -} - -// ============================================================================ -// AGGREGATED USAGE TYPES -// ============================================================================ - -/** Daily usage aggregation (YYYY-MM-DD) */ -export interface DailyUsage { - date: string; - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; -} - -/** Hourly usage aggregation (YYYY-MM-DD HH:00) */ -export interface HourlyUsage { - hour: string; // Format: "YYYY-MM-DD HH:00" - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; -} - -/** Monthly usage aggregation (YYYY-MM) */ -export interface MonthlyUsage { - month: string; - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; -} - -/** Session-level usage aggregation */ -export interface SessionUsage { - sessionId: string; - projectPath: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - lastActivity: string; - versions: string[]; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; - source: string; -} - -// ============================================================================ -// ANALYTICS INSIGHTS TYPES -// ============================================================================ - -/** Token category with count and cost */ -export interface TokenCategoryCost { - tokens: number; - cost: number; -} - -/** Breakdown of tokens by type with individual costs */ -export interface TokenBreakdown { - input: TokenCategoryCost; - output: TokenCategoryCost; - cacheCreation: TokenCategoryCost; - cacheRead: TokenCategoryCost; -} - -/** Anomaly types for usage pattern detection */ -export type AnomalyType = - | 'high_input' // >10M tokens/day/model - | 'high_io_ratio' // >100x input/output ratio - | 'cost_spike' // >2x daily average cost - | 'high_cache_read'; // >1B cache read tokens - -/** Single anomaly detection result */ -export interface Anomaly { - date: string; - type: AnomalyType; - model?: string; - value: number; - threshold: number; - message: string; -} - -/** Summary of all detected anomalies */ -export interface AnomalySummary { - totalAnomalies: number; - highInputDays: number; - highIoRatioDays: number; - costSpikeDays: number; - highCacheReadDays: number; -} - -/** Insights API response */ -export interface UsageInsights { - anomalies: Anomaly[]; - summary: AnomalySummary; -} - -/** Extended model usage with cost breakdown */ -export interface ExtendedModelUsage { - model: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - tokens: number; - cost: number; - percentage: number; - costBreakdown: TokenBreakdown; - ioRatio: number; -} +export * from './usage/types'; diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts new file mode 100644 index 00000000..ce59ba07 --- /dev/null +++ b/src/web-server/usage/aggregator.ts @@ -0,0 +1,538 @@ +/** + * Usage Aggregator Service + * + * Handles multi-instance usage data aggregation and caching. + * Combines data from default Claude config and all CCS instances. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + loadDailyUsageData, + loadMonthlyUsageData, + loadSessionData, + loadAllUsageData, + loadHourlyUsageData, +} from './data-aggregator'; +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; +import { + readDiskCache, + writeDiskCache, + isDiskCacheFresh, + isDiskCacheStale, + clearDiskCache, + getCacheAge, +} from './disk-cache'; +import { ok, info, fail } from '../../utils/ui'; + +// ============================================================================ +// Multi-Instance Support - Aggregate usage from CCS profiles +// ============================================================================ + +/** Path to CCS instances directory */ +const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); + +/** + * Get list of CCS instance paths that have usage data + * Only returns instances with existing projects/ directory + */ +function getInstancePaths(): string[] { + if (!fs.existsSync(CCS_INSTANCES_DIR)) { + return []; + } + + try { + const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) + .filter((instancePath) => { + // Only include instances that have a projects directory + const projectsPath = path.join(instancePath, 'projects'); + return fs.existsSync(projectsPath); + }); + } catch { + console.error(fail('Failed to read CCS instances directory')); + return []; + } +} + +/** + * Load usage data from a specific instance + * Uses custom JSONL parser with instance's projects directory + */ +async function loadInstanceData(instancePath: string): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + try { + const projectsDir = path.join(instancePath, 'projects'); + const result = await loadAllUsageData({ projectsDir }); + return result; + } catch (_err) { + // Instance may have no usage data - that's OK + const instanceName = path.basename(instancePath); + console.log(info(`No usage data in instance: ${instanceName}`)); + return { daily: [], hourly: [], monthly: [], session: [] }; + } +} + +/** + * Merge daily usage data from multiple sources + * Combines entries with same date by aggregating tokens + */ +export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { + const dateMap = new Map(); + + for (const source of sources) { + for (const day of source) { + const existing = dateMap.get(day.date); + if (existing) { + // Aggregate tokens for same date + existing.inputTokens += day.inputTokens; + existing.outputTokens += day.outputTokens; + existing.cacheCreationTokens += day.cacheCreationTokens; + existing.cacheReadTokens += day.cacheReadTokens; + existing.totalCost += day.totalCost; + // Merge unique models + const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns by aggregating same modelName + for (const breakdown of day.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + // Clone to avoid mutating original + dateMap.set(day.date, { + ...day, + modelsUsed: [...day.modelsUsed], + modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Merge monthly usage data from multiple sources + */ +export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { + const monthMap = new Map(); + + for (const source of sources) { + for (const month of source) { + const existing = monthMap.get(month.month); + if (existing) { + existing.inputTokens += month.inputTokens; + existing.outputTokens += month.outputTokens; + existing.cacheCreationTokens += month.cacheCreationTokens; + existing.cacheReadTokens += month.cacheReadTokens; + existing.totalCost += month.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + } else { + monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); + } + } + } + + return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); +} + +/** + * Merge hourly usage data from multiple sources + * Combines entries with same hour by aggregating tokens + */ +export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { + const hourMap = new Map(); + + for (const source of sources) { + for (const hour of source) { + const existing = hourMap.get(hour.hour); + if (existing) { + existing.inputTokens += hour.inputTokens; + existing.outputTokens += hour.outputTokens; + existing.cacheCreationTokens += hour.cacheCreationTokens; + existing.cacheReadTokens += hour.cacheReadTokens; + existing.totalCost += hour.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns + for (const breakdown of hour.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + hourMap.set(hour.hour, { + ...hour, + modelsUsed: [...hour.modelsUsed], + modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); +} + +/** + * Merge session data from multiple sources + * Deduplicates by sessionId (same session shouldn't appear in multiple instances) + */ +export function mergeSessionData(sources: SessionUsage[][]): SessionUsage[] { + const sessionMap = new Map(); + + for (const source of sources) { + for (const session of source) { + // Use sessionId as unique key - later entries overwrite earlier ones + sessionMap.set(session.sessionId, session); + } + } + + return Array.from(sessionMap.values()).sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); +} + +// ============================================================================ +// Caching Layer - Reduces better-ccusage library calls +// ============================================================================ + +interface CacheEntry { + data: T; + timestamp: number; +} + +// Cache TTLs (milliseconds) +const CACHE_TTL = { + daily: 60 * 1000, // 1 minute - changes frequently + monthly: 5 * 60 * 1000, // 5 minutes - aggregated data + session: 60 * 1000, // 1 minute - user may refresh +}; + +/// Stale-while-revalidate: max age for stale data (7 days) +// We always show cached data to avoid blocking UI, refresh happens in background +const STALE_TTL = 7 * 24 * 60 * 60 * 1000; + +// Track when data was last fetched (for UI indicator) +let lastFetchTimestamp: number | null = null; + +/** Get timestamp of last successful data fetch */ +export function getLastFetchTimestamp(): number | null { + return lastFetchTimestamp; +} + +// In-memory cache +const cache = new Map>(); + +// Pending requests for coalescing (prevents duplicate concurrent calls) +const pendingRequests = new Map>(); + +// Track if disk cache has been loaded into memory +let diskCacheInitialized = false; + +// Track if background refresh is in progress +let isRefreshing = false; + +/** + * Persist cache to disk when we have enough data to be useful. + */ +function persistCacheIfComplete(): void { + const daily = cache.get('daily') as CacheEntry | undefined; + const hourly = cache.get('hourly') as CacheEntry | undefined; + const monthly = cache.get('monthly') as CacheEntry | undefined; + const session = cache.get('session') as CacheEntry | undefined; + + // Write if we have at least daily data (the most essential) + if (daily) { + writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []); + } +} + +/** + * Load fresh data and update both memory and disk caches + * Aggregates data from default ~/.claude/ AND all CCS instances + */ +async function refreshFromSource(): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) + const defaultData = await loadAllUsageData(); + + // Load data from all CCS instances sequentially + const instancePaths = getInstancePaths(); + const instanceDataResults: Array<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; + }> = []; + + for (const instancePath of instancePaths) { + try { + const data = await loadInstanceData(instancePath); + instanceDataResults.push(data); + } catch (err) { + const instanceName = path.basename(instancePath); + console.error(fail(`Failed to load instance ${instanceName}: ${err}`)); + } + } + + // Collect successful instance data + const allDailySources: DailyUsage[][] = [defaultData.daily]; + const allHourlySources: HourlyUsage[][] = [defaultData.hourly]; + const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; + const allSessionSources: SessionUsage[][] = [defaultData.session]; + + for (const result of instanceDataResults) { + allDailySources.push(result.daily); + allHourlySources.push(result.hourly); + allMonthlySources.push(result.monthly); + allSessionSources.push(result.session); + } + + if (instanceDataResults.length > 0) { + console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); + } + + // Merge all data sources + const daily = mergeDailyData(allDailySources); + const hourly = mergeHourlyData(allHourlySources); + const monthly = mergeMonthlyData(allMonthlySources); + const session = mergeSessionData(allSessionSources); + + // Update in-memory cache + const now = Date.now(); + cache.set('daily', { data: daily, timestamp: now }); + cache.set('hourly', { data: hourly, timestamp: now }); + cache.set('monthly', { data: monthly, timestamp: now }); + cache.set('session', { data: session, timestamp: now }); + lastFetchTimestamp = now; + + // Persist to disk + writeDiskCache(daily, hourly, monthly, session); + + return { daily, hourly, monthly, session }; +} + +/** + * Initialize in-memory cache from disk cache (lazy - called on first API request). + */ +function ensureDiskCacheLoaded(): void { + if (diskCacheInitialized) return; + diskCacheInitialized = true; + + const diskCache = readDiskCache(); + if (!diskCache) return; + + // Load disk cache into memory (regardless of freshness) + cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); + cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); + cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); + lastFetchTimestamp = diskCache.timestamp; +} + +/** + * Get cached data or fetch from loader with TTL + * Implements stale-while-revalidate pattern for instant responses + */ +async function getCachedData(key: string, ttl: number, loader: () => Promise): Promise { + // Ensure disk cache is loaded on first request + ensureDiskCacheLoaded(); + + const cached = cache.get(key) as CacheEntry | undefined; + const now = Date.now(); + + // Fresh cache - return immediately + if (cached && now - cached.timestamp < ttl) { + return cached.data; + } + + // Stale cache - return immediately, refresh in background (SWR pattern) + if (cached && now - cached.timestamp < STALE_TTL) { + // Fire and forget background refresh if not already pending + if (!pendingRequests.has(key)) { + const promise = loader() + .then((data) => { + cache.set(key, { data, timestamp: Date.now() }); + lastFetchTimestamp = Date.now(); + persistCacheIfComplete(); + }) + .catch((err) => { + console.error(fail(`Background refresh failed for ${key}: ${err}`)); + }) + .finally(() => { + pendingRequests.delete(key); + }); + pendingRequests.set(key, promise); + } + return cached.data; + } + + // No usable cache - check if request is already pending (coalesce) + const pending = pendingRequests.get(key) as Promise | undefined; + if (pending) { + return pending; + } + + // Create new request + const promise = loader() + .then((data) => { + cache.set(key, { data, timestamp: Date.now() }); + lastFetchTimestamp = Date.now(); + persistCacheIfComplete(); + return data; + }) + .finally(() => { + pendingRequests.delete(key); + }); + + pendingRequests.set(key, promise); + return promise; +} + +/** Cached loader for daily usage data */ +export async function getCachedDailyData(): Promise { + return getCachedData('daily', CACHE_TTL.daily, async () => { + return await loadDailyUsageData(); + }); +} + +/** Cached loader for monthly usage data */ +export async function getCachedMonthlyData(): Promise { + return getCachedData('monthly', CACHE_TTL.monthly, async () => { + return await loadMonthlyUsageData(); + }); +} + +/** Cached loader for session data */ +export async function getCachedSessionData(): Promise { + return getCachedData('session', CACHE_TTL.session, async () => { + return await loadSessionData(); + }); +} + +/** Cached loader for hourly usage data */ +export async function getCachedHourlyData(): Promise { + return getCachedData('hourly', CACHE_TTL.daily, async () => { + return await loadHourlyUsageData(); + }); +} + +/** + * Clear all cached data (useful for manual refresh) + */ +export function clearUsageCache(): void { + cache.clear(); + clearDiskCache(); + // Reset so next API call will try to reload from disk/source + diskCacheInitialized = false; +} + +/** + * Pre-warm usage caches on server startup + * + * Strategy: + * 1. Check disk cache - if fresh, use it (instant startup) + * 2. If stale, use it immediately but trigger background refresh + * 3. If no cache, return immediately and let first request trigger load + */ +export async function prewarmUsageCache(): Promise<{ + timestamp: number; + elapsed: number; + source: string; +}> { + const start = Date.now(); + console.log(info('Pre-warming usage cache...')); + + try { + const diskCache = readDiskCache(); + + // Fresh disk cache - use it directly + if (diskCache && isDiskCacheFresh(diskCache)) { + const now = Date.now(); + cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); + cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); + cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); + lastFetchTimestamp = diskCache.timestamp; + + const elapsed = Date.now() - start; + console.log( + ok(`Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`) + ); + return { timestamp: now, elapsed, source: 'disk-fresh' }; + } + + // Stale disk cache - use it immediately, refresh in background + if (diskCache && isDiskCacheStale(diskCache)) { + const now = Date.now(); + cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); + cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); + cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); + lastFetchTimestamp = diskCache.timestamp; + + const elapsed = Date.now() - start; + console.log( + ok( + `Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)` + ) + ); + + // Background refresh + if (!isRefreshing) { + isRefreshing = true; + refreshFromSource() + .then(() => console.log(ok('Background refresh complete'))) + .catch((err) => console.error(fail(`Background refresh failed: ${err}`))) + .finally(() => { + isRefreshing = false; + }); + } + + return { timestamp: now, elapsed, source: 'disk-stale' }; + } + + // No usable disk cache - refresh from source (blocking for first startup only) + console.log(info('No disk cache, loading from source...')); + await refreshFromSource(); + + const elapsed = Date.now() - start; + console.log(ok(`Usage cache ready (${elapsed}ms)`)); + return { timestamp: Date.now(), elapsed, source: 'fresh' }; + } catch (err) { + console.error(fail(`Failed to prewarm usage cache: ${err}`)); + throw err; + } +} diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts new file mode 100644 index 00000000..85a13c22 --- /dev/null +++ b/src/web-server/usage/data-aggregator.ts @@ -0,0 +1,516 @@ +/** + * Data Aggregator for Claude Code Usage Analytics + * + * Aggregates raw JSONL entries into daily, monthly, and session summaries. + * Uses model-pricing.ts for cost calculations. + */ + +import { type RawUsageEntry } from '../jsonl-parser'; +import { calculateCost } from '../model-pricing'; +import { + type ModelBreakdown, + type DailyUsage, + type HourlyUsage, + type MonthlyUsage, + type SessionUsage, +} from './types'; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** Extract YYYY-MM-DD from ISO timestamp */ +function extractDate(timestamp: string): string { + return timestamp.slice(0, 10); +} + +/** Extract YYYY-MM from ISO timestamp */ +function extractMonth(timestamp: string): string { + return timestamp.slice(0, 7); +} + +/** Extract YYYY-MM-DD HH:00 from ISO timestamp */ +function extractHour(timestamp: string): string { + const date = timestamp.slice(0, 10); + const hour = timestamp.slice(11, 13) || '00'; + return `${date} ${hour}:00`; +} + +/** Create model breakdown from accumulated data */ +function createModelBreakdown( + modelName: string, + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number +): ModelBreakdown { + const cost = calculateCost( + { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }, + modelName + ); + + return { + modelName, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + cost, + }; +} + +/** Accumulator for per-model token counts */ +interface ModelAccumulator { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +// ============================================================================ +// DAILY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into daily usage summaries + * Groups by date (YYYY-MM-DD), calculates costs per model + */ +export function aggregateDailyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): DailyUsage[] { + // Group entries by date + const byDate = new Map(); + + for (const entry of entries) { + const date = extractDate(entry.timestamp); + const existing = byDate.get(date) || []; + existing.push(entry); + byDate.set(date, existing); + } + + // Build daily summaries + const dailyUsage: DailyUsage[] = []; + + for (const [date, dateEntries] of byDate) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of dateEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + dailyUsage.push({ + date, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by date descending (most recent first) + dailyUsage.sort((a, b) => b.date.localeCompare(a.date)); + + return dailyUsage; +} + +// ============================================================================ +// HOURLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into hourly usage summaries + * Groups by hour (YYYY-MM-DD HH:00), calculates costs per model + */ +export function aggregateHourlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): HourlyUsage[] { + // Group entries by hour + const byHour = new Map(); + + for (const entry of entries) { + const hour = extractHour(entry.timestamp); + const existing = byHour.get(hour) || []; + existing.push(entry); + byHour.set(hour, existing); + } + + // Build hourly summaries + const hourlyUsage: HourlyUsage[] = []; + + for (const [hour, hourEntries] of byHour) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of hourEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + hourlyUsage.push({ + hour, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by hour descending (most recent first) + hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour)); + + return hourlyUsage; +} + +// ============================================================================ +// MONTHLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into monthly usage summaries + * Groups by month (YYYY-MM), calculates costs per model + */ +export function aggregateMonthlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): MonthlyUsage[] { + // Group entries by month + const byMonth = new Map(); + + for (const entry of entries) { + const month = extractMonth(entry.timestamp); + const existing = byMonth.get(month) || []; + existing.push(entry); + byMonth.set(month, existing); + } + + // Build monthly summaries + const monthlyUsage: MonthlyUsage[] = []; + + for (const [month, monthEntries] of byMonth) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of monthEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + monthlyUsage.push({ + month, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by month descending (most recent first) + monthlyUsage.sort((a, b) => b.month.localeCompare(a.month)); + + return monthlyUsage; +} + +// ============================================================================ +// SESSION AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into session usage summaries + * Groups by sessionId, tracks last activity and versions + */ +export function aggregateSessionUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): SessionUsage[] { + // Group entries by sessionId + const bySession = new Map(); + + for (const entry of entries) { + if (!entry.sessionId) continue; + const existing = bySession.get(entry.sessionId) || []; + existing.push(entry); + bySession.set(entry.sessionId, existing); + } + + // Build session summaries + const sessionUsage: SessionUsage[] = []; + + for (const [sessionId, sessionEntries] of bySession) { + // Aggregate by model + const modelMap = new Map(); + const versions = new Set(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + let lastActivity = ''; + let projectPath = ''; + + for (const entry of sessionEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + + // Track latest timestamp + if (entry.timestamp > lastActivity) { + lastActivity = entry.timestamp; + } + + // Track versions + if (entry.version) { + versions.add(entry.version); + } + + // Use project path from entry + if (entry.projectPath) { + projectPath = entry.projectPath; + } + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + sessionUsage.push({ + sessionId, + projectPath, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + lastActivity, + versions: Array.from(versions), + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + source, + }); + } + + // Sort by last activity descending (most recent first) + sessionUsage.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); + + return sessionUsage; +} + +// ============================================================================ +// MAIN DATA LOADER (drop-in replacement for better-ccusage) +// ============================================================================ + +import { scanProjectsDirectory, type ParserOptions } from '../jsonl-parser'; + +/** + * Load daily usage data (replaces better-ccusage loadDailyUsageData) + */ +export async function loadDailyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateDailyUsage(entries); +} + +/** + * Load hourly usage data for today's chart + */ +export async function loadHourlyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateHourlyUsage(entries); +} + +/** + * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) + */ +export async function loadMonthlyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateMonthlyUsage(entries); +} + +/** + * Load session data (replaces better-ccusage loadSessionData) + */ +export async function loadSessionData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateSessionUsage(entries); +} + +/** + * Load all usage data in a single pass (more efficient) + */ +export async function loadAllUsageData(options?: ParserOptions): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + const entries = await scanProjectsDirectory(options); + return { + daily: aggregateDailyUsage(entries), + hourly: aggregateHourlyUsage(entries), + monthly: aggregateMonthlyUsage(entries), + session: aggregateSessionUsage(entries), + }; +} diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts new file mode 100644 index 00000000..fd834da2 --- /dev/null +++ b/src/web-server/usage/disk-cache.ts @@ -0,0 +1,155 @@ +/** + * Persistent Disk Cache for Usage Data + * + * Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files + * on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate. + * + * Cache location: ~/.ccs/cache/usage.json + * Default TTL: 5 minutes (configurable) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; +import { ok, info, warn } from '../../utils/ui'; + +// Cache configuration +const CCS_DIR = path.join(os.homedir(), '.ccs'); +const CACHE_DIR = path.join(CCS_DIR, 'cache'); +const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) + +/** Structure of the disk cache file */ +export interface UsageDiskCache { + version: number; + timestamp: number; + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +} + +// Current cache version - increment to invalidate old caches +// v3: Added hourly data to cache +const CACHE_VERSION = 3; + +/** + * Ensure ~/.ccs/cache directory exists + */ +function ensureCacheDir(): void { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +/** + * Read usage data from disk cache + * Returns null if cache is missing, corrupted, or has incompatible version + * NOTE: Does NOT reject based on age - caller handles staleness via SWR pattern + */ +export function readDiskCache(): UsageDiskCache | null { + try { + if (!fs.existsSync(CACHE_FILE)) { + return null; + } + + const data = fs.readFileSync(CACHE_FILE, 'utf-8'); + const cache: UsageDiskCache = JSON.parse(data); + + // Version check - invalidate if schema changed + if (cache.version !== CACHE_VERSION) { + console.log(info('Cache version mismatch, will refresh')); + return null; + } + + // Always return cache regardless of age - SWR pattern handles staleness + return cache; + } catch (err) { + // Cache corrupted or unreadable - treat as miss + console.log(info('Cache read failed, will refresh:') + ` ${(err as Error).message}`); + return null; + } +} + +/** + * Check if disk cache is fresh (within TTL) + */ +export function isDiskCacheFresh(cache: UsageDiskCache | null): boolean { + if (!cache) return false; + const age = Date.now() - cache.timestamp; + return age < CACHE_TTL_MS; +} + +/** + * Check if disk cache is stale but usable (between TTL and STALE_TTL) + */ +export function isDiskCacheStale(cache: UsageDiskCache | null): boolean { + if (!cache) return false; + const age = Date.now() - cache.timestamp; + return age >= CACHE_TTL_MS && age < STALE_TTL_MS; +} + +/** + * Write usage data to disk cache + */ +export function writeDiskCache( + daily: DailyUsage[], + hourly: HourlyUsage[], + monthly: MonthlyUsage[], + session: SessionUsage[] +): void { + try { + ensureCacheDir(); + + const cache: UsageDiskCache = { + version: CACHE_VERSION, + timestamp: Date.now(), + daily, + hourly, + monthly, + session, + }; + + // Write atomically using temp file + rename + const tempFile = CACHE_FILE + '.tmp'; + fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); + fs.renameSync(tempFile, CACHE_FILE); + + console.log(ok('Disk cache updated')); + } catch (err) { + // Non-fatal - we can still serve from memory + console.log(warn('Failed to write disk cache:') + ` ${(err as Error).message}`); + } +} + +/** + * Get cache age in human-readable format + */ +export function getCacheAge(cache: UsageDiskCache | null): string { + if (!cache) return 'never'; + + const age = Date.now() - cache.timestamp; + const seconds = Math.floor(age / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) return `${hours}h ${minutes % 60}m ago`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s ago`; + return `${seconds}s ago`; +} + +/** + * Delete disk cache (for manual refresh) + */ +export function clearDiskCache(): void { + try { + if (fs.existsSync(CACHE_FILE)) { + fs.unlinkSync(CACHE_FILE); + console.log(ok('Disk cache cleared')); + } + } catch (err) { + console.log(warn('Failed to clear disk cache:') + ` ${(err as Error).message}`); + } +} diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts new file mode 100644 index 00000000..e2b69f06 --- /dev/null +++ b/src/web-server/usage/handlers.ts @@ -0,0 +1,633 @@ +/** + * Usage Route Handlers + * + * Contains all route handler logic for usage analytics endpoints. + * Separated from routes for better testability and organization. + */ + +import type { Request, Response } from 'express'; +import type { DailyUsage, Anomaly, AnomalySummary, TokenBreakdown } from './types'; +import { getModelPricing } from '../model-pricing'; +import { + getCachedDailyData, + getCachedMonthlyData, + getCachedSessionData, + getCachedHourlyData, + clearUsageCache, + getLastFetchTimestamp, +} from './aggregator'; + +// ============================================================================ +// Types +// ============================================================================ + +/** Query parameters for usage endpoints */ +export interface UsageQuery { + since?: string; // YYYYMMDD format + until?: string; // YYYYMMDD format + limit?: string; + offset?: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const MAX_LIMIT = 1000; +const DEFAULT_LIMIT = 50; +const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format + +const ANOMALY_THRESHOLDS = { + HIGH_INPUT_TOKENS: 10_000_000, + HIGH_IO_RATIO: 100, + COST_SPIKE_MULTIPLIER: 2, + HIGH_CACHE_READ_TOKENS: 1_000_000_000, +}; + +// ============================================================================ +// Validation Helpers +// ============================================================================ + +/** + * Validate date string in YYYYMMDD format + */ +export function validateDate(dateString?: string): string | undefined { + if (!dateString) return undefined; + + if (!DATE_REGEX.test(dateString)) { + throw new Error('Invalid date format. Use YYYYMMDD'); + } + + const year = parseInt(dateString.substring(0, 4), 10); + const month = parseInt(dateString.substring(4, 6), 10); + const day = parseInt(dateString.substring(6, 8), 10); + + if (year < 2024 || year > 2100) throw new Error('Year out of valid range'); + if (month < 1 || month > 12) throw new Error('Month out of valid range'); + if (day < 1 || day > 31) throw new Error('Day out of valid range'); + + return dateString; +} + +export function validateLimit(limit?: string): number { + if (!limit) return DEFAULT_LIMIT; + const num = parseInt(limit, 10); + if (isNaN(num) || num < 1 || num > MAX_LIMIT) { + throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`); + } + return num; +} + +export function validateOffset(offset?: string): number { + if (!offset) return 0; + const num = parseInt(offset, 10); + if (isNaN(num) || num < 0) { + throw new Error('Offset must be a non-negative number'); + } + return num; +} + +export function filterByDateRange< + T extends { date?: string; month?: string; lastActivity?: string }, +>(data: T[] | undefined, since?: string, until?: string): T[] { + if (!data || !Array.isArray(data)) return []; + if (!since && !until) return data; + + return data.filter((item) => { + const itemDate = + item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, ''); + if (!itemDate) return true; + + const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8); + if (since && normalizedDate < since) return false; + if (until && normalizedDate > until) return false; + return true; + }); +} + +export function errorResponse(res: Response, error: unknown, defaultMessage: string): void { + console.error(defaultMessage + ':', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const isValidationError = + errorMessage.includes('Invalid') || + errorMessage.includes('format') || + errorMessage.includes('range') || + errorMessage.includes('must be'); + + res.status(isValidationError ? 400 : 500).json({ + success: false, + error: isValidationError ? errorMessage : defaultMessage, + }); +} + +// ============================================================================ +// Cost Calculation Helpers +// ============================================================================ + +export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown { + let inputTokens = 0, + outputTokens = 0, + cacheCreationTokens = 0, + cacheReadTokens = 0; + let inputCost = 0, + outputCost = 0, + cacheCreationCost = 0, + cacheReadCost = 0; + + for (const day of dailyData) { + for (const breakdown of day.modelBreakdowns) { + const pricing = getModelPricing(breakdown.modelName); + inputTokens += breakdown.inputTokens; + outputTokens += breakdown.outputTokens; + cacheCreationTokens += breakdown.cacheCreationTokens; + cacheReadTokens += breakdown.cacheReadTokens; + inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion; + outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion; + cacheCreationCost += + (breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + } + } + + return { + input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, + output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, + cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, + cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + }; +} + +// ============================================================================ +// Hourly Gap Filling +// ============================================================================ + +export function fillHourlyGaps( + data: Array<{ + hour: string; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + cost: number; + modelsUsed: number; + requests: number; + }>, + since?: string, + until?: string +): typeof data { + if (!since && !until) return data.sort((a, b) => a.hour.localeCompare(b.hour)); + + const hourMap = new Map(data.map((d) => [d.hour, d])); + const now = new Date(); + const startDate = since + ? new Date(Date.UTC(+since.slice(0, 4), +since.slice(4, 6) - 1, +since.slice(6, 8), 0, 0, 0)) + : new Date(now.getTime() - 24 * 60 * 60 * 1000); + const endDate = until + ? new Date(Date.UTC(+until.slice(0, 4), +until.slice(4, 6) - 1, +until.slice(6, 8), 23, 59, 59)) + : now; + const cappedEndDate = endDate > now ? now : endDate; + + const result: typeof data = []; + const current = new Date(startDate); + current.setMinutes(0, 0, 0); + + while (current <= cappedEndDate) { + const year = current.getUTCFullYear(); + const month = String(current.getUTCMonth() + 1).padStart(2, '0'); + const day = String(current.getUTCDate()).padStart(2, '0'); + const hour = String(current.getUTCHours()).padStart(2, '0'); + const hourKey = `${year}-${month}-${day} ${hour}:00`; + + result.push( + hourMap.get(hourKey) || { + hour: hourKey, + tokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cost: 0, + modelsUsed: 0, + requests: 0, + } + ); + current.setTime(current.getTime() + 60 * 60 * 1000); + } + return result; +} + +// ============================================================================ +// Anomaly Detection +// ============================================================================ + +function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`; + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; + return tokens.toString(); +} + +export function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] { + const anomalies: Anomaly[] = []; + const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0); + const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0; + const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER; + + for (const day of dailyData) { + if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) { + const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'cost_spike', + value: day.totalCost, + threshold: avgDailyCost, + message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`, + }); + } + + for (const breakdown of day.modelBreakdowns) { + if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) { + const multiplier = + Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'high_input', + model: breakdown.modelName, + value: breakdown.inputTokens, + threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS, + message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`, + }); + } + + if (breakdown.outputTokens > 0) { + const ioRatio = breakdown.inputTokens / breakdown.outputTokens; + if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) { + const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'high_io_ratio', + model: breakdown.modelName, + value: ioRatio, + threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO, + message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`, + }); + } + } + + if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) { + const multiplier = + Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) / + 10; + anomalies.push({ + date: day.date, + type: 'high_cache_read', + model: breakdown.modelName, + value: breakdown.cacheReadTokens, + threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS, + message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`, + }); + } + } + } + return anomalies.sort((a, b) => b.date.localeCompare(a.date)); +} + +export function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary { + const highInputDates = new Set(); + const highIoRatioDates = new Set(); + const costSpikeDates = new Set(); + const highCacheReadDates = new Set(); + + for (const anomaly of anomalies) { + switch (anomaly.type) { + case 'high_input': + highInputDates.add(anomaly.date); + break; + case 'high_io_ratio': + highIoRatioDates.add(anomaly.date); + break; + case 'cost_spike': + costSpikeDates.add(anomaly.date); + break; + case 'high_cache_read': + highCacheReadDates.add(anomaly.date); + break; + } + } + + return { + totalAnomalies: anomalies.length, + highInputDays: highInputDates.size, + highIoRatioDays: highIoRatioDates.size, + costSpikeDays: costSpikeDates.size, + highCacheReadDays: highCacheReadDates.size, + }; +} + +// ============================================================================ +// Route Handlers +// ============================================================================ + +export async function handleSummary( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + let totalInputTokens = 0, + totalOutputTokens = 0; + let totalCacheCreationTokens = 0, + totalCacheReadTokens = 0, + totalCost = 0; + + for (const day of filtered) { + totalInputTokens += day.inputTokens; + totalOutputTokens += day.outputTokens; + totalCacheCreationTokens += day.cacheCreationTokens; + totalCacheReadTokens += day.cacheReadTokens; + totalCost += day.totalCost; + } + + const totalTokens = totalInputTokens + totalOutputTokens; + const tokenBreakdown = calculateTokenBreakdownCosts(filtered); + + res.json({ + success: true, + data: { + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens, + totalCacheCreationTokens, + totalCacheReadTokens, + totalCost: Math.round(totalCost * 100) / 100, + tokenBreakdown, + totalDays: filtered.length, + averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, + averageCostPerDay: + filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch usage summary'); + } +} + +export async function handleDaily( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + const trends = filtered.map((day) => ({ + date: day.date, + tokens: day.inputTokens + day.outputTokens, + inputTokens: day.inputTokens, + outputTokens: day.outputTokens, + cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, + cost: Math.round(day.totalCost * 100) / 100, + modelsUsed: day.modelsUsed.length, + })); + + res.json({ success: true, data: trends }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch daily usage'); + } +} + +export async function handleHourly( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const hourlyData = await getCachedHourlyData(); + + const filtered = (hourlyData || []).filter((h) => { + const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); + if (since && hourDate < since) return false; + if (until && hourDate > until) return false; + return true; + }); + + const trends = filtered.map((hour) => ({ + hour: hour.hour, + tokens: hour.inputTokens + hour.outputTokens, + inputTokens: hour.inputTokens, + outputTokens: hour.outputTokens, + cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, + cost: Math.round(hour.totalCost * 100) / 100, + modelsUsed: hour.modelsUsed.length, + requests: hour.modelBreakdowns.length, + })); + + const filledTrends = fillHourlyGaps(trends, since, until); + res.json({ success: true, data: filledTrends }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch hourly usage'); + } +} + +export async function handleModels( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + const modelMap = new Map< + string, + { + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + } + >(); + + for (const day of filtered) { + for (const breakdown of day.modelBreakdowns) { + const existing = modelMap.get(breakdown.modelName) || { + model: breakdown.modelName, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0, + }; + existing.inputTokens += breakdown.inputTokens; + existing.outputTokens += breakdown.outputTokens; + existing.cacheCreationTokens += breakdown.cacheCreationTokens; + existing.cacheReadTokens += breakdown.cacheReadTokens; + existing.cost += breakdown.cost; + modelMap.set(breakdown.modelName, existing); + } + } + + const models = Array.from(modelMap.values()); + const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); + + const result = models + .map((m) => { + const pricing = getModelPricing(m.model); + const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion; + const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion; + const cacheCreationCost = + (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; + + return { + model: m.model, + tokens: m.inputTokens + m.outputTokens, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheCreationTokens: m.cacheCreationTokens, + cacheReadTokens: m.cacheReadTokens, + cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, + cost: Math.round(m.cost * 100) / 100, + percentage: + totalTokens > 0 + ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 + : 0, + costBreakdown: { + input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, + output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, + cacheCreation: { + tokens: m.cacheCreationTokens, + cost: Math.round(cacheCreationCost * 100) / 100, + }, + cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + }, + ioRatio: Math.round(ioRatio * 10) / 10, + }; + }) + .sort((a, b) => b.tokens - a.tokens); + + res.json({ success: true, data: result }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch model usage'); + } +} + +export async function handleSessions( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const limit = validateLimit(req.query.limit); + const offset = validateOffset(req.query.offset); + + const sessionData = await getCachedSessionData(); + const filtered = filterByDateRange(sessionData, since, until); + const sorted = [...filtered].sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); + const paginated = sorted.slice(offset, offset + limit); + + const sessions = paginated.map((s) => ({ + sessionId: s.sessionId, + projectPath: s.projectPath, + tokens: s.inputTokens + s.outputTokens, + inputTokens: s.inputTokens, + outputTokens: s.outputTokens, + cost: Math.round(s.totalCost * 100) / 100, + lastActivity: s.lastActivity, + modelsUsed: s.modelsUsed, + })); + + res.json({ + success: true, + data: { + sessions, + total: filtered.length, + limit, + offset, + hasMore: offset + limit < filtered.length, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch sessions'); + } +} + +export async function handleMonthly( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const monthlyData = await getCachedMonthlyData(); + + const filtered = + since || until + ? monthlyData.filter((m) => { + const monthDate = m.month.replace('-', '') + '01'; + if (since && monthDate < since) return false; + if (until && monthDate > until) return false; + return true; + }) + : monthlyData; + + const result = filtered.map((m) => ({ + month: m.month, + tokens: m.inputTokens + m.outputTokens, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, + cost: Math.round(m.totalCost * 100) / 100, + modelsUsed: m.modelsUsed.length, + })); + + res.json({ success: true, data: result.sort((a, b) => a.month.localeCompare(b.month)) }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch monthly usage'); + } +} + +export function handleRefresh(_req: Request, res: Response): void { + clearUsageCache(); + res.json({ success: true, message: 'Usage cache cleared' }); +} + +export function handleStatus(_req: Request, res: Response): void { + const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator + res.json({ + success: true, + data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size }, + }); +} + +export async function handleInsights( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + const anomalies = detectAnomalies(filtered); + const summary = summarizeAnomalies(anomalies); + + res.json({ success: true, data: { anomalies, summary } }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch usage insights'); + } +} diff --git a/src/web-server/usage/index.ts b/src/web-server/usage/index.ts new file mode 100644 index 00000000..a8f55168 --- /dev/null +++ b/src/web-server/usage/index.ts @@ -0,0 +1,76 @@ +/** + * Usage Module Barrel Export + * + * Re-exports all usage analytics functionality for convenient imports. + */ + +// Types +export type { + ModelBreakdown, + DailyUsage, + HourlyUsage, + MonthlyUsage, + SessionUsage, + TokenCategoryCost, + TokenBreakdown, + AnomalyType, + Anomaly, + AnomalySummary, + UsageInsights, + ExtendedModelUsage, +} from './types'; + +// Disk cache +export { + readDiskCache, + writeDiskCache, + isDiskCacheFresh, + isDiskCacheStale, + clearDiskCache, + getCacheAge, + type UsageDiskCache, +} from './disk-cache'; + +// Data aggregator - aggregation functions +export { + aggregateDailyUsage, + aggregateHourlyUsage, + aggregateMonthlyUsage, + aggregateSessionUsage, + loadDailyUsageData, + loadHourlyUsageData, + loadMonthlyUsageData, + loadSessionData, + loadAllUsageData, +} from './data-aggregator'; + +// Usage aggregator service - caching layer +export { + getCachedDailyData, + getCachedMonthlyData, + getCachedSessionData, + getCachedHourlyData, + clearUsageCache, + prewarmUsageCache, + getLastFetchTimestamp, + mergeDailyData, + mergeMonthlyData, + mergeHourlyData, + mergeSessionData, +} from './aggregator'; + +// Routes +export { usageRoutes } from './routes'; + +// Handlers (for testing) +export { + validateDate, + validateLimit, + validateOffset, + filterByDateRange, + calculateTokenBreakdownCosts, + fillHourlyGaps, + detectAnomalies, + summarizeAnomalies, + type UsageQuery, +} from './handlers'; diff --git a/src/web-server/usage/routes.ts b/src/web-server/usage/routes.ts new file mode 100644 index 00000000..c4a2dabe --- /dev/null +++ b/src/web-server/usage/routes.ts @@ -0,0 +1,52 @@ +/** + * Usage Analytics API Routes + * + * Provides REST endpoints for Claude Code usage analytics. + * Supports daily, monthly, and session-based usage data aggregation. + * + * Route handlers are in ./handlers.ts + */ + +import { Router } from 'express'; +import { + handleSummary, + handleDaily, + handleHourly, + handleModels, + handleSessions, + handleMonthly, + handleRefresh, + handleStatus, + handleInsights, +} from './handlers'; + +export { prewarmUsageCache, clearUsageCache, getLastFetchTimestamp } from './aggregator'; + +export const usageRoutes = Router(); + +// Summary endpoint +usageRoutes.get('/summary', handleSummary); + +// Daily usage endpoint +usageRoutes.get('/daily', handleDaily); + +// Hourly usage endpoint +usageRoutes.get('/hourly', handleHourly); + +// Models usage endpoint +usageRoutes.get('/models', handleModels); + +// Sessions endpoint +usageRoutes.get('/sessions', handleSessions); + +// Monthly usage endpoint +usageRoutes.get('/monthly', handleMonthly); + +// Cache refresh endpoint +usageRoutes.post('/refresh', handleRefresh); + +// Status endpoint +usageRoutes.get('/status', handleStatus); + +// Insights endpoint (anomaly detection) +usageRoutes.get('/insights', handleInsights); diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts new file mode 100644 index 00000000..820bfc53 --- /dev/null +++ b/src/web-server/usage/types.ts @@ -0,0 +1,146 @@ +/** + * Usage Data Types + * + * Type definitions for aggregated usage data. + * Compatible with better-ccusage interfaces for drop-in replacement. + */ + +// ============================================================================ +// MODEL BREAKDOWN +// ============================================================================ + +/** Per-model token and cost breakdown */ +export interface ModelBreakdown { + modelName: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; +} + +// ============================================================================ +// AGGREGATED USAGE TYPES +// ============================================================================ + +/** Daily usage aggregation (YYYY-MM-DD) */ +export interface DailyUsage { + date: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Hourly usage aggregation (YYYY-MM-DD HH:00) */ +export interface HourlyUsage { + hour: string; // Format: "YYYY-MM-DD HH:00" + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Monthly usage aggregation (YYYY-MM) */ +export interface MonthlyUsage { + month: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Session-level usage aggregation */ +export interface SessionUsage { + sessionId: string; + projectPath: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + lastActivity: string; + versions: string[]; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; + source: string; +} + +// ============================================================================ +// ANALYTICS INSIGHTS TYPES +// ============================================================================ + +/** Token category with count and cost */ +export interface TokenCategoryCost { + tokens: number; + cost: number; +} + +/** Breakdown of tokens by type with individual costs */ +export interface TokenBreakdown { + input: TokenCategoryCost; + output: TokenCategoryCost; + cacheCreation: TokenCategoryCost; + cacheRead: TokenCategoryCost; +} + +/** Anomaly types for usage pattern detection */ +export type AnomalyType = + | 'high_input' // >10M tokens/day/model + | 'high_io_ratio' // >100x input/output ratio + | 'cost_spike' // >2x daily average cost + | 'high_cache_read'; // >1B cache read tokens + +/** Single anomaly detection result */ +export interface Anomaly { + date: string; + type: AnomalyType; + model?: string; + value: number; + threshold: number; + message: string; +} + +/** Summary of all detected anomalies */ +export interface AnomalySummary { + totalAnomalies: number; + highInputDays: number; + highIoRatioDays: number; + costSpikeDays: number; + highCacheReadDays: number; +} + +/** Insights API response */ +export interface UsageInsights { + anomalies: Anomaly[]; + summary: AnomalySummary; +} + +/** Extended model usage with cost breakdown */ +export interface ExtendedModelUsage { + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + tokens: number; + cost: number; + percentage: number; + costBreakdown: TokenBreakdown; + ioRatio: number; +}