From 8c3c91e64902d096a467a072938f8a21fc3f3010 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 23:37:44 -0700 Subject: [PATCH 1/3] add loops analytics events --- packages/shared/src/analytics-events.ts | 153 ++++++++++++++++++ .../loops/components/LoopDetailView.tsx | 88 +++++++++- .../features/loops/components/LoopForm.tsx | 43 ++++- .../features/loops/components/LoopRunRow.tsx | 16 +- .../loops/components/LoopsListView.tsx | 28 +++- 5 files changed, 321 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 9a2a1a968e..c47dc00641 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -1084,6 +1084,137 @@ export interface AutoresearchRunStartedProperties { workspace_mode?: "local" | "worktree" | "cloud"; } +// Loops events +export type LoopReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type LoopOverlapPolicy = "skip" | "allow" | "cancel_previous"; +export type LoopRunBlockedReason = + | "deduped" + | "overlap_skipped" + | "rate_capped" + | "team_rate_capped" + | "disabled" + | "gate_blocked" + | "owner_inactive" + | "owner_changed"; +export type LoopRunStatus = + | "not_started" + | "queued" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +export interface LoopListViewedProperties { + loop_count: number; + personal_loop_count: number; + team_loop_count: number; + is_at_limit: boolean; + /** Backend-enforced per-project cap; omitted while the limit is still loading. */ + loop_limit?: number; + builder_session_count: number; +} + +export interface LoopViewedProperties { + loop_id: string; + visibility: "personal" | "team"; + enabled: boolean; + /** Backend-open string; null when enabled or manually paused with no reason given. */ + disabled_reason: string | null; + runtime_adapter: "claude" | "codex"; + model?: string; + reasoning_effort: LoopReasoningEffort | null; + repository_count: number; + trigger_count: number; + has_schedule_trigger: boolean; + has_github_trigger: boolean; + has_api_trigger: boolean; + /** Backend-open string, not a closed enum. */ + last_run_status: string | null; + consecutive_failures: number; + recent_run_count: number; +} + +export interface LoopCreatedProperties { + loop_id: string; + visibility: "personal" | "team"; + runtime_adapter: "claude" | "codex"; + model?: string; + reasoning_effort: LoopReasoningEffort | null; + repository_count: number; + trigger_count: number; + has_schedule_trigger: boolean; + has_github_trigger: boolean; + has_api_trigger: boolean; + is_pr_creation_enabled: boolean; + is_auto_fix_enabled: boolean; + /** Count of notifications.{push,email,slack} that are enabled. */ + notification_channel_count: number; + has_context_target: boolean; +} + +export interface LoopUpdatedProperties { + loop_id: string; + visibility: "personal" | "team"; + runtime_adapter: "claude" | "codex"; + model?: string; + reasoning_effort: LoopReasoningEffort | null; + repository_count: number; + trigger_count: number; + has_schedule_trigger: boolean; + has_github_trigger: boolean; + has_api_trigger: boolean; + is_pr_creation_enabled: boolean; + is_auto_fix_enabled: boolean; + /** Count of notifications.{push,email,slack} that are enabled. */ + notification_channel_count: number; + has_context_target: boolean; +} + +export interface LoopDeletedProperties { + loop_id: string; + visibility: "personal" | "team"; + enabled: boolean; + trigger_count: number; + /** State at time of deletion, distinguishes deleting a healthy loop from abandoning a failing one. */ + consecutive_failures: number; +} + +export interface LoopEnabledToggledProperties { + loop_id: string; + /** The new value the loop is being switched to. */ + enabled: boolean; + visibility: "personal" | "team"; + /** True when this toggle clears or reinstates a backend auto-pause rather than a routine manual pause/resume. */ + was_auto_paused: boolean; + success: boolean; +} + +export interface LoopRunStartedProperties { + loop_id: string; + task_id: string | null; + task_run_id: string | null; + runtime_adapter: "claude" | "codex"; + model?: string; + trigger_count: number; +} + +export interface LoopRunBlockedProperties { + loop_id: string; + reason: LoopRunBlockedReason; + overlap_policy: LoopOverlapPolicy; + trigger_count: number; +} + +export interface LoopRunViewedProperties { + loop_id: string; + run_id: string; + task_id: string; + status: LoopRunStatus; + environment: "local" | "cloud"; + /** True when the run wasn't triggered by a schedule/github/api trigger. */ + is_manual_run: boolean; +} + // Event names as constants export const ANALYTICS_EVENTS = { // App lifecycle @@ -1255,6 +1386,17 @@ export const ANALYTICS_EVENTS = { LOOPS_PROMO_OPENED: "Loops promo opened", LOOPS_PROMO_DISMISSED: "Loops promo dismissed", LOOPS_PROMO_LEARN_MORE_CLICKED: "Loops promo learn more clicked", + + // Loops events + LOOP_LIST_VIEWED: "Loop list viewed", + LOOP_VIEWED: "Loop viewed", + LOOP_CREATED: "Loop created", + LOOP_UPDATED: "Loop updated", + LOOP_DELETED: "Loop deleted", + LOOP_ENABLED_TOGGLED: "Loop enabled toggled", + LOOP_RUN_STARTED: "Loop run started", + LOOP_RUN_BLOCKED: "Loop run blocked", + LOOP_RUN_VIEWED: "Loop run viewed", } as const; // Event property mapping @@ -1420,6 +1562,17 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.LOOPS_PROMO_OPENED]: never; [ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED]: never; [ANALYTICS_EVENTS.LOOPS_PROMO_LEARN_MORE_CLICKED]: never; + + // Loops events + [ANALYTICS_EVENTS.LOOP_LIST_VIEWED]: LoopListViewedProperties; + [ANALYTICS_EVENTS.LOOP_VIEWED]: LoopViewedProperties; + [ANALYTICS_EVENTS.LOOP_CREATED]: LoopCreatedProperties; + [ANALYTICS_EVENTS.LOOP_UPDATED]: LoopUpdatedProperties; + [ANALYTICS_EVENTS.LOOP_DELETED]: LoopDeletedProperties; + [ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED]: LoopEnabledToggledProperties; + [ANALYTICS_EVENTS.LOOP_RUN_STARTED]: LoopRunStartedProperties; + [ANALYTICS_EVENTS.LOOP_RUN_BLOCKED]: LoopRunBlockedProperties; + [ANALYTICS_EVENTS.LOOP_RUN_VIEWED]: LoopRunViewedProperties; }; /** diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 2d7bae6ff8..5e9ff586fd 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -13,6 +13,10 @@ import { Switch, Textarea, } from "@posthog/quill"; +import { + ANALYTICS_EVENTS, + type LoopRunBlockedReason, +} from "@posthog/shared/analytics-events"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage"; import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; @@ -26,8 +30,9 @@ import { navigateToEditLoop, navigateToLoops, } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; import { Flex, Text } from "@radix-ui/themes"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLoop } from "../hooks/useLoop"; import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel"; import { @@ -59,6 +64,34 @@ export function LoopDetailView({ loopId }: { loopId: string }) { const runsQuery = useLoopRuns(loopId); const runs = runsQuery.data ?? []; + const viewTrackedFor = useRef(null); + useEffect(() => { + if (isLoading || runsQuery.isLoading || !loop) return; + if (viewTrackedFor.current === loop.id) return; + viewTrackedFor.current = loop.id; + track(ANALYTICS_EVENTS.LOOP_VIEWED, { + loop_id: loop.id, + visibility: loop.visibility, + enabled: loop.enabled, + disabled_reason: loop.disabled_reason, + runtime_adapter: loop.runtime_adapter, + model: loop.model || undefined, + reasoning_effort: loop.reasoning_effort, + repository_count: loop.repositories.length, + trigger_count: loop.triggers.length, + has_schedule_trigger: loop.triggers.some( + (trigger) => trigger.type === "schedule", + ), + has_github_trigger: loop.triggers.some( + (trigger) => trigger.type === "github", + ), + has_api_trigger: loop.triggers.some((trigger) => trigger.type === "api"), + last_run_status: loop.last_run_status, + consecutive_failures: loop.consecutive_failures, + recent_run_count: runs.length, + }); + }, [isLoading, runsQuery.isLoading, loop, runs.length]); + useSetHeaderContent( @@ -72,31 +105,69 @@ export function LoopDetailView({ loopId }: { loopId: string }) { ); const handleToggleEnabled = (enabled: boolean) => { + if (!loop) return; updateLoop.mutate( { enabled }, { - onError: (error) => + onSuccess: () => { + track(ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED, { + loop_id: loop.id, + enabled, + visibility: loop.visibility, + was_auto_paused: loop.disabled_reason !== null, + success: true, + }); + }, + onError: (error) => { + track(ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED, { + loop_id: loop.id, + enabled, + visibility: loop.visibility, + was_auto_paused: loop.disabled_reason !== null, + success: false, + }); toast.error("Failed to update loop", { description: error.message, - }), + }); + }, }, ); }; const handleRunNow = async () => { - if (runNowPending) return; + if (runNowPending || !loop) return; setRunNowPending(true); try { if (!(await assertCloudUsageAvailable())) return; const result = await runLoop.mutateAsync(); if (result.created) { toast.success("Loop run started"); + track(ANALYTICS_EVENTS.LOOP_RUN_STARTED, { + loop_id: loop.id, + task_id: result.task_id, + task_run_id: result.task_run_id, + runtime_adapter: loop.runtime_adapter, + model: loop.model || undefined, + trigger_count: loop.triggers.length, + }); } else if (result.reason === "gate_blocked") { useUsageLimitStore.getState().show({ cause: "org_limit" }); + track(ANALYTICS_EVENTS.LOOP_RUN_BLOCKED, { + loop_id: loop.id, + reason: result.reason, + overlap_policy: loop.overlap_policy, + trigger_count: loop.triggers.length, + }); } else { toast.error("Run not started", { description: loopFireBlockedMessage(result.reason), }); + track(ANALYTICS_EVENTS.LOOP_RUN_BLOCKED, { + loop_id: loop.id, + reason: result.reason as LoopRunBlockedReason, + overlap_policy: loop.overlap_policy, + trigger_count: loop.triggers.length, + }); } } catch (error) { toast.error("Failed to start run", { @@ -108,8 +179,16 @@ export function LoopDetailView({ loopId }: { loopId: string }) { }; const handleDelete = () => { + if (!loop) return; deleteLoop.mutate(loopId, { onSuccess: () => { + track(ANALYTICS_EVENTS.LOOP_DELETED, { + loop_id: loop.id, + visibility: loop.visibility, + enabled: loop.enabled, + trigger_count: loop.triggers.length, + consecutive_failures: loop.consecutive_failures, + }); toast.success("Loop deleted"); navigateToLoops(); }, @@ -235,6 +314,7 @@ export function LoopDetailView({ loopId }: { loopId: string }) { {runs.map((run) => ( void runsQuery.refetch()} /> diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index 93b1b3f485..b7f32e070e 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -5,7 +5,12 @@ import { Check, } from "@phosphor-icons/react"; import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + type LoopCreatedProperties, + type LoopUpdatedProperties, + PROJECT_BLUEBIRD_FLAG, +} from "@posthog/shared"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; @@ -16,6 +21,7 @@ import { navigateToLoopDetail, navigateToLoops, } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; import { type ReactNode, useEffect, useState } from "react"; import { useAuthStateValue } from "../../auth/store"; @@ -56,6 +62,33 @@ const ADAPTER_LABELS: Record = { const STEPS = ["Prompt", "When", "Options", "Review"] as const; +function buildLoopAnalyticsProperties( + loop: LoopSchemas.Loop, +): LoopCreatedProperties | LoopUpdatedProperties { + return { + loop_id: loop.id, + visibility: loop.visibility, + runtime_adapter: loop.runtime_adapter, + model: loop.model || undefined, + reasoning_effort: loop.reasoning_effort, + repository_count: loop.repositories.length, + trigger_count: loop.triggers.length, + has_schedule_trigger: loop.triggers.some( + (trigger) => trigger.type === "schedule", + ), + has_github_trigger: loop.triggers.some( + (trigger) => trigger.type === "github", + ), + has_api_trigger: loop.triggers.some((trigger) => trigger.type === "api"), + is_pr_creation_enabled: loop.behaviors.create_prs, + is_auto_fix_enabled: isAutoFixEnabled(loop.behaviors), + notification_channel_count: (["push", "email", "slack"] as const).filter( + (channel) => loop.notifications[channel]?.enabled, + ).length, + has_context_target: loop.context_target !== null, + }; +} + interface LoopFormProps { /** Present in edit mode; absent when creating a new loop. */ loop?: LoopSchemas.Loop; @@ -140,9 +173,17 @@ export function LoopForm({ loop }: LoopFormProps) { try { if (isEdit) { const updated = await updateLoop.mutateAsync(body); + track( + ANALYTICS_EVENTS.LOOP_UPDATED, + buildLoopAnalyticsProperties(updated), + ); navigateToLoopDetail(updated.id); } else { const created = await createLoop.mutateAsync(body); + track( + ANALYTICS_EVENTS.LOOP_CREATED, + buildLoopAnalyticsProperties(created), + ); navigateToLoopDetail(created.id); } } catch (error) { diff --git a/packages/ui/src/features/loops/components/LoopRunRow.tsx b/packages/ui/src/features/loops/components/LoopRunRow.tsx index 4d5619ce15..078a6c2e26 100644 --- a/packages/ui/src/features/loops/components/LoopRunRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRunRow.tsx @@ -12,11 +12,13 @@ import { } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { cn } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog"; import { Badge } from "@posthog/ui/primitives/Badge"; import { Button } from "@posthog/ui/primitives/Button"; import { toast } from "@posthog/ui/primitives/toast"; import { navigateToTaskDetail } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; import { Flex, Text } from "@radix-ui/themes"; import { type ReactNode, useState } from "react"; @@ -119,9 +121,11 @@ function isStoppable(run: LoopSchemas.LoopRun): boolean { } export function LoopRunRow({ + loopId, run, onStopped, }: { + loopId: string; run: LoopSchemas.LoopRun; onStopped?: () => void; }) { @@ -194,7 +198,17 @@ export function LoopRunRow({ variant="soft" color="gray" size="1" - onClick={() => navigateToTaskDetail(run.task_id)} + onClick={() => { + track(ANALYTICS_EVENTS.LOOP_RUN_VIEWED, { + loop_id: loopId, + run_id: run.id, + task_id: run.task_id, + status: run.status, + environment: run.environment, + is_manual_run: run.loop_trigger_id === null, + }); + navigateToTaskDetail(run.task_id); + }} > View run diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index a01b27d0c9..3491328acc 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -5,6 +5,7 @@ import { RepeatIcon, } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog"; @@ -15,8 +16,9 @@ import { navigateToNewLoop, navigateToTaskDetail, } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; import { Flex, Heading, Text } from "@radix-ui/themes"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLoopBuilderSessions } from "../hooks/useLoopBuilderSessions"; import { useLoopLimits, useLoops } from "../hooks/useLoops"; import { @@ -87,6 +89,9 @@ export function LoopsListView() { const builderSessions = useLoopBuilderSessions(); const allLoops = loops ?? []; + const personalLoops = allLoops.filter( + (loop) => loop.visibility === "personal", + ); const teamLoops = allLoops.filter((loop) => loop.visibility === "team"); const { members, @@ -95,6 +100,27 @@ export function LoopsListView() { isComplete: membersComplete, } = useOrgMembers({ enabled: teamLoops.length > 0 }); + const hasTrackedListViewedRef = useRef(false); + useEffect(() => { + if (isLoading || hasTrackedListViewedRef.current) return; + hasTrackedListViewedRef.current = true; + track(ANALYTICS_EVENTS.LOOP_LIST_VIEWED, { + loop_count: allLoops.length, + personal_loop_count: personalLoops.length, + team_loop_count: teamLoops.length, + is_at_limit: limits?.atLimit ?? false, + loop_limit: limits?.max, + builder_session_count: builderSessions.length, + }); + }, [ + isLoading, + allLoops.length, + personalLoops.length, + teamLoops.length, + limits, + builderSessions.length, + ]); + return ( Date: Thu, 23 Jul 2026 23:45:33 -0700 Subject: [PATCH 2/3] address loops analytics review feedback --- .../loops/components/LoopDetailView.tsx | 26 ++------- .../features/loops/components/LoopForm.tsx | 45 ++------------ .../loops/components/LoopsListView.tsx | 7 ++- .../loops/hooks/useLoopBuilderSessions.ts | 15 ++++- .../ui/src/features/loops/loopAnalytics.ts | 58 +++++++++++++++++++ 5 files changed, 85 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/features/loops/loopAnalytics.ts diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 5e9ff586fd..c0cf763258 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -41,6 +41,7 @@ import { useUpdateLoop, } from "../hooks/useLoopMutations"; import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns"; +import { buildLoopViewedProps } from "../loopAnalytics"; import { describeTrigger, loopFireBlockedMessage, @@ -69,27 +70,10 @@ export function LoopDetailView({ loopId }: { loopId: string }) { if (isLoading || runsQuery.isLoading || !loop) return; if (viewTrackedFor.current === loop.id) return; viewTrackedFor.current = loop.id; - track(ANALYTICS_EVENTS.LOOP_VIEWED, { - loop_id: loop.id, - visibility: loop.visibility, - enabled: loop.enabled, - disabled_reason: loop.disabled_reason, - runtime_adapter: loop.runtime_adapter, - model: loop.model || undefined, - reasoning_effort: loop.reasoning_effort, - repository_count: loop.repositories.length, - trigger_count: loop.triggers.length, - has_schedule_trigger: loop.triggers.some( - (trigger) => trigger.type === "schedule", - ), - has_github_trigger: loop.triggers.some( - (trigger) => trigger.type === "github", - ), - has_api_trigger: loop.triggers.some((trigger) => trigger.type === "api"), - last_run_status: loop.last_run_status, - consecutive_failures: loop.consecutive_failures, - recent_run_count: runs.length, - }); + track( + ANALYTICS_EVENTS.LOOP_VIEWED, + buildLoopViewedProps(loop, runs.length), + ); }, [isLoading, runsQuery.isLoading, loop, runs.length]); useSetHeaderContent( diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index b7f32e070e..a2c56d5b91 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -5,12 +5,7 @@ import { Check, } from "@phosphor-icons/react"; import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops"; -import { - ANALYTICS_EVENTS, - type LoopCreatedProperties, - type LoopUpdatedProperties, - PROJECT_BLUEBIRD_FLAG, -} from "@posthog/shared"; +import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; @@ -26,6 +21,7 @@ import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; import { type ReactNode, useEffect, useState } from "react"; import { useAuthStateValue } from "../../auth/store"; import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations"; +import { buildLoopSavedProps } from "../loopAnalytics"; import { summarizeTrigger } from "../loopDisplay"; import { useLoopDraftStore } from "../loopDraftStore"; import { @@ -62,33 +58,6 @@ const ADAPTER_LABELS: Record = { const STEPS = ["Prompt", "When", "Options", "Review"] as const; -function buildLoopAnalyticsProperties( - loop: LoopSchemas.Loop, -): LoopCreatedProperties | LoopUpdatedProperties { - return { - loop_id: loop.id, - visibility: loop.visibility, - runtime_adapter: loop.runtime_adapter, - model: loop.model || undefined, - reasoning_effort: loop.reasoning_effort, - repository_count: loop.repositories.length, - trigger_count: loop.triggers.length, - has_schedule_trigger: loop.triggers.some( - (trigger) => trigger.type === "schedule", - ), - has_github_trigger: loop.triggers.some( - (trigger) => trigger.type === "github", - ), - has_api_trigger: loop.triggers.some((trigger) => trigger.type === "api"), - is_pr_creation_enabled: loop.behaviors.create_prs, - is_auto_fix_enabled: isAutoFixEnabled(loop.behaviors), - notification_channel_count: (["push", "email", "slack"] as const).filter( - (channel) => loop.notifications[channel]?.enabled, - ).length, - has_context_target: loop.context_target !== null, - }; -} - interface LoopFormProps { /** Present in edit mode; absent when creating a new loop. */ loop?: LoopSchemas.Loop; @@ -173,17 +142,11 @@ export function LoopForm({ loop }: LoopFormProps) { try { if (isEdit) { const updated = await updateLoop.mutateAsync(body); - track( - ANALYTICS_EVENTS.LOOP_UPDATED, - buildLoopAnalyticsProperties(updated), - ); + track(ANALYTICS_EVENTS.LOOP_UPDATED, buildLoopSavedProps(updated)); navigateToLoopDetail(updated.id); } else { const created = await createLoop.mutateAsync(body); - track( - ANALYTICS_EVENTS.LOOP_CREATED, - buildLoopAnalyticsProperties(created), - ); + track(ANALYTICS_EVENTS.LOOP_CREATED, buildLoopSavedProps(created)); navigateToLoopDetail(created.id); } } catch (error) { diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 3491328acc..7c0f9f2f69 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -86,7 +86,8 @@ export function LoopsListView() { ); useSetHeaderContent(headerContent); - const builderSessions = useLoopBuilderSessions(); + const { sessions: builderSessions, isSettled: builderSessionsSettled } = + useLoopBuilderSessions(); const allLoops = loops ?? []; const personalLoops = allLoops.filter( @@ -102,7 +103,8 @@ export function LoopsListView() { const hasTrackedListViewedRef = useRef(false); useEffect(() => { - if (isLoading || hasTrackedListViewedRef.current) return; + if (isLoading || !builderSessionsSettled || hasTrackedListViewedRef.current) + return; hasTrackedListViewedRef.current = true; track(ANALYTICS_EVENTS.LOOP_LIST_VIEWED, { loop_count: allLoops.length, @@ -114,6 +116,7 @@ export function LoopsListView() { }); }, [ isLoading, + builderSessionsSettled, allLoops.length, personalLoops.length, teamLoops.length, diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts index e6085ea65d..037bae5be5 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts @@ -23,8 +23,14 @@ import { * session. Other identities' sessions are never shown or pruned: the summaries * this hook queries are only authoritative for the signed-in account. The * liveness decision itself is the pure `isBuilderSessionEnded`. + * + * `isSettled` is false until the summaries backing the liveness check have + * resolved, i.e. while `sessions` may still contain entries about to be pruned. */ -export function useLoopBuilderSessions(): LoopBuilderSession[] { +export function useLoopBuilderSessions(): { + sessions: LoopBuilderSession[]; + isSettled: boolean; +} { const identity = useAuthStateValue(getAuthIdentity); const allSessions = useLoopBuilderSessionStore((state) => state.sessions); const sessions = useMemo( @@ -82,7 +88,7 @@ export function useLoopBuilderSessions(): LoopBuilderSession[] { } }, [summaries, archivedTaskIds, now, identity]); - return useMemo(() => { + const liveSessions = useMemo(() => { if (!summaries) { return sessions.filter((session) => !archivedTaskIds.has(session.taskId)); } @@ -91,4 +97,9 @@ export function useLoopBuilderSessions(): LoopBuilderSession[] { !isBuilderSessionEnded(session, summaries, archivedTaskIds, now), ); }, [sessions, summaries, archivedTaskIds, now]); + + return { + sessions: liveSessions, + isSettled: sessions.length === 0 || summaries !== null, + }; } diff --git a/packages/ui/src/features/loops/loopAnalytics.ts b/packages/ui/src/features/loops/loopAnalytics.ts new file mode 100644 index 0000000000..7ccfbc5c06 --- /dev/null +++ b/packages/ui/src/features/loops/loopAnalytics.ts @@ -0,0 +1,58 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; +import type { + LoopCreatedProperties, + LoopUpdatedProperties, + LoopViewedProperties, +} from "@posthog/shared/analytics-events"; +import { isAutoFixEnabled } from "./loopFormTypes"; + +function triggerFlags(triggers: LoopSchemas.Loop["triggers"]) { + return { + trigger_count: triggers.length, + has_schedule_trigger: triggers.some( + (trigger) => trigger.type === "schedule", + ), + has_github_trigger: triggers.some((trigger) => trigger.type === "github"), + has_api_trigger: triggers.some((trigger) => trigger.type === "api"), + }; +} + +export function buildLoopViewedProps( + loop: LoopSchemas.Loop, + recentRunCount: number, +): LoopViewedProperties { + return { + loop_id: loop.id, + visibility: loop.visibility, + enabled: loop.enabled, + disabled_reason: loop.disabled_reason, + runtime_adapter: loop.runtime_adapter, + model: loop.model || undefined, + reasoning_effort: loop.reasoning_effort, + repository_count: loop.repositories.length, + ...triggerFlags(loop.triggers), + last_run_status: loop.last_run_status, + consecutive_failures: loop.consecutive_failures, + recent_run_count: recentRunCount, + }; +} + +export function buildLoopSavedProps( + loop: LoopSchemas.Loop, +): LoopCreatedProperties | LoopUpdatedProperties { + return { + loop_id: loop.id, + visibility: loop.visibility, + runtime_adapter: loop.runtime_adapter, + model: loop.model || undefined, + reasoning_effort: loop.reasoning_effort, + repository_count: loop.repositories.length, + ...triggerFlags(loop.triggers), + is_pr_creation_enabled: loop.behaviors.create_prs, + is_auto_fix_enabled: isAutoFixEnabled(loop.behaviors), + notification_channel_count: (["push", "email", "slack"] as const).filter( + (channel) => loop.notifications[channel]?.enabled, + ).length, + has_context_target: loop.context_target !== null, + }; +} From 9ab43cf97b0ad7068f15c0f63a3dea2497df6e21 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 24 Jul 2026 00:06:38 -0700 Subject: [PATCH 3/3] harden loops analytics gating and add tests --- packages/shared/src/analytics-events.ts | 32 +- .../loops/components/LoopDetailView.tsx | 50 ++- .../features/loops/components/LoopRunRow.tsx | 2 +- .../loops/components/LoopsListView.tsx | 8 +- .../hooks/useLoopBuilderSessions.test.tsx | 135 ++++++++ .../loops/hooks/useLoopBuilderSessions.ts | 8 +- .../src/features/loops/loopAnalytics.test.ts | 288 ++++++++++++++++++ .../ui/src/features/loops/loopAnalytics.ts | 20 +- .../features/loops/loopBuilderSessionStore.ts | 13 + 9 files changed, 496 insertions(+), 60 deletions(-) create mode 100644 packages/ui/src/features/loops/hooks/useLoopBuilderSessions.test.tsx create mode 100644 packages/ui/src/features/loops/loopAnalytics.test.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index c47dc00641..c6b6c42644 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -1085,9 +1085,9 @@ export interface AutoresearchRunStartedProperties { } // Loops events -export type LoopReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; -export type LoopOverlapPolicy = "skip" | "allow" | "cancel_previous"; -export type LoopRunBlockedReason = +type LoopReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +type LoopOverlapPolicy = "skip" | "allow" | "cancel_previous"; +type LoopRunBlockedReason = | "deduped" | "overlap_skipped" | "rate_capped" @@ -1096,7 +1096,7 @@ export type LoopRunBlockedReason = | "gate_blocked" | "owner_inactive" | "owner_changed"; -export type LoopRunStatus = +type LoopRunStatus = | "not_started" | "queued" | "in_progress" @@ -1134,25 +1134,7 @@ export interface LoopViewedProperties { recent_run_count: number; } -export interface LoopCreatedProperties { - loop_id: string; - visibility: "personal" | "team"; - runtime_adapter: "claude" | "codex"; - model?: string; - reasoning_effort: LoopReasoningEffort | null; - repository_count: number; - trigger_count: number; - has_schedule_trigger: boolean; - has_github_trigger: boolean; - has_api_trigger: boolean; - is_pr_creation_enabled: boolean; - is_auto_fix_enabled: boolean; - /** Count of notifications.{push,email,slack} that are enabled. */ - notification_channel_count: number; - has_context_target: boolean; -} - -export interface LoopUpdatedProperties { +export interface LoopSavedProperties { loop_id: string; visibility: "personal" | "team"; runtime_adapter: "claude" | "codex"; @@ -1566,8 +1548,8 @@ export type EventPropertyMap = { // Loops events [ANALYTICS_EVENTS.LOOP_LIST_VIEWED]: LoopListViewedProperties; [ANALYTICS_EVENTS.LOOP_VIEWED]: LoopViewedProperties; - [ANALYTICS_EVENTS.LOOP_CREATED]: LoopCreatedProperties; - [ANALYTICS_EVENTS.LOOP_UPDATED]: LoopUpdatedProperties; + [ANALYTICS_EVENTS.LOOP_CREATED]: LoopSavedProperties; + [ANALYTICS_EVENTS.LOOP_UPDATED]: LoopSavedProperties; [ANALYTICS_EVENTS.LOOP_DELETED]: LoopDeletedProperties; [ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED]: LoopEnabledToggledProperties; [ANALYTICS_EVENTS.LOOP_RUN_STARTED]: LoopRunStartedProperties; diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index c0cf763258..d3c54c65f6 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -13,10 +13,7 @@ import { Switch, Textarea, } from "@posthog/quill"; -import { - ANALYTICS_EVENTS, - type LoopRunBlockedReason, -} from "@posthog/shared/analytics-events"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage"; import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; @@ -41,7 +38,10 @@ import { useUpdateLoop, } from "../hooks/useLoopMutations"; import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns"; -import { buildLoopViewedProps } from "../loopAnalytics"; +import { + buildLoopEnabledToggledProps, + buildLoopViewedProps, +} from "../loopAnalytics"; import { describeTrigger, loopFireBlockedMessage, @@ -67,14 +67,14 @@ export function LoopDetailView({ loopId }: { loopId: string }) { const viewTrackedFor = useRef(null); useEffect(() => { - if (isLoading || runsQuery.isLoading || !loop) return; + if (isLoading || runsQuery.isLoading || runsQuery.isError || !loop) return; if (viewTrackedFor.current === loop.id) return; viewTrackedFor.current = loop.id; track( ANALYTICS_EVENTS.LOOP_VIEWED, buildLoopViewedProps(loop, runs.length), ); - }, [isLoading, runsQuery.isLoading, loop, runs.length]); + }, [isLoading, runsQuery.isLoading, runsQuery.isError, loop, runs.length]); useSetHeaderContent( @@ -94,22 +94,16 @@ export function LoopDetailView({ loopId }: { loopId: string }) { { enabled }, { onSuccess: () => { - track(ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED, { - loop_id: loop.id, - enabled, - visibility: loop.visibility, - was_auto_paused: loop.disabled_reason !== null, - success: true, - }); + track( + ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED, + buildLoopEnabledToggledProps(loop, enabled, true), + ); }, onError: (error) => { - track(ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED, { - loop_id: loop.id, - enabled, - visibility: loop.visibility, - was_auto_paused: loop.disabled_reason !== null, - success: false, - }); + track( + ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED, + buildLoopEnabledToggledProps(loop, enabled, false), + ); toast.error("Failed to update loop", { description: error.message, }); @@ -146,12 +140,14 @@ export function LoopDetailView({ loopId }: { loopId: string }) { toast.error("Run not started", { description: loopFireBlockedMessage(result.reason), }); - track(ANALYTICS_EVENTS.LOOP_RUN_BLOCKED, { - loop_id: loop.id, - reason: result.reason as LoopRunBlockedReason, - overlap_policy: loop.overlap_policy, - trigger_count: loop.triggers.length, - }); + if (result.reason !== "created") { + track(ANALYTICS_EVENTS.LOOP_RUN_BLOCKED, { + loop_id: loop.id, + reason: result.reason, + overlap_policy: loop.overlap_policy, + trigger_count: loop.triggers.length, + }); + } } } catch (error) { toast.error("Failed to start run", { diff --git a/packages/ui/src/features/loops/components/LoopRunRow.tsx b/packages/ui/src/features/loops/components/LoopRunRow.tsx index 078a6c2e26..869e64e3a5 100644 --- a/packages/ui/src/features/loops/components/LoopRunRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRunRow.tsx @@ -205,7 +205,7 @@ export function LoopRunRow({ task_id: run.task_id, status: run.status, environment: run.environment, - is_manual_run: run.loop_trigger_id === null, + is_manual_run: !triggered, }); navigateToTaskDetail(run.task_id); }} diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 7c0f9f2f69..44ec788d21 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -103,7 +103,12 @@ export function LoopsListView() { const hasTrackedListViewedRef = useRef(false); useEffect(() => { - if (isLoading || !builderSessionsSettled || hasTrackedListViewedRef.current) + if ( + isLoading || + isError || + !builderSessionsSettled || + hasTrackedListViewedRef.current + ) return; hasTrackedListViewedRef.current = true; track(ANALYTICS_EVENTS.LOOP_LIST_VIEWED, { @@ -116,6 +121,7 @@ export function LoopsListView() { }); }, [ isLoading, + isError, builderSessionsSettled, allLoops.length, personalLoops.length, diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.test.tsx b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.test.tsx new file mode 100644 index 0000000000..2649ed583a --- /dev/null +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.test.tsx @@ -0,0 +1,135 @@ +import { useTaskSummaries } from "@posthog/ui/features/tasks/useTasks"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type LoopBuilderSession, + useLoopBuilderSessionStore, +} from "../loopBuilderSessionStore"; +import { useLoopBuilderSessions } from "./useLoopBuilderSessions"; + +vi.mock("@posthog/ui/shell/rendererStorage", () => ({ + electronStorage: { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + }, + flushRendererStateWrites: async () => {}, +})); +vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ + useTaskSummaries: vi.fn(), +})); +vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ + useArchivedTaskIds: () => new Set(), +})); +vi.mock("@posthog/ui/features/auth/store", () => ({ + getAuthIdentity: () => "us:1", + useAuthStateValue: () => "us:1", +})); + +const mockedUseTaskSummaries = vi.mocked(useTaskSummaries); + +function session(taskId: string): LoopBuilderSession { + return { + taskId, + prompt: `prompt ${taskId}`, + startedAt: Date.now(), + identity: "us:1", + }; +} + +function summariesQuery( + state: "pending" | "placeholder" | "resolved", + data?: { + id: string; + latest_run: { environment: string; status: string } | null; + }[], +) { + return { + data: state === "pending" ? undefined : (data ?? []), + isSuccess: state !== "pending", + isPlaceholderData: state === "placeholder", + } as ReturnType; +} + +beforeEach(() => { + useLoopBuilderSessionStore.setState({ sessions: [], _hasHydrated: false }); +}); + +describe("useLoopBuilderSessions isSettled", () => { + it.each([ + { + name: "unsettled before the store hydrates, even with no sessions", + hydrated: false, + sessions: [] as LoopBuilderSession[], + query: summariesQuery("pending"), + expected: false, + }, + { + name: "settled immediately when hydrated with no sessions", + hydrated: true, + sessions: [] as LoopBuilderSession[], + query: summariesQuery("pending"), + expected: true, + }, + { + name: "unsettled while summaries are pending", + hydrated: true, + sessions: [session("a")], + query: summariesQuery("pending"), + expected: false, + }, + { + name: "unsettled while summaries are placeholder data", + hydrated: true, + sessions: [session("a")], + query: summariesQuery("placeholder"), + expected: false, + }, + { + name: "settled once summaries resolve", + hydrated: true, + sessions: [session("a")], + query: summariesQuery("resolved", [ + { + id: "a", + latest_run: { environment: "cloud", status: "in_progress" }, + }, + ]), + expected: true, + }, + ])("$name", ({ hydrated, sessions, query, expected }) => { + useLoopBuilderSessionStore.setState({ sessions, _hasHydrated: hydrated }); + mockedUseTaskSummaries.mockReturnValue(query); + const { result } = renderHook(() => useLoopBuilderSessions()); + expect(result.current.isSettled).toBe(expected); + }); + + it("keeps unpruned sessions while unsettled", () => { + useLoopBuilderSessionStore.setState({ + sessions: [session("a")], + _hasHydrated: true, + }); + mockedUseTaskSummaries.mockReturnValue(summariesQuery("pending")); + const { result } = renderHook(() => useLoopBuilderSessions()); + expect(result.current.sessions.map((s) => s.taskId)).toEqual(["a"]); + }); + + it("prunes ended sessions once summaries resolve", () => { + useLoopBuilderSessionStore.setState({ + sessions: [session("a"), session("b")], + _hasHydrated: true, + }); + mockedUseTaskSummaries.mockReturnValue( + summariesQuery("resolved", [ + { id: "a", latest_run: { environment: "cloud", status: "completed" } }, + { + id: "b", + latest_run: { environment: "cloud", status: "in_progress" }, + }, + ]), + ); + const { result } = renderHook(() => useLoopBuilderSessions()); + expect(result.current.isSettled).toBe(true); + expect(result.current.sessions.map((s) => s.taskId)).toEqual(["b"]); + }); +}); diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts index 037bae5be5..06f39aeda0 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts @@ -24,8 +24,9 @@ import { * this hook queries are only authoritative for the signed-in account. The * liveness decision itself is the pure `isBuilderSessionEnded`. * - * `isSettled` is false until the summaries backing the liveness check have - * resolved, i.e. while `sessions` may still contain entries about to be pruned. + * `isSettled` is false until the persisted store has hydrated and the + * summaries backing the liveness check have resolved, i.e. while `sessions` + * may still be missing entries or contain entries about to be pruned. */ export function useLoopBuilderSessions(): { sessions: LoopBuilderSession[]; @@ -33,6 +34,7 @@ export function useLoopBuilderSessions(): { } { const identity = useAuthStateValue(getAuthIdentity); const allSessions = useLoopBuilderSessionStore((state) => state.sessions); + const hasHydrated = useLoopBuilderSessionStore((state) => state._hasHydrated); const sessions = useMemo( () => identity @@ -100,6 +102,6 @@ export function useLoopBuilderSessions(): { return { sessions: liveSessions, - isSettled: sessions.length === 0 || summaries !== null, + isSettled: hasHydrated && (sessions.length === 0 || summaries !== null), }; } diff --git a/packages/ui/src/features/loops/loopAnalytics.test.ts b/packages/ui/src/features/loops/loopAnalytics.test.ts new file mode 100644 index 0000000000..73fc20bcb3 --- /dev/null +++ b/packages/ui/src/features/loops/loopAnalytics.test.ts @@ -0,0 +1,288 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; +import { describe, expect, it } from "vitest"; +import { + buildLoopEnabledToggledProps, + buildLoopSavedProps, + buildLoopViewedProps, +} from "./loopAnalytics"; + +function notificationChannel( + enabled: boolean, +): LoopSchemas.LoopNotificationChannel { + return { enabled, events: [], params: {} }; +} + +function trigger( + type: LoopSchemas.LoopTriggerTypeEnum, +): LoopSchemas.LoopTrigger { + return { + id: `trigger-${type}`, + loop_id: "loop-1", + type, + enabled: true, + config: {}, + schedule_sync_status: null, + last_fired_at: null, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + }; +} + +function loop(overrides: Partial = {}): LoopSchemas.Loop { + return { + id: "loop-1", + team_id: 1, + created_by_id: 1, + name: "Nightly triage", + description: "", + visibility: "personal", + instructions: "triage the inbox", + runtime_adapter: "claude", + model: "", + reasoning_effort: null, + repositories: [], + sandbox_environment_id: null, + enabled: true, + disabled_reason: null, + overlap_policy: "skip", + behaviors: { + create_prs: false, + watch_ci: false, + fix_review_comments: false, + max_fix_iterations: 0, + }, + connectors: { mcp_installation_ids: [], posthog_mcp_scopes: "read_only" }, + notifications: { + push: notificationChannel(false), + email: notificationChannel(false), + slack: notificationChannel(false), + }, + context_target: null, + internal: false, + origin_product: "user_created", + last_run_at: null, + last_run_status: null, + last_error: null, + consecutive_failures: 0, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + triggers: [], + ...overrides, + }; +} + +describe("trigger flags", () => { + it.each([ + { + name: "no triggers", + types: [] as LoopSchemas.LoopTriggerTypeEnum[], + expected: { + trigger_count: 0, + has_schedule_trigger: false, + has_github_trigger: false, + has_api_trigger: false, + }, + }, + { + name: "schedule only", + types: ["schedule"] as LoopSchemas.LoopTriggerTypeEnum[], + expected: { + trigger_count: 1, + has_schedule_trigger: true, + has_github_trigger: false, + has_api_trigger: false, + }, + }, + { + name: "github only", + types: ["github"] as LoopSchemas.LoopTriggerTypeEnum[], + expected: { + trigger_count: 1, + has_schedule_trigger: false, + has_github_trigger: true, + has_api_trigger: false, + }, + }, + { + name: "api only", + types: ["api"] as LoopSchemas.LoopTriggerTypeEnum[], + expected: { + trigger_count: 1, + has_schedule_trigger: false, + has_github_trigger: false, + has_api_trigger: true, + }, + }, + { + name: "duplicate type keeps raw count", + types: ["schedule", "schedule"] as LoopSchemas.LoopTriggerTypeEnum[], + expected: { + trigger_count: 2, + has_schedule_trigger: true, + has_github_trigger: false, + has_api_trigger: false, + }, + }, + { + name: "all types", + types: ["schedule", "github", "api"] as LoopSchemas.LoopTriggerTypeEnum[], + expected: { + trigger_count: 3, + has_schedule_trigger: true, + has_github_trigger: true, + has_api_trigger: true, + }, + }, + ])("$name", ({ types, expected }) => { + const props = buildLoopViewedProps( + loop({ triggers: types.map(trigger) }), + 0, + ); + expect(props).toMatchObject(expected); + }); +}); + +describe("buildLoopViewedProps", () => { + it("omits model when the loop uses the adapter default", () => { + expect(buildLoopViewedProps(loop({ model: "" }), 0).model).toBeUndefined(); + }); + + it("passes a pinned model through", () => { + expect(buildLoopViewedProps(loop({ model: "gpt-5" }), 0).model).toBe( + "gpt-5", + ); + }); + + it("carries loop state and the given run count", () => { + const props = buildLoopViewedProps( + loop({ + visibility: "team", + enabled: false, + disabled_reason: "repeated_failures", + reasoning_effort: "high", + repositories: [{ github_integration_id: 1, full_name: "posthog/code" }], + last_run_status: "failed", + consecutive_failures: 3, + }), + 7, + ); + expect(props).toMatchObject({ + loop_id: "loop-1", + visibility: "team", + enabled: false, + disabled_reason: "repeated_failures", + reasoning_effort: "high", + repository_count: 1, + last_run_status: "failed", + consecutive_failures: 3, + recent_run_count: 7, + }); + }); +}); + +describe("buildLoopSavedProps", () => { + it.each([ + { + name: "none enabled", + push: false, + email: false, + slack: false, + expected: 0, + }, + { + name: "one enabled", + push: true, + email: false, + slack: false, + expected: 1, + }, + { name: "two enabled", push: false, email: true, slack: true, expected: 2 }, + { name: "all enabled", push: true, email: true, slack: true, expected: 3 }, + ])( + "counts notification channels: $name", + ({ push, email, slack, expected }) => { + const props = buildLoopSavedProps( + loop({ + notifications: { + push: notificationChannel(push), + email: notificationChannel(email), + slack: notificationChannel(slack), + }, + }), + ); + expect(props.notification_channel_count).toBe(expected); + }, + ); + + it.each([ + { + name: "attached", + context_target: { + folder_id: "f1", + name: "growth", + outputs: { post_to_feed: true, update_context: false, canvas_id: null }, + }, + expected: true, + }, + { name: "unattached", context_target: null, expected: false }, + ])("has_context_target when $name", ({ context_target, expected }) => { + expect( + buildLoopSavedProps(loop({ context_target })).has_context_target, + ).toBe(expected); + }); + + it("mirrors behavior flags", () => { + const props = buildLoopSavedProps( + loop({ + behaviors: { + create_prs: true, + watch_ci: true, + fix_review_comments: true, + max_fix_iterations: 3, + }, + }), + ); + expect(props.is_pr_creation_enabled).toBe(true); + expect(props.is_auto_fix_enabled).toBe(true); + }); + + it("omits model when the loop uses the adapter default", () => { + expect(buildLoopSavedProps(loop({ model: "" })).model).toBeUndefined(); + }); +}); + +describe("buildLoopEnabledToggledProps", () => { + it.each([ + { + name: "manual pause cleared", + disabled_reason: null, + enabled: true, + success: true, + was_auto_paused: false, + }, + { + name: "auto-pause cleared", + disabled_reason: "usage_limited", + enabled: true, + success: true, + was_auto_paused: true, + }, + { + name: "failed toggle", + disabled_reason: null, + enabled: false, + success: false, + was_auto_paused: false, + }, + ])("$name", ({ disabled_reason, enabled, success, was_auto_paused }) => { + expect( + buildLoopEnabledToggledProps(loop({ disabled_reason }), enabled, success), + ).toEqual({ + loop_id: "loop-1", + enabled, + visibility: "personal", + was_auto_paused, + success, + }); + }); +}); diff --git a/packages/ui/src/features/loops/loopAnalytics.ts b/packages/ui/src/features/loops/loopAnalytics.ts index 7ccfbc5c06..fd8a0d0b0f 100644 --- a/packages/ui/src/features/loops/loopAnalytics.ts +++ b/packages/ui/src/features/loops/loopAnalytics.ts @@ -1,7 +1,7 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; import type { - LoopCreatedProperties, - LoopUpdatedProperties, + LoopEnabledToggledProperties, + LoopSavedProperties, LoopViewedProperties, } from "@posthog/shared/analytics-events"; import { isAutoFixEnabled } from "./loopFormTypes"; @@ -39,7 +39,7 @@ export function buildLoopViewedProps( export function buildLoopSavedProps( loop: LoopSchemas.Loop, -): LoopCreatedProperties | LoopUpdatedProperties { +): LoopSavedProperties { return { loop_id: loop.id, visibility: loop.visibility, @@ -56,3 +56,17 @@ export function buildLoopSavedProps( has_context_target: loop.context_target !== null, }; } + +export function buildLoopEnabledToggledProps( + loop: LoopSchemas.Loop, + enabled: boolean, + success: boolean, +): LoopEnabledToggledProperties { + return { + loop_id: loop.id, + enabled, + visibility: loop.visibility, + was_auto_paused: loop.disabled_reason !== null, + success, + }; +} diff --git a/packages/ui/src/features/loops/loopBuilderSessionStore.ts b/packages/ui/src/features/loops/loopBuilderSessionStore.ts index 33e20aa133..dc63562678 100644 --- a/packages/ui/src/features/loops/loopBuilderSessionStore.ts +++ b/packages/ui/src/features/loops/loopBuilderSessionStore.ts @@ -19,14 +19,19 @@ export const MAX_BUILDER_SESSIONS = 5; interface LoopBuilderSessionState { sessions: LoopBuilderSession[]; + // Hydration is async (Electron storage over IPC); readers that must not + // mistake "not loaded yet" for "no sessions" wait on this flag. + _hasHydrated: boolean; addSession: (session: LoopBuilderSession) => void; removeSession: (taskId: string) => void; + setHasHydrated: (hydrated: boolean) => void; } export const useLoopBuilderSessionStore = create()( persist( (set) => ({ sessions: [], + _hasHydrated: false, // Flushed immediately: adding is followed by navigating away, and a lost // debounced write is exactly the "can't find my builder" bug again. addSession: (session) => { @@ -51,6 +56,7 @@ export const useLoopBuilderSessionStore = create()( })); void flushRendererStateWrites(); }, + setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), }), { name: "posthog-code-loop-builder-sessions", @@ -59,6 +65,13 @@ export const useLoopBuilderSessionStore = create()( // v0 entries had no identity and can't be attributed; drop them. version: 1, migrate: () => ({ sessions: [] as LoopBuilderSession[] }), + onRehydrateStorage: () => (state) => { + if (state) { + state.setHasHydrated(true); + return; + } + useLoopBuilderSessionStore.setState({ _hasHydrated: true }); + }, }, ), );