Skip to content

Commit 1a5cf49

Browse files
fix(attribution): workspace id attr should be best-effort for self hosted users (#4953)
1 parent 3c22e1e commit 1a5cf49

3 files changed

Lines changed: 91 additions & 13 deletions

File tree

apps/sim/app/api/billing/update-cost/route.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { createMockRequest } from '@sim/testing'
4+
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
@@ -16,6 +16,8 @@ const {
1616
mockCheckAndBillOverageThreshold: vi.fn(),
1717
}))
1818

19+
vi.mock('@sim/db', () => dbChainMock)
20+
1921
vi.mock('@/lib/copilot/request/http', () => ({
2022
checkInternalApiKey: mockCheckInternalApiKey,
2123
}))
@@ -47,10 +49,12 @@ import { POST } from '@/app/api/billing/update-cost/route'
4749
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
4850
beforeEach(() => {
4951
vi.clearAllMocks()
52+
resetDbChainMock()
5053
mockCheckInternalApiKey.mockReturnValue({ success: true })
5154
mockRecordUsage.mockResolvedValue(undefined)
5255
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
5356
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
57+
dbChainMockFns.limit.mockResolvedValue([{ id: 'ws-1' }])
5458
})
5559

5660
it('stamps workspaceId onto recorded usage when provided (no idempotency key)', async () => {
@@ -120,15 +124,45 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
120124
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
121125
})
122126

123-
it('rejects with 400 when workspaceId is omitted (contract-required, fail loud)', async () => {
127+
it('records unattributed when workspaceId is omitted (headless client)', async () => {
124128
const res = await POST(
125129
createMockRequest(
126130
'POST',
127131
{ userId: 'user-1', cost: 0.5, model: 'gpt', source: 'copilot' },
128132
{ 'x-api-key': 'internal' }
129133
)
130134
)
131-
expect(res.status).toBe(400)
132-
expect(mockRecordUsage).not.toHaveBeenCalled()
135+
expect(res.status).toBe(200)
136+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
137+
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
138+
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
139+
userId: 'user-1',
140+
workspaceId: undefined,
141+
})
142+
})
143+
144+
it('records unattributed when the workspace does not exist in this deployment (self-hosted client)', async () => {
145+
dbChainMockFns.limit.mockResolvedValue([])
146+
const res = await POST(
147+
createMockRequest(
148+
'POST',
149+
{
150+
userId: 'user-1',
151+
cost: 0.5,
152+
model: 'claude-opus-4.8',
153+
source: 'workspace-chat',
154+
workspaceId: 'self-hosted-ws',
155+
idempotencyKey: 'msg-1-billing',
156+
},
157+
{ 'x-api-key': 'internal' }
158+
)
159+
)
160+
expect(res.status).toBe(200)
161+
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
162+
expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({
163+
userId: 'user-1',
164+
workspaceId: undefined,
165+
eventKey: 'update-cost:msg-1-billing',
166+
})
133167
})
134168
})

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { Span } from '@opentelemetry/api'
2+
import { db } from '@sim/db'
3+
import { workspace } from '@sim/db/schema'
24
import { createLogger } from '@sim/logger'
3-
import { toError } from '@sim/utils/errors'
5+
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
6+
import { eq } from 'drizzle-orm'
47
import { type NextRequest, NextResponse } from 'next/server'
58
import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
69
import { parseRequest } from '@/lib/api/server'
@@ -17,6 +20,35 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1720

1821
const logger = createLogger('BillingUpdateCostAPI')
1922

