Skip to content

Commit 9df26f2

Browse files
committed
improvement(analytics): attribute every request, credential, and queued run
1 parent 95e0607 commit 9df26f2

29 files changed

Lines changed: 513 additions & 87 deletions

apps/docs/content/docs/cli/usage-data.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ One event per command, after the command finishes:
2020
| Duration | `1432` ms | From process start to completion |
2121
| CLI, Node, OS, CPU | `2.1.2`, `22.14.0`, `darwin`, `arm64` | |
2222
| Terminal and CI | `is_tty`, `is_ci` | Whether stdout is a terminal, whether a CI variable is set |
23-
| Coding agent | `claude-code` | When the CLI runs inside an AI coding agent's shell |
23+
| Coding agent | `claude-code`, or `none` | The AI coding agent whose shell the CLI runs in, if any |
2424
| Deployment kind | `hosted` or `self_hosted` | Never the address |
2525
| Device and session ids | random UUIDs | See below |
2626

apps/sim/app/api/v1/auth.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
import {
2-
describePrincipalAuth,
3-
type PersonalApiKeyPrincipal,
4-
type WorkspaceApiKeyPrincipal,
5-
} from '@sim/auth/principal'
6-
import { createLogger, setRequestAuth } from '@sim/logger'
1+
import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
2+
import { createLogger } from '@sim/logger'
73
import type { NextRequest } from 'next/server'
84
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
95
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
@@ -76,7 +72,6 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
7672
}
7773

7874
await updateApiKeyLastUsed(result.keyId)
79-
setRequestAuth(describePrincipalAuth(principal))
8075

