Skip to content

Commit 65dc7e8

Browse files
feat(usage): accept X-API-Key on usage-logs list + export
/api/users/me/usage-logs and /export now use checkHybridAuth — the same auth /api/users/me/usage-limits already accepts — so external monitors can read summary.bySourceCredits (the source breakdown of usage-limits' aggregate currentPeriodCost) instead of estimating Copilot spend by subtraction. Workspace-scoped keys are pinned to their own workspace's slice of the ledger: the filter defaults to the key's workspace and an explicit mismatch 403s. Both endpoints documented in openapi-core.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz
1 parent 3e8141c commit 65dc7e8

5 files changed

Lines changed: 394 additions & 9 deletions

File tree

apps/docs/openapi-core.json

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,291 @@
10251025
},
10261026
"parameters": []
10271027
}
1028+
},
1029+
"/api/users/me/usage-logs": {
1030+
"get": {
1031+
"operationId": "listUsageLogs",
1032+
"summary": "List Usage Logs",
1033+
"description": "The authenticated account's credit-consuming usage events with a per-source summary. Accepts a session or `X-API-Key` — the same key `GET /api/users/me/usage-limits` accepts; `summary.bySourceCredits` is the source breakdown of that endpoint's aggregate `currentPeriodCost`, suitable for monitoring e.g. Copilot consumption. Workspace-scoped keys read only their own workspace's slice of the ledger.",
1034+
"tags": ["Usage"],
1035+
"security": [
1036+
{
1037+
"apiKey": []
1038+
}
1039+
],
1040+
"parameters": [
1041+
{
1042+
"name": "source",
1043+
"in": "query",
1044+
"required": false,
1045+
"schema": {
1046+
"type": "string"
1047+
},
1048+
"description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources."
1049+
},
1050+
{
1051+
"name": "workspaceId",
1052+
"in": "query",
1053+
"required": false,
1054+
"schema": {
1055+
"type": "string"
1056+
},
1057+
"description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403."
1058+
},
1059+
{
1060+
"name": "period",
1061+
"in": "query",
1062+
"required": false,
1063+
"schema": {
1064+
"enum": ["1d", "7d", "30d", "custom", "all"],
1065+
"default": "30d"
1066+
},
1067+
"description": "Relative window, `all`, or `custom` (requires `startDate`)."
1068+
},
1069+
{
1070+
"name": "startDate",
1071+
"in": "query",
1072+
"required": false,
1073+
"schema": {
1074+
"type": "string"
1075+
},
1076+
"description": "Start of a `custom` window. Any `Date`-parseable string."
1077+
},
1078+
{
1079+
"name": "endDate",
1080+
"in": "query",
1081+
"required": false,
1082+
"schema": {
1083+
"type": "string"
1084+
},
1085+
"description": "End of a `custom` window; defaults to now."
1086+
},
1087+
{
1088+
"name": "limit",
1089+
"in": "query",
1090+
"required": false,
1091+
"schema": {
1092+
"type": "integer",
1093+
"minimum": 1,
1094+
"maximum": 100,
1095+
"default": 50
1096+
}
1097+
},
1098+
{
1099+
"name": "cursor",
1100+
"in": "query",
1101+
"required": false,
1102+
"schema": {
1103+
"type": "string"
1104+
},
1105+
"description": "Opaque cursor from the previous page."
1106+
},
1107+
{
1108+
"name": "includeCredits",
1109+
"in": "query",
1110+
"required": false,
1111+
"schema": {
1112+
"type": "boolean",
1113+
"default": true
1114+
},
1115+
"description": "Set `false` to skip per-row credit apportionment when only the summary is needed."
1116+
}
1117+
],
1118+
"responses": {
1119+
"200": {
1120+
"description": "A page of usage events with the per-source credit summary.",
1121+
"content": {
1122+
"application/json": {
1123+
"schema": {
1124+
"type": "object",
1125+
"required": ["success", "logs", "summary", "pagination"],
1126+
"properties": {
1127+
"success": {
1128+
"type": "boolean"
1129+
},
1130+
"logs": {
1131+
"type": "array",
1132+
"items": {
1133+
"type": "object",
1134+
"required": [
1135+
"id",
1136+
"createdAt",
1137+
"source",
1138+
"workflowName",
1139+
"creditCost",
1140+
"dollarCost"
1141+
],
1142+
"properties": {
1143+
"id": {
1144+
"type": "string"
1145+
},
1146+
"createdAt": {
1147+
"type": "string",
1148+
"format": "date-time"
1149+
},
1150+
"source": {
1151+
"type": "string",
1152+
"description": "Usage source: `workflow`, `copilot`, and the other credit-consuming surfaces."
1153+
},
1154+
"workflowName": {
1155+
"type": ["string", "null"],
1156+
"description": "Populated only when `source` is `workflow`."
1157+
},
1158+
"creditCost": {
1159+
"type": "number",
1160+
"description": "Credit-denominated cost (1,000 credits = $5), apportioned so page rows sum exactly to the rounded page total."
1161+
},
1162+
"dollarCost": {
1163+
"type": "number",
1164+
"description": "Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event."
1165+
}
1166+
}
1167+
}
1168+
},
1169+
"summary": {
1170+
"type": "object",
1171+
"required": ["totalCredits", "bySourceCredits"],
1172+
"properties": {
1173+
"totalCredits": {
1174+
"type": "number"
1175+
},
1176+
"bySourceCredits": {
1177+
"type": "object",
1178+
"additionalProperties": {
1179+
"type": "number"
1180+
},
1181+
"description": "Credits per usage source over the whole filter — the source-aware breakdown of `usage-limits`’ aggregate `currentPeriodCost`."
1182+
}
1183+
}
1184+
},
1185+
"pagination": {
1186+
"type": "object",
1187+
"required": ["hasMore"],
1188+
"properties": {
1189+
"nextCursor": {
1190+
"type": "string"
1191+
},
1192+
"hasMore": {
1193+
"type": "boolean"
1194+
}
1195+
}
1196+
}
1197+
}
1198+
},
1199+
"example": {
1200+
"success": true,
1201+
"logs": [
1202+
{
1203+
"id": "log_1",
1204+
"createdAt": "2026-07-29T18:04:11.000Z",
1205+
"source": "copilot",
1206+
"workflowName": null,
1207+
"creditCost": 12,
1208+
"dollarCost": 0.06
1209+
}
1210+
],
1211+
"summary": {
1212+
"totalCredits": 512,
1213+
"bySourceCredits": {
1214+
"workflow": 380,
1215+
"copilot": 120,
1216+
"knowledge-base": 12
1217+
}
1218+
},
1219+
"pagination": {
1220+
"hasMore": false
1221+
}
1222+
}
1223+
}
1224+
}
1225+
},
1226+
"401": {
1227+
"$ref": "#/components/responses/Unauthorized"
1228+
},
1229+
"403": {
1230+
"$ref": "#/components/responses/Forbidden"
1231+
}
1232+
}
1233+
}
1234+
},
1235+
"/api/users/me/usage-logs/export": {
1236+
"get": {
1237+
"operationId": "exportUsageLogs",
1238+
"summary": "Export Usage Logs (CSV)",
1239+
"description": "Every usage event matching the filter as a CSV download (`Date`, `Type`, `Credits`) — the unpaginated form of `GET /api/users/me/usage-logs`, same auth and workspace-key scoping.",
1240+
"tags": ["Usage"],
1241+
"security": [
1242+
{
1243+
"apiKey": []
1244+
}
1245+
],
1246+
"parameters": [
1247+
{
1248+
"name": "source",
1249+
"in": "query",
1250+
"required": false,
1251+
"schema": {
1252+
"type": "string"
1253+
},
1254+
"description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources."
1255+
},
1256+
{
1257+
"name": "workspaceId",
1258+
"in": "query",
1259+
"required": false,
1260+
"schema": {
1261+
"type": "string"
1262+
},
1263+
"description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403."
1264+
},
1265+
{
1266+
"name": "period",
1267+
"in": "query",
1268+
"required": false,
1269+
"schema": {
1270+
"enum": ["1d", "7d", "30d", "custom", "all"],
1271+
"default": "30d"
1272+
},
1273+
"description": "Relative window, `all`, or `custom` (requires `startDate`)."
1274+
},
1275+
{
1276+
"name": "startDate",
1277+
"in": "query",
1278+
"required": false,
1279+
"schema": {
1280+
"type": "string"
1281+
},
1282+
"description": "Start of a `custom` window. Any `Date`-parseable string."
1283+
},
1284+
{
1285+
"name": "endDate",
1286+
"in": "query",
1287+
"required": false,
1288+
"schema": {
1289+
"type": "string"
1290+
},
1291+
"description": "End of a `custom` window; defaults to now."
1292+
}
1293+
],
1294+
"responses": {
1295+
"200": {
1296+
"description": "CSV attachment. `X-Export-Truncated: 1` signals the 50,000-row safety cap was hit.",
1297+
"content": {
1298+
"text/csv": {
1299+
"schema": {
1300+
"type": "string"
1301+
}
1302+
}
1303+
}
1304+
},
1305+
"401": {
1306+
"$ref": "#/components/responses/Unauthorized"
1307+
},
1308+
"403": {
1309+
"$ref": "#/components/responses/Forbidden"
1310+
}
1311+
}
1312+
}
10281313
}
10291314
},
10301315
"components": {

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { exportUsageLogsContract } from '@/lib/api/contracts/user'
44
import { parseRequest } from '@/lib/api/server'
5-
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
5+
import { checkHybridAuth } from '@/lib/auth/hybrid'
66
import {
77
getUsageCreditsByLogId,
88
getUserUsageLogs,
99
type UsageLogSource,
1010
} from '@/lib/billing/core/usage-log'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
13-
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
13+
import {
14+
resolveDateRange,
15+
resolveUsageLogsWorkspaceFilter,
16+
} from '@/app/api/users/me/usage-logs/shared'
1417
import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels'
1518

1619
const logger = createLogger('UsageLogsExportAPI')
@@ -33,7 +36,7 @@ const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits'])
3336
* (unlike, say, a workspace's full execution history).
3437
*/
3538
export const GET = withRouteHandler(async (request: NextRequest) => {
36-
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
39+
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
3740
if (!auth.success || !auth.userId) {
3841
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3942
}
@@ -42,10 +45,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4245
if (!parsed.success) return parsed.response
4346
const { source, workspaceId, period, startDate, endDate } = parsed.data.query
4447

48+
const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId)
49+
if (!workspaceFilter.ok) return workspaceFilter.response
50+
4551
const dateRange = resolveDateRange(period, startDate, endDate)
4652
const filter = {
4753
source: source as UsageLogSource | undefined,
48-
workspaceId,
54+
workspaceId: workspaceFilter.workspaceId,
4955
startDate: dateRange.startDate,
5056
endDate: dateRange.endDate,
5157
}

0 commit comments

Comments
 (0)