mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +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.
|
* @deprecated Import from './usage/data-aggregator' instead
|
||||||
* Uses model-pricing.ts for cost calculations.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type RawUsageEntry } from './jsonl-parser';
|
export * from './usage/data-aggregator';
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,538 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Usage Aggregator Service
|
* Usage Aggregator Service - Re-export from modularized location
|
||||||
*
|
*
|
||||||
* Handles multi-instance usage data aggregation and caching.
|
* @deprecated Import from '../usage/aggregator' instead
|
||||||
* Combines data from default Claude config and all CCS instances.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
export * from '../usage/aggregator';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
* @deprecated Import from './usage/disk-cache' instead
|
||||||
* 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';
|
export * from './usage/disk-cache';
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
* @deprecated Import from './usage/routes' instead
|
||||||
* Supports daily, monthly, and session-based usage data aggregation.
|
|
||||||
*
|
|
||||||
* Data aggregation and caching logic is in services/usage-aggregator.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 {
|
export {
|
||||||
|
usageRoutes,
|
||||||
prewarmUsageCache,
|
prewarmUsageCache,
|
||||||
clearUsageCache,
|
clearUsageCache,
|
||||||
getLastFetchTimestamp,
|
getLastFetchTimestamp,
|
||||||
} from './services/usage-aggregator';
|
} from './usage/routes';
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,146 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Usage Data Types
|
* Usage Types - Re-export from modularized location
|
||||||
*
|
*
|
||||||
* Type definitions for aggregated usage data.
|
* @deprecated Import from './usage/types' instead
|
||||||
* Compatible with better-ccusage interfaces for drop-in replacement.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================================================================
|
export * from './usage/types';
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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