Skip to content

Commit 843a194

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(billing): record function sandbox usage
1 parent 836b87f commit 843a194

25 files changed

Lines changed: 1918 additions & 77 deletions

apps/sim/app/api/webhooks/outbox/process/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal'
88
import { enterpriseOwnerClaimOutboxHandlers } from '@/lib/billing/enterprise-owner-claim'
99
import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning'
1010
import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation'
11+
import { sandboxUsageOutboxHandlers } from '@/lib/billing/sandbox-usage-outbox'
1112
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
1213
import { processOutboxEvents } from '@/lib/core/outbox/service'
1314
import { generateRequestId } from '@/lib/core/utils/request'
@@ -29,6 +30,7 @@ const handlers = {
2930
...adminMemberOperationOutboxHandlers,
3031
...billingOutboxHandlers,
3132
...membershipBillingOutboxHandlers,
33+
...sandboxUsageOutboxHandlers,
3234
...enterpriseIssuanceOutboxHandlers,
3335
...enterpriseOwnerClaimOutboxHandlers,
3436
...invitationMigrationOutboxHandlers,

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe('recordUsage', () => {
8686
})
8787

8888
it('commits canonical usage rows with deterministic event keys and billing scope', async () => {
89-
await recordUsage({
89+
const insertedCost = await recordUsage({
9090
userId: 'external-actor',
9191
workspaceId: 'workspace-1',
9292
billingEntity: { type: 'organization', id: 'workspace-org' },
@@ -124,9 +124,34 @@ describe('recordUsage', () => {
124124
expect(mockOnConflictDoNothing.mock.calls[0][0]).toMatchObject({
125125
target: usageLog.eventKey,
126126
})
127+
expect(insertedCost).toBeCloseTo(0.3, 8)
127128
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
128129
})
129130

131+
it('returns zero when idempotency skips a duplicate event', async () => {
132+
mockReturning.mockResolvedValueOnce([])
133+
134+
const insertedCost = await recordUsage({
135+
userId: 'user-1',
136+
billingEntity: { type: 'user', id: 'user-1' },
137+
billingPeriod: {
138+
start: new Date('2026-05-01T00:00:00.000Z'),
139+
end: new Date('2026-06-01T00:00:00.000Z'),
140+
},
141+
entries: [
142+
{
143+
category: 'tool',
144+
source: 'workflow',
145+
description: 'Code sandbox',
146+
cost: 0.1,
147+
eventKey: 'sandbox-event',
148+
},
149+
],
150+
})
151+
152+
expect(insertedCost).toBe(0)
153+
})
154+
130155
it('uses pre-resolved billing context without loading subscriptions', async () => {
131156
await recordUsage({
132157
userId: 'user-1',

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ export async function getStampedPeriodRangeUsageCostByUser(
417417
* as the pre-cutover period baseline and for low-frequency billing trackers,
418418
* but usage writes no longer contend on the user_stats row.
419419
*/
420-
export async function recordUsage(params: RecordUsageParams): Promise<void> {
420+
export async function recordUsage(params: RecordUsageParams): Promise<number> {
421421
// The usage ledger is written regardless of BILLING_ENABLED so it is the
422422
// single, universal source of truth for cost (including self-hosted, where
423423
// it powers the logs-page cost display). Billing *enforcement* (Stripe /
@@ -441,7 +441,7 @@ export async function recordUsage(params: RecordUsageParams): Promise<void> {
441441
)
442442

443443
if (validEntries.length === 0) {
444-
return
444+
return 0
445445
}
446446

447447
if (workspaceId && (!billingEntity || !billingPeriod)) {
@@ -514,6 +514,8 @@ export async function recordUsage(params: RecordUsageParams): Promise<void> {
514514
entryCount: validEntries.length,
515515
sources: [...new Set(validEntries.map((e) => e.source))],
516516
})
517+
518+
return insertedCost
517519
}
518520

519521
/**
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import { createSandboxPricingSnapshot, priceSandboxUsage } from '@/lib/billing/sandbox-pricing'
7+
8+
const STARTED_AT = new Date('2026-08-27T12:00:00.000Z')
9+
const ONE_HOUR_LATER = new Date('2026-08-27T13:00:00.000Z')
10+
11+
describe('sandbox pricing', () => {
12+
it.each([
13+
['e2b', 0.1656],
14+
['daytona', 0.16668],
15+
] as const)('prices one hour of the %s Function profile', (provider, expectedRawCost) => {
16+
const priced = priceSandboxUsage(
17+
createSandboxPricingSnapshot(provider, 1),
18+
STARTED_AT,
19+
ONE_HOUR_LATER,
20+
ONE_HOUR_LATER
21+
)
22+
23+
expect(priced.durationMs).toBe(3_600_000)
24+
expect(priced.rawCost).toBeCloseTo(expectedRawCost, 10)
25+
expect(priced.billedCost).toBe(expectedRawCost)
26+
})
27+
28+
it('applies the multiplier once and rounds to eight decimals', () => {
29+
const priced = priceSandboxUsage(
30+
createSandboxPricingSnapshot('e2b', 2.5),
31+
STARTED_AT,
32+
new Date(STARTED_AT.getTime() + 1234),
33+
ONE_HOUR_LATER
34+
)
35+
36+
expect(priced.billedCost).toBe(Number.parseFloat((priced.rawCost * 2.5).toFixed(8)))
37+
})
38+
39+
it('caps duration at provider expiry and rejects a non-positive multiplier', () => {
40+
const expiresAt = new Date(STARTED_AT.getTime() + 15_000)
41+
const priced = priceSandboxUsage(
42+
createSandboxPricingSnapshot('daytona', 1),
43+
STARTED_AT,
44+
ONE_HOUR_LATER,
45+
expiresAt
46+
)
47+
48+
expect(priced.durationMs).toBe(15_000)
49+
expect(() => createSandboxPricingSnapshot('e2b', 0)).toThrow('finite positive')
50+
})
51+
})
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { getCostMultiplier } from '@/lib/core/config/env-flags'
2+
import {
3+
FUNCTION_DAYTONA_DISK_GB,
4+
FUNCTION_SANDBOX_CPU_COUNT,
5+
FUNCTION_SANDBOX_MEMORY_GB,
6+
} from '@/lib/execution/remote-sandbox/function-resources'
7+
import type { SandboxProviderId } from '@/lib/execution/remote-sandbox/types'
8+
9+
const PRICING_V1_VERIFIED_AT = '2026-08-27' as const
10+
const E2B_CPU_USD_PER_VCPU_SECOND = 0.000014
11+
const E2B_MEMORY_USD_PER_GIB_SECOND = 0.0000045
12+
const DAYTONA_CPU_USD_PER_VCPU_SECOND = 0.0504 / 3600
13+
const DAYTONA_MEMORY_USD_PER_GIB_SECOND = 0.0162 / 3600
14+
const DAYTONA_DISK_USD_PER_GIB_SECOND = 0.000108 / 3600
15+
export interface SandboxResourceProfile {
16+
vcpu: number
17+
memoryGiB: number
18+
diskGiB: number
19+
}
20+
21+
export interface SandboxUnitRates {
22+
cpuUsdPerVcpuSecond: number
23+
memoryUsdPerGiBSecond: number
24+
diskUsdPerGiBSecond: number
25+
}
26+
27+
export interface SandboxPricingSnapshot {
28+
version: 1
29+
provider: SandboxProviderId
30+
verifiedAt: typeof PRICING_V1_VERIFIED_AT
31+
multiplier: number
32+
resources: SandboxResourceProfile
33+
rates: SandboxUnitRates
34+
}
35+
36+
export interface PricedSandboxUsage {
37+
durationMs: number
38+
sandboxSeconds: number
39+
vcpuSeconds: number
40+
memoryGiBSeconds: number
41+
diskGiBSeconds: number
42+
rawCost: number
43+
billedCost: number
44+
}
45+
46+
const PRICING_BY_PROVIDER: Record<
47+
SandboxProviderId,
48+
Pick<SandboxPricingSnapshot, 'resources' | 'rates'>
49+
> = {
50+
e2b: {
51+
resources: {
52+
vcpu: FUNCTION_SANDBOX_CPU_COUNT,
53+
memoryGiB: FUNCTION_SANDBOX_MEMORY_GB,
54+
diskGiB: 0,
55+
},
56+
rates: {
57+
cpuUsdPerVcpuSecond: E2B_CPU_USD_PER_VCPU_SECOND,
58+
memoryUsdPerGiBSecond: E2B_MEMORY_USD_PER_GIB_SECOND,
59+
diskUsdPerGiBSecond: 0,
60+
},
61+
},
62+
daytona: {
63+
resources: {
64+
vcpu: FUNCTION_SANDBOX_CPU_COUNT,
65+
memoryGiB: FUNCTION_SANDBOX_MEMORY_GB,
66+
diskGiB: FUNCTION_DAYTONA_DISK_GB,
67+
},
68+
rates: {
69+
cpuUsdPerVcpuSecond: DAYTONA_CPU_USD_PER_VCPU_SECOND,
70+
memoryUsdPerGiBSecond: DAYTONA_MEMORY_USD_PER_GIB_SECOND,
71+
diskUsdPerGiBSecond: DAYTONA_DISK_USD_PER_GIB_SECOND,
72+
},
73+
},
74+
}
75+
76+
function assertFiniteNonNegative(value: number, field: string): void {
77+
if (!Number.isFinite(value) || value < 0) {
78+
throw new Error(`Sandbox pricing ${field} must be a finite non-negative number`)
79+
}
80+
}
81+
82+
function assertFinitePositive(value: number, field: string): void {
83+
if (!Number.isFinite(value) || value <= 0) {
84+
throw new Error(`Sandbox pricing ${field} must be a finite positive number`)
85+
}
86+
}
87+
88+
export function createSandboxPricingSnapshot(
89+
provider: SandboxProviderId,
90+
multiplier = getCostMultiplier()
91+
): SandboxPricingSnapshot {
92+
assertFinitePositive(multiplier, 'multiplier')
93+
const pricing = PRICING_BY_PROVIDER[provider]
94+
return {
95+
version: 1,
96+
provider,
97+
verifiedAt: PRICING_V1_VERIFIED_AT,
98+
multiplier,
99+
resources: { ...pricing.resources },
100+
rates: { ...pricing.rates },
101+
}
102+
}
103+
104+
export function assertSandboxPricingSnapshot(value: unknown): SandboxPricingSnapshot {
105+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
106+
throw new Error('Sandbox pricing snapshot must be an object')
107+
}
108+
const snapshot = value as Partial<SandboxPricingSnapshot>
109+
if (
110+
snapshot.version !== 1 ||
111+
(snapshot.provider !== 'e2b' && snapshot.provider !== 'daytona') ||
112+
snapshot.verifiedAt !== PRICING_V1_VERIFIED_AT ||
113+
!snapshot.resources ||
114+
!snapshot.rates
115+
) {
116+
throw new Error('Sandbox pricing snapshot is invalid')
117+
}
118+
assertFinitePositive(snapshot.multiplier ?? Number.NaN, 'multiplier')
119+
assertFiniteNonNegative(snapshot.resources.vcpu, 'resources.vcpu')
120+
assertFiniteNonNegative(snapshot.resources.memoryGiB, 'resources.memoryGiB')
121+
assertFiniteNonNegative(snapshot.resources.diskGiB, 'resources.diskGiB')
122+
assertFiniteNonNegative(snapshot.rates.cpuUsdPerVcpuSecond, 'rates.cpuUsdPerVcpuSecond')
123+
assertFiniteNonNegative(snapshot.rates.memoryUsdPerGiBSecond, 'rates.memoryUsdPerGiBSecond')
124+
assertFiniteNonNegative(snapshot.rates.diskUsdPerGiBSecond, 'rates.diskUsdPerGiBSecond')
125+
return snapshot as SandboxPricingSnapshot
126+
}
127+
128+
export function priceSandboxUsage(
129+
pricingValue: SandboxPricingSnapshot,
130+
startedAt: Date,
131+
endedAt: Date,
132+
expiresAt: Date
133+
): PricedSandboxUsage {
134+
const pricing = assertSandboxPricingSnapshot(pricingValue)
135+
const durationMs = Math.max(
136+
0,
137+
Math.min(endedAt.getTime(), expiresAt.getTime()) - startedAt.getTime()
138+
)
139+
const sandboxSeconds = durationMs / 1000
140+
const vcpuSeconds = sandboxSeconds * pricing.resources.vcpu
141+
const memoryGiBSeconds = sandboxSeconds * pricing.resources.memoryGiB
142+
const diskGiBSeconds = sandboxSeconds * pricing.resources.diskGiB
143+
const rawCost =
144+
vcpuSeconds * pricing.rates.cpuUsdPerVcpuSecond +
145+
memoryGiBSeconds * pricing.rates.memoryUsdPerGiBSecond +
146+
diskGiBSeconds * pricing.rates.diskUsdPerGiBSecond
147+
148+
return {
149+
durationMs,
150+
sandboxSeconds,
151+
vcpuSeconds,
152+
memoryGiBSeconds,
153+
diskGiBSeconds,
154+
rawCost,
155+
billedCost: Number.parseFloat((rawCost * pricing.multiplier).toFixed(8)),
156+
}
157+
}

0 commit comments

Comments
 (0)