Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.",
},
Expand All @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Window-close abandonment events can be lost because this beforeunload handler uses a normal asynchronous PostHog capture rather than an unload-safe sendBeacon capture. An unload-specific analytics path should pass transport: "sendBeacon" and send_instantly: true so onboarding_abandoned is sent before the renderer exits.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx, line 63:

<comment>Window-close abandonment events can be lost because this `beforeunload` handler uses a normal asynchronous PostHog capture rather than an unload-safe `sendBeacon` capture. An unload-specific analytics path should pass `transport: "sendBeacon"` and `send_instantly: true` so `onboarding_abandoned` is sent before the renderer exits.</comment>

<file context>
@@ -43,6 +51,22 @@ function OnboardingFlowLayout() {
+	}, [isPending, onboarded, pathname]);
+
+	useEffect(() => {
+		window.addEventListener("beforeunload", flushOnboardingAbandoned);
+		return () => {
+			window.removeEventListener("beforeunload", flushOnboardingAbandoned);
</file context>

return () => {
window.removeEventListener("beforeunload", flushOnboardingAbandoned);
scheduleOnboardingAbandoned();
};
}, []);

if (isPending) return null;
if (session?.user?.onboardedAt) {
return rootRedirect;
Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -134,7 +135,12 @@ function OnboardingDashboardPage() {
<GhAuthDialog
open={ghAuthOpen}
onOpenChange={setGhAuthOpen}
onExit={() => void refetchGh()}
onExit={async () => {
const result = await refetchGh();
if (!(result.data?.installed && result.data.authenticated)) {
trackOnboardingError("providers", "gh_auth", "auth_incomplete");
}
}}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Comment on lines +65 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mark onboarding finished only after persistence succeeds.

Line 66 sets finished before completeOnboarding succeeds. If the mutation fails, flushOnboardingAbandoned skips abandonment tracking even though the user remains in onboarding. Set this state after completeOnboarding.mutate() resolves.

Proposed fix
 const finish = async (projectId: string) => {
 	trackOnboardingStepCompleted("project");
-	markOnboardingFinished();
 	track("onboarding_finished", { outcome: "completed" });
 	try {
 		await apiTrpcClient.user.completeOnboarding.mutate();
+		markOnboardingFinished();
 		await refetchSession({ query: { disableCookieCache: true } });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx`
around lines 65 - 66, Update the onboarding completion flow around
completeOnboarding.mutate and markOnboardingFinished so the finished state is
set only after the mutation resolves successfully. Keep
trackOnboardingStepCompleted in the existing completion path, but ensure failed
persistence leaves onboarding unfinished so abandonment tracking can run.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The newly added markOnboardingFinished() (and trackOnboardingStepCompleted("project")) run before completeOnboarding.mutate() succeeds. If that mutation (or the subsequent refetchSession) rejects, the code returns early with an error toast, but the module-level finished flag has already been set to true. As a result flushOnboardingAbandoned() will now silently skip emission of onboarding_abandoned even though the user never actually finished onboarding, and onboarding_finished/step-completed are recorded for a flow that failed. Move the finish/step-completed marks (and the onboarding_finished event) to the success path after the try block completes so failed completions don't get reported as finished and abandonment stays trackable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx, line 66:

<comment>The newly added markOnboardingFinished() (and trackOnboardingStepCompleted("project")) run before completeOnboarding.mutate() succeeds. If that mutation (or the subsequent refetchSession) rejects, the code returns early with an error toast, but the module-level finished flag has already been set to true. As a result flushOnboardingAbandoned() will now silently skip emission of onboarding_abandoned even though the user never actually finished onboarding, and onboarding_finished/step-completed are recorded for a flow that failed. Move the finish/step-completed marks (and the onboarding_finished event) to the success path after the try block completes so failed completions don't get reported as finished and abandonment stays trackable.</comment>

<file context>
@@ -57,6 +62,8 @@ function OnboardingProjectPage() {
 	// 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 {
</file context>

track("onboarding_finished", { outcome: "completed" });
try {
await apiTrpcClient.user.completeOnboarding.mutate();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -138,6 +151,7 @@ function OnboardingProjectPage() {
if (projectId) await finish(projectId);
}
} catch (err) {
trackOnboardingError("project", "clone", cloneFailureReason(err));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: V1 clone failures never emit the new onboarding_error event because cloneFromUrl swallows them before this outer catch runs. Expose the failure through an error callback or typed result and track it at the V1 mutation boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/onboarding/project/page.tsx, line 154:

<comment>V1 clone failures never emit the new `onboarding_error` event because `cloneFromUrl` swallows them before this outer catch runs. Expose the failure through an error callback or typed result and track it at the V1 mutation boundary.</comment>

<file context>
@@ -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",
</file context>

toast.error(
err instanceof Error ? err.message : "Failed to clone repository",
);
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
flushOnboardingAbandoned,
markOnboardingFinished,
type OnboardingStep,
scheduleOnboardingAbandoned,
trackOnboardingEntered,
trackOnboardingError,
trackOnboardingStepCompleted,
} from "./onboardingAnalytics";
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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 });
}
Comment on lines +27 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Deduplicate step-completion events.

trackOnboardingStepCompleted emits an event on every call. A repeated activation of the Continue control can record duplicate providers completion events. Store completed steps and emit one completion event per step.

Proposed fix
 let currentStep: OnboardingStep | null = null;
+const completedSteps = new Set<OnboardingStep>();
 let abandonTimer: ReturnType<typeof setTimeout> | null = null;
 
 export function trackOnboardingStepCompleted(step: OnboardingStep): void {
+	if (completedSteps.has(step)) return;
+	completedSteps.add(step);
 	track("onboarding_step_completed", { step });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/onboarding/utils/onboardingAnalytics/onboardingAnalytics.ts`
around lines 27 - 29, Update trackOnboardingStepCompleted to maintain
module-level state of already completed OnboardingStep values, returning without
tracking when the supplied step was previously recorded; otherwise record the
step and emit exactly one onboarding_step_completed event.


/** 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);
}
Loading