diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index c3db07b511e..7ddf2abbcaa 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,16 +1,18 @@ 'use client' -import { Suspense, useMemo, useRef, useState } from 'react' +import { Suspense, useEffect, useMemo, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { Eye, EyeOff, Loader2 } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { Input, Label } from '@/components/emcn' import { client, useSession } from '@/lib/auth/auth-client' import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { captureEvent } from '@/lib/posthog/client' import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' @@ -81,7 +83,12 @@ function SignupFormContent({ const router = useRouter() const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() + const posthog = usePostHog() const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + captureEvent(posthog, 'signup_page_viewed', {}) + }, [posthog]) const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) diff --git a/apps/sim/app/(home)/landing-analytics.tsx b/apps/sim/app/(home)/landing-analytics.tsx new file mode 100644 index 00000000000..10be29e5edd --- /dev/null +++ b/apps/sim/app/(home)/landing-analytics.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useEffect } from 'react' +import { usePostHog } from 'posthog-js/react' +import { captureEvent } from '@/lib/posthog/client' + +export function LandingAnalytics() { + const posthog = usePostHog() + + useEffect(() => { + captureEvent(posthog, 'landing_page_viewed', {}) + }, [posthog]) + + return null +} diff --git a/apps/sim/app/(home)/landing.tsx b/apps/sim/app/(home)/landing.tsx index 43a1d665a13..31e17a118d7 100644 --- a/apps/sim/app/(home)/landing.tsx +++ b/apps/sim/app/(home)/landing.tsx @@ -13,6 +13,7 @@ import { Templates, Testimonials, } from '@/app/(home)/components' +import { LandingAnalytics } from '@/app/(home)/landing-analytics' /** * Landing page root component. @@ -45,6 +46,7 @@ export default async function Landing() { > Skip to main content +
diff --git a/apps/sim/app/api/a2a/agents/[agentId]/route.ts b/apps/sim/app/api/a2a/agents/[agentId]/route.ts index 688011352e3..877093ae30a 100644 --- a/apps/sim/app/api/a2a/agents/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/agents/[agentId]/route.ts @@ -7,6 +7,7 @@ import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-c import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getRedisClient } from '@/lib/core/config/redis' +import { captureServerEvent } from '@/lib/posthog/server' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -180,6 +181,17 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise logger.info(`Deleted A2A agent: ${agentId}`) + captureServerEvent( + auth.userId, + 'a2a_agent_deleted', + { + agent_id: agentId, + workflow_id: existingAgent.workflowId, + workspace_id: existingAgent.workspaceId, + }, + { groups: { workspace: existingAgent.workspaceId } } + ) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error deleting agent:', error) @@ -251,6 +263,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise 0, + has_contexts: Array.isArray(contexts) && contexts.length > 0, + mode, + }, + { + groups: resolvedWorkspaceId ? { workspace: resolvedWorkspaceId } : undefined, + 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..f09f3365373 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,19 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` ) + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? '' + captureServerEvent( + auth.userId, + 'knowledge_base_connector_removed', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId, + connector_type: existingConnector[0].connectorType, + documents_deleted: deleteDocuments ? docCount : 0, + }, + kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined + ) + 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..df7057fc904 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,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) { logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`) + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? '' + captureServerEvent( + auth.userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId, + connector_type: connectorRows[0].connectorType, + }, + kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined + ) + 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..5105d23b217 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,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`) + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? '' + captureServerEvent( + auth.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined, + 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..73c6f43fd56 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,20 @@ export const POST = withMcpAuth('write')( // Silently fail } + const sourceParam = body.source as string | undefined + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + + captureServerEvent( + userId, + 'mcp_server_connected', + { workspace_id: workspaceId, server_name: body.name, transport: body.transport, source }, + { + groups: { workspace: workspaceId }, + setOnce: { first_mcp_connected_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId, actorId: userId, @@ -214,6 +229,9 @@ export const DELETE = withMcpAuth('admin')( try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') + const sourceParam = searchParams.get('source') + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined if (!serverId) { return createMcpErrorResponse( @@ -242,6 +260,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, source }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ workspaceId, actorId: userId, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index e41a7c713d6..972651cf41b 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -13,6 +13,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('MothershipChatAPI') @@ -142,12 +143,41 @@ export async function PATCH( return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } - if (title !== undefined && updatedChat.workspaceId) { - taskPubSub?.publishStatusChanged({ - workspaceId: updatedChat.workspaceId, - chatId, - type: 'renamed', - }) + if (updatedChat.workspaceId) { + if (title !== undefined) { + taskPubSub?.publishStatusChanged({ + workspaceId: updatedChat.workspaceId, + chatId, + type: 'renamed', + }) + captureServerEvent( + userId, + 'task_renamed', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } + if (isUnread === false) { + captureServerEvent( + userId, + 'task_marked_read', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } else if (isUnread === true) { + captureServerEvent( + userId, + 'task_marked_unread', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } } return NextResponse.json({ success: true }) @@ -203,6 +233,14 @@ export async function DELETE( chatId, type: 'deleted', }) + captureServerEvent( + userId, + 'task_deleted', + { workspace_id: deletedChat.workspaceId }, + { + groups: { workspace: deletedChat.workspaceId }, + } + ) } return NextResponse.json({ success: true }) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 91177b0a9d6..bc694d1d9fe 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -11,6 +11,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' +import { captureServerEvent } from '@/lib/posthog/server' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatsAPI') @@ -95,6 +96,15 @@ export async function POST(request: NextRequest) { taskPubSub?.publishStatusChanged({ workspaceId, chatId: chat.id, type: 'created' }) + captureServerEvent( + userId, + 'task_created', + { workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + } + ) + return NextResponse.json({ success: true, id: chat.id }) } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index a45539d20cc..41173c13188 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -4,6 +4,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 { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -23,6 +24,7 @@ const SkillSchema = z.object({ }) ), workspaceId: z.string().optional(), + source: z.enum(['settings', 'tool_input']).optional(), }) /** GET - Fetch all skills for a workspace */ @@ -75,7 +77,7 @@ export async function POST(req: NextRequest) { const body = await req.json() try { - const { skills, workspaceId } = SkillSchema.parse(body) + const { skills, workspaceId, source } = SkillSchema.parse(body) if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId in request body`) @@ -107,6 +109,12 @@ export async function POST(req: NextRequest) { resourceName: skill.name, description: `Created/updated skill "${skill.name}"`, }) + captureServerEvent( + userId, + 'skill_created', + { skill_id: skill.id, skill_name: skill.name, workspace_id: workspaceId, source }, + { groups: { workspace: workspaceId } } + ) } return NextResponse.json({ success: true, data: resultSkills }) @@ -137,6 +145,9 @@ export async function DELETE(request: NextRequest) { const searchParams = request.nextUrl.searchParams const skillId = searchParams.get('id') const workspaceId = searchParams.get('workspaceId') + const sourceParam = searchParams.get('source') + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -180,6 +191,13 @@ export async function DELETE(request: NextRequest) { description: `Deleted skill`, }) + captureServerEvent( + userId, + 'skill_deleted', + { skill_id: skillId, workspace_id: workspaceId, source }, + { groups: { workspace: workspaceId } } + ) + logger.info(`[${requestId}] Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) } catch (error) { 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..7d45353e609 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' @@ -34,6 +35,7 @@ const CustomToolSchema = z.object({ }) ), workspaceId: z.string().optional(), + source: z.enum(['settings', 'tool_input']).optional(), }) // GET - Fetch all custom tools for the workspace @@ -135,7 +137,7 @@ export async function POST(req: NextRequest) { try { // Validate the request body - const { tools, workspaceId } = CustomToolSchema.parse(body) + const { tools, workspaceId, source } = CustomToolSchema.parse(body) if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId in request body`) @@ -168,6 +170,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, source }, + { + groups: { workspace: workspaceId }, + setOnce: { first_custom_tool_saved_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId, actorId: userId, @@ -205,6 +217,9 @@ export async function DELETE(request: NextRequest) { const searchParams = request.nextUrl.searchParams const toolId = searchParams.get('id') const workspaceId = searchParams.get('workspaceId') + const sourceParam = searchParams.get('source') + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined if (!toolId) { logger.warn(`[${requestId}] Missing tool ID for deletion`) @@ -278,6 +293,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, source }, + toolWorkspaceId ? { groups: { workspace: toolWorkspaceId } } : undefined + ) + recordAudit({ workspaceId: tool.workspaceId || undefined, actorId: userId, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 88d8f26e0b3..24d93fc0609 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -8,6 +8,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateInteger } from '@/lib/core/security/input-validation' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -274,6 +275,19 @@ export async function DELETE( request, }) + const wsId = webhookData.workflow.workspaceId || undefined + captureServerEvent( + userId, + 'webhook_trigger_deleted', + { + webhook_id: id, + workflow_id: webhookData.workflow.id, + provider: foundWebhook.provider || 'generic', + workspace_id: wsId ?? '', + }, + wsId ? { groups: { workspace: wsId } } : undefined + ) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Error deleting webhook`, { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 911ce97b348..e58baa8d54c 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import { cleanupExternalWebhook, @@ -763,6 +764,19 @@ export async function POST(request: NextRequest) { metadata: { provider, workflowId }, request, }) + + const wsId = workflowRecord.workspaceId || undefined + captureServerEvent( + userId, + 'webhook_trigger_created', + { + webhook_id: savedWebhook.id, + workflow_id: workflowId, + provider: provider || 'generic', + workspace_id: wsId ?? '', + }, + wsId ? { groups: { workspace: wsId } } : undefined + ) } const status = targetWebhookId ? 200 : 201 diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index c4f6d0087af..e1130d42ffe 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: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + const responseApiKeyInfo = workflowData!.workspaceId ? 'Workspace API keys' : 'Personal API keys' @@ -118,7 +129,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const { id } = await params try { - const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin') + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } @@ -148,6 +163,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) + const wsId = workflowData?.workspaceId + captureServerEvent( + session!.user.id, + 'workflow_public_api_toggled', + { workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi }, + wsId ? { groups: { workspace: wsId } } : undefined + ) + return createSuccessResponse({ isPublicApi }) } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to update deployment settings' @@ -164,7 +187,11 @@ export async function DELETE( const { id } = await params try { - const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin') + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } @@ -179,6 +206,14 @@ export async function DELETE( return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) } + const wsId = workflowData?.workspaceId + captureServerEvent( + session!.user.id, + 'workflow_undeployed', + { workflow_id: id, workspace_id: wsId ?? '' }, + wsId ? { groups: { workspace: wsId } } : undefined + ) + return createSuccessResponse({ isDeployed: false, deployedAt: null, 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..618a1de8f94 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,19 @@ 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, + }, + workflowRecord?.workspaceId + ? { groups: { workspace: workflowRecord.workspaceId } } + : undefined + ) + recordAudit({ workspaceId: workflowRecord?.workspaceId ?? null, actorId: session!.user.id, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 7d4ab62d52c..74fd68d137f 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,6 +4,7 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -174,6 +175,14 @@ export async function PATCH( } } + const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId + captureServerEvent( + actorUserId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: wsId ?? '', version: versionNum }, + wsId ? { groups: { workspace: wsId } } : undefined + ) + return createSuccessResponse({ success: true, deployedAt: activateResult.deployedAt, diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index cc00c0c0b7c..63c230f686b 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 ?? '', + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + 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..4fb045c5816 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 ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + return NextResponse.json({ success: cancellation.durablyRecorded || locallyAborted, executionId, diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index df9c0966de9..a9d6b6ba1a5 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' 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 { restoreWorkflow } from '@/lib/workflows/lifecycle' import { getWorkflowById } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -58,6 +59,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ request, }) + captureServerEvent( + auth.userId, + 'workflow_restored', + { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, + workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + ) + return NextResponse.json({ success: true }) } catch (error) { logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index c746d394db2..95a6ee43dc7 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 ?? '' }, + workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + ) + 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..cab2bbb2324 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' @@ -274,6 +275,16 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`) + captureServerEvent( + userId, + 'workflow_created', + { workflow_id: workflowId, workspace_id: workspaceId ?? '', name }, + { + groups: workspaceId ? { workspace: workspaceId } : undefined, + setOnce: { first_workflow_created_at: new Date().toISOString() }, + } + ) + recordAudit({ workspaceId, actorId: userId, 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/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 96acb82811d..08d3f5802d2 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -7,6 +7,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_WORKFLOW_IDS } from '../constants' @@ -342,6 +343,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { request, }) + captureServerEvent( + session.user.id, + 'notification_channel_deleted', + { + notification_id: notificationId, + notification_type: deletedSubscription.notificationType, + workspace_id: workspaceId, + }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error deleting notification', { error }) 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/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index cf2ed3826d8..375e0879b8b 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +import { captureServerEvent } from '@/lib/posthog/server' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' const logger = createLogger('WorkspaceByIdAPI') @@ -292,6 +293,13 @@ export async function DELETE( request, }) + captureServerEvent( + session.user.id, + 'workspace_deleted', + { workspace_id: workspaceId, workflow_count: workflowIds.length }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true }) } catch (error) { logger.error(`Error deleting workspace ${workspaceId}:`, error) 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]/components/product-tour/product-tour.tsx b/apps/sim/app/workspace/[workspaceId]/components/product-tour/product-tour.tsx index ba659237800..11487f191db 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/product-tour.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/product-tour/product-tour.tsx @@ -26,6 +26,7 @@ export function NavTour() { steps: navTourSteps, triggerEvent: START_NAV_TOUR_EVENT, tourName: 'Navigation tour', + tourType: 'nav', disabled: isWorkflowPage, }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts index 345932b765d..dc41bf013fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { usePostHog } from 'posthog-js/react' import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride' +import { captureEvent } from '@/lib/posthog/client' const logger = createLogger('useTour') @@ -16,6 +18,8 @@ interface UseTourOptions { triggerEvent?: string /** Identifier for logging */ tourName?: string + /** Analytics tour type for PostHog events */ + tourType?: 'nav' | 'workflow' /** When true, stops a running tour (e.g. navigating away from the relevant page) */ disabled?: boolean } @@ -45,8 +49,10 @@ export function useTour({ steps, triggerEvent, tourName = 'tour', + tourType, disabled = false, }: UseTourOptions): UseTourReturn { + const posthog = usePostHog() const [run, setRun] = useState(false) const [stepIndex, setStepIndex] = useState(0) const [tourKey, setTourKey] = useState(0) @@ -152,6 +158,9 @@ export function useTour({ setRun(true) logger.info(`${tourName} triggered via event`) scheduleReveal() + if (tourType) { + captureEvent(posthog, 'tour_started', { tour_type: tourType }) + } }, 50) } @@ -181,6 +190,13 @@ export function useTour({ if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { stopTour() logger.info(`${tourName} ended`, { status }) + if (tourType) { + if (status === STATUS.FINISHED) { + captureEvent(posthog, 'tour_completed', { tour_type: tourType }) + } else { + captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index }) + } + } return } @@ -188,6 +204,9 @@ export function useTour({ if (action === ACTIONS.CLOSE) { stopTour() logger.info(`${tourName} closed by user`) + if (tourType) { + captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index }) + } return } @@ -203,7 +222,7 @@ export function useTour({ transitionToStep(nextIndex) } }, - [stopTour, transitionToStep, steps, tourName] + [stopTour, transitionToStep, steps, tourName, tourType, posthog] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx b/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx index 383a3311c0b..d9c7f334549 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx @@ -26,6 +26,7 @@ export function WorkflowTour() { steps: workflowTourSteps, triggerEvent: START_WORKFLOW_TOUR_EVENT, tourName: 'Workflow tour', + tourType: 'workflow', }) const tourState = useMemo( 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..b0bf8532f99 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 @@ -353,7 +353,17 @@ const TemplateCard = memo(function TemplateCard({ template, onSelect }: Template return (