From b2e4be310d19dec83810e15f11a770deda4b4cd8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 27 Apr 2026 12:30:56 -0400 Subject: [PATCH] fix(ui): tighten analytics session and query contracts --- src/web-server/usage/cliproxy-usage-syncer.ts | 2 +- .../usage/droid-native-usage-collector.ts | 11 ++ .../web-server/cliproxy-usage-syncer.test.ts | 176 +++++++++--------- .../droid-native-usage-collector.test.ts | 35 +++- .../analytics/session-stats-card.tsx | 12 +- ui/src/hooks/use-usage.ts | 1 - ui/tests/setup/vitest-setup.ts | 2 + .../analytics/session-stats-card.test.tsx | 7 +- .../analytics/usage-api-contract.test.ts | 22 +-- 9 files changed, 162 insertions(+), 106 deletions(-) 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
- {stats.totalSessions} + {stats.displayedSessions}

- {/* TODO i18n: missing key for "Total Sessions" */} - Total Sessions + {stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}

+ {stats.hasPartialSample && ( +

+ {stats.totalSessions} total +

+ )}
{/* Avg Cost */} diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index b0f6dcb3..9c929fdd 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -123,7 +123,6 @@ export interface MonthlyUsage { export interface UsageQueryOptions { startDate?: Date; endDate?: Date; - profile?: string; limit?: number; offset?: number; } diff --git a/ui/tests/setup/vitest-setup.ts b/ui/tests/setup/vitest-setup.ts index 0fa24398..c3815df1 100644 --- a/ui/tests/setup/vitest-setup.ts +++ b/ui/tests/setup/vitest-setup.ts @@ -11,6 +11,8 @@ import { afterEach, vi } from 'vitest'; // Cleanup after each test afterEach(() => { cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); // Mock matchMedia for components that use media queries diff --git a/ui/tests/unit/components/analytics/session-stats-card.test.tsx b/ui/tests/unit/components/analytics/session-stats-card.test.tsx index eee51d93..04e5eae8 100644 --- a/ui/tests/unit/components/analytics/session-stats-card.test.tsx +++ b/ui/tests/unit/components/analytics/session-stats-card.test.tsx @@ -58,7 +58,7 @@ describe('SessionStatsCard', () => { expect(screen.getByText('No session data available')).toBeInTheDocument(); }); - it('uses the API total for overall session count while labeling subset averages as recent', () => { + it('keeps subset session metrics explicitly sample-scoped when pagination truncates the result set', () => { const data = buildPaginatedSessions({ sessions: [ buildSession({ sessionId: 'session-1', cost: 0.08 }), @@ -84,8 +84,9 @@ describe('SessionStatsCard', () => { render(, { wrapper: AllProviders }); - expect(screen.getByText('Total Sessions')).toBeInTheDocument(); - expect(screen.getByText('9')).toBeInTheDocument(); + expect(screen.getByText('Sampled Sessions')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('9 total')).toBeInTheDocument(); expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument(); expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0); expect(screen.getByText('codex')).toBeInTheDocument(); diff --git a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts index 96a62a2d..645151c2 100644 --- a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts +++ b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts @@ -10,21 +10,22 @@ function jsonResponse(body: unknown) { describe('analytics usage API contract', () => { beforeEach(() => { - global.fetch = vi - .fn() - .mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch + ); }); - it('omits unsupported profile and months query params while keeping supported filters', async () => { + it('serializes only the supported analytics query params', async () => { const startDate = new Date(2026, 3, 1); const endDate = new Date(2026, 3, 30); - await usageApi.summary({ startDate, endDate, profile: 'work' }); - await usageApi.trends({ startDate, endDate, profile: 'work' }); - await usageApi.models({ startDate, endDate, profile: 'work' }); - await usageApi.sessions({ startDate, endDate, profile: 'work', limit: 50, offset: 10 }); - await usageApi.insights({ startDate, endDate, profile: 'work' }); - await usageApi.monthly({ startDate, endDate, profile: 'work' }); + await usageApi.summary({ startDate, endDate }); + await usageApi.trends({ startDate, endDate }); + await usageApi.models({ startDate, endDate }); + await usageApi.sessions({ startDate, endDate, limit: 50, offset: 10 }); + await usageApi.insights({ startDate, endDate }); + await usageApi.monthly({ startDate, endDate }); const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url)); @@ -34,7 +35,6 @@ describe('analytics usage API contract', () => { expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10'); expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430'); expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430'); - expect(urls.every((url) => !url.includes('profile='))).toBe(true); expect(urls.every((url) => !url.includes('months='))).toBe(true); }); });