diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/components/ProviderConnectModal/ProviderConnectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/onboarding/components/ProviderConnectModal/ProviderConnectModal.tsx index e92af1ff77d..c0ab03278df 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/onboarding/components/ProviderConnectModal/ProviderConnectModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/components/ProviderConnectModal/ProviderConnectModal.tsx @@ -16,6 +16,7 @@ import { OpenAIOAuthDialog } from "renderer/components/Chat/ChatInterface/compon import { useAnthropicOAuth } from "renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth"; import { useOpenAIOAuth } from "renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth"; import { track } from "renderer/lib/analytics"; +import { trackOnboardingError } from "../../utils/onboardingAnalytics"; export type Provider = "anthropic" | "openai"; @@ -61,7 +62,12 @@ function AnthropicConnectDialog({ }); const handleApiKeySubmit = async (rawKey: string) => { - await setApiKey.mutateAsync({ apiKey: rawKey }); + try { + await setApiKey.mutateAsync({ apiKey: rawKey }); + } catch (err) { + trackOnboardingError("providers", "provider_connect", "api_key_failed"); + throw err; + } track("onboarding_provider_connected", { provider: "anthropic", method: "api-key", @@ -121,7 +127,12 @@ function OpenAIConnectDialog({ }); const handleApiKeySubmit = async (rawKey: string) => { - await setApiKey.mutateAsync({ apiKey: rawKey }); + try { + await setApiKey.mutateAsync({ apiKey: rawKey }); + } catch (err) { + trackOnboardingError("providers", "provider_connect", "api_key_failed"); + throw err; + } track("onboarding_provider_connected", { provider: "openai", method: "api-key", diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx index 27fa34fd0e6..4a8baf9534d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx @@ -6,12 +6,18 @@ import { useLocation, useNavigate, } from "@tanstack/react-router"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { createChatServiceIpcClient } from "renderer/components/Chat/utils/chat-service-client"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronQueryClient } from "renderer/providers/ElectronTRPCProvider"; import { OnboardingNavigation } from "./components/OnboardingNavigation"; +import { + flushOnboardingAbandoned, + scheduleOnboardingAbandoned, + trackOnboardingEntered, + trackOnboardingStepCompleted, +} from "./utils/onboardingAnalytics"; export const Route = createFileRoute("/_authenticated/onboarding")({ component: OnboardingFlowLayout, @@ -24,12 +30,14 @@ const STEPS = [ { path: "/onboarding", match: (p: string) => p === "/onboarding", + slug: "providers", title: "Setup Superset", subtitle: "Connect your agents and tools to get started.", }, { path: "/onboarding/project", match: (p: string) => p === "/onboarding/project", + slug: "project", title: "Create or add a project", subtitle: "Start from scratch, open a folder, or clone a repo.", }, @@ -43,6 +51,22 @@ function OnboardingFlowLayout() { const location = useLocation(); const navigate = useNavigate(); + const onboarded = Boolean(session?.user?.onboardedAt); + const pathname = location.pathname; + useEffect(() => { + if (isPending || onboarded) return; + const step = STEPS.find((s) => s.match(pathname)); + if (step) trackOnboardingEntered(step.slug); + }, [isPending, onboarded, pathname]); + + useEffect(() => { + window.addEventListener("beforeunload", flushOnboardingAbandoned); + return () => { + window.removeEventListener("beforeunload", flushOnboardingAbandoned); + scheduleOnboardingAbandoned(); + }; + }, []); + if (isPending) return null; if (session?.user?.onboardedAt) { return rootRedirect; @@ -63,7 +87,10 @@ function OnboardingFlowLayout() { // Step 1 advances to the project step; the project step finishes onboarding // itself the moment a project is added, so it has no footer Continue. const handleContinue = isFirstStep - ? () => navigate({ to: "/onboarding/project" }) + ? () => { + trackOnboardingStepCompleted("providers"); + navigate({ to: "/onboarding/project" }); + } : null; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/onboarding/page.tsx index 376ccf0e7e8..a463cdd0522 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/onboarding/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/page.tsx @@ -14,6 +14,7 @@ import { ProviderConnectModal, } from "./components/ProviderConnectModal"; import { ClaudeLogo } from "./providers/components/ClaudeLogo"; +import { trackOnboardingError } from "./utils/onboardingAnalytics"; export const Route = createFileRoute("/_authenticated/onboarding/")({ component: OnboardingDashboardPage, @@ -134,7 +135,12 @@ function OnboardingDashboardPage() { void refetchGh()} + onExit={async () => { + const result = await refetchGh(); + if (!(result.data?.installed && result.data.authenticated)) { + trackOnboardingError("providers", "gh_auth", "auth_incomplete"); + } + }} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx index 49923ddfecf..dc330b9fcd9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx @@ -27,6 +27,11 @@ import { EmptyProjectModal } from "renderer/routes/_authenticated/components/Emp import { TemplateGalleryModal } from "renderer/routes/_authenticated/components/TemplateGalleryModal"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { + markOnboardingFinished, + trackOnboardingError, + trackOnboardingStepCompleted, +} from "../utils/onboardingAnalytics"; export const Route = createFileRoute("/_authenticated/onboarding/project/")({ component: OnboardingProjectPage, @@ -57,6 +62,8 @@ function OnboardingProjectPage() { // Adding a project finishes onboarding: mark onboarded, then hand off to the // dashboard's new-workspace modal pre-selected to the project just added. const finish = async (projectId: string) => { + trackOnboardingStepCompleted("project"); + markOnboardingFinished(); track("onboarding_finished", { outcome: "completed" }); try { await apiTrpcClient.user.completeOnboarding.mutate(); @@ -65,6 +72,11 @@ function OnboardingProjectPage() { // _authenticated guard bounces /v2-workspaces back to /onboarding. await refetchSession({ query: { disableCookieCache: true } }); } catch (error) { + trackOnboardingError( + "project", + "complete_onboarding", + requestFailureReason(error), + ); console.error("[onboarding] completeOnboarding failed", error); toast.error("Could not finish onboarding. Please try again."); return; @@ -120,6 +132,7 @@ function OnboardingProjectPage() { if (isV2CloudEnabled) { const activeHostUrl = await waitForHostReady(); if (!activeHostUrl) { + trackOnboardingError("project", "clone", "host_not_ready"); toast.error("Local host service isn't ready yet. Please try again."); return; } @@ -138,6 +151,7 @@ function OnboardingProjectPage() { if (projectId) await finish(projectId); } } catch (err) { + trackOnboardingError("project", "clone", cloneFailureReason(err)); toast.error( err instanceof Error ? err.message : "Failed to clone repository", ); @@ -254,6 +268,35 @@ function OnboardingProjectPage() { ); } +// Reason codes only — error messages can contain URLs/paths, never log them. +function cloneFailureReason(err: unknown): string { + const msg = err instanceof Error ? err.message.toLowerCase() : ""; + if (msg.includes("not found") || msg.includes("does not exist")) + return "repo_not_found"; + if ( + msg.includes("auth") || + msg.includes("permission denied") || + msg.includes("403") + ) + return "auth_failed"; + if (msg.includes("could not resolve host") || msg.includes("network")) + return "network_error"; + if (msg.includes("already exists")) return "already_exists"; + return "clone_failed"; +} + +function requestFailureReason(err: unknown): string { + const msg = err instanceof Error ? err.message.toLowerCase() : ""; + if ( + msg.includes("fetch failed") || + msg.includes("load failed") || + msg.includes("network") + ) + return "api_unreachable"; + if (msg.includes("unauthorized") || msg.includes("401")) return "auth_failed"; + return "request_failed"; +} + function repoNameFromUrl(url: string): string { const lastSegment = url .trim() diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/index.ts b/apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/index.ts new file mode 100644 index 00000000000..7e628710679 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/index.ts @@ -0,0 +1,9 @@ +export { + flushOnboardingAbandoned, + markOnboardingFinished, + type OnboardingStep, + scheduleOnboardingAbandoned, + trackOnboardingEntered, + trackOnboardingError, + trackOnboardingStepCompleted, +} from "./onboardingAnalytics"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/onboardingAnalytics.ts b/apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/onboardingAnalytics.ts new file mode 100644 index 00000000000..3f8c7691993 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/onboardingAnalytics.ts @@ -0,0 +1,58 @@ +import { track } from "renderer/lib/analytics"; + +export type OnboardingStep = "providers" | "project"; + +// Module-level so remounts and route bounces within a renderer session +// can't double-fire onboarding_started or duplicate step_started events. +let started = false; +let finished = false; +let currentStep: OnboardingStep | null = null; +let abandonTimer: ReturnType | null = null; + +export function trackOnboardingEntered(step: OnboardingStep): void { + if (abandonTimer !== null) { + clearTimeout(abandonTimer); + abandonTimer = null; + } + if (!started) { + started = true; + track("onboarding_started"); + } + if (currentStep !== step) { + currentStep = step; + track("onboarding_step_started", { step }); + } +} + +export function trackOnboardingStepCompleted(step: OnboardingStep): void { + track("onboarding_step_completed", { step }); +} + +/** Reason codes only — never include URLs, repo names, paths, or messages. */ +export function trackOnboardingError( + step: OnboardingStep, + action: string, + reason: string, +): void { + track("onboarding_error", { step, action, reason }); +} + +export function markOnboardingFinished(): void { + finished = true; +} + +export function flushOnboardingAbandoned(): void { + if (!started || finished || currentStep === null) return; + track("onboarding_abandoned", { step: currentStep }); + currentStep = null; +} + +// Deferred a tick so StrictMode's unmount/remount and route bounces through +// the layout don't register as abandonment; re-entry cancels the timer. +export function scheduleOnboardingAbandoned(): void { + if (abandonTimer !== null) clearTimeout(abandonTimer); + abandonTimer = setTimeout(() => { + abandonTimer = null; + flushOnboardingAbandoned(); + }, 0); +}