Skip to content

Commit 35a57bf

Browse files
authored
feat(audit): added audit log for billing line items (#2500)
* feat(audit): added audit log for billing line items * remove migration * reran migrations after resolving merge conflict * ack PR comment
1 parent f8678b1 commit 35a57bf

File tree

9 files changed

+9110
-45
lines changed

9 files changed

+9110
-45
lines changed

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
33
import { eq, sql } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { z } from 'zod'
6+
import { logModelUsage } from '@/lib/billing/core/usage-log'
67
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
78
import { checkInternalApiKey } from '@/lib/copilot/utils'
89
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -14,6 +15,9 @@ const logger = createLogger('BillingUpdateCostAPI')
1415
const UpdateCostSchema = z.object({
1516
userId: z.string().min(1, 'User ID is required'),
1617
cost: z.number().min(0, 'Cost must be a non-negative number'),
18+
model: z.string().min(1, 'Model is required'),
19+
inputTokens: z.number().min(0).default(0),
20+
outputTokens: z.number().min(0).default(0),
1721
})
1822

1923
/**
@@ -71,11 +75,12 @@ export async function POST(req: NextRequest) {
7175
)
7276
}
7377

74-
const { userId, cost } = validation.data
78+
const { userId, cost, model, inputTokens, outputTokens } = validation.data
7579

7680
logger.info(`[${requestId}] Processing cost update`, {
7781
userId,
7882
cost,
83+
model,
7984
})
8085

8186
// Check if user stats record exists (same as ExecutionLogger)
@@ -107,6 +112,16 @@ export async function POST(req: NextRequest) {
107112
addedCost: cost,
108113
})
109114

115+
// Log usage for complete audit trail
116+
await logModelUsage({
117+
userId,
118+
source: 'copilot',
119+
model,
120+
inputTokens,
121+
outputTokens,
122+
cost,
123+
})
124+
110125
// Check if user has hit overage threshold and bill incrementally
111126
await checkAndBillOverageThreshold(userId)
112127

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { checkHybridAuth } from '@/lib/auth/hybrid'
4+
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
5+
import { createLogger } from '@/lib/logs/console/logger'
6+
7+
const logger = createLogger('UsageLogsAPI')
8+
9+
const QuerySchema = z.object({
10+
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
11+
workspaceId: z.string().optional(),
12+
period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'),
13+
limit: z.coerce.number().min(1).max(100).optional().default(50),
14+
cursor: z.string().optional(),
15+
})
16+
17+
/**
18+
* GET /api/users/me/usage-logs
19+
* Get usage logs for the authenticated user
20+
*/
21+
export async function GET(req: NextRequest) {
22+
try {
23+
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
24+
25+
if (!auth.success || !auth.userId) {
26+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const userId = auth.userId
30+
31+
const { searchParams } = new URL(req.url)
32+
const queryParams = {
33+
source: searchParams.get('source') || undefined,
34+
workspaceId: searchParams.get('workspaceId') || undefined,
35+
period: searchParams.get('period') || '30d',
36+
limit: searchParams.get('limit') || '50',
37+
cursor: searchParams.get('cursor') || undefined,
38+
}
39+
40+
const validation = QuerySchema.safeParse(queryParams)
41+
42+
if (!validation.success) {
43+
return NextResponse.json(
44+
{
45+
error: 'Invalid query parameters',
46+
details: validation.error.issues,
47+
},
48+
{ status: 400 }
49+
)
50+
}
51+
52+
const { source, workspaceId, period, limit, cursor } = validation.data
53+
54+
let startDate: Date | undefined
55+
const endDate = new Date()
56+
57+
if (period !== 'all') {
58+
startDate = new Date()
59+
switch (period) {
60+
case '1d':
61+
startDate.setDate(startDate.getDate() - 1)
62+
break
63+
case '7d':
64+
startDate.setDate(startDate.getDate() - 7)
65+
break
66+
case '30d':
67+
startDate.setDate(startDate.getDate() - 30)
68+
break
69+
}
70+
}
71+
72+
const result = await getUserUsageLogs(userId, {
73+
source: source as UsageLogSource | undefined,
74+
workspaceId,
75+
startDate,
76+
endDate,
77+
limit,
78+
cursor,
79+
})
80+
81+
logger.debug('Retrieved usage logs', {
82+
userId,
83+
source,
84+
period,
85+
logCount: result.logs.length,
86+
hasMore: result.pagination.hasMore,
87+
})
88+
89+
return NextResponse.json({
90+
success: true,
91+
...result,
92+
})
93+
} catch (error) {
94+
logger.error('Failed to get usage logs', {
95+
error: error instanceof Error ? error.message : String(error),
96+
})
97+
98+
return NextResponse.json(
99+
{
100+
error: 'Failed to retrieve usage logs',
101+
},
102+
{ status: 500 }
103+
)
104+
}
105+
}

apps/sim/app/api/wand/route.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { eq, sql } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import OpenAI, { AzureOpenAI } from 'openai'
66
import { getSession } from '@/lib/auth'
7+
import { logModelUsage } from '@/lib/billing/core/usage-log'
78
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
89
import { env } from '@/lib/core/config/env'
910
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -88,7 +89,7 @@ async function updateUserStatsForWand(
8889

8990
try {
9091
const [workflowRecord] = await db
91-
.select({ userId: workflow.userId })
92+
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
9293
.from(workflow)
9394
.where(eq(workflow.id, workflowId))
9495
.limit(1)
@@ -101,6 +102,7 @@ async function updateUserStatsForWand(
101102
}
102103

103104
const userId = workflowRecord.userId
105+
const workspaceId = workflowRecord.workspaceId
104106
const totalTokens = usage.total_tokens || 0
105107
const promptTokens = usage.prompt_tokens || 0
106108
const completionTokens = usage.completion_tokens || 0
@@ -137,6 +139,17 @@ async function updateUserStatsForWand(
137139
costAdded: costToStore,
138140
})
139141

142+
await logModelUsage({
143+
userId,
144+
source: 'wand',
145+
model: modelName,
146+
inputTokens: promptTokens,
147+
outputTokens: completionTokens,
148+
cost: costToStore,
149+
workspaceId: workspaceId ?? undefined,
150+
workflowId,
151+
})
152+
140153
await checkAndBillOverageThreshold(userId)
141154
} catch (error) {
142155
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)

0 commit comments

Comments
 (0)