Skip to content

Commit e8cbcc5

Browse files
icecrasher321claude
andcommitted
fix(usage): keep reporting-only rows and rounding out of the credit views
Fifth review round, plus a migration renumber — staging took 0310, so the enum addition is now 0311. Unbilled rows leaked into every credit-denominated dimension. They carry a user, a workspace, a workflow and `source = 'workflow'` like any other row, so a BYOK-only member appeared in Members at zero credits and their runs inflated the event counts on Workspaces and Sources. Excluded with `HAVING SUM(cost) > 0` rather than a `category` predicate: `category` is not in `usage_log_billing_entity_created_at_cost_idx`, so filtering on it would force a heap fetch on `member` and `source`, the two dimensions that are index-only and the reason first paint is cheap. `cost` is in that index, and only an unbilled row can sum to zero. Ranking BYOK by tokens last round was half a change: `share` still divided cost by a total cost of zero, so every provider's bar rendered at the same minimum width and the ranking was invisible. Share is now measured in whatever the list is ranked by, derived from the same argument so the two cannot disagree. Series buckets were each rounded to credits independently, so any day under half a credit rendered as zero — an organization spending a fraction of a credit a day drew a flat chart beneath a positive headline. They now go through one `apportionCredits` pass, the same rule the breakdown rows use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 37a6c33 commit e8cbcc5

4 files changed

Lines changed: 72 additions & 15 deletions

File tree

apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
usageWindowBounds,
1313
} from '@/lib/billing/core/usage-analytics'
1414
import { readUsageTimeSeries, readUsageTotals } from '@/lib/billing/core/usage-analytics-queries'
15-
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
15+
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
1616

1717
export interface OrganizationUsageSummaryInput {
1818
organizationId: string
@@ -73,6 +73,27 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
7373
])
7474

