From 3e2f991855e90e54e80bec809f2b439cd29b7bd6 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 19:00:15 -0700 Subject: [PATCH 01/20] feat(analytics): add PostHog product analytics --- apps/sim/app/(home)/landing-analytics.tsx | 19 ++ .../app/_shell/providers/session-provider.tsx | 5 + apps/sim/app/api/a2a/agents/route.ts | 11 + apps/sim/app/api/billing/switch-plan/route.ts | 8 + apps/sim/app/api/copilot/chat/route.ts | 17 ++ apps/sim/app/api/copilot/feedback/route.ts | 7 + apps/sim/app/api/credentials/[id]/route.ts | 35 +++ apps/sim/app/api/credentials/route.ts | 11 + .../[id]/connectors/[connectorId]/route.ts | 13 + .../connectors/[connectorId]/sync/route.ts | 12 + .../api/knowledge/[id]/connectors/route.ts | 16 + .../app/api/knowledge/[id]/documents/route.ts | 33 +++ apps/sim/app/api/knowledge/route.ts | 15 + apps/sim/app/api/mcp/servers/route.ts | 18 ++ apps/sim/app/api/table/[tableId]/route.ts | 8 + apps/sim/app/api/table/route.ts | 15 + apps/sim/app/api/tools/custom/route.ts | 19 ++ .../app/api/workflows/[id]/deploy/route.ts | 11 + .../deployments/[version]/revert/route.ts | 12 + .../app/api/workflows/[id]/duplicate/route.ts | 12 + .../executions/[executionId]/cancel/route.ts | 11 + apps/sim/app/api/workflows/[id]/route.ts | 8 + apps/sim/app/api/workflows/route.ts | 8 + .../workspaces/[id]/api-keys/[keyId]/route.ts | 8 + .../app/api/workspaces/[id]/api-keys/route.ts | 14 +- .../api/workspaces/[id]/byok-keys/route.ts | 18 ++ .../app/api/workspaces/[id]/files/route.ts | 8 + .../workspaces/[id]/notifications/route.ts | 12 + .../api/workspaces/[id]/permissions/route.ts | 8 + .../app/api/workspaces/invitations/route.ts | 11 + .../app/api/workspaces/members/[id]/route.ts | 8 + apps/sim/app/api/workspaces/route.ts | 11 + .../template-prompts/template-prompts.tsx | 10 +- .../app/workspace/[workspaceId]/home/home.tsx | 9 +- .../[workspaceId]/knowledge/[id]/base.tsx | 10 + .../settings/[section]/settings.tsx | 8 + .../create-api-key-modal.tsx | 3 + .../components/deploy-modal/deploy-modal.tsx | 1 + apps/sim/hooks/queries/api-keys.ts | 8 +- apps/sim/lib/auth/auth.ts | 8 + apps/sim/lib/billing/webhooks/subscription.ts | 24 ++ apps/sim/lib/posthog/client.ts | 27 ++ apps/sim/lib/posthog/events.ts | 279 ++++++++++++++++++ apps/sim/lib/posthog/server.ts | 110 +++++++ .../workflows/executor/execute-workflow.ts | 42 +++ apps/sim/stores/panel/store.ts | 5 + apps/sim/stores/workflows/workflow/store.ts | 26 ++ 47 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/(home)/landing-analytics.tsx create mode 100644 apps/sim/lib/posthog/client.ts create mode 100644 apps/sim/lib/posthog/events.ts create mode 100644 apps/sim/lib/posthog/server.ts diff --git a/apps/sim/app/(home)/landing-analytics.tsx b/apps/sim/app/(home)/landing-analytics.tsx new file mode 100644 index 00000000000..446fbdcf622 --- /dev/null +++ b/apps/sim/app/(home)/landing-analytics.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useEffect } from 'react' +import { usePostHog } from 'posthog-js/react' + +/** + * Fires a `landing_page_viewed` PostHog event on mount. + * Renders nothing — exists only to bridge the server/client boundary + * so the server-rendered landing page can emit analytics. + */ +export function LandingAnalytics() { + const posthog = usePostHog() + + useEffect(() => { + posthog?.capture('landing_page_viewed', {}) + }, [posthog]) + + return null +} diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index b2fe1fb43f2..c7a2ac19b96 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -92,6 +92,11 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { email_verified: data.user.emailVerified, created_at: data.user.createdAt, }) + + const workspaceId = data.session?.activeOrganizationId + if (workspaceId && typeof posthog.group === 'function') { + posthog.group('workspace', workspaceId) + } } else { posthog.reset() } diff --git a/apps/sim/app/api/a2a/agents/route.ts b/apps/sim/app/api/a2a/agents/route.ts index bd3615e1ae1..dee8bb59979 100644 --- a/apps/sim/app/api/a2a/agents/route.ts +++ b/apps/sim/app/api/a2a/agents/route.ts @@ -14,6 +14,7 @@ import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card' import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants' import { sanitizeAgentName } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { captureServerEvent } from '@/lib/posthog/server' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -201,6 +202,16 @@ export async function POST(request: NextRequest) { logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`) + captureServerEvent( + auth.userId, + 'a2a_agent_created', + { agent_id: agentId, workflow_id: workflowId, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_a2a_agent_created_at: new Date().toISOString() }, + } + ) + return NextResponse.json({ success: true, agent }, { status: 201 }) } catch (error) { logger.error('Error creating agent:', error) diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index b668335db2a..4bb7dbb366c 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -17,6 +17,7 @@ import { hasUsableSubscriptionStatus, } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('SwitchPlan') @@ -173,6 +174,13 @@ export async function POST(request: NextRequest) { interval: targetInterval, }) + captureServerEvent( + userId, + 'subscription_changed', + { from_plan: sub.plan ?? 'unknown', to_plan: targetPlanName, interval: targetInterval }, + { set: { plan: targetPlanName } } + ) + return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval }) } catch (error) { logger.error('Failed to switch subscription', { diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index c1938b5f06c..b0e001e4e8e 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -27,6 +27,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { captureServerEvent } from '@/lib/posthog/server' import { authorizeWorkflowByWorkspacePermission, resolveWorkflowIdForUser, @@ -188,6 +189,22 @@ export async function POST(req: NextRequest) { .warn('Failed to resolve workspaceId from workflow') } + captureServerEvent( + authenticatedUserId, + 'copilot_chat_sent', + { + workflow_id: workflowId, + workspace_id: resolvedWorkspaceId ?? '', + has_file_attachments: Array.isArray(fileAttachments) && fileAttachments.length > 0, + has_contexts: Array.isArray(contexts) && contexts.length > 0, + mode, + }, + { + groups: { workspace: resolvedWorkspaceId ?? '' }, + setOnce: { first_copilot_use_at: new Date().toISOString() }, + } + ) + const userMessageIdToUse = userMessageId || crypto.randomUUID() const reqLogger = logger.withMetadata({ requestId: tracker.requestId, diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index 4786d1d7d86..92abaa1c3e9 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -11,6 +11,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CopilotFeedbackAPI') @@ -76,6 +77,12 @@ export async function POST(req: NextRequest) { duration: tracker.getDuration(), }) + captureServerEvent(authenticatedUserId, 'copilot_feedback_submitted', { + is_positive: isPositiveFeedback, + has_text_feedback: !!feedback, + has_workflow_yaml: !!workflowYaml, + }) + return NextResponse.json({ success: true, feedbackId: feedbackRecord.feedbackId, diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index ac992c067df..b86031bf2f3 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -11,6 +11,7 @@ import { syncPersonalEnvCredentialsForUser, syncWorkspaceEnvCredentials, } from '@/lib/credentials/environment' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialByIdAPI') @@ -236,6 +237,17 @@ export async function DELETE( envKeys: Object.keys(current), }) + captureServerEvent( + session.user.id, + 'credential_deleted', + { + credential_type: 'env_personal', + provider_id: access.credential.envKey, + workspace_id: access.credential.workspaceId, + }, + { groups: { workspace: access.credential.workspaceId } } + ) + return NextResponse.json({ success: true }, { status: 200 }) } @@ -278,10 +290,33 @@ export async function DELETE( actingUserId: session.user.id, }) + captureServerEvent( + session.user.id, + 'credential_deleted', + { + credential_type: 'env_workspace', + provider_id: access.credential.envKey, + workspace_id: access.credential.workspaceId, + }, + { groups: { workspace: access.credential.workspaceId } } + ) + return NextResponse.json({ success: true }, { status: 200 }) } await db.delete(credential).where(eq(credential.id, id)) + + captureServerEvent( + session.user.id, + 'credential_deleted', + { + credential_type: access.credential.type as 'oauth' | 'service_account', + provider_id: access.credential.providerId ?? id, + workspace_id: access.credential.workspaceId, + }, + { groups: { workspace: access.credential.workspaceId } } + ) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error('Failed to delete credential', error) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 3184a82ba9f..9242a620fa2 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth' +import { captureServerEvent } from '@/lib/posthog/server' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { isValidEnvVarName } from '@/executor/constants' @@ -600,6 +601,16 @@ export async function POST(request: NextRequest) { .where(eq(credential.id, credentialId)) .limit(1) + captureServerEvent( + session.user.id, + 'credential_connected', + { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + return NextResponse.json({ credential: created }, { status: 201 }) } catch (error: any) { if (error?.code === '23505') { diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index d1b7e1054d7..0533582ed34 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -16,6 +16,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service' +import { captureServerEvent } from '@/lib/posthog/server' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -351,6 +352,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` ) + captureServerEvent( + auth.userId, + 'knowledge_base_connector_removed', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: writeCheck.knowledgeBase.workspaceId, + connector_type: existingConnector[0].connectorType, + documents_deleted: deleteDocuments ? docCount : 0, + }, + { groups: { workspace: writeCheck.knowledgeBase.workspaceId } } + ) + recordAudit({ workspaceId: writeCheck.knowledgeBase.workspaceId, actorId: auth.userId, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 4faa2013698..9c08175c926 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -7,6 +7,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' +import { captureServerEvent } from '@/lib/posthog/server' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('ConnectorManualSyncAPI') @@ -55,6 +56,17 @@ export async function POST(request: NextRequest, { params }: RouteParams) { logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`) + captureServerEvent( + auth.userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: writeCheck.knowledgeBase.workspaceId, + connector_type: connectorRows[0].connectorType, + }, + { groups: { workspace: writeCheck.knowledgeBase.workspaceId } } + ) + recordAudit({ workspaceId: writeCheck.knowledgeBase.workspaceId, actorId: auth.userId, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 989d6056ba5..48ebb5efc70 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -11,6 +11,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' import { allocateTagSlots } from '@/lib/knowledge/constants' import { createTagDefinition } from '@/lib/knowledge/tags/service' +import { captureServerEvent } from '@/lib/posthog/server' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -227,6 +228,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`) + captureServerEvent( + auth.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: writeCheck.knowledgeBase.workspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: { workspace: writeCheck.knowledgeBase.workspaceId }, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId: writeCheck.knowledgeBase.workspaceId, actorId: auth.userId, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index c65507d81f7..183ac757125 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -16,6 +16,7 @@ import { type TagFilterCondition, } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { captureServerEvent } from '@/lib/posthog/server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -214,6 +215,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId + if (body.bulk === true) { try { const validatedData = BulkCreateDocumentsSchema.parse(body) @@ -240,6 +243,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // Silently fail } + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId ?? '', + document_count: createdDocuments.length, + upload_type: 'bulk', + }, + { + ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) + processDocumentsWithQueue( createdDocuments, knowledgeBaseId, @@ -314,6 +332,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // Silently fail } + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId ?? '', + document_count: 1, + upload_type: 'single', + }, + { + ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, actorId: userId, diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 28fe86ef016..31951276176 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -11,6 +11,7 @@ import { KnowledgeBaseConflictError, type KnowledgeBaseScope, } from '@/lib/knowledge/service' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('KnowledgeBaseAPI') @@ -115,6 +116,20 @@ export async function POST(req: NextRequest) { // Telemetry should not fail the operation } + captureServerEvent( + session.user.id, + 'knowledge_base_created', + { + knowledge_base_id: newKnowledgeBase.id, + workspace_id: validatedData.workspaceId, + name: validatedData.name, + }, + { + groups: { workspace: validatedData.workspaceId }, + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) + logger.info( `[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}` ) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index ff08085d1ec..3be0aaa9694 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -18,6 +18,7 @@ import { createMcpSuccessResponse, generateMcpServerId, } from '@/lib/mcp/utils' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('McpServersAPI') @@ -180,6 +181,16 @@ export const POST = withMcpAuth('write')( // Silently fail } + captureServerEvent( + userId, + 'mcp_server_connected', + { workspace_id: workspaceId, server_name: body.name, transport: body.transport }, + { + groups: { workspace: workspaceId }, + setOnce: { first_mcp_connected_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId, actorId: userId, @@ -242,6 +253,13 @@ export const DELETE = withMcpAuth('admin')( logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) + captureServerEvent( + userId, + 'mcp_server_disconnected', + { workspace_id: workspaceId, server_name: deletedServer.name }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: userId, diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 30a99c951b3..1e84313c028 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { deleteTable, NAME_PATTERN, @@ -183,6 +184,13 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams) await deleteTable(tableId, requestId) + captureServerEvent( + authResult.userId, + 'table_deleted', + { table_id: tableId, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } + ) + return NextResponse.json({ success: true, data: { diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 18387ea80d8..bac9965766f 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { createTable, getWorkspaceTableLimits, @@ -141,6 +142,20 @@ export async function POST(request: NextRequest) { requestId ) + captureServerEvent( + authResult.userId, + 'table_created', + { + table_id: table.id, + workspace_id: params.workspaceId, + column_count: params.schema.columns.length, + }, + { + groups: { workspace: params.workspaceId }, + setOnce: { first_table_created_at: new Date().toISOString() }, + } + ) + return NextResponse.json({ success: true, data: { diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 6bcbf553067..521b239467a 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -168,6 +169,16 @@ export async function POST(req: NextRequest) { }) for (const tool of resultTools) { + captureServerEvent( + userId, + 'custom_tool_saved', + { tool_id: tool.id, workspace_id: workspaceId, tool_name: tool.title }, + { + groups: { workspace: workspaceId }, + setOnce: { first_custom_tool_saved_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId, actorId: userId, @@ -278,6 +289,14 @@ export async function DELETE(request: NextRequest) { // Delete the tool await db.delete(customTools).where(eq(customTools.id, toolId)) + const toolWorkspaceId = tool.workspaceId ?? workspaceId ?? '' + captureServerEvent( + userId, + 'custom_tool_deleted', + { tool_id: toolId, workspace_id: toolWorkspaceId }, + toolWorkspaceId ? { groups: { workspace: toolWorkspaceId } } : undefined + ) + recordAudit({ workspaceId: tool.workspaceId || undefined, actorId: userId, diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index c4f6d0087af..ff6eac774c6 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { @@ -96,6 +97,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) + captureServerEvent( + actorUserId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' }, + { + groups: { workspace: workflowData!.workspaceId ?? '' }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + const responseApiKeyInfo = workflowData!.workspaceId ? 'Workspace API keys' : 'Personal API keys' diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index d3762c9181f..3dac9d2d6bd 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -104,6 +105,17 @@ export async function POST( logger.error('Error sending workflow reverted event to socket server', e) } + captureServerEvent( + session!.user.id, + 'workflow_deployment_reverted', + { + workflow_id: id, + workspace_id: workflowRecord?.workspaceId ?? '', + version, + }, + { groups: { workspace: workflowRecord?.workspaceId ?? '' } } + ) + recordAudit({ workspaceId: workflowRecord?.workspaceId ?? null, actorId: session!.user.id, diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index cc00c0c0b7c..157dd4a924b 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' const logger = createLogger('WorkflowDuplicateAPI') @@ -60,6 +61,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // Telemetry should not fail the operation } + captureServerEvent( + userId, + 'workflow_duplicated', + { + source_workflow_id: sourceWorkflowId, + new_workflow_id: result.id, + workspace_id: workspaceId ?? '', + }, + { groups: { workspace: workspaceId ?? '' } } + ) + const elapsed = Date.now() - startTime logger.info( `[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms` diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 81d3afdb202..a6eed00a80a 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { markExecutionCancelled } from '@/lib/execution/cancellation' import { abortManualExecution } from '@/lib/execution/manual-cancellation' +import { captureServerEvent } from '@/lib/posthog/server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('CancelExecutionAPI') @@ -60,6 +61,16 @@ export async function POST( }) } + if (cancellation.durablyRecorded || locallyAborted) { + const workspaceId = workflowAuthorization.workflow?.workspaceId ?? '' + captureServerEvent( + auth.userId, + 'workflow_execution_cancelled', + { workflow_id: workflowId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + } + return NextResponse.json({ success: cancellation.durablyRecorded || locallyAborted, executionId, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index c746d394db2..c610a54d8d4 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' @@ -225,6 +226,13 @@ export async function DELETE( return NextResponse.json({ error: result.error }, { status }) } + captureServerEvent( + userId, + 'workflow_deleted', + { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, + { groups: { workspace: workflowData.workspaceId ?? '' } } + ) + const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index a9aba1bcc44..19cb28f5ce3 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -249,6 +250,13 @@ export async function POST(req: NextRequest) { // Silently fail }) + captureServerEvent( + userId, + 'workflow_created', + { workflow_id: workflowId, workspace_id: workspaceId ?? '', name }, + { groups: { workspace: workspaceId ?? '' } } + ) + const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() await db.transaction(async (tx) => { diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index bb9a5ff6989..42711f1fa8c 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceApiKeyAPI') @@ -145,6 +146,13 @@ export async function DELETE( const deletedKey = deletedRows[0] + captureServerEvent( + userId, + 'api_key_revoked', + { workspace_id: workspaceId, key_name: deletedKey.name }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: userId, diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 631037089d6..62638bbb47a 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -10,12 +10,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceApiKeysAPI') const CreateKeySchema = z.object({ name: z.string().trim().min(1, 'Name is required'), + source: z.enum(['settings', 'deploy_modal']).optional(), }) const DeleteKeysSchema = z.object({ @@ -101,7 +103,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } const body = await request.json() - const { name } = CreateKeySchema.parse(body) + const { name, source } = CreateKeySchema.parse(body) const existingKey = await db .select() @@ -158,6 +160,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Telemetry should not fail the operation } + captureServerEvent( + userId, + 'api_key_created', + { workspace_id: workspaceId, key_name: name, source }, + { + groups: { workspace: workspaceId }, + setOnce: { first_api_key_created_at: new Date().toISOString() }, + } + ) + logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) recordAudit({ diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index b16d67257b4..49efb08d59f 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -9,6 +9,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceBYOKKeysAPI') @@ -201,6 +202,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`) + captureServerEvent( + userId, + 'byok_key_added', + { workspace_id: workspaceId, provider_id: providerId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_byok_key_added_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId, actorId: userId, @@ -272,6 +283,13 @@ export async function DELETE( logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`) + captureServerEvent( + userId, + 'byok_key_removed', + { workspace_id: workspaceId, provider_id: providerId }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: userId, diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 9c1bc89cbc5..5c887442796 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { FileConflictError, listWorkspaceFiles, @@ -116,6 +117,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) + captureServerEvent( + session.user.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: session.user.id, diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index 6c46cef900a..c49c451752f 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' +import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants' @@ -256,6 +257,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ type: data.notificationType, }) + captureServerEvent( + session.user.id, + 'notification_channel_created', + { + workspace_id: workspaceId, + notification_type: data.notificationType, + alert_rule: data.alertConfig?.rule ?? null, + }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: session.user.id, diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 067256b3dbd..01d5a01ae9d 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' +import { captureServerEvent } from '@/lib/posthog/server' import { getUsersWithPermissions, hasWorkspaceAdminAccess, @@ -188,6 +189,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const updatedUsers = await getUsersWithPermissions(workspaceId) for (const update of body.updates) { + captureServerEvent( + session.user.id, + 'workspace_member_role_changed', + { workspace_id: workspaceId, new_role: update.permissions }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: session.user.id, diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 208e0a0e267..4dbcc3152e7 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -19,6 +19,7 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { InvitationsNotAllowedError, @@ -214,6 +215,16 @@ export async function POST(req: NextRequest) { // Telemetry should not fail the operation } + captureServerEvent( + session.user.id, + 'workspace_member_invited', + { workspace_id: workspaceId, invitee_role: permission }, + { + groups: { workspace: workspaceId }, + setOnce: { first_invitation_sent_at: new Date().toISOString() }, + } + ) + await sendInvitationEmail({ to: email, inviterName: session.user.name || session.user.email || 'A user', diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 937c9fa5da0..ca918712946 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' +import { captureServerEvent } from '@/lib/posthog/server' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceMemberAPI') @@ -105,6 +106,13 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i await revokeWorkspaceCredentialMemberships(workspaceId, userId) + captureServerEvent( + session.user.id, + 'workspace_member_removed', + { workspace_id: workspaceId, is_self_removal: isSelf }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: session.user.id, diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index f6f51fee7b7..686d6a0a1a2 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' +import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { getRandomWorkspaceColor } from '@/lib/workspaces/colors' @@ -96,6 +97,16 @@ export async function POST(req: Request) { const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow, color) + captureServerEvent( + session.user.id, + 'workspace_created', + { workspace_id: newWorkspace.id, name: newWorkspace.name }, + { + groups: { workspace: newWorkspace.id }, + setOnce: { first_workspace_created_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId: newWorkspace.id, actorId: session.user.id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx index c0593aebc1f..057821b1e08 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx @@ -2,6 +2,7 @@ import { type ComponentType, memo, type SVGProps } from 'react' import Image from 'next/image' +import { usePostHog } from 'posthog-js/react' import { AgentIcon, ScheduleIcon, StartIcon } from '@/components/icons' import type { Category, ModuleTag } from './consts' import { CATEGORY_META, TEMPLATES } from './consts' @@ -349,11 +350,18 @@ interface TemplateCardProps { const TemplateCard = memo(function TemplateCard({ template, onSelect }: TemplateCardProps) { const Icon = template.icon + const posthog = usePostHog() return (