fix(ui): tighten analytics session and query contracts

This commit is contained in:
Tam Nhu Tran
2026-04-27 12:30:56 -04:00
parent e95b354297
commit b2e4be310d
9 changed files with 162 additions and 106 deletions
@@ -206,7 +206,7 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
return snapshot as CliproxyUsageSnapshot; return snapshot as CliproxyUsageSnapshot;
} }
if (snapshot.version === 2) { if (snapshot.version === 1 || snapshot.version === 2) {
return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings); return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings);
} }
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { RawUsageEntry } from '../jsonl-parser'; import type { RawUsageEntry } from '../jsonl-parser';
import { resolveDroidConfigPaths } from '../services/droid-dashboard-service'; import { resolveDroidConfigPaths } from '../services/droid-dashboard-service';
import { warn } from '../../utils/ui';
import { querySqliteJson } from './sqlite-cli'; import { querySqliteJson } from './sqlite-cli';
export type DroidSqliteQuery = typeof querySqliteJson; export type DroidSqliteQuery = typeof querySqliteJson;
@@ -168,6 +169,7 @@ export async function scanDroidNativeUsageEntries(
const metadata = loadSessionMetadata(factoryDir); const metadata = loadSessionMetadata(factoryDir);
const entries: RawUsageEntry[] = []; const entries: RawUsageEntry[] = [];
let skippedRowsWithoutMetadata = 0;
for (const row of rows) { for (const row of rows) {
if (!isObject(row)) continue; if (!isObject(row)) continue;
@@ -179,6 +181,7 @@ export async function scanDroidNativeUsageEntries(
const session = metadata.get(sessionId); const session = metadata.get(sessionId);
if (!session) { if (!session) {
skippedRowsWithoutMetadata++;
continue; 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; return entries;
} }
@@ -175,94 +175,98 @@ describe('cliproxy usage syncer', () => {
}); });
}); });
it('migrates legacy v2 snapshots forward before merging new history', async () => { it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => {
await runWithScopedConfigDir(ccsDir, async () => { for (const version of [1, 2]) {
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); await runWithScopedConfigDir(ccsDir, async () => {
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
fs.writeFileSync( fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
snapshotPath, fs.writeFileSync(
JSON.stringify({ snapshotPath,
version: 2, JSON.stringify({
timestamp: Date.now() - 60_000, version,
daily: [ timestamp: Date.now() - 60_000,
{ daily: [
date: '2026-03-01', {
source: 'cliproxy', date: '2026-03-01',
inputTokens: 100, source: 'cliproxy',
outputTokens: 20, inputTokens: 100,
cacheCreationTokens: 0, outputTokens: 20,
cacheReadTokens: 10, cacheCreationTokens: 0,
cost: 0.2, cacheReadTokens: 10,
totalCost: 0.2, cost: 0.2,
modelsUsed: ['gemini-2.5-pro'], totalCost: 0.2,
modelBreakdowns: [ modelsUsed: ['gemini-2.5-pro'],
{ modelBreakdowns: [
modelName: 'gemini-2.5-pro', {
inputTokens: 100, modelName: 'gemini-2.5-pro',
outputTokens: 20, inputTokens: 100,
cacheCreationTokens: 0, outputTokens: 20,
cacheReadTokens: 10, cacheCreationTokens: 0,
cost: 0.2, cacheReadTokens: 10,
}, cost: 0.2,
], },
}, ],
], },
hourly: [ ],
{ hourly: [
hour: '2026-03-01 12:00', {
source: 'cliproxy', hour: '2026-03-01 12:00',
requestCount: 7, source: 'cliproxy',
inputTokens: 100, requestCount: 7,
outputTokens: 20, inputTokens: 100,
cacheCreationTokens: 0, outputTokens: 20,
cacheReadTokens: 10, cacheCreationTokens: 0,
cost: 0.2, cacheReadTokens: 10,
totalCost: 0.2, cost: 0.2,
modelsUsed: ['gemini-2.5-pro'], totalCost: 0.2,
modelBreakdowns: [ modelsUsed: ['gemini-2.5-pro'],
{ modelBreakdowns: [
modelName: 'gemini-2.5-pro', {
inputTokens: 100, modelName: 'gemini-2.5-pro',
outputTokens: 20, inputTokens: 100,
cacheCreationTokens: 0, outputTokens: 20,
cacheReadTokens: 10, cacheCreationTokens: 0,
cost: 0.2, cacheReadTokens: 10,
}, cost: 0.2,
], },
}, ],
], },
monthly: [ ],
{ monthly: [
month: '2026-03', {
source: 'cliproxy', month: '2026-03',
inputTokens: 100, source: 'cliproxy',
outputTokens: 20, inputTokens: 100,
cacheCreationTokens: 0, outputTokens: 20,
cacheReadTokens: 10, cacheCreationTokens: 0,
totalCost: 0.2, cacheReadTokens: 10,
modelsUsed: ['gemini-2.5-pro'], totalCost: 0.2,
modelBreakdowns: [ modelsUsed: ['gemini-2.5-pro'],
{ modelBreakdowns: [
modelName: 'gemini-2.5-pro', {
inputTokens: 100, modelName: 'gemini-2.5-pro',
outputTokens: 20, inputTokens: 100,
cacheCreationTokens: 0, outputTokens: 20,
cacheReadTokens: 10, cacheCreationTokens: 0,
cost: 0.2, cacheReadTokens: 10,
}, cost: 0.2,
], },
}, ],
], },
}), ],
'utf-8' }),
); '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(); const cached = await loadCachedCliproxyData();
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); 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); 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 () => { it('prunes history details older than the configured retention window', async () => {
@@ -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 fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
@@ -148,4 +148,37 @@ describe('droid native usage collector', () => {
}) })
).rejects.toThrow('sqlite blew up'); ).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')
);
});
}); });
@@ -31,6 +31,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
const sessions = data.sessions; const sessions = data.sessions;
const totalSessions = data.total; const totalSessions = data.total;
const sampledSessions = sessions.length;
const hasPartialSample = data.hasMore || data.offset > 0; const hasPartialSample = data.hasMore || data.offset > 0;
// Calculate total cost for visible sessions // Calculate total cost for visible sessions
@@ -38,6 +39,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
const avgCost = totalCost / sessions.length; const avgCost = totalCost / sessions.length;
return { return {
displayedSessions: hasPartialSample ? sampledSessions : totalSessions,
totalSessions, totalSessions,
avgCost, avgCost,
hasPartialSample, hasPartialSample,
@@ -95,12 +97,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
<div className="p-2 rounded-md bg-muted/50 border text-center"> <div className="p-2 rounded-md bg-muted/50 border text-center">
<div className="flex items-center justify-center gap-1.5 text-blue-600 dark:text-blue-400"> <div className="flex items-center justify-center gap-1.5 text-blue-600 dark:text-blue-400">
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
<span className="text-xl font-bold">{stats.totalSessions}</span> <span className="text-xl font-bold">{stats.displayedSessions}</span>
</div> </div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5"> <p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
{/* TODO i18n: missing key for "Total Sessions" */} {stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}
Total Sessions
</p> </p>
{stats.hasPartialSample && (
<p className="text-[10px] text-muted-foreground mt-0.5">
{stats.totalSessions} total
</p>
)}
</div> </div>
{/* Avg Cost */} {/* Avg Cost */}
-1
View File
@@ -123,7 +123,6 @@ export interface MonthlyUsage {
export interface UsageQueryOptions { export interface UsageQueryOptions {
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
profile?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
} }
+2
View File
@@ -11,6 +11,8 @@ import { afterEach, vi } from 'vitest';
// Cleanup after each test // Cleanup after each test
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
}); });
// Mock matchMedia for components that use media queries // Mock matchMedia for components that use media queries
@@ -58,7 +58,7 @@ describe('SessionStatsCard', () => {
expect(screen.getByText('No session data available')).toBeInTheDocument(); 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({ const data = buildPaginatedSessions({
sessions: [ sessions: [
buildSession({ sessionId: 'session-1', cost: 0.08 }), buildSession({ sessionId: 'session-1', cost: 0.08 }),
@@ -84,8 +84,9 @@ describe('SessionStatsCard', () => {
render(<SessionStatsCard data={data} />, { wrapper: AllProviders }); render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
expect(screen.getByText('Total Sessions')).toBeInTheDocument(); expect(screen.getByText('Sampled Sessions')).toBeInTheDocument();
expect(screen.getByText('9')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('9 total')).toBeInTheDocument();
expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument(); expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument();
expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0); expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0);
expect(screen.getByText('codex')).toBeInTheDocument(); expect(screen.getByText('codex')).toBeInTheDocument();
@@ -10,21 +10,22 @@ function jsonResponse(body: unknown) {
describe('analytics usage API contract', () => { describe('analytics usage API contract', () => {
beforeEach(() => { beforeEach(() => {
global.fetch = vi vi.stubGlobal(
.fn() 'fetch',
.mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof 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 startDate = new Date(2026, 3, 1);
const endDate = new Date(2026, 3, 30); const endDate = new Date(2026, 3, 30);
await usageApi.summary({ startDate, endDate, profile: 'work' }); await usageApi.summary({ startDate, endDate });
await usageApi.trends({ startDate, endDate, profile: 'work' }); await usageApi.trends({ startDate, endDate });
await usageApi.models({ startDate, endDate, profile: 'work' }); await usageApi.models({ startDate, endDate });
await usageApi.sessions({ startDate, endDate, profile: 'work', limit: 50, offset: 10 }); await usageApi.sessions({ startDate, endDate, limit: 50, offset: 10 });
await usageApi.insights({ startDate, endDate, profile: 'work' }); await usageApi.insights({ startDate, endDate });
await usageApi.monthly({ startDate, endDate, profile: 'work' }); await usageApi.monthly({ startDate, endDate });
const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url)); 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/sessions?since=20260401&until=20260430&limit=50&offset=10');
expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430'); expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430');
expect(urls).toContain('/api/usage/monthly?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); expect(urls.every((url) => !url.includes('months='))).toBe(true);
}); });
}); });