7575
const bounds = usageWindowBounds(window)
76+
/**
77+
* One apportionment across the buckets, not a per-bucket round.
78+
*
79+
* A day costing less than half a credit rounds to zero on its own, so an
80+
* organization spending a fraction of a credit a day drew a flat empty chart under
81+
* a positive headline — the reader's conclusion being that the chart is broken.
82+
* Largest-remainder distributes the period's rounded total across its buckets, so
83+
* the bars sum to the headline and a nonzero day is never drawn as zero.
84+
*
85+
* Same routine, and the same reasoning, as the breakdown's rows-plus-remainder.
86+
*/
87+
const densified = densifyUsageSeries(seriesRows, window, bucket, input.timezone)
88+
const apportioned = apportionCredits(
89+
densified.map((point, index) => ({ key: `b:${index}` as const, dollars: point.cost }))
90+
)
91+
const bucketCredits = densified.map((point, index) => ({
92+
timestamp: point.timestamp,
93+
credits: apportioned[`b:${index}`] ?? 0,
94+
events: point.events,
95+
}))
96+
7697
return {
7798
window: {
7899
start: bounds.start.toISOString(),
@@ -82,10 +103,9 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
82103
bucket,
83104
totals: { credits: dollarsToCredits(totals.cost) },
84105
previousTotals: previous ? { credits: dollarsToCredits(previous.cost) } : null,
85-
// Same timezone the query grouped by, or the series keys cannot match its rows.
86-
series: densifyUsageSeries(seriesRows, window, bucket, input.timezone).map((point) => ({
106+
series: bucketCredits.map((point) => ({
87107
timestamp: point.timestamp,
88-
credits: dollarsToCredits(point.cost),
108+
credits: point.credits,
89109
events: point.events,
90110
})),
91111
}

apps/sim/lib/billing/core/usage-analytics-queries.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,29 @@ export async function readUsageBreakdown(
143143
if (dimension === 'workflow') conditions.push(isNotNull(usageLog.workflowId))
144144

145145
if (!MODEL_DIMENSIONS.has(dimension)) {
146-
return executor
147-
.select({
148-
key: sql<string | null>`${column}`,
149-
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
150-
events: sql<number>`COUNT(*)`.mapWith(Number),
151-
})
152-
.from(usageLog)
153-
.where(and(...conditions))
154-
.groupBy(column)
146+
return (
147+
executor
148+
.select({
149+
key: sql<string | null>`${column}`,
150+
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
151+
events: sql<number>`COUNT(*)`.mapWith(Number),
152+
})
153+
.from(usageLog)
154+
.where(and(...conditions))
155+
.groupBy(column)
156+
/**
157+
* Drops groups whose every row is reporting-only. Unbilled rows carry a user,
158+
* a workspace, a workflow and `source = 'workflow'` like any other, so without
159+
* this a BYOK-only member appeared in a credit-denominated list at 0 credits.
160+
*
161+
* As a `HAVING` on the aggregate rather than a `category` predicate on purpose:
162+
* `category` is not in `usage_log_billing_entity_created_at_cost_idx`, so
163+
* filtering on it would force a heap fetch on `member` and `source` — the two
164+
* dimensions that are index-only today. `cost` is in that index, and only an
165+
* unbilled row can sum to zero, since `recordUsage` admits nothing else at zero.
166+
*/
167+
.having(sql`COALESCE(SUM(${usageLog.cost}), 0) > 0`)
168+
)
155169
}
156170

157171
// Already heap-reading `description`, so summing `metadata` costs nothing extra —

apps/sim/lib/billing/core/usage-analytics.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,18 @@ describe('foldUsageBreakdown', () => {
434434
// BYOK ranks by a cost that is zero for every row, so which rows land in the
435435
// visible slice is effectively arbitrary — dropping the tail's tokens would
436436
// hide real volume behind an em dash.
437+
const byokRows = [
438+
{ key: 'gpt-4o', cost: '0', events: 1, inputTokens: 100, outputTokens: 50 },
439+
{ key: 'claude', cost: '0', events: 1, inputTokens: 700, outputTokens: 300 },
440+
{ key: 'gemini', cost: '0', events: 1, inputTokens: 20, outputTokens: 5 },
441+
]
442+
const fold = foldUsageBreakdown(byokRows, 0, labelFor, 3, 'tokens')
443+
// Share is measured in the ranking unit, or every BYOK bar renders identical:
444+
// a cost-based share is 0/0 for every provider.
445+
expect(fold.rows.map((row) => row.share)).toEqual([1000 / 1175, 150 / 1175, 25 / 1175])
446+
})
447+
448+
it('carries the omitted rows tokens, so a zero-cost dimension still adds up', () => {
437449
const byokRows = [
438450
{ key: 'gpt-4o', cost: '0', events: 1, inputTokens: 100, outputTokens: 50 },
439451
{ key: 'claude', cost: '0', events: 1, inputTokens: 700, outputTokens: 300 },

apps/sim/lib/billing/core/usage-analytics.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,10 +469,21 @@ export function foldUsageBreakdown(
469469

470470
const visible = ranked.slice(0, limit)
471471
const hidden = ranked.slice(limit)
472-
const share = (cost: number) => (totalCost > 0 ? cost / totalCost : 0)
472+
/**
473+
* Share is measured in whatever the list is ranked by, because it is what draws the
474+
* bar. On BYOK every row costs zero, so a cost-based share made every provider's bar
475+
* identical — the minimum width — and the ranking above became invisible.
476+
*/
477+
const shareTotal =
478+
rankBy === 'tokens' ? ranked.reduce((sum, row) => sum + row.tokens, 0) : totalCost
479+
const share = (row: { cost: number; tokens: number }) =>
480+
shareTotal > 0 ? (rankBy === 'tokens' ? row.tokens : row.cost) / shareTotal : 0
473481

474482
return {
475-
rows: visible.map(({ tokens: _tokens, ...row }) => ({ ...row, share: share(row.cost) })),
483+
rows: visible.map(({ tokens: _tokens, ...row }) => ({
484+
...row,
485+
share: share({ cost: row.cost, tokens: _tokens }),
486+
})),
476487
other: {
477488
cost: hidden.reduce((sum, row) => sum + row.cost, 0),
478489
events: hidden.reduce((sum, row) => sum + row.events, 0),

0 commit comments

Comments
 (0)