refactor(web-server): extract usage module to usage/ directory

- create web-server/usage/ with 7 focused modules

- extract: types, disk-cache, data-aggregator, aggregator, handlers, routes

- original files now re-export for backward compatibility

- slim routes.ts (49 lines) delegates to handlers.ts (489 lines)
This commit is contained in:
kaitranntt
2025-12-19 16:02:18 -05:00
parent c1e5ec70b5
commit bae323c0d3
12 changed files with 2132 additions and 1961 deletions
+3 -512
View File
@@ -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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
const versions = new Set<string>();
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<DailyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateDailyUsage(entries);
}
/**
* Load hourly usage data for today's chart
*/
export async function loadHourlyUsageData(options?: ParserOptions): Promise<HourlyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateHourlyUsage(entries);
}
/**
* Load monthly usage data (replaces better-ccusage loadMonthlyUsageData)
*/
export async function loadMonthlyUsageData(options?: ParserOptions): Promise<MonthlyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateMonthlyUsage(entries);
}
/**
* Load session data (replaces better-ccusage loadSessionData)
*/
export async function loadSessionData(options?: ParserOptions): Promise<SessionUsage[]> {
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';
+3 -534
View File
@@ -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<string, DailyUsage>();
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<string, MonthlyUsage>();
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<string, HourlyUsage>();
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<string, SessionUsage>();
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<T> {
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<string, CacheEntry<unknown>>();
// Pending requests for coalescing (prevents duplicate concurrent calls)
const pendingRequests = new Map<string, Promise<unknown>>();
// 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<DailyUsage[]> | undefined;
const hourly = cache.get('hourly') as CacheEntry<HourlyUsage[]> | undefined;
const monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
const session = cache.get('session') as CacheEntry<SessionUsage[]> | 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<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
// Ensure disk cache is loaded on first request
ensureDiskCacheLoaded();
const cached = cache.get(key) as CacheEntry<T> | 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<T> | 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<DailyUsage[]> {
return getCachedData('daily', CACHE_TTL.daily, async () => {
return await loadDailyUsageData();
});
}
/** Cached loader for monthly usage data */
export async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
return await loadMonthlyUsageData();
});
}
/** Cached loader for session data */
export async function getCachedSessionData(): Promise<SessionUsage[]> {
return getCachedData('session', CACHE_TTL.session, async () => {
return await loadSessionData();
});
}
/** Cached loader for hourly usage data */
export async function getCachedHourlyData(): Promise<HourlyUsage[]> {
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';
+3 -151
View File
@@ -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';
+4 -622
View File
@@ -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<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;
});
}
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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<string>();
const highIoRatioDates = new Set<string>();
const costSpikeDates = new Set<string>();
const highCacheReadDates = new Set<string>();
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<object, object, object, UsageQuery>, 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';
+3 -142
View File
@@ -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';
+538
View File
@@ -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<string, DailyUsage>();
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<string, MonthlyUsage>();
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<string, HourlyUsage>();
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<string, SessionUsage>();
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<T> {
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<string, CacheEntry<unknown>>();
// Pending requests for coalescing (prevents duplicate concurrent calls)
const pendingRequests = new Map<string, Promise<unknown>>();
// 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<DailyUsage[]> | undefined;
const hourly = cache.get('hourly') as CacheEntry<HourlyUsage[]> | undefined;
const monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
const session = cache.get('session') as CacheEntry<SessionUsage[]> | 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<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
// Ensure disk cache is loaded on first request
ensureDiskCacheLoaded();
const cached = cache.get(key) as CacheEntry<T> | 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<T> | 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<DailyUsage[]> {
return getCachedData('daily', CACHE_TTL.daily, async () => {
return await loadDailyUsageData();
});
}
/** Cached loader for monthly usage data */
export async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
return await loadMonthlyUsageData();
});
}
/** Cached loader for session data */
export async function getCachedSessionData(): Promise<SessionUsage[]> {
return getCachedData('session', CACHE_TTL.session, async () => {
return await loadSessionData();
});
}
/** Cached loader for hourly usage data */
export async function getCachedHourlyData(): Promise<HourlyUsage[]> {
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;
}
}
+516
View File
@@ -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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
const versions = new Set<string>();
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<DailyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateDailyUsage(entries);
}
/**
* Load hourly usage data for today's chart
*/
export async function loadHourlyUsageData(options?: ParserOptions): Promise<HourlyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateHourlyUsage(entries);
}
/**
* Load monthly usage data (replaces better-ccusage loadMonthlyUsageData)
*/
export async function loadMonthlyUsageData(options?: ParserOptions): Promise<MonthlyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateMonthlyUsage(entries);
}
/**
* Load session data (replaces better-ccusage loadSessionData)
*/
export async function loadSessionData(options?: ParserOptions): Promise<SessionUsage[]> {
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),
};
}
+155
View File
@@ -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}`);
}
}
+633
View File
@@ -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<string>();
const highIoRatioDates = new Set<string>();
const costSpikeDates = new Set<string>();
const highCacheReadDates = new Set<string>();
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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<object, object, object, UsageQuery>,
res: Response
): Promise<void> {
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');
}
}
+76
View File
@@ -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';
+52
View File
@@ -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);
+146
View File
@@ -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;
}