diff --git a/surfsense_web/atoms/automations/automations-mutation.atoms.ts b/surfsense_web/atoms/automations/automations-mutation.atoms.ts index f5e4fd5f4..a81cd1578 100644 --- a/surfsense_web/atoms/automations/automations-mutation.atoms.ts +++ b/surfsense_web/atoms/automations/automations-mutation.atoms.ts @@ -7,6 +7,21 @@ import type { TriggerUpdateRequest, } from "@/contracts/types/automation.types"; import { automationsApiService } from "@/lib/apis/automations-api.service"; +import { + trackAutomationCreated, + trackAutomationCreateFailed, + trackAutomationDeleted, + trackAutomationDeleteFailed, + trackAutomationStatusChanged, + trackAutomationTriggerAdded, + trackAutomationTriggerAddFailed, + trackAutomationTriggerRemoved, + trackAutomationTriggerRemoveFailed, + trackAutomationTriggerUpdated, + trackAutomationTriggerUpdateFailed, + trackAutomationUpdated, + trackAutomationUpdateFailed, +} from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; @@ -33,13 +48,28 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({ mutationFn: async (request: AutomationCreateRequest) => { return automationsApiService.createAutomation(request); }, - onSuccess: (_, variables) => { + onSuccess: (automation, variables) => { invalidateList(variables.search_space_id); toast.success("Automation created"); + trackAutomationCreated({ + search_space_id: variables.search_space_id, + automation_id: automation.id, + task_count: variables.definition.plan.length, + trigger_type: variables.triggers?.[0]?.type ?? "none", + has_schedule: (variables.triggers?.length ?? 0) > 0, + agent_llm_id: variables.definition.models?.agent_llm_id, + image_generation_config_id: variables.definition.models?.image_generation_config_id, + vision_llm_config_id: variables.definition.models?.vision_llm_config_id, + tags_count: variables.definition.metadata?.tags?.length, + }); }, - onError: (error: Error) => { + onError: (error: Error, variables) => { console.error("Error creating automation:", error); toast.error("Failed to create automation"); + trackAutomationCreateFailed({ + search_space_id: variables.search_space_id, + error: error.message, + }); }, })); @@ -52,10 +82,32 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({ invalidateDetail(vars.automationId); invalidateList(automation.search_space_id); toast.success("Automation updated"); + // A status-only patch (pause/resume/archive) is a distinct action from a + // definition/name edit, so split it into its own event. + if (vars.patch.status && !vars.patch.definition) { + trackAutomationStatusChanged({ + automation_id: vars.automationId, + search_space_id: automation.search_space_id, + next_status: vars.patch.status, + }); + } else { + trackAutomationUpdated({ + automation_id: vars.automationId, + search_space_id: automation.search_space_id, + has_definition_change: !!vars.patch.definition, + has_name_change: vars.patch.name != null, + has_description_change: vars.patch.description !== undefined, + task_count: vars.patch.definition?.plan?.length, + }); + } }, - onError: (error: Error) => { + onError: (error: Error, vars) => { console.error("Error updating automation:", error); toast.error("Failed to update automation"); + trackAutomationUpdateFailed({ + automation_id: vars.automationId, + error: error.message, + }); }, })); @@ -69,10 +121,18 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({ invalidateList(vars.searchSpaceId); invalidateDetail(vars.automationId); toast.success("Automation deleted"); + trackAutomationDeleted({ + automation_id: vars.automationId, + search_space_id: vars.searchSpaceId, + }); }, - onError: (error: Error) => { + onError: (error: Error, vars) => { console.error("Error deleting automation:", error); toast.error("Failed to delete automation"); + trackAutomationDeleteFailed({ + automation_id: vars.automationId, + error: error.message, + }); }, })); @@ -81,13 +141,24 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({ mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => { return automationsApiService.addTrigger(vars.automationId, vars.payload); }, - onSuccess: (_, vars) => { + onSuccess: (trigger, vars) => { invalidateDetail(vars.automationId); toast.success("Trigger added"); + trackAutomationTriggerAdded({ + automation_id: vars.automationId, + trigger_id: trigger.id, + trigger_type: trigger.type, + enabled: trigger.enabled, + has_cron: !!trigger.params?.cron, + }); }, - onError: (error: Error) => { + onError: (error: Error, vars) => { console.error("Error adding trigger:", error); toast.error("Failed to add trigger"); + trackAutomationTriggerAddFailed({ + automation_id: vars.automationId, + error: error.message, + }); }, })); @@ -103,10 +174,26 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({ onSuccess: (_, vars) => { invalidateDetail(vars.automationId); toast.success("Trigger updated"); + const change: "enabled" | "params" | "other" = vars.patch.params + ? "params" + : vars.patch.enabled !== undefined && vars.patch.enabled !== null + ? "enabled" + : "other"; + trackAutomationTriggerUpdated({ + automation_id: vars.automationId, + trigger_id: vars.triggerId, + change, + enabled: vars.patch.enabled ?? undefined, + }); }, - onError: (error: Error) => { + onError: (error: Error, vars) => { console.error("Error updating trigger:", error); toast.error("Failed to update trigger"); + trackAutomationTriggerUpdateFailed({ + automation_id: vars.automationId, + trigger_id: vars.triggerId, + error: error.message, + }); }, })); @@ -119,9 +206,18 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({ onSuccess: (vars) => { invalidateDetail(vars.automationId); toast.success("Trigger removed"); + trackAutomationTriggerRemoved({ + automation_id: vars.automationId, + trigger_id: vars.triggerId, + }); }, - onError: (error: Error) => { + onError: (error: Error, vars) => { console.error("Error removing trigger:", error); toast.error("Failed to remove trigger"); + trackAutomationTriggerRemoveFailed({ + automation_id: vars.automationId, + trigger_id: vars.triggerId, + error: error.message, + }); }, })); diff --git a/surfsense_web/components/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx index 1bd20bbde..d4cc0ec4d 100644 --- a/surfsense_web/components/tool-ui/automation/create-automation.tsx +++ b/surfsense_web/components/tool-ui/automation/create-automation.tsx @@ -4,7 +4,7 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AutomationModelFields, type AutomationModelSelection, @@ -17,6 +17,13 @@ import { automationCreateRequest } from "@/contracts/types/automation.types"; import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl"; import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl"; import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models"; +import { + trackAutomationChatApproved, + trackAutomationChatCreateFailed, + trackAutomationChatCreateSucceeded, + trackAutomationChatDraftEdited, + trackAutomationChatRejected, +} from "@/lib/posthog/events"; import { AutomationDraftPreview } from "./automation-draft-preview"; const editArgsSchema = automationCreateRequest.omit({ search_space_id: true }); @@ -145,6 +152,19 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { }, }, }; + const plan = Array.isArray(baseDefinition.plan) ? baseDefinition.plan : []; + const triggers = Array.isArray(baseArgs.triggers) ? baseArgs.triggers : []; + trackAutomationChatApproved({ + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + edited: pendingEdits !== null, + task_count: plan.length, + trigger_type: + (triggers[0] as { type?: string } | undefined)?.type ?? + (triggers.length ? undefined : "none"), + agent_llm_id: resolvedModels.agentLlmId, + image_generation_config_id: resolvedModels.imageConfigId, + vision_llm_config_id: resolvedModels.visionConfigId, + }); onDecision({ type: "edit", edited_action: { @@ -163,13 +183,17 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { args, pendingEdits, resolvedModels, + searchSpaceId, ]); const handleReject = useCallback(() => { if (phase !== "pending" || !canReject || isEditing) return; setRejected(); + trackAutomationChatRejected({ + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + }); onDecision({ type: "reject", message: "User rejected the automation draft." }); - }, [phase, canReject, isEditing, setRejected, onDecision]); + }, [phase, canReject, isEditing, setRejected, onDecision, searchSpaceId]); useEffect(() => { if (isEditing) return; @@ -242,6 +266,9 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { onSave={(parsed) => { setPendingEdits(parsed); setIsEditing(false); + trackAutomationChatDraftEdited({ + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + }); }} onCancel={() => setIsEditing(false)} /> @@ -356,6 +383,17 @@ function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) { function SavedCard({ result }: { result: SavedResult }) { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const tracked = useRef(false); + useEffect(() => { + if (tracked.current) return; + tracked.current = true; + trackAutomationChatCreateSucceeded({ + automation_id: result.automation_id, + name: result.name, + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + }); + }, [result.automation_id, result.name, searchSpaceId]); + const detailHref = searchSpaceId ? `/dashboard/${searchSpaceId}/automations/${result.automation_id}` : null; @@ -388,6 +426,18 @@ function SavedCard({ result }: { result: SavedResult }) { } function InvalidCard({ result }: { result: InvalidResult }) { + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const tracked = useRef(false); + useEffect(() => { + if (tracked.current) return; + tracked.current = true; + trackAutomationChatCreateFailed({ + reason: "invalid", + issue_count: result.issues.length, + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + }); + }, [result.issues.length, searchSpaceId]); + return (