diff --git a/apps/code/SCHEMA.md b/apps/code/SCHEMA.md new file mode 100644 index 0000000000..5ecc56d98b --- /dev/null +++ b/apps/code/SCHEMA.md @@ -0,0 +1,250 @@ +# Analytics Event Schema + +Naming conventions and the canonical catalog of PostHog events emitted by the desktop app. The authoritative type definitions live in [`src/shared/types/analytics.ts`](./src/shared/types/analytics.ts) — this doc explains the *why* and what each event means. + +Two PostHog clients emit events: + +- **Renderer** (`posthog-js`) via `track(eventName, properties)` in [`src/renderer/utils/analytics.ts`](./src/renderer/utils/analytics.ts). +- **Main process** (`posthog-node`) via `trackAppEvent(eventName, properties)` in [`src/main/services/posthog-analytics.ts`](./src/main/services/posthog-analytics.ts). + +Both register a super-property `team: "posthog-code"` on every event. All event names and property types are defined in `ANALYTICS_EVENTS` and `EventPropertyMap` — adding a new event without entries there will fail typechecking. + +--- + +## Naming conventions + +### Event names + +- **Format**: `Object verbed` — Title Case, sentence-cased, spaces between words. +- **First word is the object** (`Task`, `Prompt`, `Branch`, `File`, `Setup discovery`, `Onboarding`). +- **Second word is a past-tense verb** (`created`, `viewed`, `sent`, `started`, `completed`, `failed`, `cancelled`). +- **Only the first word is capitalized.** Spell out abbreviations (`Pull request created`, not `PR created`). +- **Group by object, not by feature.** Prefer `Branch linked` over `Workspace branch linked`. +- **Use generic events with a discriminator property over many bespoke events** when the shape is the same — e.g. `Setting changed` with `setting_name` instead of `Theme changed` + `Font changed` + ... +- **Do not prefix events with `First`** — "first X" is always derivable in PostHog from the first occurrence of `X` per distinct ID. Emit `X`, not `First X`. + +✅ `Task created`, `Prompt sent`, `Setup discovery completed`, `Onboarding step completed` +❌ `task_created`, `TaskCreated`, `created_task`, `userClickedSendButton`, `PR created` + +### Property names + +- **snake_case**, lowercase, no leading underscore. +- **Booleans**: prefix with `is_`, `has_`, or `can_` (`is_initial`, `has_branch`, `has_uncommitted_changes`). +- **Counts**: suffix with `_count` (`event_count`, `staged_file_count`, `total_discovered`). +- **Durations / sizes**: suffix with the unit (`duration_seconds`, `entry_age_seconds`, `prompt_length_chars`). +- **IDs**: suffix with `_id` (`task_id`, `discovery_task_run_id`, `discovered_task_id`). +- **Enums**: suffix with `_type`, `_mode`, `_source`, `_kind`, `_reason`, `_action`, or use the bare noun if obvious (`category`, `region`). +- **Pairs**: when an event captures a transition, use `from_*` / `to_*` (`from_mode`, `to_mode`, `from_value`, `to_value`). + +✅ `task_id`, `is_initial`, `duration_seconds`, `prompt_length_chars`, `repository_provider` +❌ `taskId`, `initial`, `duration`, `promptLength`, `repo_provider_type` (redundant suffix) + +### Enum values + +- **snake_case strings**, lowercase. e.g. `"user_cancelled"`, `"stale_feature_flag"`. +- **Never `true`/`false` as a state value** — use a meaningful enum (`"completed"` / `"cancelled"` / `"failed"`, not `success: true/false` unless it really is just success). +- **Open-ended values are fine** when the set evolves freely (e.g. `setting_name`, `tour_id`). Closed enums get a TypeScript union in `analytics.ts`. + +### What does *not* go into properties + +- **No PII** in event names or property values. No email addresses, full names, file paths, prompt contents, or repo URLs. Hash if you need to dedupe (`path_hash`). +- **No free-form strings** when an enum will do. If you find yourself writing `category: "bug" | "security" | ...`, define the union once in `analytics.ts`. +- **No giant payloads.** If the value can be reconstructed from another event + an ID, store the ID. + +### Adding a new event + +1. Add the constant to `ANALYTICS_EVENTS` in [`src/shared/types/analytics.ts`](./src/shared/types/analytics.ts). +2. Add the property interface (even if empty — use `never` for no-prop events). +3. Register it in `EventPropertyMap`. +4. Call `track(ANALYTICS_EVENTS.MY_EVENT, { … })` in the renderer or `trackAppEvent(...)` in main. +5. Add a row to the catalog below. + +--- + +## Common properties + +These appear across many events and should always use the same name and type when present. + +| Property | Type | Meaning | +|---|---|---| +| `task_id` | `string` | The task UUID. | +| `task_run_id` | `string` | The agent run UUID inside a task. | +| `execution_type` | `"local" \| "cloud"` | Where the agent runs. | +| `adapter` | `"claude" \| "codex"` | Which agent SDK adapter is in use. | +| `repository_provider` | `"github" \| "gitlab" \| "local" \| "none"` | Source of the repo associated with the task. | +| `workspace_mode` | `"local" \| "worktree" \| "cloud"` | How files are checked out for the task. | +| `source` | enum per event | Where the action originated from (button, menu, keyboard, etc.). | +| `region` | `string` | PostHog region (`us`, `eu`, etc.). | +| `project_id` | `string` | PostHog project ID. | +| `step_id` | `string` | Onboarding step identifier — matches `ONBOARDING_STEPS`. | +| `duration_seconds` | `number` | Wall-clock duration of the action. | + +--- + +## Event catalog + +### App lifecycle (main process) + +| Event | Properties | +|---|---| +| `App started` | — | +| `App quit` | — | + +### Authentication + +| Event | Properties | +|---|---| +| `User logged in` | `project_id?`, `region?` | +| `User logged out` | — | + +### Onboarding + +The first-session funnel. `step_id` ∈ `welcome`, `project-select`, `invite-code`, `github`, `install-cli` — matches the values in [`src/renderer/features/onboarding/types.ts`](./src/renderer/features/onboarding/types.ts). + +| Event | Properties | +|---|---| +| `Onboarding started` | — | +| `Onboarding step viewed` | `step_id`, `step_index`, `total_steps` | +| `Onboarding step completed` | `step_id`, `step_index`, `total_steps`, `duration_seconds` | +| `Onboarding step skipped` | `step_id`, `step_index`, `reason` | +| `Onboarding sign in initiated` | `region` | +| `Onboarding project selected` | `had_multiple_orgs`, `had_multiple_projects` | +| `Onboarding invite code submitted` | `success`, `error_type?` | +| `Onboarding folder selected` | `has_git_remote`, `repository_provider` | +| `Onboarding github connected` | — | +| `Onboarding cli check completed` | `git_installed`, `gh_installed`, `gh_authenticated` | +| `Onboarding completed` | `duration_seconds`, `github_connected`, `cli_skipped` | +| `Onboarding abandoned` | `last_step_id`, `duration_seconds` | +| `Ai consent gate shown` | `is_org_admin` | +| `Ai consent approved` | — | + +#### First-session funnel + +``` +App opened + → Onboarding started (welcome screen mounts) + → Onboarding step viewed [welcome] + → Onboarding step completed [welcome] + → Onboarding step viewed [project-select] + → Onboarding sign in initiated (clicked OAuth button) + → User logged in + → Onboarding project selected + → Onboarding step completed [project-select] + → Onboarding step viewed [invite-code] (conditional) + → Onboarding invite code submitted + → Onboarding step completed [invite-code] + → Onboarding step viewed [github] + → Onboarding folder selected + → Onboarding github connected (optional) + → Onboarding step completed [github] + → Onboarding step viewed [install-cli] + → Onboarding cli check completed + → Onboarding step completed [install-cli] (or skipped) + → Onboarding completed + → Ai consent gate shown (conditional) + → Ai consent approved (conditional) + → Setup discovery started + → Setup discovery completed + → Prompt sent (first occurrence per user = first prompt) + → Task created (ACTIVATION; first occurrence = activation) +``` + +`Onboarding abandoned` fires when the user closes the app or logs out while inside `OnboardingFlow` (i.e. the last `Onboarding step viewed` has no matching `Onboarding step completed`). + +Activation cohort: distinct ID has both `Onboarding started` and `Task created` (with `created_from: "command-menu"`) within 24h. + +### Task management + +| Event | Properties | +|---|---| +| `Task created` | `auto_run`, `created_from`, `repository_provider?`, `workspace_mode?`, `has_branch?`, `has_environment_setup?`, `has_sandbox_environment?`, `cloud_run_source?`, `cloud_pr_authorship_mode?`, `uses_worktree_link?`, `uses_worktree_include?`, `adapter?` | +| `Task viewed` | `task_id` | +| `Inbox viewed` | — | +| `Task run started` | `task_id`, `execution_type`, `initial_mode?`, `adapter?`, `model?` | +| `Task run cancelled` | `task_id`, `execution_type`, `duration_seconds`, `prompts_sent` | +| `Prompt sent` | `task_id`, `is_initial`, `execution_type`, `prompt_length_chars` | +| `Session config changed` | `task_id`, `category`, `from_value`, `to_value` | +| `Task feedback` | `task_id`, `task_run_id?`, `log_url?`, `event_count`, `feedback_type`, `feedback_comment?` | + +### Permissions + +| Event | Properties | +|---|---| +| `Permission responded` | `task_id`, `tool_name?`, `option_id?`, `option_kind?`, `custom_input?` | +| `Permission cancelled` | `task_id`, `tool_name?`, `option_id?`, `option_kind?` | + +### Git / branch + +| Event | Properties | +|---|---| +| `Git action executed` | `action_type`, `success`, `task_id?`, `staged_file_count?`, `unstaged_file_count?`, `commit_all?`, `staged_only?` | +| `Pull request created` | `task_id?`, `success` | +| `Agent file activity` | `task_id`, `branch_name` | +| `Branch linked` | `task_id`, `branch_name`, `source` | +| `Branch unlinked` | `task_id`, `source` | +| `Branch link default branch unknown` | `task_id`, `branch_name` | +| `Branch mismatch warning shown` | `task_id`, `linked_branch`, `current_branch`, `has_uncommitted_changes` | +| `Branch mismatch action` | `task_id`, `action`, `linked_branch`, `current_branch` | + +`action_type` for `Git action executed`: `push`, `pull`, `sync`, `publish`, `commit`, `commit_push`, `create_pr`, `view_pr`, `update_pr`, `branch_here`. + +### Files / diffs + +| Event | Properties | +|---|---| +| `File opened` | `file_extension`, `source`, `task_id?` | +| `File diff viewed` | `file_extension`, `change_type`, `task_id?` | +| `Diff view mode changed` | `from_mode`, `to_mode` | + +### Navigation + +| Event | Properties | +|---|---| +| `Command menu opened` | — | +| `Command menu action` | `action_type` | +| `Command center viewed` | — | +| `Skill button triggered` | `task_id`, `button_id`, `source` | + +### Settings + +| Event | Properties | +|---|---| +| `Setting changed` | `setting_name`, `new_value`, `old_value?` | + +Generic event — `setting_name` is the discriminator (`theme`, `terminal_font`, `desktop_notifications`, etc.). + +### Tour + +| Event | Properties | +|---|---| +| `Tour event` | `tour_id`, `action`, `step_id?`, `step_index?`, `total_steps?` | + +`action` ∈ `started`, `step_advanced`, `dismissed`, `completed`. + +### Setup discovery + +| Event | Properties | +|---|---| +| `Setup discovery started` | `discovery_task_id`, `discovery_task_run_id` | +| `Setup discovery completed` | `discovery_task_id`, `discovery_task_run_id`, `task_count`, `duration_seconds`, `signal_source` | +| `Setup discovery failed` | `discovery_task_id?`, `discovery_task_run_id?`, `reason`, `error_message?` | +| `Setup task selected` | `discovered_task_id`, `category`, `position`, `total_discovered` | +| `Setup task dismissed` | `discovered_task_id`, `category`, `position`, `total_discovered` | + +`category` ∈ `bug`, `security`, `dead_code`, `duplication`, `performance`, `stale_feature_flag`, `error_tracking`, `event_tracking`, `funnel`, `posthog_setup`, `experiment`. + +### Billing + +| Event | Properties | +|---|---| +| `Subscription started` | `plan_key`, `previous_plan_key?` | +| `Subscription cancelled` | `plan_key` | + +### Inbox & prompt history + +| Event | Properties | +|---|---| +| `Inbox viewed` | — | +| `Inbox interest registered` | — | +| `Prompt history opened` | `entry_count` | +| `Prompt history selected` | `entry_count`, `entry_age_seconds`, `had_pending_draft`, `had_search_query`, `prompt_length` | diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index f0bd95526b..9ea710796b 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -199,6 +199,14 @@ function App() { wasInMainApp.current = isInMainApp; }, [isAuthenticated, hasCompletedOnboarding, isDarkMode]); + const wasShowingAiGateRef = useRef(false); + useEffect(() => { + if (wasShowingAiGateRef.current && !needsAiApproval && currentOrg != null) { + track(ANALYTICS_EVENTS.AI_CONSENT_APPROVED); + } + wasShowingAiGateRef.current = needsAiApproval; + }, [needsAiApproval, currentOrg]); + const handleTransitionComplete = () => { setShowTransition(false); }; diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx index 19b5979309..2dfce464d4 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -13,8 +13,11 @@ import { import { Button, Callout, Flex, Text } from "@radix-ui/themes"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { trpcClient } from "@renderer/trpc/client"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { track } from "@utils/analytics"; import { motion } from "framer-motion"; +import { useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; interface AiApprovalScreenProps { @@ -27,6 +30,11 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const openSettings = useSettingsDialogStore((s) => s.open); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount; later isAdmin changes from query resolution should not re-fire + useEffect(() => { + track(ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN, { is_org_admin: isAdmin }); + }, []); + useHotkeys(SHORTCUTS.SETTINGS, () => openSettings(), { preventDefault: true, enableOnFormTags: true, diff --git a/apps/code/src/renderer/features/auth/components/OAuthControls.tsx b/apps/code/src/renderer/features/auth/components/OAuthControls.tsx index e6e15b2b83..2655834c3a 100644 --- a/apps/code/src/renderer/features/auth/components/OAuthControls.tsx +++ b/apps/code/src/renderer/features/auth/components/OAuthControls.tsx @@ -1,9 +1,14 @@ import { useOAuthFlow } from "@features/auth/hooks/useOAuthFlow"; import { Callout, Flex, Spinner } from "@radix-ui/themes"; import posthogIcon from "@renderer/assets/images/posthog-icon.svg"; +import type { CloudRegion } from "@shared/types/regions"; import { RegionSelect } from "./RegionSelect"; -export function OAuthControls() { +interface OAuthControlsProps { + onAuthInitiated?: (region: CloudRegion) => void; +} + +export function OAuthControls({ onAuthInitiated }: OAuthControlsProps = {}) { const { region, handleAuth, @@ -13,6 +18,15 @@ export function OAuthControls() { errorMessage, } = useOAuthFlow(); + const handleClick = () => { + if (isPending) { + void handleCancel(); + return; + } + onAuthInitiated?.(region); + handleAuth(); + }; + return ( void; } -export function SignInCard({ hogSrc, hogMessage, subtitle }: SignInCardProps) { +export function SignInCard({ + hogSrc, + hogMessage, + subtitle, + onAuthInitiated, +}: SignInCardProps) { return ( @@ -17,7 +24,7 @@ export function SignInCard({ hogSrc, hogMessage, subtitle }: SignInCardProps) { {subtitle} - + ); diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts index 5a210622a5..12404fe288 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts +++ b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts @@ -103,12 +103,17 @@ interface StateMachine { scheduleDevPolling: () => void; } -function useConnectStateMachine(projectId: number | null): StateMachine { +function useConnectStateMachine( + projectId: number | null, + onConnected?: () => void, +): StateMachine { const queryClient = useQueryClient(); const [state, setState] = useState("idle"); const [error, setError] = useState(null); const stateRef = useRef(state); stateRef.current = state; + const onConnectedRef = useRef(onConnected); + onConnectedRef.current = onConnected; const pollTimerRef = useRef | null>(null); const pollTimeoutRef = useRef | null>(null); @@ -145,6 +150,7 @@ function useConnectStateMachine(projectId: number | null): StateMachine { setState("idle"); setError(null); invalidate(callbackProjectId ?? projectId); + onConnectedRef.current?.(); }, onError: (cbError) => { stopPolling(); @@ -275,6 +281,7 @@ interface ConnectOptions extends Options { * is `false` get the team-level OAuth flow (Cloud also seeds their * `UserIntegration` in the same round-trip). */ projectHasTeamIntegration: boolean | null; + onConnected?: () => void; } /** @@ -287,11 +294,12 @@ interface ConnectOptions extends Options { export function useGithubConnect({ projectId, projectHasTeamIntegration, + onConnected, }: ConnectOptions): Result { const client = useOptionalAuthenticatedClient(); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { isAdmin } = useIsOrgAdmin(); - const machine = useConnectStateMachine(projectId); + const machine = useConnectStateMachine(projectId, onConnected); const shouldUseTeamFlow = isAdmin === true && diff --git a/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx b/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx index 4e47ed6640..718876d310 100644 --- a/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx @@ -15,10 +15,12 @@ import { import { Box, Button, Flex, IconButton, Text } from "@radix-ui/themes"; import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { track } from "@utils/analytics"; import { EXTERNAL_LINKS } from "@utils/links"; import { motion } from "framer-motion"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { OnboardingHogTip } from "./OnboardingHogTip"; import { StepActions } from "./StepActions"; @@ -62,11 +64,11 @@ function CommandLine({ command }: { command: string }) { } interface CliInstallStepProps { - onNext: () => void; + onComplete: (cliSkipped: boolean) => void; onBack: () => void; } -export function CliInstallStep({ onNext, onBack }: CliInstallStepProps) { +export function CliInstallStep({ onComplete, onBack }: CliInstallStepProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); const [isCheckingGit, setIsCheckingGit] = useState(false); @@ -84,6 +86,17 @@ export function CliInstallStep({ onNext, onBack }: CliInstallStepProps) { const ghAuthenticated = ghStatus?.authenticated ?? false; const allReady = gitInstalled && ghInstalled && ghAuthenticated; + const checkFiredRef = useRef(false); + useEffect(() => { + if (checkFiredRef.current || isLoadingGit || isLoadingGh) return; + checkFiredRef.current = true; + track(ANALYTICS_EVENTS.ONBOARDING_CLI_CHECK_COMPLETED, { + git_installed: gitInstalled, + gh_installed: ghInstalled, + gh_authenticated: ghAuthenticated, + }); + }, [isLoadingGit, isLoadingGh, gitInstalled, ghInstalled, ghAuthenticated]); + const handleCheckGit = useCallback(async () => { setIsCheckingGit(true); await queryClient.invalidateQueries(trpc.git.getGitStatus.queryFilter()); @@ -337,12 +350,17 @@ export function CliInstallStep({ onNext, onBack }: CliInstallStepProps) { Back {allReady ? ( - ) : ( - diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index 762f68b714..3fd3b060c1 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -37,7 +37,9 @@ import { } from "@radix-ui/themes"; import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { trpcClient } from "@renderer/trpc/client"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { track } from "@utils/analytics"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo, useState } from "react"; import { toast } from "sonner"; @@ -116,6 +118,7 @@ export function GitIntegrationStep({ } = useGithubConnect({ projectId: selectedProjectId, projectHasTeamIntegration: selectedProject?.hasGithubIntegration ?? null, + onConnected: () => track(ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECTED), }); const canTakeAction = !isConnecting && !timedOut && !hasConnectError; const defaultPanelMessage = getPanelMessage({ diff --git a/apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx b/apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx index 8dc83e29dc..0e17070ff3 100644 --- a/apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx @@ -3,6 +3,8 @@ import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; import { ArrowLeft, ArrowRight } from "@phosphor-icons/react"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; import { motion } from "framer-motion"; import { OnboardingHogTip } from "./OnboardingHogTip"; import { StepActions } from "./StepActions"; @@ -24,9 +26,18 @@ export function InviteCodeStep({ onNext, onBack }: InviteCodeStepProps) { if (!code.trim()) return; redeemMutation.mutate(code.trim(), { onSuccess: () => { + track(ANALYTICS_EVENTS.ONBOARDING_INVITE_CODE_SUBMITTED, { + success: true, + }); resetInviteCode(); onNext(); }, + onError: (err) => { + track(ANALYTICS_EVENTS.ONBOARDING_INVITE_CODE_SUBMITTED, { + success: false, + error_type: err instanceof Error ? err.message : "unknown", + }); + }, }); }; diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 1201618ebe..985f5826f5 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -2,11 +2,15 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useUserGithubIntegrations } from "@hooks/useIntegrations"; import { ArrowRight, SignOut } from "@phosphor-icons/react"; import { Button, Flex } from "@radix-ui/themes"; import { IS_DEV } from "@shared/constants/environment"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useNavigationStore } from "@stores/navigationStore"; +import { track } from "@utils/analytics"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; +import { useEffect, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; @@ -26,6 +30,7 @@ const stepVariants = { export function OnboardingFlow() { const { currentStep, + currentIndex, activeSteps, direction, next, @@ -46,20 +51,112 @@ export function OnboardingFlow() { const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); + const { data: githubUserIntegrations = [] } = useUserGithubIntegrations(); - useHotkeys("right", next, { enableOnFormTags: false }, [next]); - useHotkeys("left", back, { enableOnFormTags: false }, [back]); + const flowStartedAtRef = useRef(Date.now()); + const stepEnteredAtRef = useRef(Date.now()); - const handleComplete = () => { + // biome-ignore lint/correctness/useExhaustiveDependencies: fires once on mount; subsequent step views fire from handleNext/handleBack + useEffect(() => { + track(ANALYTICS_EVENTS.ONBOARDING_STARTED); + track(ANALYTICS_EVENTS.ONBOARDING_STEP_VIEWED, { + step_id: currentStep, + step_index: currentIndex, + total_steps: activeSteps.length, + }); + }, []); + + useEffect(() => { + const handleBeforeUnload = () => { + track(ANALYTICS_EVENTS.ONBOARDING_ABANDONED, { + last_step_id: currentStep, + duration_seconds: Math.round( + (Date.now() - flowStartedAtRef.current) / 1000, + ), + }); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [currentStep]); + + const trackStepCompleted = () => { + track(ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETED, { + step_id: currentStep, + step_index: currentIndex, + total_steps: activeSteps.length, + duration_seconds: Math.round( + (Date.now() - stepEnteredAtRef.current) / 1000, + ), + }); + }; + + const trackStepViewed = (stepIndex: number) => { + const stepId = activeSteps[stepIndex]; + if (!stepId) return; + track(ANALYTICS_EVENTS.ONBOARDING_STEP_VIEWED, { + step_id: stepId, + step_index: stepIndex, + total_steps: activeSteps.length, + }); + stepEnteredAtRef.current = Date.now(); + }; + + const handleNext = () => { + trackStepCompleted(); + trackStepViewed(currentIndex + 1); + next(); + }; + + const handleBack = () => { + trackStepViewed(currentIndex - 1); + back(); + }; + + useHotkeys("right", handleNext, { enableOnFormTags: false }, [handleNext]); + useHotkeys("left", handleBack, { enableOnFormTags: false }, [handleBack]); + + const handleComplete = (cliSkipped: boolean) => { + if (cliSkipped) { + track(ANALYTICS_EVENTS.ONBOARDING_STEP_SKIPPED, { + step_id: currentStep, + step_index: currentIndex, + reason: "tools_not_installed", + }); + } else { + trackStepCompleted(); + } + track(ANALYTICS_EVENTS.ONBOARDING_COMPLETED, { + duration_seconds: Math.round( + (Date.now() - flowStartedAtRef.current) / 1000, + ), + github_connected: githubUserIntegrations.length > 0, + cli_skipped: cliSkipped, + }); completeOnboarding(); navigateToTaskInput(); }; const handleSkip = () => { + track(ANALYTICS_EVENTS.ONBOARDING_STEP_SKIPPED, { + step_id: currentStep, + step_index: currentIndex, + reason: "dev_skip", + }); completeOnboarding(); navigateToTaskInput(); }; + const handleLogout = () => { + track(ANALYTICS_EVENTS.ONBOARDING_ABANDONED, { + last_step_id: currentStep, + duration_seconds: Math.round( + (Date.now() - flowStartedAtRef.current) / 1000, + ), + }); + logoutMutation.mutate(); + resetOnboarding(); + }; + const footerRight = ( {isAuthenticated && ( @@ -67,10 +164,7 @@ export function OnboardingFlow() { size="1" variant="ghost" color="gray" - onClick={() => { - logoutMutation.mutate(); - resetOnboarding(); - }} + onClick={handleLogout} className="opacity-50" > @@ -107,7 +201,7 @@ export function OnboardingFlow() { transition={{ duration: 0.3 }} className="min-h-0 w-full flex-1" > - + )} @@ -122,7 +216,7 @@ export function OnboardingFlow() { transition={{ duration: 0.3 }} className="min-h-0 w-full flex-1" > - + )} @@ -137,7 +231,7 @@ export function OnboardingFlow() { transition={{ duration: 0.3 }} className="min-h-0 w-full flex-1" > - + )} @@ -153,8 +247,8 @@ export function OnboardingFlow() { className="min-h-0 w-full flex-1" > - + )} diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx index c2143e6390..64b3f8e81a 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx @@ -35,7 +35,9 @@ import { FIELD_TRIGGER_CLASS, } from "@renderer/styles/fieldTrigger"; import { BILLING_FLAG } from "@shared/constants"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -179,6 +181,11 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { hogSrc={happyHog} hogMessage="I don't bite. Just need to know who I'm working with." subtitle="Connect your account to get started." + onAuthInitiated={(region) => + track(ANALYTICS_EVENTS.ONBOARDING_SIGN_IN_INITIATED, { + region, + }) + } /> ) : null} @@ -387,7 +394,13 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { {isAuthenticated && !isLoading && (