mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 06:24:25 +00:00
feat(analytics): add usage analytics page with caching layer
- Add Analytics page with usage trends, model breakdown, sessions table - Add server-side caching layer for better-ccusage data (TTL-based) - Add request coalescing to prevent duplicate concurrent API calls - Add /api/usage/refresh endpoint to manually clear cache - Add date-range filter, summary cards, trend charts components - Fix API parameter mismatch (since/until in YYYYMMDD format) - Wire up Refresh button with loading state animation
This commit is contained in:
@@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
const { overviewRoutes } = await import('./overview-routes');
|
||||
app.use('/api/overview', overviewRoutes);
|
||||
|
||||
// Usage analytics routes
|
||||
const { usageRoutes } = await import('./usage-routes');
|
||||
app.use('/api/usage', usageRoutes);
|
||||
|
||||
// Dev mode: use Vite middleware for HMR
|
||||
if (options.dev) {
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Usage Analytics API Routes
|
||||
*
|
||||
* Provides REST endpoints for Claude Code usage analytics using better-ccusage library.
|
||||
* Supports daily, monthly, and session-based usage data aggregation.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - TTL-based caching to reduce better-ccusage library calls
|
||||
* - Request coalescing to prevent duplicate concurrent requests
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
loadDailyUsageData,
|
||||
loadMonthlyUsageData,
|
||||
loadSessionData,
|
||||
type DailyUsage,
|
||||
type MonthlyUsage,
|
||||
type SessionUsage,
|
||||
} from 'better-ccusage/data-loader';
|
||||
|
||||
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
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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>>();
|
||||
|
||||
/**
|
||||
* Get cached data or fetch from loader with TTL
|
||||
* Also coalesces concurrent requests to prevent duplicate library calls
|
||||
*/
|
||||
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
|
||||
// Check cache first
|
||||
const cached = cache.get(key) as CacheEntry<T> | undefined;
|
||||
if (cached && Date.now() - cached.timestamp < ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 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() });
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(key);
|
||||
});
|
||||
|
||||
pendingRequests.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Cached loader for daily usage data */
|
||||
async function getCachedDailyData(): Promise<DailyUsage[]> {
|
||||
return getCachedData('daily', CACHE_TTL.daily, async () => {
|
||||
return (await loadDailyUsageData()) as DailyUsage[];
|
||||
});
|
||||
}
|
||||
|
||||
/** Cached loader for monthly usage data */
|
||||
async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
|
||||
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
|
||||
return (await loadMonthlyUsageData()) as MonthlyUsage[];
|
||||
});
|
||||
}
|
||||
|
||||
/** Cached loader for session data */
|
||||
async function getCachedSessionData(): Promise<SessionUsage[]> {
|
||||
return getCachedData('session', CACHE_TTL.session, async () => {
|
||||
return (await loadSessionData()) as SessionUsage[];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data (useful for manual refresh)
|
||||
*/
|
||||
export function clearUsageCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Basic range check
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse limit parameter
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse offset parameter
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter data by date range
|
||||
*/
|
||||
function filterByDateRange<T extends { date?: string; month?: string; lastActivity?: string }>(
|
||||
data: T[],
|
||||
since?: string,
|
||||
until?: string
|
||||
): T[] {
|
||||
if (!since && !until) return data;
|
||||
|
||||
return data.filter((item) => {
|
||||
// Get the date field (prioritize date, then month, then lastActivity)
|
||||
const itemDate =
|
||||
item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, '');
|
||||
if (!itemDate) return true;
|
||||
|
||||
// Normalize to YYYYMMDD for comparison
|
||||
const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8);
|
||||
|
||||
if (since && normalizedDate < since) return false;
|
||||
if (until && normalizedDate > until) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standard error response
|
||||
*/
|
||||
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');
|
||||
|
||||
const statusCode = isValidationError ? 400 : 500;
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: isValidationError ? errorMessage : defaultMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/usage/summary
|
||||
*
|
||||
* Returns usage summary data for quick dashboard display.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
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);
|
||||
|
||||
// Calculate totals
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalCacheTokens = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
for (const day of filtered) {
|
||||
totalInputTokens += day.inputTokens;
|
||||
totalOutputTokens += day.outputTokens;
|
||||
totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens;
|
||||
totalCost += day.totalCost;
|
||||
}
|
||||
|
||||
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalTokens,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalCacheTokens,
|
||||
totalCost: Math.round(totalCost * 100) / 100,
|
||||
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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/daily
|
||||
*
|
||||
* Returns daily usage trends for chart visualization.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
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);
|
||||
|
||||
// Transform for chart consumption
|
||||
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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/models
|
||||
*
|
||||
* Returns usage breakdown by model for pie/bar charts.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
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);
|
||||
|
||||
// Aggregate model usage across all days
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: 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,
|
||||
cacheTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
existing.inputTokens += breakdown.inputTokens;
|
||||
existing.outputTokens += breakdown.outputTokens;
|
||||
existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens;
|
||||
existing.cost += breakdown.cost;
|
||||
|
||||
modelMap.set(breakdown.modelName, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals for percentage
|
||||
const models = Array.from(modelMap.values());
|
||||
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
|
||||
|
||||
// Add percentage and sort by tokens
|
||||
const result = models
|
||||
.map((m) => ({
|
||||
...m,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
cost: Math.round(m.cost * 100) / 100,
|
||||
percentage:
|
||||
totalTokens > 0
|
||||
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch model usage');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/sessions
|
||||
*
|
||||
* Returns paginated list of sessions.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD&limit=50&offset=0
|
||||
*/
|
||||
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();
|
||||
|
||||
// Filter by date range using lastActivity
|
||||
const filtered = filterByDateRange(sessionData, since, until);
|
||||
|
||||
// Sort by lastActivity descending
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
||||
);
|
||||
|
||||
// Paginate
|
||||
const paginated = sorted.slice(offset, offset + limit);
|
||||
|
||||
// Transform for frontend
|
||||
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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/monthly
|
||||
*
|
||||
* Returns monthly usage summary for charts.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
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();
|
||||
|
||||
// Filter by date range (convert month YYYY-MM to YYYYMM01 for comparison)
|
||||
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;
|
||||
|
||||
// Transform for charts
|
||||
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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/usage/refresh
|
||||
*
|
||||
* Clears the usage cache to force fresh data fetch.
|
||||
* Useful when user wants to see latest data immediately.
|
||||
*/
|
||||
usageRoutes.post('/refresh', (_req: Request, res: Response) => {
|
||||
clearUsageCache();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Usage cache cleared',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user