Skip to content

Commit 5db62b8

Browse files
authored
feat(observability): extend audit log, PostHog, and storage metering coverage (#5269)
* feat(observability): extend audit log, PostHog, and storage metering coverage Instruments previously-uncaptured resources and actions across the three observability layers (audit log, PostHog product analytics, usage metering): - Exfiltration audit: file downloads (workspace/public-share/v1), table, workflow, and workspace exports. - Credential access audited at the token-issuance boundary (success-only). - Full revenue trail: invoice paid/failed, overage billed, disputes, credit fulfillment, subscription lifecycle, plan/seat changes. - Session/login lifecycle audit via Better Auth hooks (login, blocked sign-in, logout, session revoke, account delete). - v1/admin programmatic surface and copilot tool handlers instrumented. - Dead audit/PostHog constants wired (lock/unlock, table, custom tool, etc.). - Storage metering extended to KB documents and copilot files. - PostHog person identify + workspace/organization group hygiene. Audit package hardened to null the actor FK for system actors (admin-api) and adds an awaitable recordAuditNow for pre-delete hooks. All instrumentation is fire-and-forget and never blocks or breaks the primary operation. * fix(observability): audit file/credential egress only on success Address Cursor Bugbot review: - GET OAuth token: emit CREDENTIAL_ACCESSED/credential_used after refreshTokenIfNeeded succeeds (matches POST), not before. - File export: audit on each actual success exit via a shared helper — including the non-markdown serve redirect (previously unaudited) and after the zip is generated (previously before asset fetch). * fix(audit): record null actor for anonymous public-share downloads Address Greptile P1: setting actorId to the file owner made every anonymous external download read as a self-download, undermining the exfiltration trail. recordAudit now accepts a null actor; the public content/inline routes record actorId=null with the owner in metadata.sharedByUserId and rely on ip/user-agent for the forensic trail. The misleading owner-attributed file_downloaded PostHog event is dropped on these anonymous paths. * fix(observability): audit admin exports after zip; tidy comments - Admin workflow/workspace ZIP exports now audit only after the archive is built (via a local helper), so a zip/build failure no longer logs an export. - Remove redundant 'success-only placement' inline comments and tighten the remaining design-rationale notes (export helper now TSDoc). * fix(billing): complete the chargeback + overage financial trail Address Cursor review: - handleDisputeClosed now records CHARGE_DISPUTE_CLOSED for every closed dispute (won/lost/warning_closed), unblocking only on favorable outcomes. dispute.status in the metadata distinguishes the outcome, so lost chargebacks are no longer missing from the trail. - Threshold overage now emits OVERAGE_BILLED + overage_billed even when credits fully cover the overage (settledVia: 'credits' vs 'stripe'), so credit-settled overages are audited instead of silently returning null. * fix(storage): decrement copilot quota before deleting metadata Address Greptile P1: deleting the metadata row before the decrement meant a decrement failure left the quota permanently inflated with no record to retry from. Decrement first; only remove the metadata row once it succeeds. * fix(observability): atomic copilot storage release; signup-blocked action - Copilot file delete now releases storage via a single transaction (releaseDeletedFileStorage): the soft-delete is the idempotency claim and the decrement shares the transaction, so neither a partial failure (inflated counter) nor a retry (double-decrement) can desync the quota. Resolves the conflicting Greptile/Cursor ordering findings. - Policy-blocked sign-ups now record USER_SIGNUP_BLOCKED instead of USER_SIGNIN_BLOCKED, so account-lifecycle events aren't mislabeled. * fix(storage): meter copilot ingest centrally for path symmetry Address Cursor review: only uploadCopilotFile incremented storage, but copilot files also enter via the generic upload route and presigned uploads — all of which persist metadata through insertFileMetadata. Move the increment into insertFileMetadata (scoped to context='copilot', on genuine insert/restore) so every ingest path is symmetric with the delete-time decrement, and drop the now redundant per-path increment in uploadCopilotFile. Other contexts are metered by their own managers and remain unaffected. * fix(audit): skip WORKFLOW_EXPORTED when admin export is empty Address Cursor review: when every requested workflow fails to load, the admin export still returned 200 but recorded a successful export of zero workflows. Guard auditExport so an empty result records nothing. * fix(storage): settle copilot accounting before deleting the blob Address Cursor review: the blob was removed before releaseDeletedFileStorage, so a release failure left the counter inflated and the metadata active with the blob gone. Now the atomic soft-delete + decrement runs first and the blob is deleted only if it succeeds, so a failure leaves the file fully intact and retryable. * fix(storage): decrement KB document storage atomically with deletion Address Cursor review: hardDeleteDocuments deleted the rows then decremented best-effort, so a decrement failure left billed storage inflated with no row to reconcile. Resolve each owner's subscription up front, then decrement inside the same transaction that deletes the embeddings/documents (decrementStorageUsageInTx, also now shared by releaseDeletedFileStorage), so the counter and the content commit or roll back together. Connector docs remain excluded. * fix(audit): don't treat email verification as a login Address Cursor review: /verify-email could emit USER_LOGIN when verification mints or refreshes a session, mislabeling (or double-counting) a non-sign-in as a login. Restrict isLoginPath to genuine sign-in entrypoints. * fix(audit): null actor FK when the user lookup throws Address Greptile P1: the catch branch left the original actorId, so a system actor like 'admin-api' (or a since-deleted user) would FK-violate the insert and lose the row when the existence lookup errored. Mirror the not-found branch — null the FK with a readable label — so the audit row always persists. * revert(audit): drop session/account-lifecycle auth instrumentation Remove the Better Auth login/logout/session-revoke/account-delete/blocked-signin audit hooks from auth.ts — they touch sensitive auth paths and are noisy. Also removes the now-unused taxonomy (USER_LOGIN/_FAILED, USER_SIGNIN_BLOCKED, USER_SIGNUP_BLOCKED, USER_LOGOUT, SESSION_REVOKED, ACCOUNT_DELETED, ACCOUNT_EMAIL_CHANGED actions; SESSION/USER resource types) and the awaitable recordAuditNow helper that only the pre-delete hook used. auth.ts is back to the staging baseline. * fix(storage): harden copilot+KB delete accounting against read errors and concurrency Final-audit follow-ups: - deleteCopilotFile: a failed metadata *read* (vs a genuine missing row) now blocks the blob delete too, so a transient read error can't leave an active row un-decremented with the blob gone. - hardDeleteDocuments: drive the per-user decrement from the delete's returning() (the rows this tx actually removed), so two concurrent deletes of the same ids can't both decrement. * chore(observability): drop two unused definitions Final-audit cleanup: remove the orphaned knowledge_base_searched PostHog event (never wired; KB search analytics already flow through the OpenTelemetry channel) and the redundant AuditAction.SUBSCRIPTION_UPDATED (subscription/plan changes are audited via ORG_PLAN_CONVERTED). Every remaining new action/event has a real emit site. * fix(knowledge): key hard-delete result off rows actually deleted Address Cursor review: hardDeleteDocuments returned existingIds.length (the requested count) and cleaned storage for the full pre-tx set, even though the decrement was driven by the rows the transaction actually deleted. Under a concurrent delete that claimed some ids first, that overstated the result and re-touched storage for rows this call didn't delete. Drive the storage cleanup, log, and return value from deletedDocs (the returning() rows) so all four are consistent. * fix(storage): gate copilot quota on all ingest paths; tidy inline comments - Copilot uploads via the generic /api/files/upload route and presigned URLs now run checkStorageQuota before writing (matching uploadCopilotFile), so no copilot ingest path can grow usage past the plan limit. The central increment stays in insertFileMetadata. - Trim/remove redundant inline comments across the diff; keep only concise notes on non-obvious decisions (left pre-existing comments untouched). * fix(analytics): omit empty workspace_id on workspace-less file downloads Address Greptile P1: the generic key-based /api/files/download route and the no-workspace markdown-export path emitted file_downloaded with workspace_id:'' creating a phantom '' bucket in PostHog. Make workspace_id optional on the event and omit it when there is no workspace (workspace-scoped routes still pass it). * fix(analytics): omit empty workspace_id/workflow_id on copilot_chat_sent Final-sweep nit: copilot_chat_sent sent '' for workspace_id/workflow_id in the agent (workspace-less) branch, same phantom-bucket issue as file_downloaded. Make both properties optional and omit them when absent. * fix(analytics): clear stale org PostHog group on personal workspaces Address Cursor review: switching from a team workspace to a personal one left the previous organization group set, so later events kept rolling up under it. When the active workspace has no organizationId, resetGroups() to drop the stale org group, then re-apply the workspace group. * fix: address review — export audit timing, copilot delete signal, group reset - Table export (Cursor MED): audit fires before streaming begins, not after controller.close(), so a mid-stream failure still records the partial export. - deleteCopilotFile (Greptile P1): throw when storage accounting can't be settled instead of silently returning, so callers can detect the file was not deleted. - workspace-scope-sync (Cursor MED): only resetGroups() once workspace metadata is loaded (activeWorkspace present), so a team workspace doesn't transiently lose its org group while organizationId is still null during load. * refactor(observability): final line-justification cleanup Address final audit flags: - Table import: move the 'columns added' audit OUT of the import transaction into a post-commit auditTableColumnsAdded() helper called by the three tx-owning callers, so a mid-import row-batch rollback no longer logs a false 'added N columns' (matches the PR's success-only discipline). - Omit empty-string analytics dimensions on workflow_lock_toggled (workspace_id) and organization_created (name), consistent with file_downloaded/copilot_chat_sent. - Restore an unrelated capacity-check comment removed incidentally. - Move copilot_chat_sent emit above the traceparent comment so the comment sits with the code it documents. * fix(table): attribute import column audit to the importing user Address Cursor review: addImportColumns (async createColumns import path) now threads the importing userId into auditTableColumnsAdded instead of falling back to table.createdBy, so column additions are attributed to the actual member who ran the import rather than the table creator. * feat(observability): drop copilot from storage accounting Per design decision: copilot files are working/conversational artifacts, so gating their materialization on storage quota would fail an agent operation mid-flow when a user is over limit, and metering them would inflate usage enough to block KB/workspace uploads indirectly. Remove copilot quota gates + copilot ingest metering entirely (revert copilot-file-manager, metadata, and the upload route's copilot branch to baseline) and drop the now-unused releaseDeletedFileStorage. KB document metering + quota enforcement (the deliberate, persistent storage path) is unchanged. * fix(analytics): switch workspace + org PostHog groups together Address Cursor review: gate the group-sync effect on workspace metadata being loaded (activeWorkspace present) so the workspace and organization groups always update atomically. Acting during the load window paired the new workspace group with the previous workspace's org group; until metadata loads, events stay consistently attributed to the previous workspace. * fix(table): audit async export at authorization, not job completion Address Cursor review: async exports only emitted TABLE_EXPORTED when the background job reached 'ready', so an authorized export whose job later failed or was abandoned left no audit trail — inconsistent with the sync export route, which audits before streaming. Move the audit + analytics to the async route's authorization point (after the job is claimed/dispatched) and remove it from the runner. Drop the now-unused userId from TableExportPayload. * fix(billing): never let payment_failed instrumentation skip user blocking Address Greptile P1: the payment_failed audit hoisted an unguarded isSubscriptionOrgScoped DB read (for entity_type) directly before the attempt-count user-blocking block. A transient failure of that read would throw out of the handler and skip blocking. Wrap the whole audit/analytics block in try/catch (best-effort), and let the blocking compute its own isSubscriptionOrgScoped as it did originally — instrumentation can no longer abort payment processing. * chore(observability): trim verbose inline comments to concise notes * fix(billing): wrap new dispute/enterprise/subscription instrumentation in Stripe webhook idempotency recordAudit/captureServerEvent calls added for charge disputes, enterprise subscription provisioning, and free->paid subscription creation ran unconditionally with no idempotency guard, unlike their sibling handlers in the same files. Stripe redelivers webhooks at-least-once, so a retry would double-record the audit row and PostHog event even though the underlying DB writes were already idempotent. * fix(observability): tag org-scoped audit metadata with organizationId, guard concurrent table delete The org-scoped self-service audit-log endpoint matches org-level rows via metadata.organizationId (or resourceType=organization). Six recordAudit call sites (subscription create/cancel, admin credit issuance, threshold overage billing, charge disputes, credit purchase, invoice payment succeeded/failed) tagged org-scoped events with a differently-named key (referenceId/entityId/targetOrgId), making them invisible to org admins querying their own audit trail despite being stored in the DB. Add the missing organizationId key everywhere the pattern was missed. Also guard deleteTable's archive UPDATE with isNull(archivedAt) so a concurrent duplicate delete request is a no-op instead of re-archiving and re-firing a duplicate TABLE_DELETED audit row. Adds test coverage for the ORG_MEMBER_ADDED audit/analytics emission in acceptInvitation, which previously had none. * fix(test): queue resolveBillingActorId's owner lookup in payment-failure email test handleInvoicePaymentFailed's new payment_failed audit instrumentation resolves the billing actor via an extra db.select before sendPaymentFailureEmails runs. The test's fixed select-response queue didn't account for it, so the org-admin lookup consumed the wrong queued row and the assertion saw zero email sends. Production behavior is unaffected — each query is independent; this was a mock-queue ordering issue only. * fix(billing): don't let a post-commit usage-limit sync failure suppress the seat audit Cursor Bugbot flagged: reconcileOrganizationSeats committed the seat change and Stripe outbox enqueue in a transaction, then called syncSubscriptionUsageLimits outside it before recording the audit/ PostHog events. A thrown sync left a genuinely-changed seat count with no ORG_SEAT_PROVISIONED/DEPROVISIONED trail. Wrap the sync in its own try/catch (log + continue) so the events always fire for a committed change, matching the fire-and-forget instrumentation pattern used elsewhere in this PR. * fix(billing): guard the new subscription-created instrumentation block Greptile P1: the org-scope check I added for the audit-metadata fix (isSubscriptionOrgScoped) was a raw DB call with no error guard, unlike resolveSubscriptionActorId next to it. Since it ran inside the idempotency lambda with an unconditional rethrow, a transient DB error would abort and retry the whole webhook after the free -> paid usage reset had already committed. Wrap the actor/org-scope resolution + audit + analytics block in try/catch, matching the same guarded instrumentation pattern already used in handleInvoicePaymentFailed.
1 parent fea3391 commit 5db62b8

61 files changed

Lines changed: 2412 additions & 477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/realtime/src/handlers/variables.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { db } from '@sim/db'
23
import { workflow } from '@sim/db/schema'
34
import { createLogger } from '@sim/logger'
@@ -18,6 +19,9 @@ type PendingVariable = {
1819
latest: { variableId: string; field: string; value: any; timestamp: number }
1920
timeout: NodeJS.Timeout
2021
opToSocket: Map<string, string>
22+
/** Most recent writer, used as the audit actor when the coalesced update is persisted. */
23+
actorId: string
24+
actorName?: string
2125
}
2226

2327
// Keyed by `${workflowId}:${variableId}:${field}`
@@ -177,6 +181,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
177181
if (existing) {
178182
clearTimeout(existing.timeout)
179183
existing.latest = { variableId, field, value, timestamp }
184+
existing.actorId = session.userId
185+
existing.actorName = session.userName
180186
if (operationId) existing.opToSocket.set(operationId, socket.id)
181187
existing.timeout = setTimeout(async () => {
182188
await flushVariableUpdate(workflowId, existing, roomManager)
@@ -196,6 +202,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
196202
latest: { variableId, field, value, timestamp },
197203
timeout,
198204
opToSocket,
205+
actorId: session.userId,
206+
actorName: session.userName,
199207
})
200208
}
201209
} catch (error) {
@@ -231,7 +239,11 @@ async function flushVariableUpdate(
231239

232240
try {
233241
const workflowExists = await db
234-
.select({ id: workflow.id })
242+
.select({
243+
id: workflow.id,
244+
name: workflow.name,
245+
workspaceId: workflow.workspaceId,
246+
})
235247
.from(workflow)
236248
.where(eq(workflow.id, workflowId))
237249
.limit(1)
@@ -294,6 +306,19 @@ async function flushVariableUpdate(
294306
})
295307

296308
if (updateSuccessful) {
309+
const workflowRow = workflowExists[0]
310+
recordAudit({
311+
workspaceId: workflowRow.workspaceId ?? null,
312+
actorId: pending.actorId,
313+
actorName: pending.actorName,
314+
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
315+
resourceType: AuditResourceType.WORKFLOW,
316+
resourceId: workflowId,
317+
resourceName: workflowRow.name ?? undefined,
318+
description: `Updated workflow variables`,
319+
metadata: { variableId, field },
320+
})
321+
297322
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
298323
const senderSocketIds = [...pending.opToSocket.values()]
299324
const broadcastPayload = {

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { createLogger } from '@sim/logger'
23
import { getErrorMessage } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
@@ -11,6 +12,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1112
import { generateRequestId } from '@/lib/core/utils/request'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
15+
import { captureServerEvent } from '@/lib/posthog/server'
1416
import {
1517
getAtlassianServiceAccountSecret,
1618
getCredential,
@@ -96,6 +98,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9698
)
9799
}
98100

101+
recordAudit({
102+
actorId: auth.userId,
103+
action: AuditAction.CREDENTIAL_ACCESSED,
104+
resourceType: AuditResourceType.CREDENTIAL,
105+
resourceId: providerId,
106+
description: `Accessed OAuth credential for provider ${providerId}`,
107+
metadata: {
108+
provider: providerId,
109+
credentialType: 'oauth',
110+
credentialAccountUserId,
111+
},
112+
request,
113+
})
114+
captureServerEvent(auth.userId, 'credential_used', {
115+
credential_type: 'oauth',
116+
provider_id: providerId,
117+
})
118+
99119
return NextResponse.json({ accessToken }, { status: 200 })
100120
} catch (error) {
101121
const message = getErrorMessage(error, 'Failed to get OAuth token')
@@ -120,9 +140,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
120140
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
121141
}
122142

143+
const saActorId = authz.requesterUserId
144+
const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null
145+
const emitServiceAccountAccess = () => {
146+
if (!saActorId) return
147+
recordAudit({
148+
workspaceId: saWorkspaceId,
149+
actorId: saActorId,
150+
action: AuditAction.CREDENTIAL_ACCESSED,
151+
resourceType: AuditResourceType.CREDENTIAL,
152+
resourceId: resolved.credentialId ?? credentialId,
153+
description: `Accessed service account credential for provider ${resolved.providerId ?? 'unknown'}`,
154+
metadata: {
155+
provider: resolved.providerId,
156+
credentialType: 'service_account',
157+
},
158+
request,
159+
})
160+
captureServerEvent(
161+
saActorId,
162+
'credential_used',
163+
{
164+
credential_type: 'service_account',
165+
provider_id: resolved.providerId ?? 'unknown',
166+
...(saWorkspaceId ? { workspace_id: saWorkspaceId } : {}),
167+
},
168+
saWorkspaceId ? { groups: { workspace: saWorkspaceId } } : undefined
169+
)
170+
}
171+
123172
try {
124173
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
125174
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
175+
emitServiceAccountAccess()
126176
return NextResponse.json(
127177
{
128178
accessToken: secret.apiToken,
@@ -137,6 +187,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
137187
scopes ?? [],
138188
impersonateEmail
139189
)
190+
emitServiceAccountAccess()
140191
return NextResponse.json({ accessToken }, { status: 200 })
141192
} catch (error) {
142193
logger.error(`[${requestId}] Service account token error:`, error)
@@ -165,13 +216,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
165216
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
166217
}
167218

219+
const oauthActorId = authz.requesterUserId
220+
const oauthWorkspaceId = authz.workspaceId ?? null
221+
168222
try {
169223
const { accessToken } = await refreshTokenIfNeeded(
170224
requestId,
171225
credential,
172226
resolvedCredentialId
173227
)
174228

229+
if (oauthActorId) {
230+
recordAudit({
231+
workspaceId: oauthWorkspaceId,
232+
actorId: oauthActorId,
233+
action: AuditAction.CREDENTIAL_ACCESSED,
234+
resourceType: AuditResourceType.CREDENTIAL,
235+
resourceId: resolvedCredentialId,
236+
description: `Accessed OAuth credential for provider ${credential.providerId}`,
237+
metadata: {
238+
provider: credential.providerId,
239+
credentialType: 'oauth',
240+
},
241+
request,
242+
})
243+
captureServerEvent(
244+
oauthActorId,
245+
'credential_used',
246+
{
247+
credential_type: 'oauth',
248+
provider_id: credential.providerId,
249+
...(oauthWorkspaceId ? { workspace_id: oauthWorkspaceId } : {}),
250+
},
251+
oauthWorkspaceId ? { groups: { workspace: oauthWorkspaceId } } : undefined
252+
)
253+
}
254+
175255
let instanceUrl: string | undefined
176256
if (credential.providerId === 'salesforce' && credential.scope) {
177257
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
@@ -247,13 +327,42 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
247327
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
248328
}
249329

330+
const actorId = authz.requesterUserId
331+
const workspaceId = authz.workspaceId ?? null
332+
250333
try {
251334
const { accessToken } = await refreshTokenIfNeeded(
252335
requestId,
253336
credential,
254337
resolvedCredentialId
255338
)
256339

340+
if (actorId) {
341+
recordAudit({
342+
workspaceId,
343+
actorId,
344+
action: AuditAction.CREDENTIAL_ACCESSED,
345+
resourceType: AuditResourceType.CREDENTIAL,
346+
resourceId: resolvedCredentialId,
347+
description: `Accessed OAuth credential for provider ${credential.providerId}`,
348+
metadata: {
349+
provider: credential.providerId,
350+
credentialType: 'oauth',
351+
},
352+
request,
353+
})
354+
captureServerEvent(
355+
actorId,
356+
'credential_used',
357+
{
358+
credential_type: 'oauth',
359+
provider_id: credential.providerId,
360+
...(workspaceId ? { workspace_id: workspaceId } : {}),
361+
},
362+
workspaceId ? { groups: { workspace: workspaceId } } : undefined
363+
)
364+
}
365+
257366
// For Salesforce, extract instanceUrl from the scope field
258367
let instanceUrl: string | undefined
259368
if (credential.providerId === 'salesforce' && credential.scope) {

apps/sim/app/api/billing/switch-plan/route.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { db } from '@sim/db'
23
import { subscription as subscriptionTable } from '@sim/db/schema'
34
import { createLogger } from '@sim/logger'
@@ -174,6 +175,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
174175
{ set: { plan: targetPlanName } }
175176
)
176177

178+
if (isOrgScopedSubscription(sub, userId)) {
179+
recordAudit({
180+
actorId: userId,
181+
action: AuditAction.ORG_PLAN_CONVERTED,
182+
resourceType: AuditResourceType.ORGANIZATION,
183+
resourceId: sub.referenceId,
184+
description: `Plan converted from ${sub.plan ?? 'unknown'} to ${targetPlanName}`,
185+
metadata: {
186+
organizationId: sub.referenceId,
187+
subscriptionId: sub.id,
188+
fromPlan: sub.plan,
189+
toPlan: targetPlanName,
190+
interval: targetInterval,
191+
},
192+
request,
193+
})
194+
captureServerEvent(userId, 'plan_converted', {
195+
organization_id: sub.referenceId,
196+
from_plan: sub.plan ?? 'unknown',
197+
to_plan: targetPlanName,
198+
})
199+
}
200+
177201
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
178202
} catch (error) {
179203
logger.error('Failed to switch subscription', {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
1616
import type { EnvironmentVariable } from '@/lib/environment/api'
17+
import { captureServerEvent } from '@/lib/posthog/server'
1718

1819
const logger = createLogger('EnvironmentAPI')
1920

@@ -89,6 +90,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
8990
request: req,
9091
})
9192

93+
captureServerEvent(session.user.id, 'environment_updated', {
94+
key_count: Object.keys(variables).length,
95+
scope: 'personal',
96+
})
97+
9298
return NextResponse.json({ success: true })
9399
} catch (error) {
94100
logger.error(`[${requestId}] Error updating environment variables`, error)

apps/sim/app/api/files/download/route.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { createLogger } from '@sim/logger'
23
import { type NextRequest, NextResponse } from 'next/server'
34
import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer'
45
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
56
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
67
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { captureServerEvent } from '@/lib/posthog/server'
79
import { hasCloudStorage } from '@/lib/uploads/core/storage-service'
810
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
911
import { verifyFileAccess } from '@/app/api/files/authorization'
@@ -80,10 +82,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8082

8183
logger.info(`Generated download URL for ${storageContext} file: ${key}`)
8284

85+
const downloadName = name || key.split('/').pop() || 'download'
86+
recordAudit({
87+
workspaceId: null,
88+
actorId: userId,
89+
action: AuditAction.FILE_DOWNLOADED,
90+
resourceType: AuditResourceType.FILE,
91+
resourceName: downloadName,
92+
description: `Downloaded file "${downloadName}"`,
93+
metadata: { key, fileName: downloadName, context: storageContext },
94+
request,
95+
})
96+
captureServerEvent(userId, 'file_downloaded', {
97+
is_bulk: false,
98+
file_count: 1,
99+
})
100+
83101
return NextResponse.json({
84102
downloadUrl,
85103
expiresIn: null,
86-
fileName: name || key.split('/').pop() || 'download',
104+
fileName: downloadName,
87105
})
88106
} catch (error) {
89107
logger.error('Error in file download endpoint:', error)

0 commit comments

Comments
 (0)