diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index ce616399..db0ad837 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -206,7 +206,7 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { return snapshot as CliproxyUsageSnapshot; } - if (snapshot.version === 2) { + if (snapshot.version === 1 || snapshot.version === 2) { return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings); } diff --git a/src/web-server/usage/droid-native-usage-collector.ts b/src/web-server/usage/droid-native-usage-collector.ts index f6bbb843..2814742d 100644 --- a/src/web-server/usage/droid-native-usage-collector.ts +++ b/src/web-server/usage/droid-native-usage-collector.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { RawUsageEntry } from '../jsonl-parser'; import { resolveDroidConfigPaths } from '../services/droid-dashboard-service'; +import { warn } from '../../utils/ui'; import { querySqliteJson } from './sqlite-cli'; export type DroidSqliteQuery = typeof querySqliteJson; @@ -168,6 +169,7 @@ export async function scanDroidNativeUsageEntries( const metadata = loadSessionMetadata(factoryDir); const entries: RawUsageEntry[] = []; + let skippedRowsWithoutMetadata = 0; for (const row of rows) { if (!isObject(row)) continue; @@ -179,6 +181,7 @@ export async function scanDroidNativeUsageEntries( const session = metadata.get(sessionId); if (!session) { + skippedRowsWithoutMetadata++; continue; } @@ -200,5 +203,13 @@ export async function scanDroidNativeUsageEntries( }); } + if (skippedRowsWithoutMetadata > 0) { + console.log( + warn( + `Skipped ${skippedRowsWithoutMetadata} Droid native cost row(s) without local session metadata` + ) + ); + } + return entries; } diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index c5edf9f0..54b192ce 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -175,94 +175,98 @@ describe('cliproxy usage syncer', () => { }); }); - it('migrates legacy v2 snapshots forward before merging new history', async () => { - await runWithScopedConfigDir(ccsDir, async () => { - const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); - fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); - fs.writeFileSync( - snapshotPath, - JSON.stringify({ - version: 2, - timestamp: Date.now() - 60_000, - daily: [ - { - date: '2026-03-01', - source: 'cliproxy', - inputTokens: 100, - outputTokens: 20, - cacheCreationTokens: 0, - cacheReadTokens: 10, - cost: 0.2, - totalCost: 0.2, - modelsUsed: ['gemini-2.5-pro'], - modelBreakdowns: [ - { - modelName: 'gemini-2.5-pro', - inputTokens: 100, - outputTokens: 20, - cacheCreationTokens: 0, - cacheReadTokens: 10, - cost: 0.2, - }, - ], - }, - ], - hourly: [ - { - hour: '2026-03-01 12:00', - source: 'cliproxy', - requestCount: 7, - inputTokens: 100, - outputTokens: 20, - cacheCreationTokens: 0, - cacheReadTokens: 10, - cost: 0.2, - totalCost: 0.2, - modelsUsed: ['gemini-2.5-pro'], - modelBreakdowns: [ - { - modelName: 'gemini-2.5-pro', - inputTokens: 100, - outputTokens: 20, - cacheCreationTokens: 0, - cacheReadTokens: 10, - cost: 0.2, - }, - ], - }, - ], - monthly: [ - { - month: '2026-03', - source: 'cliproxy', - inputTokens: 100, - outputTokens: 20, - cacheCreationTokens: 0, - cacheReadTokens: 10, - totalCost: 0.2, - modelsUsed: ['gemini-2.5-pro'], - modelBreakdowns: [ - { - modelName: 'gemini-2.5-pro', - inputTokens: 100, - outputTokens: 20, - cacheCreationTokens: 0, - cacheReadTokens: 10, - cost: 0.2, - }, - ], - }, - ], - }), - 'utf-8' - ); + it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => { + for (const version of [1, 2]) { + await runWithScopedConfigDir(ccsDir, async () => { + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + version, + timestamp: Date.now() - 60_000, + daily: [ + { + date: '2026-03-01', + source: 'cliproxy', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + }, + ], + }, + ], + hourly: [ + { + hour: '2026-03-01 12:00', + source: 'cliproxy', + requestCount: 7, + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + }, + ], + }, + ], + monthly: [ + { + month: '2026-03', + source: 'cliproxy', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + }, + ], + }, + ], + }), + 'utf-8' + ); - await syncCliproxyUsage(() => Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z'))); + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z')) + ); - const cached = await loadCachedCliproxyData(); - expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); - expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7); - }); + const cached = await loadCachedCliproxyData(); + expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); + expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7); + }); + } }); it('prunes history details older than the configured retention window', async () => { diff --git a/tests/unit/web-server/droid-native-usage-collector.test.ts b/tests/unit/web-server/droid-native-usage-collector.test.ts index 50f8e651..21f31680 100644 --- a/tests/unit/web-server/droid-native-usage-collector.test.ts +++ b/tests/unit/web-server/droid-native-usage-collector.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -148,4 +148,37 @@ describe('droid native usage collector', () => { }) ).rejects.toThrow('sqlite blew up'); }); + + it('warns when cost rows are skipped because local session metadata is missing', async () => { + writeDroidGlobalSettings(tempRoot); + const sessionId = writeDroidSessionFixture(tempRoot); + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + + const querySqliteJson: DroidSqliteQuery = async () => [ + { + session_id: 'missing-session', + timestamp: '2026-03-02T09:00:00.000Z', + input_tokens: 50, + output_tokens: 10, + }, + { + session_id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + input_tokens: 120, + output_tokens: 30, + }, + ]; + + const entries = await scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.sessionId).toBe(sessionId); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipped 1 Droid native cost row(s) without local session metadata') + ); + }); }); diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx index 7f53eba0..d1b1d0b1 100644 --- a/ui/src/components/analytics/session-stats-card.tsx +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -31,6 +31,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar const sessions = data.sessions; const totalSessions = data.total; + const sampledSessions = sessions.length; const hasPartialSample = data.hasMore || data.offset > 0; // Calculate total cost for visible sessions @@ -38,6 +39,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar const avgCost = totalCost / sessions.length; return { + displayedSessions: hasPartialSample ? sampledSessions : totalSessions, totalSessions, avgCost, hasPartialSample, @@ -95,12 +97,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
- {/* TODO i18n: missing key for "Total Sessions" */} - Total Sessions + {stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}
+ {stats.hasPartialSample && ( ++ {stats.totalSessions} total +
+ )}