8176
return {
8277
authenticated: true,

apps/sim/background/schedule-execution.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
workflowExecutionLogs,
77
workflowSchedule,
88
} from '@sim/db'
9-
import { createLogger, runWithRequestContext } from '@sim/logger'
9+
import { createLogger, type RequestContext, runWithRequestContext } from '@sim/logger'
1010
import { describeError, toError } from '@sim/utils/errors'
1111
import { generateId } from '@sim/utils/id'
1212
import { task, timeout } from '@trigger.dev/sdk'
@@ -822,7 +822,12 @@ export async function executeScheduleJob(
822822
const scheduledFor = payload.scheduledFor ? new Date(payload.scheduledFor) : null
823823

824824
try {
825-
return await runWithRequestContext({ requestId }, async () => {
825+
/** A trigger, not a client, started this run. */
826+
const requestContext: RequestContext = {
827+
requestId,
828+
client: { surface: 'schedule', source: 'trigger' },
829+
}
830+
return await runWithRequestContext(requestContext, async () => {
826831
logger.info(`[${requestId}] Starting schedule execution`, {
827832
scheduleId: payload.scheduleId,
828833
workflowId: payload.workflowId,

apps/sim/background/webhook-execution.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '@sim/auth/principal'
77
import { db } from '@sim/db'
88
import { account, webhook } from '@sim/db/schema'
9-
import { createLogger, runWithRequestContext } from '@sim/logger'
9+
import { createLogger, type RequestContext, runWithRequestContext } from '@sim/logger'
1010
import { toError } from '@sim/utils/errors'
1111
import { interruptibleSleep } from '@sim/utils/helpers'
1212
import { generateId } from '@sim/utils/id'
@@ -556,7 +556,12 @@ export async function executeWebhookJob(
556556
})
557557
}
558558

559-
return await runWithRequestContext({ requestId }, async () => {
559+
/** A trigger, not a client, started this run. */
560+
const requestContext: RequestContext = {
561+
requestId,
562+
client: { surface: 'webhook', source: 'trigger' },
563+
}
564+
return await runWithRequestContext(requestContext, async () => {
560565
logger.info(`[${requestId}] Starting webhook execution`, {
561566
webhookId: authenticatedPayload.webhookId,
562567
workflowId: authenticatedPayload.workflowId,

apps/sim/background/workflow-execution.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getTimeoutErrorMessage,
2626
RESERVATION_TTL_BUFFER_MS,
2727
} from '@/lib/core/execution-limits'
28+
import type { RequestAttribution } from '@/lib/core/utils/request-attribution'
2829
import { preprocessExecution } from '@/lib/execution/preprocessing'
2930
import { LoggingSession } from '@/lib/logs/execution/logging-session'
3031
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -76,6 +77,8 @@ export type WorkflowExecutionPayload = {
7677
correlation?: AsyncExecutionCorrelation
7778
metadata?: Record<string, any>
7879
callChain?: string[]
80+
/** Who queued the run, restored into the job's context so its events stay attributed. */
81+
attribution?: RequestAttribution
7982
executionMode?: 'sync' | 'stream' | 'async'
8083
/** Upstream preprocessing already consumed rate-limit quota and owns the usage reservation. */
8184
admissionCompleted?: boolean
@@ -177,7 +180,7 @@ export async function executeWorkflowJob(
177180
}
178181
}
179182

180-
return await runWithRequestContext({ requestId }, async () => {
183+
return await runWithRequestContext({ requestId, ...payload.attribution }, async () => {
181184
logger.info(`[${requestId}] Starting workflow execution job: ${workflowId}`, {
182185
userId: payload.userId,
183186
triggerType: payload.triggerType,

apps/sim/lib/api-key/service.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*
88
* @vitest-environment node
99
*/
10+
import { setRequestAuth } from '@sim/logger'
1011
import { dbChainMockFns } from '@sim/testing'
1112
import { beforeEach, describe, expect, it, vi } from 'vitest'
1213

@@ -95,6 +96,27 @@ describe('authenticateApiKeyFromHeader', () => {
9596
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
9697
})
9798

99+
it('records the key kind on the request without replacing a more specific principal', async () => {
100+
dbChainMockFns.where.mockResolvedValueOnce([
101+
personalKeyRecord({ type: 'workspace', workspaceId: 'workspace-1' }),
102+
])
103+
104+
await authenticateApiKeyFromHeader('sk-sim-plain-key')
105+
106+
expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith(
107+
{ kind: 'workspace_api_key' },
108+
{ preserveExisting: true }
109+
)
110+
})
111+
112+
it('records nothing for a key that fails its checks', async () => {
113+
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord({ userId: 'other-user' })])
114+
115+
await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })
116+
117+
expect(vi.mocked(setRequestAuth)).not.toHaveBeenCalled()
118+
})
119+
98120
it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
99121
const record = personalKeyRecord({ userId: 'other-user' })
100122
dbChainMockFns.where.mockResolvedValueOnce([record])

apps/sim/lib/api-key/service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from '@sim/db'
22
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
3-
import { createLogger } from '@sim/logger'
3+
import { createLogger, setRequestAuth } from '@sim/logger'
44
import { and, eq, isNull, lt, or } from 'drizzle-orm'
55
import { hashApiKey } from '@/lib/api-key/crypto'
66
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -132,6 +132,10 @@ export async function authenticateApiKeyFromHeader(
132132
}
133133

134134
logger.debug('API key matched via hash lookup', { keyId: record.id, keyType })
135+
setRequestAuth(
136+
{ kind: keyType === 'personal' ? 'personal_api_key' : 'workspace_api_key' },
137+
{ preserveExisting: true }
138+
)
135139

136140
return {
137141
success: true,

apps/sim/lib/auth/auth.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sso } from '@better-auth/sso'
44
import { stripe } from '@better-auth/stripe'
55
import { db } from '@sim/db'
66
import * as schema from '@sim/db/schema'
7-
import { createLogger } from '@sim/logger'
7+
import { createLogger, setRequestAuth } from '@sim/logger'
88
import { getErrorMessage, toError } from '@sim/utils/errors'
99
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
1010
import {
@@ -1758,13 +1758,25 @@ export const auth = betterAuth({
17581758
async function getSessionImpl() {
17591759
if (isAuthDisabled) {
17601760
await ensureAnonymousUserExists()
1761-
return createAnonymousSession()
1761+
return recordSessionAuth(createAnonymousSession())
17621762
}
17631763

17641764
const hdrs = await headers()
1765-
return await auth.api.getSession({
1766-
headers: hdrs,
1767-
})
1765+
return recordSessionAuth(
1766+
await auth.api.getSession({
1767+
headers: hdrs,
1768+
})
1769+
)
1770+
}
1771+
1772+
/**
1773+
* Records a resolved session as the request's auth kind. Stamped here, where
1774+
* every session is resolved, so the many routes that authenticate by calling
1775+
* `getSession` directly are attributed without each one remembering to.
1776+
*/
1777+
function recordSessionAuth<T extends { user?: { id?: string } } | null>(session: T): T {
1778+
if (session?.user?.id) setRequestAuth({ kind: 'session' }, { preserveExisting: true })
1779+
return session
17681780
}
17691781

17701782
export const getSession = cache(getSessionImpl)

apps/sim/lib/auth/hybrid.ts

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describePrincipalAuth, type WorkflowExecutionPrincipal } from '@sim/auth/principal'
2-
import { createLogger, setRequestAuth } from '@sim/logger'
1+
import type { WorkflowExecutionPrincipal } from '@sim/auth/principal'
2+
import { createLogger } from '@sim/logger'
33
import type { NextRequest } from 'next/server'
44
import { API_KEY_HEADER, BEARER_PREFIX } from '@/lib/api/server/credential-headers'
55
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
@@ -83,7 +83,7 @@ function resolveUserFromJwt(
8383
* @param options - Optional configuration
8484
* @param options.requireWorkflowId - Whether workflowId/userId is required (default: true)
8585
*/
86-
async function resolveInternalAuth(
86+
export async function checkInternalAuth(
8787
request: NextRequest,
8888
options: { requireWorkflowId?: boolean } = {}
8989
): Promise<AuthResult> {
@@ -131,7 +131,7 @@ async function resolveInternalAuth(
131131
* @param options - Optional configuration
132132
* @param options.requireWorkflowId - Whether workflowId/userId is required for JWT (default: true)
133133
*/
134-
async function resolveSessionOrInternalAuth(
134+
export async function checkSessionOrInternalAuth(
135135
request: NextRequest,
136136
options: { requireWorkflowId?: boolean } = {}
137137
): Promise<AuthResult> {
@@ -195,7 +195,7 @@ async function resolveSessionOrInternalAuth(
195195
*
196196
* For internal JWT calls, requires workflowId to determine user context
197197
*/
198-
async function resolveHybridAuth(
198+
export async function checkHybridAuth(
199199
request: NextRequest,
200200
options: { requireWorkflowId?: boolean } = {}
201201
): Promise<AuthResult> {
@@ -277,36 +277,3 @@ async function resolveHybridAuth(
277277
}
278278
}
279279
}
280-
281-
type AuthCheck = (
282-
request: NextRequest,
283-
options?: { requireWorkflowId?: boolean }
284-
) => Promise<AuthResult>
285-
286-
/**
287-
* Records how a request authenticated on the request context, so the logs and
288-
* analytics of a route that authenticates through these helpers rather than a
289-
* route builder carry the same `auth` attribution. A principal describes
290-
* itself; an internal JWT that produced none is recorded by its auth type.
291-
*/
292-
function recordingAuth(resolve: AuthCheck): AuthCheck {
293-
return async (request, options) => {
294-
const result = await resolve(request, options)
295-
if (!result.success) return result
296-
if (result.principal) {
297-
setRequestAuth(describePrincipalAuth(result.principal))
298-
} else if (result.authType) {
299-
setRequestAuth({ kind: result.authType })
300-
}
301-
return result
302-
}
303-
}
304-
305-
/** Internal JWT authentication only. See {@link resolveInternalAuth}. */
306-
export const checkInternalAuth = recordingAuth(resolveInternalAuth)
307-
308-
/** Session or internal JWT authentication, never an API key. See {@link resolveSessionOrInternalAuth}. */
309-
export const checkSessionOrInternalAuth = recordingAuth(resolveSessionOrInternalAuth)
310-
311-
/** Any of the three supported credentials. See {@link resolveHybridAuth}. */
312-
export const checkHybridAuth = recordingAuth(resolveHybridAuth)

apps/sim/lib/auth/internal.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { serializePrincipal } from '@sim/auth/principal'
6+
import { setRequestAuth } from '@sim/logger'
67
import { resetEnvMock } from '@sim/testing'
78
import { decodeJwt, SignJWT } from 'jose'
89
import { afterAll, describe, expect, it, vi } from 'vitest'
@@ -40,6 +41,19 @@ describe('internal JWT claims', () => {
4041
})
4142
})
4243

44+
it('records a verified internal token as the request auth kind, and a refused one not at all', async () => {
45+
vi.mocked(setRequestAuth).mockClear()
46+
47+
await verifyInternalToken('not-a-jwt')
48+
expect(vi.mocked(setRequestAuth)).not.toHaveBeenCalled()
49+
50+
await verifyInternalToken(await generateInternalToken('user-1'))
51+
expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith(
52+
{ kind: 'internal_jwt' },
53+
{ preserveExisting: true }
54+
)
55+
})
56+
4357
it('rejects unknown sandbox profiles instead of falling back to another image', async () => {
4458
const token = await generateInternalToken('user-1', {
4559
sandboxProfile: 'unknown-profile' as never,

0 commit comments

Comments
 (0)