feat(automations): enhance tracking for automation lifecycle events

- Added tracking for automation creation, updates, deletions, and trigger modifications, including success and failure events.
- Implemented event tracking in the automation creation process, including chat approval and rejection scenarios.
- Updated the instrumentation client to ensure correct typing for PostHog integration.
- Refactored existing mutation atoms to include tracking calls for automation-related actions, improving analytics capabilities.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-30 01:13:21 -07:00
parent b1b51ada89
commit 92b1d7a9f7
4 changed files with 316 additions and 14 deletions

View file

@ -7,6 +7,21 @@ import type {
TriggerUpdateRequest, TriggerUpdateRequest,
} from "@/contracts/types/automation.types"; } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service"; 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 { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client"; import { queryClient } from "@/lib/query-client/client";
@ -33,13 +48,28 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AutomationCreateRequest) => { mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request); return automationsApiService.createAutomation(request);
}, },
onSuccess: (_, variables) => { onSuccess: (automation, variables) => {
invalidateList(variables.search_space_id); invalidateList(variables.search_space_id);
toast.success("Automation created"); 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); console.error("Error creating automation:", error);
toast.error("Failed to create automation"); 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); invalidateDetail(vars.automationId);
invalidateList(automation.search_space_id); invalidateList(automation.search_space_id);
toast.success("Automation updated"); 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); console.error("Error updating automation:", error);
toast.error("Failed to update automation"); 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); invalidateList(vars.searchSpaceId);
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Automation deleted"); 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); console.error("Error deleting automation:", error);
toast.error("Failed to delete automation"); 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 }) => { mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload); return automationsApiService.addTrigger(vars.automationId, vars.payload);
}, },
onSuccess: (_, vars) => { onSuccess: (trigger, vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger added"); 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); console.error("Error adding trigger:", error);
toast.error("Failed to add trigger"); toast.error("Failed to add trigger");
trackAutomationTriggerAddFailed({
automation_id: vars.automationId,
error: error.message,
});
}, },
})); }));
@ -103,10 +174,26 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (_, vars) => { onSuccess: (_, vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger updated"); 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); console.error("Error updating trigger:", error);
toast.error("Failed to update trigger"); 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) => { onSuccess: (vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger removed"); 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); console.error("Error removing trigger:", error);
toast.error("Failed to remove trigger"); toast.error("Failed to remove trigger");
trackAutomationTriggerRemoveFailed({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
error: error.message,
});
}, },
})); }));

View file

@ -4,7 +4,7 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react"; import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
AutomationModelFields, AutomationModelFields,
type AutomationModelSelection, type AutomationModelSelection,
@ -17,6 +17,13 @@ import { automationCreateRequest } from "@/contracts/types/automation.types";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl"; import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl"; import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models"; import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
import {
trackAutomationChatApproved,
trackAutomationChatCreateFailed,
trackAutomationChatCreateSucceeded,
trackAutomationChatDraftEdited,
trackAutomationChatRejected,
} from "@/lib/posthog/events";
import { AutomationDraftPreview } from "./automation-draft-preview"; import { AutomationDraftPreview } from "./automation-draft-preview";
const editArgsSchema = automationCreateRequest.omit({ search_space_id: true }); 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({ onDecision({
type: "edit", type: "edit",
edited_action: { edited_action: {
@ -163,13 +183,17 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
args, args,
pendingEdits, pendingEdits,
resolvedModels, resolvedModels,
searchSpaceId,
]); ]);
const handleReject = useCallback(() => { const handleReject = useCallback(() => {
if (phase !== "pending" || !canReject || isEditing) return; if (phase !== "pending" || !canReject || isEditing) return;
setRejected(); setRejected();
trackAutomationChatRejected({
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
});
onDecision({ type: "reject", message: "User rejected the automation draft." }); onDecision({ type: "reject", message: "User rejected the automation draft." });
}, [phase, canReject, isEditing, setRejected, onDecision]); }, [phase, canReject, isEditing, setRejected, onDecision, searchSpaceId]);
useEffect(() => { useEffect(() => {
if (isEditing) return; if (isEditing) return;
@ -242,6 +266,9 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
onSave={(parsed) => { onSave={(parsed) => {
setPendingEdits(parsed); setPendingEdits(parsed);
setIsEditing(false); setIsEditing(false);
trackAutomationChatDraftEdited({
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
});
}} }}
onCancel={() => setIsEditing(false)} onCancel={() => setIsEditing(false)}
/> />
@ -356,6 +383,17 @@ function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
function SavedCard({ result }: { result: SavedResult }) { function SavedCard({ result }: { result: SavedResult }) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); 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 const detailHref = searchSpaceId
? `/dashboard/${searchSpaceId}/automations/${result.automation_id}` ? `/dashboard/${searchSpaceId}/automations/${result.automation_id}`
: null; : null;
@ -388,6 +426,18 @@ function SavedCard({ result }: { result: SavedResult }) {
} }
function InvalidCard({ result }: { result: InvalidResult }) { 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 ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"> <div className="px-5 pt-5 pb-4">
@ -411,6 +461,18 @@ function InvalidCard({ result }: { result: InvalidResult }) {
} }
function ErrorCard({ result }: { result: ErrorResult }) { function ErrorCard({ result }: { result: ErrorResult }) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const tracked = useRef(false);
useEffect(() => {
if (tracked.current) return;
tracked.current = true;
trackAutomationChatCreateFailed({
reason: "error",
message: result.message,
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
});
}, [result.message, searchSpaceId]);
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"> <div className="px-5 pt-5 pb-4">

View file

@ -88,9 +88,12 @@ async function initPostHog() {
} }
return event; return event;
}, },
loaded: (ph) => { loaded: () => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.posthog = ph; // `loaded` hands back a `PostHogInterface`, but it's the same
// singleton as the default import (typed `PostHog`); use that to
// keep `window.posthog` correctly typed.
window.posthog = posthog;
} }
}, },
}); });

