Skip to content

Commit d1dcf6c

Browse files
committed
fix(billing): remove the export's arbitrary row cap, fix a cursor pagination bug it exposed
A personal credit ledger doesn't have the same unbounded-growth problem a workspace table does — capping the export at 5,000 rows just meant long-tenured or high-usage accounts (exactly the ones most likely to need a full export to reconcile a billing question) got silently truncated. Replaced the cap with a 50,000-row circuit breaker that should never fire in normal use (logged as an error, not a warning, if it ever does) and bumped the page size from 500 to 1,000 to cut round trips. Removing the cap surfaced a real, pre-existing bug in getUserUsageLogs's cursor pagination: a raw `sql` template embedded a JS Date object directly as a bound parameter, which the postgres driver can't serialize (unlike drizzle's typed gte/lte operators, which already handle Date correctly elsewhere in the same function). It only ever manifested past the first page, which nothing before this export route's tight multi-page loop reliably exercised. Replaced the raw sql template with drizzle's typed lt/eq/or/and operators, matching the pattern already proven correct in this file. Verified live: seeded 6,000 rows (past the old cap) and confirmed the export downloads all of them in one request with credits reconciling exactly against the total.
1 parent 2871234 commit d1dcf6c

3 files changed

Lines changed: 26 additions & 18 deletions

File tree

apps/sim/app/api/users/me/usage-logs/export/route.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,16 @@ describe('GET /api/users/me/usage-logs/export', () => {
5555
expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100')
5656
})
5757

58-
it('sets X-Export-Truncated when the row cap is hit with more data remaining', async () => {
58+
it('sets X-Export-Truncated when the safety cap is hit with more data remaining', async () => {
5959
mockGetUserUsageLogs.mockResolvedValueOnce({
60-
logs: Array.from({ length: 5000 }, (_, i) => ({
60+
logs: Array.from({ length: 50000 }, (_, i) => ({
6161
id: `log-${i}`,
6262
createdAt: '2026-07-01T00:00:00.000Z',
6363
source: 'copilot',
6464
cost: 0.1,
6565
})),
6666
summary: { totalCost: 0, bySource: {} },
67-
pagination: { hasMore: true, nextCursor: 'log-4999' },
67+
pagination: { hasMore: true, nextCursor: 'log-49999' },
6868
})
6969

7070
const response = await GET(createMockRequest('GET'))
@@ -178,16 +178,16 @@ describe('GET /api/users/me/usage-logs/export', () => {
178178
expect(csv.split('\n')).toHaveLength(3)
179179
})
180180

181-
it('stops at exactly the row cap without an extra wasted page fetch', async () => {
181+
it('stops at exactly the safety cap without an extra wasted page fetch', async () => {
182182
mockGetUserUsageLogs.mockResolvedValueOnce({
183-
logs: Array.from({ length: 5000 }, (_, i) => ({
183+
logs: Array.from({ length: 50000 }, (_, i) => ({
184184
id: `log-${i}`,
185185
createdAt: '2026-07-01T00:00:00.000Z',
186186
source: 'copilot',
187187
cost: 0.1,
188188
})),
189189
summary: { totalCost: 0, bySource: {} },
190-
pagination: { hasMore: true, nextCursor: 'log-4999' },
190+
pagination: { hasMore: true, nextCursor: 'log-49999' },
191191
})
192192

193193
await GET(createMockRequest('GET'))

apps/sim/app/api/users/me/usage-logs/export/route.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@ import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-la
1212

1313
const logger = createLogger('UsageLogsExportAPI')
1414

15-
/** Safety cap on export size — a single user's credit ledger; not expected to approach this. */
16-
const MAX_EXPORT_ROWS = 5000
17-
const EXPORT_PAGE_SIZE = 500
15+
/**
16+
* Circuit breaker, not a UX boundary — a personal credit ledger is bounded by
17+
* the user's own usage history and should never realistically approach this.
18+
* Exists only to keep a pathological account (or a bug upstream) from paging
19+
* forever; hitting it is worth alerting on, not a normal truncation case.
20+
*/
21+
const EXPORT_SAFETY_CAP = 50000
22+
const EXPORT_PAGE_SIZE = 1000
1823

1924
const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits'])
2025

2126
/**
2227
* Downloads every usage log matching the current filter as CSV — unlike the
23-
* paginated list route, this fetches up to `MAX_EXPORT_ROWS` in one response
28+
* paginated list route, this fetches every matching row in one response
2429
* rather than a single page, since a user's own credit ledger is bounded
2530
* (unlike, say, a workspace's full execution history).
2631
*/
@@ -39,27 +44,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3944
const rows: Awaited<ReturnType<typeof getUserUsageLogs>>['logs'] = []
4045
let cursor: string | undefined
4146
let truncated = false
42-
while (rows.length < MAX_EXPORT_ROWS) {
47+
while (rows.length < EXPORT_SAFETY_CAP) {
4348
const page = await getUserUsageLogs(auth.userId, {
4449
source: source as UsageLogSource | undefined,
4550
workspaceId,
4651
startDate: dateRange.startDate,
4752
endDate: dateRange.endDate,
48-
limit: Math.min(EXPORT_PAGE_SIZE, MAX_EXPORT_ROWS - rows.length),
53+
limit: Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length),
4954
cursor,
5055
includeSummary: false,
5156
})
5257
rows.push(...page.logs)
5358
if (!page.pagination.hasMore) break
54-
truncated = rows.length >= MAX_EXPORT_ROWS
59+
truncated = rows.length >= EXPORT_SAFETY_CAP
5560
cursor = page.pagination.nextCursor
5661
}
5762

5863
if (truncated) {
59-
logger.warn('Usage log export truncated at safety cap', {
64+
logger.error('Usage log export hit the safety cap — investigate this account', {
6065
userId: auth.userId,
6166
period,
62-
cap: MAX_EXPORT_ROWS,
67+
cap: EXPORT_SAFETY_CAP,
6368
})
6469
}
6570

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { usageLog, workflow, workspace } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
7-
import { and, desc, eq, gte, inArray, lt, lte, sql } from 'drizzle-orm'
7+
import { and, desc, eq, gte, inArray, lt, lte, or, sql } from 'drizzle-orm'
88
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
99
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
1010
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
@@ -624,9 +624,12 @@ export async function getUserUsageLogs(
624624
.limit(1)
625625

626626
if (cursorLog.length > 0) {
627-
conditions.push(
628-
sql`(${usageLog.createdAt} < ${cursorLog[0].createdAt} OR (${usageLog.createdAt} = ${cursorLog[0].createdAt} AND ${usageLog.id} < ${cursor}))`
627+
const cursorCreatedAt = cursorLog[0].createdAt
628+
const cursorCondition = or(
629+
lt(usageLog.createdAt, cursorCreatedAt),
630+
and(eq(usageLog.createdAt, cursorCreatedAt), lt(usageLog.id, cursor))
629631
)
632+
if (cursorCondition) conditions.push(cursorCondition)
630633
}
631634
}
632635

0 commit comments

Comments
 (0)