Skip to content

Commit 1e11d2e

Browse files
committed
feat(analytics): aggregate usage from all CCS auth profiles
Add multi-instance support to usage analytics. The dashboard now aggregates usage data from ~/.claude/ AND all CCS instances in ~/.ccs/instances/<profile>/. - Add getInstancePaths() to discover CCS instances with usage data - Add loadInstanceData() to load data from specific instance - Add merge functions for daily/monthly/session data with deduplication - Modify refreshFromSource() to aggregate from all instances sequentially - Merge by date/sessionId to avoid double-counting overlapping usage
1 parent f255a20 commit 1e11d2e

File tree

1 file changed

+208
-2
lines changed

1 file changed

+208
-2
lines changed

src/web-server/usage-routes.ts

Lines changed: 208 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
*/
1313

1414
import { Router, Request, Response } from 'express';
15+
import * as fs from 'fs';
16+
import * as path from 'path';
17+
import * as os from 'os';
1518
import {
1619
loadDailyUsageData,
1720
loadMonthlyUsageData,
@@ -29,6 +32,169 @@ import {
2932
getCacheAge,
3033
} from './usage-disk-cache';
3134

35+
// ============================================================================
36+
// Multi-Instance Support - Aggregate usage from CCS profiles
37+
// ============================================================================
38+
39+
/** Path to CCS instances directory */
40+
const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances');
41+
42+
/**
43+
* Get list of CCS instance paths that have usage data
44+
* Only returns instances with existing projects/ directory
45+
*/
46+
function getInstancePaths(): string[] {
47+
if (!fs.existsSync(CCS_INSTANCES_DIR)) {
48+
return [];
49+
}
50+
51+
try {
52+
const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true });
53+
return entries
54+
.filter((entry) => entry.isDirectory())
55+
.map((entry) => path.join(CCS_INSTANCES_DIR, entry.name))
56+
.filter((instancePath) => {
57+
// Only include instances that have a projects directory
58+
const projectsPath = path.join(instancePath, 'projects');
59+
return fs.existsSync(projectsPath);
60+
});
61+
} catch {
62+
console.error('[!] Failed to read CCS instances directory');
63+
return [];
64+
}
65+
}
66+
67+
/**
68+
* Load usage data from a specific instance by temporarily setting CLAUDE_CONFIG_DIR
69+
* Returns empty arrays if instance has no usage data
70+
*/
71+
async function loadInstanceData(instancePath: string): Promise<{
72+
daily: DailyUsage[];
73+
monthly: MonthlyUsage[];
74+
session: SessionUsage[];
75+
}> {
76+
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
77+
78+
try {
79+
// Set CLAUDE_CONFIG_DIR to instance path for better-ccusage to read from
80+
process.env.CLAUDE_CONFIG_DIR = instancePath;
81+
82+
const [daily, monthly, session] = await Promise.all([
83+
loadDailyUsageData() as Promise<DailyUsage[]>,
84+
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
85+
loadSessionData() as Promise<SessionUsage[]>,
86+
]);
87+
88+
return { daily, monthly, session };
89+
} catch (_err) {
90+
// Instance may have no usage data - that's OK
91+
const instanceName = path.basename(instancePath);
92+
console.log(`[i] No usage data in instance: ${instanceName}`);
93+
return { daily: [], monthly: [], session: [] };
94+
} finally {
95+
// Restore original env var
96+
if (originalConfigDir === undefined) {
97+
delete process.env.CLAUDE_CONFIG_DIR;
98+
} else {
99+
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
100+
}
101+
}
102+
}
103+
104+
/**
105+
* Merge daily usage data from multiple sources
106+
* Combines entries with same date by aggregating tokens
107+
*/
108+
function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
109+
const dateMap = new Map<string, DailyUsage>();
110+
111+
for (const source of sources) {
112+
for (const day of source) {
113+
const existing = dateMap.get(day.date);
114+
if (existing) {
115+
// Aggregate tokens for same date
116+
existing.inputTokens += day.inputTokens;
117+
existing.outputTokens += day.outputTokens;
118+
existing.cacheCreationTokens += day.cacheCreationTokens;
119+
existing.cacheReadTokens += day.cacheReadTokens;
120+
existing.totalCost += day.totalCost;
121+
// Merge unique models
122+
const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]);
123+
existing.modelsUsed = Array.from(modelSet);
124+
// Merge model breakdowns by aggregating same modelName
125+
for (const breakdown of day.modelBreakdowns) {
126+
const existingBreakdown = existing.modelBreakdowns.find(
127+
(b) => b.modelName === breakdown.modelName
128+
);
129+
if (existingBreakdown) {
130+
existingBreakdown.inputTokens += breakdown.inputTokens;
131+
existingBreakdown.outputTokens += breakdown.outputTokens;
132+
existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens;
133+
existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens;
134+
existingBreakdown.cost += breakdown.cost;
135+
} else {
136+
existing.modelBreakdowns.push({ ...breakdown });
137+
}
138+
}
139+
} else {
140+
// Clone to avoid mutating original
141+
dateMap.set(day.date, {
142+
...day,
143+
modelsUsed: [...day.modelsUsed],
144+
modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })),
145+
});
146+
}
147+
}
148+
}
149+
150+
return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
151+
}
152+
153+
/**
154+
* Merge monthly usage data from multiple sources
155+
*/
156+
function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
157+
const monthMap = new Map<string, MonthlyUsage>();
158+
159+
for (const source of sources) {
160+
for (const month of source) {
161+
const existing = monthMap.get(month.month);
162+
if (existing) {
163+
existing.inputTokens += month.inputTokens;
164+
existing.outputTokens += month.outputTokens;
165+
existing.cacheCreationTokens += month.cacheCreationTokens;
166+
existing.cacheReadTokens += month.cacheReadTokens;
167+
existing.totalCost += month.totalCost;
168+
const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]);
169+
existing.modelsUsed = Array.from(modelSet);
170+
} else {
171+
monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] });
172+
}
173+
}
174+
}
175+
176+
return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
177+
}
178+
179+
/**
180+
* Merge session data from multiple sources
181+
* Deduplicates by sessionId (same session shouldn't appear in multiple instances)
182+
*/
183+
function mergeSessionData(sources: SessionUsage[][]): SessionUsage[] {
184+
const sessionMap = new Map<string, SessionUsage>();
185+
186+
for (const source of sources) {
187+
for (const session of source) {
188+
// Use sessionId as unique key - later entries overwrite earlier ones
189+
sessionMap.set(session.sessionId, session);
190+
}
191+
}
192+
193+
return Array.from(sessionMap.values()).sort(
194+
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
195+
);
196+
}
197+
32198
export const usageRoutes = Router();
33199