View file

@ -1,5 +1,5 @@
import posthog from "posthog-js";
import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier"; import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry"; import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/** /**
@ -19,6 +19,7 @@ import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
* - connector: External connector events (all lifecycle stages) * - connector: External connector events (all lifecycle stages)
* - contact: Contact form events * - contact: Contact form events
* - settings: Settings changes * - settings: Settings changes
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
* - marketing: Marketing/referral tracking * - marketing: Marketing/referral tracking
*/ */
@ -33,7 +34,7 @@ function safeCapture(event: string, properties?: Record<string, unknown>) {
/** /**
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise. * Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
*/ */
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> { function compact<T extends object>(obj: T): Record<string, unknown> {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
if (v !== undefined) out[k] = v; if (v !== undefined) out[k] = v;
@ -598,6 +599,146 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
}); });
} }
// ============================================
// AUTOMATION EVENTS
// ============================================
interface AutomationCreatedProps {
search_space_id: number;
automation_id: number;
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
agent_llm_id?: number;
image_generation_config_id?: number;
vision_llm_config_id?: number;
tags_count?: number;
}
export function trackAutomationCreated(props: AutomationCreatedProps) {
safeCapture("automation_created", compact(props));
}
export function trackAutomationCreateFailed(props: { search_space_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props));
}
export function trackAutomationUpdated(props: {
automation_id: number;
search_space_id?: number;
has_definition_change?: boolean;
has_name_change?: boolean;
has_description_change?: boolean;
task_count?: number;
}) {
safeCapture("automation_updated", compact(props));
}
export function trackAutomationStatusChanged(props: {
automation_id: number;
search_space_id?: number;
next_status: string;
}) {
safeCapture("automation_status_changed", compact(props));
}
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props));
}
export function trackAutomationDeleted(props: { automation_id: number; search_space_id?: number }) {
safeCapture("automation_deleted", compact(props));
}
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props));
}
export function trackAutomationTriggerAdded(props: {
automation_id: number;
trigger_id?: number;
trigger_type?: string;
enabled?: boolean;
has_cron?: boolean;
}) {
safeCapture("automation_trigger_added", compact(props));
}
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props));
}
export function trackAutomationTriggerUpdated(props: {
automation_id: number;
trigger_id: number;
change?: "enabled" | "params" | "other";
enabled?: boolean;
}) {
safeCapture("automation_trigger_updated", compact(props));
}
export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number;
trigger_id: number;
error?: string;
}) {
safeCapture("automation_trigger_update_failed", compact(props));
}
export function trackAutomationTriggerRemoved(props: {
automation_id: number;
trigger_id: number;
}) {
safeCapture("automation_trigger_removed", compact(props));
}
export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number;
trigger_id: number;
error?: string;
}) {
safeCapture("automation_trigger_remove_failed", compact(props));
}
interface AutomationChatDecisionProps {
search_space_id?: number;
edited?: boolean;
task_count?: number;
trigger_type?: string;
agent_llm_id?: number;
image_generation_config_id?: number;
vision_llm_config_id?: number;
}
export function trackAutomationChatApproved(props: AutomationChatDecisionProps) {
safeCapture("automation_chat_approved", compact(props));
}
export function trackAutomationChatRejected(props: { search_space_id?: number }) {
safeCapture("automation_chat_rejected", compact(props));
}
export function trackAutomationChatDraftEdited(props: { search_space_id?: number }) {
safeCapture("automation_chat_draft_edited", compact(props));
}
export function trackAutomationChatCreateSucceeded(props: {
automation_id: number;
name?: string;
search_space_id?: number;
}) {
safeCapture("automation_chat_create_succeeded", compact(props));
}
export function trackAutomationChatCreateFailed(props: {
reason: "invalid" | "error";
search_space_id?: number;
issue_count?: number;
message?: string;
}) {
safeCapture("automation_chat_create_failed", compact(props));
}
// ============================================ // ============================================
// USER IDENTIFICATION // USER IDENTIFICATION
// ============================================ // ============================================