Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,119 @@ export interface AutoresearchRunStartedProperties {
workspace_mode?: "local" | "worktree" | "cloud";
}

// Loops events
type LoopReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
type LoopOverlapPolicy = "skip" | "allow" | "cancel_previous";
type LoopRunBlockedReason =
| "deduped"
| "overlap_skipped"
| "rate_capped"
| "team_rate_capped"
| "disabled"
| "gate_blocked"
| "owner_inactive"
| "owner_changed";
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 LoopSavedProperties {
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
Expand Down Expand Up @@ -1255,6 +1368,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
Expand Down Expand Up @@ -1420,6 +1544,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]: LoopSavedProperties;
[ANALYTICS_EVENTS.LOOP_UPDATED]: LoopSavedProperties;
[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;
};

/**
Expand Down
68 changes: 64 additions & 4 deletions packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Switch,
Textarea,
} from "@posthog/quill";
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";
Expand All @@ -26,8 +27,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 {
Expand All @@ -36,6 +38,10 @@ import {
useUpdateLoop,
} from "../hooks/useLoopMutations";
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
import {
buildLoopEnabledToggledProps,
buildLoopViewedProps,
} from "../loopAnalytics";
import {
describeTrigger,
loopFireBlockedMessage,
Expand All @@ -59,6 +65,17 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
const runsQuery = useLoopRuns(loopId);
const runs = runsQuery.data ?? [];

const viewTrackedFor = useRef<string | null>(null);
useEffect(() => {
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, runsQuery.isError, loop, runs.length]);

useSetHeaderContent(
<Flex align="center" gap="2" className="w-full min-w-0">
<RepeatIcon size={12} className="shrink-0 text-gray-10" />
Expand All @@ -72,31 +89,65 @@ 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,
buildLoopEnabledToggledProps(loop, enabled, true),
);
},
onError: (error) => {
track(
ANALYTICS_EVENTS.LOOP_ENABLED_TOGGLED,
buildLoopEnabledToggledProps(loop, enabled, 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),
});
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", {
Expand All @@ -108,8 +159,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();
},
Expand Down Expand Up @@ -235,6 +294,7 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
{runs.map((run) => (
<LoopRunRow
key={run.id}
loopId={loop.id}
run={run}
onStopped={() => void runsQuery.refetch()}
/>
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/features/loops/components/LoopForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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, 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";
Expand All @@ -16,10 +16,12 @@ 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";
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
import { buildLoopSavedProps } from "../loopAnalytics";
import { summarizeTrigger } from "../loopDisplay";
import { useLoopDraftStore } from "../loopDraftStore";
import {
Expand Down Expand Up @@ -140,9 +142,11 @@ export function LoopForm({ loop }: LoopFormProps) {
try {
if (isEdit) {
const updated = await updateLoop.mutateAsync(body);
track(ANALYTICS_EVENTS.LOOP_UPDATED, buildLoopSavedProps(updated));
navigateToLoopDetail(updated.id);
} else {
const created = await createLoop.mutateAsync(body);
track(ANALYTICS_EVENTS.LOOP_CREATED, buildLoopSavedProps(created));
navigateToLoopDetail(created.id);
}
} catch (error) {
Expand Down
16 changes: 15 additions & 1 deletion packages/ui/src/features/loops/components/LoopRunRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

export function LoopRunRow({
loopId,
run,
onStopped,
}: {
loopId: string;
run: LoopSchemas.LoopRun;
onStopped?: () => void;
}) {
Expand Down Expand Up @@ -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: !triggered,
});
navigateToTaskDetail(run.task_id);
}}
>
View run
</Button>
Expand Down
Loading
Loading