34200
/** Query parameters for usage endpoints */
@@ -195,17 +361,57 @@ let isRefreshing = false;
195361

196362
/**
197363
* Load fresh data from better-ccusage and update both memory and disk caches
364+
* Aggregates data from default ~/.claude/ AND all CCS instances
198365
*/
199366
async function refreshFromSource(): Promise<{
200367
daily: DailyUsage[];
201368
monthly: MonthlyUsage[];
202369
session: SessionUsage[];
203370
}> {
204-
const [daily, monthly, session] = await Promise.all([
371+
// Load default data (from ~/.claude/ or current CLAUDE_CONFIG_DIR)
372+
const defaultData = await Promise.all([
205373
loadDailyUsageData() as Promise<DailyUsage[]>,
206374
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
207375
loadSessionData() as Promise<SessionUsage[]>,
208-
]);
376+
]).then(([daily, monthly, session]) => ({ daily, monthly, session }));
377+
378+
// Load data from all CCS instances sequentially (to avoid env var race condition)
379+
const instancePaths = getInstancePaths();
380+
const instanceDataResults: Array<{
381+
daily: DailyUsage[];
382+
monthly: MonthlyUsage[];
383+
session: SessionUsage[];
384+
}> = [];
385+
386+
for (const instancePath of instancePaths) {
387+
try {
388+
const data = await loadInstanceData(instancePath);
389+
instanceDataResults.push(data);
390+
} catch (err) {
391+
const instanceName = path.basename(instancePath);
392+
console.error(`[!] Failed to load instance ${instanceName}:`, err);
393+
}
394+
}
395+
396+
// Collect successful instance data
397+
const allDailySources: DailyUsage[][] = [defaultData.daily];
398+
const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly];
399+
const allSessionSources: SessionUsage[][] = [defaultData.session];
400+
401+
for (const result of instanceDataResults) {
402+
allDailySources.push(result.daily);
403+
allMonthlySources.push(result.monthly);
404+
allSessionSources.push(result.session);
405+
}
406+
407+
if (instanceDataResults.length > 0) {
408+
console.log(`[i] Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`);
409+
}
410+
411+
// Merge all data sources
412+
const daily = mergeDailyData(allDailySources);
413+
const monthly = mergeMonthlyData(allMonthlySources);
414+
const session = mergeSessionData(allSessionSources);
209415

210416
// Update in-memory cache
211417
const now = Date.now();

0 commit comments

Comments
 (0)