23+
/**
24+
* Resolves the request-supplied workspace to one that exists in this
25+
* deployment. Workspace attribution on the usage ledger is best-effort:
26+
* self-hosted and headless clients bill through this endpoint with workspace
27+
* IDs from their own databases, and `usage_log.workspace_id` carries an FK to
28+
* `workspace`, so stamping a foreign ID would fail the entire flush with an
29+
* FK violation and strand real cost in the caller's dead-letter queue.
30+
* Unknown workspaces are recorded unattributed instead — billing is keyed on
31+
* the user's billing entity and never depends on the workspace.
32+
*/
33+
async function resolveAttributableWorkspaceId(
34+
requestId: string,
35+
workspaceId: string | undefined
36+
): Promise<string | undefined> {
37+
if (!workspaceId) return undefined
38+
39+
const [row] = await db
40+
.select({ id: workspace.id })
41+
.from(workspace)
42+
.where(eq(workspace.id, workspaceId))
43+
.limit(1)
44+
if (row) return row.id
45+
46+
logger.warn(`[${requestId}] Workspace not found in this deployment; recording unattributed`, {
47+
workspaceId,
48+
})
49+
return undefined
50+
}
51+
2052
/**
2153
* POST /api/billing/update-cost
2254
* Update user cost with a pre-calculated cost value (internal API key auth required)
@@ -129,6 +161,8 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
129161
source,
130162
})
131163

164+
const attributedWorkspaceId = await resolveAttributableWorkspaceId(requestId, workspaceId)
165+
132166
// Go sends the request's CUMULATIVE cost, possibly more than once (a
133167
// mid-loop provider-error flush, then the recovered terminal flush, plus
134168
// abort-race duplicates). Record it as a monotonic top-up: one ledger row
@@ -141,7 +175,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
141175
if (idempotencyKey) {
142176
const result = await recordCumulativeUsage({
143177
userId,
144-
workspaceId,
178+
workspaceId: attributedWorkspaceId,
145179
source,
146180
model,
147181
cost,
@@ -160,7 +194,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
160194
} else {
161195
await recordUsage({
162196
userId,
163-
workspaceId,
197+
workspaceId: attributedWorkspaceId,
164198
entries: [
165199
{
166200
category: 'model',
@@ -229,8 +263,16 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
229263
} catch (error) {
230264
const duration = Date.now() - startTime
231265

266+
// Surface the underlying Postgres failure (e.g. 23503 FK violation vs a
267+
// lock timeout) — Drizzle's "Failed query" wrapper alone cannot
268+
// distinguish them, which made the dead-workspace incident undiagnosable
269+
// from logs.
270+
const pgCode = getPostgresErrorCode(error)
271+
const pgConstraint = getPostgresConstraintName(error)
232272
logger.error(`[${requestId}] Cost update failed`, {
233273
error: toError(error).message,
274+
...(pgCode && { pgCode }),
275+
...(pgConstraint && { pgConstraint }),
234276
stack: error instanceof Error ? error.stack : undefined,
235277
duration,
236278
})

apps/sim/lib/api/contracts/subscription.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ export const billingUpdateCostBodySchema = z.object({
2121
.default('copilot'),
2222
idempotencyKey: z.string().min(1).optional(),
2323
/**
24-
* Originating workspace. Stamped onto `usage_log.workspaceId` so mothership/
25-
* copilot cost is attributable to org-owned workspaces (per-member usage).
26-
* Required: the Go mothership always resolves a workspace for a billed request,
27-
* so a missing value is a bug to surface (fail loud) rather than silently drop
28-
* the cost from the per-member meter.
24+
* Originating workspace, used for org-workspace cost attribution on hosted
25+
* Sim. Best-effort by design: self-hosted and headless clients bill through
26+
* this endpoint with workspace IDs that exist only in their own deployment
27+
* (or with none at all — the Go client omits the field when empty), so the
28+
* value is optional and the route only stamps it onto the ledger when it
29+
* resolves to a workspace in this deployment. Billing is keyed on the
30+
* user's billing entity and must never fail over attribution metadata.
2931
*/
30-
workspaceId: z.string().min(1),
32+
workspaceId: z.string().min(1).optional(),
3133
})
3234
export type BillingUpdateCostBody = z.input<typeof billingUpdateCostBodySchema>
3335

0 commit comments

Comments
 (0)