Skip to content

Commit 27d6923

Browse files
snowopsdevcodex
andauthored
fix(cms): aggregate report costs in Postgres (#93)
Co-Authored-By: Codex <noreply@openai.com>
1 parent 1d33b13 commit 27d6923

3 files changed

Lines changed: 101 additions & 66 deletions

File tree

cms/src/components/ops/ReportsView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function ReportsView(props: AdminViewServerProps) {
5656
user: req.user,
5757
overrideAccess: false,
5858
}),
59-
loadReportCosts(req, costWhere),
59+
loadReportCosts(req, { createdAtFrom: periodStart ?? undefined }),
6060
req.payload.find({
6161
collection: 'pipeline-runs',
6262
select: { runId: true, status: true, errorSummary: true, completedAt: true },

cms/src/lib/reportQueries.ts

Lines changed: 58 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,75 @@
1-
import type { PayloadRequest, Where } from 'payload'
1+
import { sql } from '@payloadcms/db-postgres'
2+
import { executeAccess, Forbidden, type PayloadRequest } from 'payload'
23
import type { CostReport, SpendRow } from '../components/ops/ReportsPanel'
34
import type { StageKpiRow } from './opsKpis'
45

5-
/** Traverse append-only logs by ID, keeping just aggregates between batches. */
6+
/** The report's supported filters, bound as values rather than SQL fragments. */
7+
export type ReportCostFilter = {
8+
createdAtFrom?: string
9+
pipelineRunId?: string
10+
}
11+
12+
/** Aggregate in one database snapshot without hydrating individual cost logs. */
613
export async function loadReportCosts(
714
req: PayloadRequest,
8-
where: Where,
15+
filter: ReportCostFilter,
916
): Promise<{
1017
aggregate: Pick<CostReport, 'totalUsd' | 'byStage' | 'byModel' | 'rowCount'>
1118
stages: StageKpiRow[]
1219
}> {
13-
let cursor = 0
20+
const access = await executeAccess({ req }, req.payload.collections['cost-log'].config.access.read)
21+
// CostLog grants all rows to authenticated users. Fail closed if that policy
22+
// ever becomes row-scoped, rather than silently bypassing a new constraint.
23+
if (access !== true) throw new Forbidden(req.t)
24+
25+
const conditions = [sql`true`]
26+
if (filter.createdAtFrom !== undefined) {
27+
conditions.push(sql`created_at >= ${filter.createdAtFrom}::timestamptz`)
28+
}
29+
if (filter.pipelineRunId !== undefined) {
30+
conditions.push(sql`pipeline_run_id = ${filter.pipelineRunId}`)
31+
}
32+
const grouped = await req.payload.db.drizzle.execute<{
33+
stage: string | null
34+
model: string | null
35+
calls: string
36+
cost_usd: string
37+
input_tokens: string
38+
output_tokens: string
39+
}>(sql`
40+
SELECT stage, model, count(*) AS calls,
41+
coalesce(sum(cost_usd), 0) AS cost_usd,
42+
coalesce(sum(input_tokens), 0) AS input_tokens,
43+
coalesce(sum(output_tokens), 0) AS output_tokens
44+
FROM ${req.payload.db.tables.cost_log}
45+
WHERE ${sql.join(conditions, sql` AND `)}
46+
GROUP BY stage, model
47+
`)
48+
1449
let rowCount = 0
1550
let totalUsd = 0
1651
const byModel = new Map<string, number>()
1752
const stages = new Map<string, StageKpiRow>()
18-
// Pin the upper ID so concurrent appends cannot extend a report indefinitely.
19-
const newest = await req.payload.find({
20-
collection: 'cost-log',
21-
where,
22-
sort: '-id',
23-
limit: 1,
24-
depth: 0,
25-
select: { costUsd: true },
26-
user: req.user,
27-
overrideAccess: false,
28-
})
29-
const upper = newest.docs[0]?.id ?? 0
30-
while (cursor < upper) {
31-
const result = await req.payload.find({
32-
collection: 'cost-log',
33-
where: { and: [where, { id: { greater_than: cursor, less_than_equal: upper } }] },
34-
sort: 'id',
35-
limit: 1000,
36-
// Payload 3.88's Postgres adapter still applies LIMIT with pagination off.
37-
// The ID cursor does not need a COUNT of the remaining rows on each batch.
38-
pagination: false,
39-
depth: 0,
40-
select: { costUsd: true, stage: true, model: true, inputTokens: true, outputTokens: true },
41-
user: req.user,
42-
overrideAccess: false,
43-
})
44-
if (result.docs.length === 0) break
45-
for (const row of result.docs) {
46-
rowCount += 1
47-
const usd = row.costUsd ?? 0
48-
totalUsd += usd
49-
const stage = row.stage ?? '(unknown)'
50-
const entry = stages.get(stage) ?? {
51-
stage,
52-
calls: 0,
53-
costUsd: 0,
54-
inputTokens: 0,
55-
outputTokens: 0,
56-
}
57-
entry.calls += 1
58-
entry.costUsd += usd
59-
entry.inputTokens += row.inputTokens ?? 0
60-
entry.outputTokens += row.outputTokens ?? 0
61-
stages.set(stage, entry)
62-
const model = row.model ?? '(unknown)'
63-
byModel.set(model, (byModel.get(model) ?? 0) + usd)
53+
for (const row of grouped.rows) {
54+
const calls = Number(row.calls)
55+
const usd = Number(row.cost_usd)
56+
rowCount += calls
57+
totalUsd += usd
58+
const stage = row.stage ?? '(unknown)'
59+
const entry = stages.get(stage) ?? {
60+
stage,
61+
calls: 0,
62+
costUsd: 0,
63+
inputTokens: 0,
64+
outputTokens: 0,
6465
}
65-
cursor = result.docs[result.docs.length - 1].id
66+
entry.calls += calls
67+
entry.costUsd += usd
68+
entry.inputTokens += Number(row.input_tokens)
69+
entry.outputTokens += Number(row.output_tokens)
70+
stages.set(stage, entry)
71+
const model = row.model ?? '(unknown)'
72+
byModel.set(model, (byModel.get(model) ?? 0) + usd)
6673
}
6774
const stageRows = [...stages.values()].sort((a, b) => b.costUsd - a.costUsd)
6875
const modelRows: SpendRow[] = [...byModel]

cms/tests/int/adminPerformance.int.spec.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('admin queries against Postgres', () => {
134134
).toBe(false)
135135
})
136136

137-
it('accumulates all cost rows beyond 5,000 without reading request bodies', async () => {
137+
it('aggregates all cost rows beyond 5,000 without hydrating individual logs', async () => {
138138
// Real inserts exercise Payload's stored numeric types and pagination.
139139
for (let offset = 0; offset < 5025; offset += 25) {
140140
await Promise.all(
@@ -155,11 +155,11 @@ describe('admin queries against Postgres', () => {
155155
),
156156
)
157157
}
158-
// Observe real query results, not mocked pagination. Correct totals alone
159-
// would also pass if an adapter accidentally returned all rows at once.
158+
// Totals must remain correct without transferring thousands of Payload
159+
// documents into the report request, regardless of machine speed.
160160
const find = vi.spyOn(payload, 'find')
161161
try {
162-
const result = await loadReportCosts(req, { pipelineRunId: { equals: prefix } })
162+
const result = await loadReportCosts(req, { pipelineRunId: prefix })
163163
expect(result.aggregate.rowCount).toBe(5026)
164164
expect(result.aggregate.totalUsd).toBe(5026 * 0.25)
165165
expect(result.stages[0]).toMatchObject({
@@ -168,21 +168,49 @@ describe('admin queries against Postgres', () => {
168168
outputTokens: 5026,
169169
})
170170
expect(JSON.stringify(result)).not.toContain('UNUSED_COST_REQUEST')
171-
const batchSizes: number[] = []
172-
for (const [index, [options]] of find.mock.calls.entries()) {
173-
if (options.collection === 'cost-log' && options.limit === 1000) {
174-
const batch = await find.mock.results[index].value
175-
batchSizes.push(batch.docs.length)
176-
expect(JSON.stringify(batch.docs)).not.toContain('UNUSED_COST_REQUEST')
177-
}
178-
}
179-
expect(batchSizes).toEqual([1000, 1000, 1000, 1000, 1000, 26])
171+
expect(find.mock.calls.filter(([options]) => options.collection === 'cost-log')).toHaveLength(0)
180172
} finally {
181173
find.mockRestore()
182174
}
183-
const empty = await loadReportCosts(req, { pipelineRunId: { equals: `${prefix}-empty` } })
175+
const empty = await loadReportCosts(req, { pipelineRunId: `${prefix}-empty` })
184176
expect(empty.aggregate.rowCount).toBe(0)
185177
}, 120000)
178+
179+
it('preserves stage/model totals, null buckets, and inclusive period filters', async () => {
180+
const pipelineRunId = `${prefix}-quoted'run`
181+
const rows = [
182+
{ stage: 'generate' as const, model: 'alpha', costUsd: 0.5, inputTokens: 5, createdAt: '2026-01-01T00:00:00.000Z' },
183+
{ stage: 'generate' as const, model: 'alpha', costUsd: 0.25, outputTokens: 2 },
184+
{ stage: 'factCheck' as const, model: 'beta', costUsd: 1, inputTokens: 8, outputTokens: 3 },
185+
{},
186+
{ model: '(unknown)', costUsd: 0.125 },
187+
]
188+
for (const row of rows) {
189+
await payload.create({
190+
collection: 'cost-log',
191+
overrideAccess: true,
192+
data: { pipelineRunId, createdAt: '2026-02-01T00:00:00.000Z', ...row },
193+
})
194+
}
195+
const all = await loadReportCosts(req, { pipelineRunId })
196+
expect(all.aggregate).toEqual({
197+
rowCount: 5,
198+
totalUsd: 1.875,
199+
byStage: [{ label: 'factCheck', usd: 1 }, { label: 'generate', usd: 0.75 }, { label: '(unknown)', usd: 0.125 }],
200+
byModel: [{ label: 'beta', usd: 1 }, { label: 'alpha', usd: 0.75 }, { label: '(unknown)', usd: 0.125 }],
201+
})
202+
expect(all.stages).toEqual([
203+
{ stage: 'factCheck', calls: 1, costUsd: 1, inputTokens: 8, outputTokens: 3 },
204+
{ stage: 'generate', calls: 2, costUsd: 0.75, inputTokens: 5, outputTokens: 2 },
205+
{ stage: '(unknown)', calls: 2, costUsd: 0.125, inputTokens: 0, outputTokens: 0 },
206+
])
207+
const recent = await loadReportCosts(req, { pipelineRunId, createdAtFrom: '2026-02-01T00:00:00.000Z' })
208+
expect(recent.aggregate.rowCount).toBe(4)
209+
expect(recent.aggregate.totalUsd).toBe(1.375)
210+
const empty = await loadReportCosts(req, { pipelineRunId, createdAtFrom: '2026-03-01T00:00:00.000Z' })
211+
expect(empty.aggregate).toEqual({ rowCount: 0, totalUsd: 0, byStage: [], byModel: [] })
212+
expect(empty.stages).toEqual([])
213+
})
186214
})
187215

188216
it('report summaries preserve missing decisions, QA denominators, and spend', () => {

0 commit comments

Comments
 (0)