mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user