Repository navigation
feat(desktop): restore onboarding funnel instrumentation #6122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
Comment on lines
+65
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: V1 clone failures never emit the new Prompt for AI agents |
||
| 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() | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Deduplicate step-completion events.
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 |
||
|
|
||
| /** 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); | ||
| } | ||
There was a problem hiding this comment.
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
beforeunloadhandler uses a normal asynchronous PostHog capture rather than an unload-safesendBeaconcapture. An unload-specific analytics path should passtransport: "sendBeacon"andsend_instantly: truesoonboarding_abandonedis sent before the renderer exits.Prompt for AI agents