-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
fix(ui): stop usage pagination from under-reporting wide date ranges #36022
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; | ||
| import { mergeDailyResults } from "./mergeDailyActivity"; | ||
|
|
||
| const metrics = (overrides: Partial<SpendMetrics> = {}): SpendMetrics => ({ | ||
| spend: 0, | ||
| prompt_tokens: 0, | ||
| completion_tokens: 0, | ||
| total_tokens: 0, | ||
| api_requests: 0, | ||
| successful_requests: 0, | ||
| failed_requests: 0, | ||
| cache_read_input_tokens: 0, | ||
| cache_creation_input_tokens: 0, | ||
| ...overrides, | ||
| }); | ||
|
|
||
| const day = (date: string, spend: number, teamSpend: Record<string, number>, keySpend: number): DailyData => ({ | ||
| date, | ||
| metrics: metrics({ spend, total_tokens: spend * 10, api_requests: 1 }), | ||
| breakdown: { | ||
| models: {}, | ||
| model_groups: {}, | ||
| mcp_servers: {}, | ||
| providers: {}, | ||
| api_keys: { | ||
| "sk-a": { metrics: metrics({ spend: keySpend }), metadata: { key_alias: "a", team_id: "team-1" } }, | ||
| }, | ||
| entities: Object.fromEntries( | ||
| Object.entries(teamSpend).map(([team, value]) => [ | ||
| team, | ||
| { | ||
| metrics: metrics({ spend: value, total_tokens: value * 10 }), | ||
| metadata: { team_alias: team }, | ||
| api_key_breakdown: { | ||
| "sk-a": { metrics: metrics({ spend: value }), metadata: { key_alias: "a", team_id: team } }, | ||
| }, | ||
| }, | ||
| ]), | ||
| ), | ||
| }, | ||
| }); | ||
|
|
||
| describe("mergeDailyResults", () => { | ||
| it("keeps one entry per date when a date straddles a page boundary", () => { | ||
| const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5), day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)]; | ||
| const pageTwo = [day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52), day("2026-06-24", 3, { "team-1": 3 }, 3)]; | ||
|
|
||
| const merged = mergeDailyResults(pageOne, pageTwo); | ||
|
|
||
| expect(merged.map((d) => d.date)).toEqual(["2026-06-26", "2026-06-25", "2026-06-24"]); | ||
| const splitDay = merged.find((d) => d.date === "2026-06-25")!; | ||
| expect(splitDay.metrics.spend).toBeCloseTo(36.9, 10); | ||
| expect(splitDay.metrics.total_tokens).toBeCloseTo(369, 10); | ||
| expect(splitDay.metrics.api_requests).toBe(2); | ||
| }); | ||
|
|
||
| it("merges every breakdown bucket of a split date instead of dropping one page's share", () => { | ||
| const merged = mergeDailyResults( | ||
| [day("2026-06-25", 10, { "team-1": 6, "team-2": 4 }, 10)], | ||
| [day("2026-06-25", 5, { "team-2": 5 }, 5)], | ||
| ); | ||
|
|
||
| const { entities, api_keys } = merged[0].breakdown; | ||
| expect(entities["team-1"].metrics.spend).toBeCloseTo(6, 10); | ||
| expect(entities["team-2"].metrics.spend).toBeCloseTo(9, 10); | ||
| expect(entities["team-2"].api_key_breakdown["sk-a"].metrics.spend).toBeCloseTo(9, 10); | ||
| expect(api_keys["sk-a"].metrics.spend).toBeCloseTo(15, 10); | ||
| }); | ||
|
|
||
| it("preserves the per-day total across pages so day sums match the response metadata", () => { | ||
| const pages = [ | ||
| [day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)], | ||
| [day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52)], | ||
| [day("2026-06-24", 3, { "team-1": 3 }, 3)], | ||
| ]; | ||
|
|
||
| const merged = pages.reduce<DailyData[]>((acc, page) => mergeDailyResults(acc, page), []); | ||
|
|
||
| expect(merged).toHaveLength(2); | ||
| expect(merged.reduce((total, d) => total + d.metrics.spend, 0)).toBeCloseTo(39.9, 10); | ||
| }); | ||
|
|
||
| it("leaves distinct dates untouched", () => { | ||
| const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5)]; | ||
| const pageTwo = [day("2026-06-25", 7, { "team-1": 7 }, 7)]; | ||
|
|
||
| expect(mergeDailyResults(pageOne, pageTwo)).toEqual([...pageOne, ...pageTwo]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import type { | ||
| BreakdownMetrics, | ||
| DailyData, | ||
| KeyMetricWithMetadata, | ||
| MetricWithMetadata, | ||
| SpendMetrics, | ||
| } from "@/components/UsagePage/types"; | ||
|
|
||
| const METRIC_KEYS: readonly (keyof SpendMetrics)[] = [ | ||
| "spend", | ||
| "prompt_tokens", | ||
| "completion_tokens", | ||
| "total_tokens", | ||
| "api_requests", | ||
| "successful_requests", | ||
| "failed_requests", | ||
| "cache_read_input_tokens", | ||
| "cache_creation_input_tokens", | ||
| "compression_saved_tokens", | ||
| "compression_savings_spend", | ||
| "prompt_caching_savings_spend", | ||
| "autorouter_savings_spend", | ||
| ]; | ||
|
|
||
| const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics => | ||
| METRIC_KEYS.reduce( | ||
| (acc, key) => | ||
| a[key] === undefined && b[key] === undefined ? acc : { ...acc, [key]: (a[key] ?? 0) + (b[key] ?? 0) }, | ||
| {} as SpendMetrics, | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Merge drops daily flat costHigh Severity
Reviewed by Cursor Bugbot for commit 45ad441. Configure here. |
||
|
|
||
| const mergeBuckets = <T>( | ||
| a: Record<string, T> | undefined, | ||
| b: Record<string, T> | undefined, | ||
| mergeEntry: (left: T, right: T) => T, | ||
| ): Record<string, T> => { | ||
| const left = a ?? {}; | ||
| const right = b ?? {}; | ||
| return Object.fromEntries( | ||
| Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => { | ||
| const leftEntry = left[key]; | ||
| const rightEntry = right[key]; | ||
| if (leftEntry === undefined) return [key, rightEntry]; | ||
| if (rightEntry === undefined) return [key, leftEntry]; | ||
| return [key, mergeEntry(leftEntry, rightEntry)]; | ||
| }), | ||
| ); | ||
| }; | ||
|
|
||
| const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({ | ||
| ...a, | ||
| metrics: addMetrics(a.metrics, b.metrics), | ||
| }); | ||
|
|
||
| const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({ | ||
| ...a, | ||
| metrics: addMetrics(a.metrics, b.metrics), | ||
| api_key_breakdown: mergeBuckets(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric), | ||
| }); | ||
|
|
||
| const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({ | ||
| models: mergeBuckets(a.models, b.models, mergeMetricWithMetadata), | ||
| model_groups: mergeBuckets(a.model_groups, b.model_groups, mergeMetricWithMetadata), | ||
| mcp_servers: mergeBuckets(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata), | ||
| providers: mergeBuckets(a.providers, b.providers, mergeMetricWithMetadata), | ||
| entities: mergeBuckets(a.entities, b.entities, mergeMetricWithMetadata), | ||
| endpoints: mergeBuckets(a.endpoints, b.endpoints, mergeMetricWithMetadata), | ||
| api_keys: mergeBuckets(a.api_keys, b.api_keys, mergeKeyMetric), | ||
| }); | ||
|
|
||
| const mergeDay = (a: DailyData, b: DailyData): DailyData => ({ | ||
| ...a, | ||
| metrics: addMetrics(a.metrics, b.metrics), | ||
| breakdown: mergeBreakdown(a.breakdown, b.breakdown), | ||
| }); | ||
|
|
||
| export const mergeDailyResults = (existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] => | ||
| incoming.reduce<DailyData[]>( | ||
| (acc, day) => { | ||
| const index = acc.findIndex((existingDay) => existingDay.date === day.date); | ||
| if (index === -1) return [...acc, day]; | ||
| return acc.map((existingDay, i) => (i === index ? mergeDay(existingDay, day) : existingDay)); | ||
| }, | ||
| [...existing], | ||
| ); | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cancelled export shows loading reason
Low Severity
incompleteis true when the user cancels, so Export stays disabled, butexportDisabledReasononly special-casesfailedand otherwise says spend data is still loading and to wait. After Stop, nothing is loading, so the tooltip steers users to wait for a fetch that will never finish.Reviewed by Cursor Bugbot for commit 45ad441. Configure here.