Skip to content

Commit bd0a351

Browse files
authored
feat(loops): Add analytics events for loops (#3783)
1 parent f969fec commit bd0a351

10 files changed

Lines changed: 779 additions & 10 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,119 @@ export interface AutoresearchRunStartedProperties {
10841084
workspace_mode?: "local" | "worktree" | "cloud";
10851085
}
10861086

1087+
// Loops events
1088+
type LoopReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
1089+
type LoopOverlapPolicy = "skip" | "allow" | "cancel_previous";
1090+
type LoopRunBlockedReason =
1091+
| "deduped"
1092+
| "overlap_skipped"
1093+
| "rate_capped"
1094+
| "team_rate_capped"
1095+
| "disabled"
1096+
| "gate_blocked"
1097+
| "owner_inactive"
1098+
| "owner_changed";
1099+
type LoopRunStatus =
1100+
| "not_started"
1101+
| "queued"
1102+
| "in_progress"
1103+
| "completed"
1104+
| "failed"
1105+
| "cancelled";
1106+
1107+
export interface LoopListViewedProperties {
1108+
loop_count: number;
1109+
personal_loop_count: number;
1110+
team_loop_count: number;
1111+
is_at_limit: boolean;
1112+
/** Backend-enforced per-project cap; omitted while the limit is still loading. */
1113+
loop_limit?: number;
1114+
builder_session_count: number;
1115+
}
1116+
1117+
export interface LoopViewedProperties {
1118+
loop_id: string;
1119+
visibility: "personal" | "team";
1120+
enabled: boolean;
1121+
/** Backend-open string; null when enabled or manually paused with no reason given. */
1122+
disabled_reason: string | null;
1123+
runtime_adapter: "claude" | "codex";
1124+
model?: string;
1125+
reasoning_effort: LoopReasoningEffort | null;
1126+
repository_count: number;
1127+
trigger_count: number;
1128+
has_schedule_trigger: boolean;
1129+
has_github_trigger: boolean;
1130+
has_api_trigger: boolean;
1131+
/** Backend-open string, not a closed enum. */
1132+
last_run_status: string | null;
1133+
consecutive_failures: number;
1134+
recent_run_count: number;
1135+
}
1136+
1137+
export interface LoopSavedProperties {
1138+
loop_id: string;
1139+
visibility: "personal" | "team";
1140+
runtime_adapter: "claude" | "codex";
1141+
model?: string;
1142+
reasoning_effort: LoopReasoningEffort | null;
1143+
repository_count: number;
1144+
trigger_count: number;
1145+
has_schedule_trigger: boolean;
1146+
has_github_trigger: boolean;
1147+
has_api_trigger: boolean;
1148+
is_pr_creation_enabled: boolean;
1149+
is_auto_fix_enabled: boolean;
1150+
/** Count of notifications.{push,email,slack} that are enabled. */
1151+
notification_channel_count: number;
1152+
has_context_target: boolean;
1153+
}
1154+
1155+
export interface LoopDeletedProperties {
1156+
loop_id: string;
1157+
visibility: "personal" | "team";
1158+
enabled: boolean;
1159+
trigger_count: number;
1160+
/** State at time of deletion, distinguishes deleting a healthy loop from abandoning a failing one. */
1161+
consecutive_failures: number;
1162+
}
1163+
1164+
export interface LoopEnabledToggledProperties {
1165+
loop_id: string;
1166+
/** The new value the loop is being switched to. */
1167+
enabled: boolean;
1168+
visibility: "personal" | "team";
1169+
/** True when this toggle clears or reinstates a backend auto-pause rather than a routine manual pause/resume. */
1170+
was_auto_paused: boolean;
1171+
success: boolean;
1172+
}
1173+
1174+
export interface LoopRunStartedProperties {
1175+
loop_id: string;
1176+
task_id: string | null;
1177+
task_run_id: string | null;
1178+
runtime_adapter: "claude" | "codex";
1179+
model?: string;
1180+
trigger_count: number;
1181+
}
1182+
1183+
export interface LoopRunBlockedProperties {
1184+
loop_id: string;
1185+
reason: LoopRunBlockedReason;
1186+
overlap_policy: LoopOverlapPolicy;
1187+
trigger_count: number;
1188+
}
1189+
1190+
export interface LoopRunViewedProperties {
1191+
loop_id: string;
1192+
run_id: string;
1193+
task_id: string;
1194+
status: LoopRunStatus;
1195+
environment: "local" | "cloud";
1196+
/** True when the run wasn't triggered by a schedule/github/api trigger. */
1197+
is_manual_run: boolean;
1198+
}
1199+
10871200
// Event names as constants
10881201
export const ANALYTICS_EVENTS = {
10891202
// App lifecycle
@@ -1255,6 +1368,17 @@ export const ANALYTICS_EVENTS = {
12551368
LOOPS_PROMO_OPENED: "Loops promo opened",
12561369
LOOPS_PROMO_DISMISSED: "Loops promo dismissed",
12571370
LOOPS_PROMO_LEARN_MORE_CLICKED: "Loops promo learn more clicked",
1371+
1372+
// Loops events
1373+
LOOP_LIST_VIEWED: "Loop list viewed",
1374+
LOOP_VIEWED: "Loop viewed",
1375+
LOOP_CREATED: "Loop created",
1376+
LOOP_UPDATED: "Loop updated",
1377+
LOOP_DELETED: "Loop deleted",
1378+
LOOP_ENABLED_TOGGLED: "Loop enabled toggled",
1379+
LOOP_RUN_STARTED: "Loop run started",
1380+
LOOP_RUN_BLOCKED: "Loop run blocked",
1381+
LOOP_RUN_VIEWED: "Loop run viewed",
12581382
} as const;
12591383

12601384
// Event property mapping
@@ -1420,6 +1544,17 @@ export type EventPropertyMap = {
14201544
[ANALYTICS_EVENTS.LOOPS_PROMO_OPENED]: never;
14211545
[ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED]: never;
14221546
[ANALYTICS_EVENTS.LOOPS_PROMO_LEARN_MORE_CLICKED]: never;
1547+
1548+
// Loops events
1549+
[ANALYTICS_EVENTS.LOOP_LIST_VIEWED]: LoopListViewedProperties;
1550+
[ANALYTICS_EVENTS.LOOP_VIEWED]: LoopViewedProperties;
1551+
[ANALYTICS_EVENTS.LOOP_CREATED]: LoopSavedProperties;
1552+
[ANALYTICS_EVENTS.LOOP_UPDATED]: LoopSavedProperties;
1553+
[ANALYTICS_EVENTS.LOOP_DELETED]: LoopDeletedProperties;
1554+
[ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED]: LoopEnabledToggledProperties;
1555+
[ANALYTICS_EVENTS.LOOP_RUN_STARTED]: LoopRunStartedProperties;
1556+
[ANALYTICS_EVENTS.LOOP_RUN_BLOCKED]: LoopRunBlockedProperties;
1557+
[ANALYTICS_EVENTS.LOOP_RUN_VIEWED]: LoopRunViewedProperties;
14231558
};
14241559

14251560
/**

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Switch,
1414
Textarea,
1515
} from "@posthog/quill";
16+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
1617
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
1718
import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage";
1819
import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore";
@@ -26,15 +27,20 @@ import {
2627
navigateToEditLoop,
2728
navigateToLoops,
2829
} from "@posthog/ui/router/navigationBridge";
30+
import { track } from "@posthog/ui/shell/analytics";
2931
import { Flex, Text } from "@radix-ui/themes";
30-
import { useRef, useState } from "react";
32+
import { useEffect, useRef, useState } from "react";
3133
import { useLoop } from "../hooks/useLoop";
3234
import {
3335
useDeleteLoop,
3436
useRunLoop,
3537
useUpdateLoop,
3638
} from "../hooks/useLoopMutations";
3739
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
40+
import {
41+
buildLoopEnabledToggledProps,
42+
buildLoopViewedProps,
43+
} from "../loopAnalytics";
3844
import {
3945
describeTrigger,
4046
loopFireBlockedMessage,
@@ -59,6 +65,17 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
5965
const runsQuery = useLoopRuns(loopId);
6066
const runs = runsQuery.data ?? [];
6167

68+
const viewTrackedFor = useRef<string | null>(null);
69+
useEffect(() => {
70+
if (isLoading || runsQuery.isLoading || runsQuery.isError || !loop) return;
71+
if (viewTrackedFor.current === loop.id) return;
72+
viewTrackedFor.current = loop.id;
73+
track(
74+
ANALYTICS_EVENTS.LOOP_VIEWED,
75+
buildLoopViewedProps(loop, runs.length),
76+
);
77+
}, [isLoading, runsQuery.isLoading, runsQuery.isError, loop, runs.length]);
78+
6279
useSetHeaderContent(
6380
<Flex align="center" gap="2" className="w-full min-w-0">
6481
<RepeatIcon size={12} className="shrink-0 text-gray-10" />
@@ -72,31 +89,65 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
7289
);
7390

7491
const handleToggleEnabled = (enabled: boolean) => {
92+
if (!loop) return;
7593
updateLoop.mutate(
7694
{ enabled },
7795
{
78-
onError: (error) =>
96+
onSuccess: () => {
97+
track(
98+
ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED,
99+
buildLoopEnabledToggledProps(loop, enabled, true),
100+
);
101+
},
102+
onError: (error) => {
103+
track(
104+
ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED,
105+
buildLoopEnabledToggledProps(loop, enabled, false),
106+
);
79107
toast.error("Failed to update loop", {
80108
description: error.message,
81-
}),
109+
});
110+
},
82111
},
83112
);
84113
};
85114

86115
const handleRunNow = async () => {
87-
if (runNowPending) return;
116+
if (runNowPending || !loop) return;
88117
setRunNowPending(true);
89118
try {
90119
if (!(await assertCloudUsageAvailable())) return;
91120
const result = await runLoop.mutateAsync();
92121
if (result.created) {
93122
toast.success("Loop run started");
123+
track(ANALYTICS_EVENTS.LOOP_RUN_STARTED, {
124+
loop_id: loop.id,
125+
task_id: result.task_id,
126+
task_run_id: result.task_run_id,
127+
runtime_adapter: loop.runtime_adapter,
128+
model: loop.model || undefined,
129+
trigger_count: loop.triggers.length,
130+
});
94131
} else if (result.reason === "gate_blocked") {
95132
useUsageLimitStore.getState().show({ cause: "org_limit" });
133+
track(ANALYTICS_EVENTS.LOOP_RUN_BLOCKED, {
134+
loop_id: loop.id,
135+
reason: result.reason,
136+
overlap_policy: loop.overlap_policy,
137+
trigger_count: loop.triggers.length,
138+
});
96139
} else {
97140
toast.error("Run not started", {
98141
description: loopFireBlockedMessage(result.reason),
99142
});
143+
if (result.reason !== "created") {
144+
track(ANALYTICS_EVENTS.LOOP_RUN_BLOCKED, {
145+
loop_id: loop.id,
146+
reason: result.reason,
147+
overlap_policy: loop.overlap_policy,
148+
trigger_count: loop.triggers.length,
149+
});
150+
}
100151
}
101152
} catch (error) {
102153
toast.error("Failed to start run", {
@@ -108,8 +159,16 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
108159
};
109160

110161
const handleDelete = () => {
162+
if (!loop) return;
111163
deleteLoop.mutate(loopId, {
112164
onSuccess: () => {
165+
track(ANALYTICS_EVENTS.LOOP_DELETED, {
166+
loop_id: loop.id,
167+
visibility: loop.visibility,
168+
enabled: loop.enabled,
169+
trigger_count: loop.triggers.length,
170+
consecutive_failures: loop.consecutive_failures,
171+
});
113172
toast.success("Loop deleted");
114173
navigateToLoops();
115174
},
@@ -235,6 +294,7 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
235294
{runs.map((run) => (
236295
<LoopRunRow
237296
key={run.id}
297+
loopId={loop.id}
238298
run={run}
239299
onStopped={() => void runsQuery.refetch()}
240300
/>

packages/ui/src/features/loops/components/LoopForm.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
Check,
66
} from "@phosphor-icons/react";
77
import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops";
8-
import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared";
8+
import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared";
99
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
1010
import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect";
1111
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
@@ -16,10 +16,12 @@ import {
1616
navigateToLoopDetail,
1717
navigateToLoops,
1818
} from "@posthog/ui/router/navigationBridge";
19+
import { track } from "@posthog/ui/shell/analytics";
1920
import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes";
2021
import { type ReactNode, useEffect, useState } from "react";
2122
import { useAuthStateValue } from "../../auth/store";
2223
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
24+
import { buildLoopSavedProps } from "../loopAnalytics";
2325
import { summarizeTrigger } from "../loopDisplay";
2426
import { useLoopDraftStore } from "../loopDraftStore";
2527
import {
@@ -141,9 +143,11 @@ export function LoopForm({ loop }: LoopFormProps) {
141143
try {
142144
if (isEdit) {
143145
const updated = await updateLoop.mutateAsync(body);
146+
track(ANALYTICS_EVENTS.LOOP_UPDATED, buildLoopSavedProps(updated));
144147
navigateToLoopDetail(updated.id);
145148
} else {
146149
const created = await createLoop.mutateAsync(body);
150+
track(ANALYTICS_EVENTS.LOOP_CREATED, buildLoopSavedProps(created));
147151
navigateToLoopDetail(created.id);
148152
}
149153
} catch (error) {

packages/ui/src/features/loops/components/LoopRunRow.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import {
1212
} from "@phosphor-icons/react";
1313
import type { LoopSchemas } from "@posthog/api-client/loops";
1414
import { cn } from "@posthog/quill";
15+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
1516
import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog";
1617
import { Badge } from "@posthog/ui/primitives/Badge";
1718
import { Button } from "@posthog/ui/primitives/Button";
1819
import { toast } from "@posthog/ui/primitives/toast";
1920
import { navigateToTaskDetail } from "@posthog/ui/router/navigationBridge";
21+
import { track } from "@posthog/ui/shell/analytics";
2022
import { Flex, Text } from "@radix-ui/themes";
2123
import { type ReactNode, useState } from "react";
2224

@@ -119,9 +121,11 @@ function isStoppable(run: LoopSchemas.LoopRun): boolean {
119121
}
120122

121123
export function LoopRunRow({
124+
loopId,
122125
run,
123126
onStopped,
124127
}: {
128+
loopId: string;
125129
run: LoopSchemas.LoopRun;
126130
onStopped?: () => void;
127131
}) {
@@ -194,7 +198,17 @@ export function LoopRunRow({
194198
variant="soft"
195199
color="gray"
196200
size="1"
197-
onClick={() => navigateToTaskDetail(run.task_id)}
201+
onClick={() => {
202+
track(ANALYTICS_EVENTS.LOOP_RUN_VIEWED, {
203+
loop_id: loopId,
204+
run_id: run.id,
205+
task_id: run.task_id,
206+
status: run.status,
207+
environment: run.environment,
208+
is_manual_run: !triggered,
209+
});
210+
navigateToTaskDetail(run.task_id);
211+
}}
198212
>
199213
View run
200214
</Button>

0 commit comments

Comments
 (0)