feat(FN-1998): instrument onboarding funnel analytics events

- Add onboarding event tracking utility with localStorage persistence, session IDs, and ring-buffer pruning
- Emit funnel events from ModelOnboardingModal for step progress, dismissal/completion, and first-task/CTA actions
- Track resume-card and auto-trigger onboarding entry points in OnboardingResumeCard and useAuthOnboarding
- Expand dashboard tests and mocks to validate onboarding event storage behavior and new instrumentation calls
This commit is contained in:
Fusion
2026-04-17 08:01:38 -07:00
committed by gsxdsm
parent bdfb4842d2
commit 1433ca98ce
8 changed files with 474 additions and 3 deletions

View File

@@ -411,6 +411,7 @@ import {
getStepData,
type OnboardingStep,
} from "./model-onboarding-state";
import { trackOnboardingEvent } from "./onboarding-events";
import type { SectionId } from "./SettingsModal";
export interface ModelOnboardingModalProps {
@@ -492,6 +493,9 @@ export function ModelOnboardingModal({
});
const pollCountRef = useRef<number>(0);
const previousCreatedTaskRef = useRef<Task | null | undefined>(firstCreatedTask);
const hasTrackedWizardOpenRef = useRef(false);
const resumedFromStep = persistedState?.currentStep;
const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete";
// Initialize skippedProviders from persisted state
const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>(
@@ -519,6 +523,18 @@ export function ModelOnboardingModal({
}
}, [step, completedSteps, skippedSteps]);
useEffect(() => {
if (hasTrackedWizardOpenRef.current) {
return;
}
hasTrackedWizardOpenRef.current = true;
trackOnboardingEvent("onboarding:wizard-opened", {
source: isResumedFlow ? "resume" : "initial",
resumedFromStep,
});
}, [isResumedFlow, resumedFromStep]);
useEffect(() => {
const hadCreatedTask = previousCreatedTaskRef.current != null;
const hasCreatedTask = firstCreatedTask != null;
@@ -775,6 +791,7 @@ export function ModelOnboardingModal({
setCompletedSteps((prev) => [...new Set([...prev, step])]);
// Completing a step clears any prior skipped status
setSkippedSteps((prev) => prev.filter((s) => s !== step));
trackOnboardingEvent("onboarding:step-completed", { step });
if (step === "github" && !isGithubAuthenticated) {
setGitHubSkippedState(false);
@@ -791,6 +808,7 @@ export function ModelOnboardingModal({
const handleSkip = useCallback(() => {
setSkippedSteps((prev) => [...new Set([...prev, step])]);
markStepSkipped(step);
trackOnboardingEvent("onboarding:step-skipped", { step });
if (step === "github" && !isGithubAuthenticated) {
setGitHubSkippedState(true);
@@ -1126,11 +1144,12 @@ export function ModelOnboardingModal({
setSaving(true);
try {
await completeOnboarding();
trackOnboardingEvent("onboarding:completed", { completedSteps, skippedSteps });
setStep("complete");
} finally {
setSaving(false);
}
}, [completeOnboarding]);
}, [completeOnboarding, completedSteps, skippedSteps]);
const handleCreateFirstTask = useCallback(async () => {
const trimmedDescription = firstTaskDescription.trim();
@@ -1148,6 +1167,7 @@ export function ModelOnboardingModal({
const createdTask = await createTask({ description: trimmedDescription });
setInlineCreatedTask(createdTask);
setShowTaskCreated(true);
trackOnboardingEvent("onboarding:first-task-created", { taskId: createdTask?.id });
addToast("Task created", "success");
success = true;
} catch (err: unknown) {
@@ -1177,6 +1197,7 @@ export function ModelOnboardingModal({
}
// Keep onboarding open so task creation can hand back to a success state
trackOnboardingEvent("onboarding:open-new-task", {});
onOpenNewTask?.();
}, [completeOnboarding, onOpenNewTask]);
@@ -1193,11 +1214,18 @@ export function ModelOnboardingModal({
// Close modal and trigger callback
setIsOpen(false);
onComplete();
trackOnboardingEvent("onboarding:open-github-import", {});
onOpenGitHubImport?.();
}, [completeOnboarding, onComplete, onOpenGitHubImport]);
// Dismiss without completing (still marks onboarding complete)
const handleDismiss = useCallback(async () => {
trackOnboardingEvent("onboarding:dismissed", {
currentStep: step,
completedSteps,
skippedSteps,
});
setSaving(true);
try {
await updateGlobalSettings({ modelOnboardingComplete: true });
@@ -1206,10 +1234,11 @@ export function ModelOnboardingModal({
}
setIsOpen(false);
onComplete();
}, [onComplete]);
}, [step, completedSteps, skippedSteps, onComplete]);
// Close from the completion step
const handleFinish = useCallback(() => {
trackOnboardingEvent("onboarding:finished", {});
setIsOpen(false);
onComplete();
}, [onComplete]);

View File

@@ -1,5 +1,6 @@
import { Play, Sparkles } from "lucide-react";
import { getOnboardingResumeStep } from "./model-onboarding-state";
import { trackOnboardingEvent } from "./onboarding-events";
interface OnboardingResumeCardProps {
/** Called when the user clicks "Continue onboarding" */
@@ -45,7 +46,13 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
<div className="onboarding-resume-card__actions">
<button
className="onboarding-resume-card__resume-btn"
onClick={onResume}
onClick={() => {
trackOnboardingEvent("onboarding:resumed", {
source: "resume-card",
resumedFromStep: resumeStep.currentStep,
});
onResume();
}}
>
<Play size={14} aria-hidden="true" />
<span>Continue onboarding</span>

View File

@@ -61,6 +61,13 @@ vi.mock("../model-onboarding-state", () => ({
getStepData: (...args: unknown[]) => mockGetStepData(...args),
}));
const mockTrackOnboardingEvent = vi.fn();
vi.mock("../onboarding-events", () => ({
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
getOnboardingSessionId: () => "test-session-id",
}));
// Mock ProviderIcon for test isolation
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => (
@@ -124,6 +131,7 @@ async function navigateToFirstTaskStep() {
beforeEach(() => {
vi.clearAllMocks();
mockTrackOnboardingEvent.mockReset();
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockFetchGlobalSettings.mockResolvedValue({});
mockUpdateGlobalSettings.mockResolvedValue({});

View File

@@ -7,6 +7,13 @@ vi.mock("../model-onboarding-state", () => ({
getOnboardingResumeStep: vi.fn(),
}));
const mockTrackOnboardingEvent = vi.fn();
vi.mock("../onboarding-events", () => ({
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
getOnboardingSessionId: () => "test-session-id",
}));
import { getOnboardingResumeStep } from "../model-onboarding-state";
describe("OnboardingResumeCard", () => {
@@ -14,11 +21,13 @@ describe("OnboardingResumeCard", () => {
beforeEach(() => {
mockGetOnboardingResumeStep.mockReset();
mockTrackOnboardingEvent.mockReset();
mockGetOnboardingResumeStep.mockReturnValue(null);
});
afterEach(() => {
mockGetOnboardingResumeStep.mockReset();
mockTrackOnboardingEvent.mockReset();
});
describe("rendering", () => {
@@ -93,6 +102,10 @@ describe("OnboardingResumeCard", () => {
const button = screen.getByRole("button", { name: "Continue onboarding" });
fireEvent.click(button);
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith(
"onboarding:resumed",
expect.objectContaining({ source: "resume-card" }),
);
expect(onResume).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,172 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import {
__test_clearSessionId,
clearOnboardingEvents,
getOnboardingEvents,
getOnboardingSessionId,
trackOnboardingEvent,
} from "../onboarding-events";
const EVENTS_STORAGE_KEY = "fusion_onboarding_events";
const SESSION_STORAGE_KEY = "fusion_onboarding_session_id";
describe("onboarding-events", () => {
let consoleInfoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.removeItem(EVENTS_STORAGE_KEY);
sessionStorage.removeItem(SESSION_STORAGE_KEY);
__test_clearSessionId();
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
});
afterEach(() => {
consoleInfoSpy.mockRestore();
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.removeItem(EVENTS_STORAGE_KEY);
sessionStorage.removeItem(SESSION_STORAGE_KEY);
__test_clearSessionId();
});
it("stores tracked events in localStorage", () => {
const setItemSpy = vi.spyOn(window.localStorage, "setItem");
trackOnboardingEvent("onboarding:wizard-opened", { source: "initial" });
expect(setItemSpy).toHaveBeenCalledWith(EVENTS_STORAGE_KEY, expect.any(String));
const raw = localStorage.getItem(EVENTS_STORAGE_KEY);
expect(raw).toBeTruthy();
const events = JSON.parse(raw ?? "[]") as Array<Record<string, unknown>>;
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "onboarding:wizard-opened",
metadata: { source: "initial" },
});
expect(typeof events[0]?.timestamp).toBe("string");
});
it("accumulates events in insertion order", () => {
trackOnboardingEvent("onboarding:step-completed", { step: "ai-setup" });
trackOnboardingEvent("onboarding:step-completed", { step: "github" });
trackOnboardingEvent("onboarding:step-completed", { step: "first-task" });
const events = getOnboardingEvents();
expect(events).toHaveLength(3);
expect(events[0]?.metadata.step).toBe("ai-setup");
expect(events[1]?.metadata.step).toBe("github");
expect(events[2]?.metadata.step).toBe("first-task");
});
it("prunes ring buffer by 50 when event count exceeds 200", () => {
for (let index = 1; index <= 201; index += 1) {
trackOnboardingEvent("onboarding:step-completed", { step: "ai-setup", sequence: index });
}
const events = getOnboardingEvents();
expect(events).toHaveLength(151);
expect(events[0]?.metadata.sequence).toBe(51);
expect(events.at(-1)?.metadata.sequence).toBe(201);
});
it("clears stored events", () => {
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
trackOnboardingEvent("onboarding:finished");
expect(getOnboardingEvents()).toHaveLength(1);
clearOnboardingEvents();
expect(getOnboardingEvents()).toEqual([]);
expect(removeItemSpy).toHaveBeenCalledWith(EVENTS_STORAGE_KEY);
});
it("returns a stable session id within one browser session", () => {
const randomUuidSpy = vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("123e4567-e89b-12d3-a456-426614174000");
const first = getOnboardingSessionId();
const second = getOnboardingSessionId();
expect(first).toBe(second);
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
expect(randomUuidSpy).toHaveBeenCalledTimes(1);
});
it("regenerates session id across storage resets", () => {
const randomUuidSpy = vi
.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174000")
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174001");
const first = getOnboardingSessionId();
sessionStorage.removeItem(SESSION_STORAGE_KEY);
__test_clearSessionId();
const second = getOnboardingSessionId();
expect(first).not.toBe(second);
expect(randomUuidSpy).toHaveBeenCalledTimes(2);
});
it("is SSR-safe when window is unavailable", () => {
vi.stubGlobal("window", undefined);
expect(getOnboardingEvents()).toEqual([]);
expect(() => trackOnboardingEvent("onboarding:finished")).not.toThrow();
});
it("stores events with the expected structure", () => {
trackOnboardingEvent("onboarding:step-completed", { step: "github", source: "test" });
const [event] = getOnboardingEvents();
expect(event).toBeDefined();
expect(event).toMatchObject({
type: "onboarding:step-completed",
metadata: { step: "github", source: "test" },
});
expect(typeof event?.sessionId).toBe("string");
expect(event?.sessionId.length).toBeGreaterThan(0);
expect(Date.parse(event?.timestamp ?? "")).not.toBeNaN();
});
it("defaults metadata to an empty object", () => {
trackOnboardingEvent("onboarding:finished");
const [event] = getOnboardingEvents();
expect(event?.metadata).toEqual({});
});
it("handles storage quota errors without throwing", () => {
const setItemSpy = vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
expect(() => trackOnboardingEvent("onboarding:finished")).not.toThrow();
expect(setItemSpy).toHaveBeenCalled();
});
it("logs events in DEV mode", () => {
trackOnboardingEvent("onboarding:finished");
expect(consoleInfoSpy).toHaveBeenCalledWith("[fusion:onboarding]", expect.any(String));
});
it("resets session id via __test_clearSessionId", () => {
const randomUuidSpy = vi
.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174000")
.mockReturnValueOnce("123e4567-e89b-12d3-a456-426614174001");
const first = getOnboardingSessionId();
__test_clearSessionId();
const second = getOnboardingSessionId();
expect(first).not.toBe(second);
expect(randomUuidSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,230 @@
import type { OnboardingStep } from "./model-onboarding-state";
/** LocalStorage key used for persisted onboarding funnel events. */
const ONBOARDING_EVENTS_STORAGE_KEY = "fusion_onboarding_events";
/** SessionStorage key used for tab-scoped onboarding session IDs. */
const ONBOARDING_SESSION_ID_STORAGE_KEY = "fusion_onboarding_session_id";
/** Maximum number of events kept in localStorage before pruning. */
const ONBOARDING_EVENTS_MAX = 200;
/** Number of oldest events removed per prune operation. */
const ONBOARDING_EVENTS_PRUNE_BATCH = 50;
/**
* Known onboarding funnel event names.
*/
export type OnboardingEventType =
| "onboarding:wizard-opened"
| "onboarding:step-completed"
| "onboarding:step-skipped"
| "onboarding:dismissed"
| "onboarding:completed"
| "onboarding:finished"
| "onboarding:first-task-created"
| "onboarding:open-new-task"
| "onboarding:open-github-import"
| "onboarding:resumed"
| "onboarding:auto-triggered";
/**
* Source metadata for wizard-opened events.
*
* Includes legacy values (`"resume"`, `"initial"`) to preserve compatibility
* with existing instrumentation call sites while funnel analytics migrate to
* the canonical source labels.
*/
export type OnboardingWizardOpenedSource =
| "auto-trigger"
| "resume-card"
| "settings"
| "unknown"
| "resume"
| "initial";
/**
* Structured onboarding analytics event persisted in localStorage.
*/
export interface OnboardingEvent {
/** Event name (for example: "onboarding:step-completed"). */
type: string;
/** Event timestamp in ISO-8601 format. */
timestamp: string;
/** Optional duplicated step index for convenience querying. */
step?: OnboardingStep;
/** Event-specific metadata payload. */
metadata: Record<string, unknown>;
/** Session-scoped identifier persisted in sessionStorage. */
sessionId: string;
}
/**
* Emits an onboarding funnel event, persists it to localStorage (ring buffer),
* and logs it to console.info in development mode.
*
* Event names expected by this module:
* - `onboarding:wizard-opened` → `{ source, resumedFromStep? }`
* - `onboarding:step-completed` → `{ step }`
* - `onboarding:step-skipped` → `{ step }`
* - `onboarding:dismissed` → `{ currentStep, completedSteps, skippedSteps }`
* - `onboarding:completed` → `{ completedSteps, skippedSteps }`
* - `onboarding:finished` → `{}`
* - `onboarding:first-task-created` → `{ taskId? }`
* - `onboarding:open-new-task` → `{}`
* - `onboarding:open-github-import` → `{}`
* - `onboarding:resumed` → `{ source, resumedFromStep? }`
* - `onboarding:auto-triggered` → `{ trigger }`
*/
export function trackOnboardingEvent(type: string, metadata: Record<string, unknown> = {}): void {
if (typeof window === "undefined") return;
try {
const events = readOnboardingEventsFromStorage();
const step = extractStep(metadata.step);
const event: OnboardingEvent = {
type,
timestamp: new Date().toISOString(),
...(step ? { step } : {}),
metadata,
sessionId: getOnboardingSessionId(),
};
const nextEvents = pruneOnboardingEvents([...events, event]);
writeOnboardingEventsToStorage(nextEvents);
if (import.meta.env?.DEV) {
console.info("[fusion:onboarding]", JSON.stringify(event));
}
} catch {
// Quota/malformed-storage/runtime errors should never break onboarding UX.
}
}
/**
* Returns all persisted onboarding events for the current browser profile.
* Returns an empty array in SSR/node contexts.
*/
export function getOnboardingEvents(): OnboardingEvent[] {
if (typeof window === "undefined") return [];
return readOnboardingEventsFromStorage();
}
/**
* Clears all persisted onboarding events.
*/
export function clearOnboardingEvents(): void {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(ONBOARDING_EVENTS_STORAGE_KEY);
} catch {
// Ignore storage errors.
}
}
/**
* Returns the onboarding session ID for this browser tab.
*
* The session ID is persisted in sessionStorage, so it survives navigation
* reloads but resets across separate tabs/sessions.
*/
export function getOnboardingSessionId(): string {
if (typeof window === "undefined") {
return "server-session";
}
try {
const existingId = sessionStorage.getItem(ONBOARDING_SESSION_ID_STORAGE_KEY);
if (existingId) {
return existingId;
}
const sessionId = createSessionId();
sessionStorage.setItem(ONBOARDING_SESSION_ID_STORAGE_KEY, sessionId);
return sessionId;
} catch {
return createSessionId();
}
}
/**
* Test-only helper to reset the cached onboarding session id in sessionStorage.
*/
export function __test_clearSessionId(): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(ONBOARDING_SESSION_ID_STORAGE_KEY);
} catch {
// Ignore storage errors.
}
}
function createSessionId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `onboarding-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
function readOnboardingEventsFromStorage(): OnboardingEvent[] {
try {
const raw = localStorage.getItem(ONBOARDING_EVENTS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isOnboardingEvent);
} catch {
return [];
}
}
function writeOnboardingEventsToStorage(events: OnboardingEvent[]): void {
try {
localStorage.setItem(ONBOARDING_EVENTS_STORAGE_KEY, JSON.stringify(events));
} catch {
// Ignore storage write errors (quota/private mode/etc).
}
}
/**
* Applies ring-buffer bounds, pruning oldest events in batches for fewer writes.
*/
function pruneOnboardingEvents(events: OnboardingEvent[]): OnboardingEvent[] {
const nextEvents = [...events];
while (nextEvents.length > ONBOARDING_EVENTS_MAX) {
nextEvents.splice(0, ONBOARDING_EVENTS_PRUNE_BATCH);
}
return nextEvents;
}
function extractStep(value: unknown): OnboardingStep | undefined {
if (
value === "ai-setup"
|| value === "github"
|| value === "first-task"
|| value === "complete"
) {
return value;
}
return undefined;
}
function isOnboardingEvent(value: unknown): value is OnboardingEvent {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<OnboardingEvent>;
return (
typeof candidate.type === "string"
&& typeof candidate.timestamp === "string"
&& typeof candidate.sessionId === "string"
&& !!candidate.metadata
&& typeof candidate.metadata === "object"
);
}

View File

@@ -5,6 +5,7 @@ import * as api from "../../api";
// Mock model-onboarding-state for isOnboardingCompleted
const mockIsOnboardingCompleted = vi.fn();
const mockTrackOnboardingEvent = vi.fn();
vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(),
@@ -15,6 +16,11 @@ vi.mock("../../components/model-onboarding-state", () => ({
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
}));
vi.mock("../../components/onboarding-events", () => ({
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
getOnboardingSessionId: () => "test-session-id",
}));
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
const mockFetchGlobalSettings = vi.mocked(api.fetchGlobalSettings);
@@ -24,6 +30,7 @@ describe("useAuthOnboarding", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTrackOnboardingEvent.mockReset();
});
// --- Trigger branches ---
@@ -50,6 +57,7 @@ describe("useAuthOnboarding", () => {
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
});
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith("onboarding:auto-triggered", { trigger: "first-run" });
expect(openSettings).not.toHaveBeenCalled();
});
@@ -101,6 +109,7 @@ describe("useAuthOnboarding", () => {
expect(openSettings).toHaveBeenCalledWith("authentication");
});
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith("onboarding:auto-triggered", { trigger: "missing-provider" });
expect(openModelOnboarding).not.toHaveBeenCalled();
});

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
import { isOnboardingCompleted } from "../components/model-onboarding-state";
import { trackOnboardingEvent } from "../components/onboarding-events";
import type { SectionId } from "../components/SettingsModal";
export interface UseAuthOnboardingOptions {
@@ -70,8 +71,10 @@ export function useAuthOnboarding({
.then(() => {
// Execute after the promise chain resolves
if (shouldOpenOnboarding) {
trackOnboardingEvent("onboarding:auto-triggered", { trigger: "first-run" });
openModelOnboarding();
} else if (shouldOpenSettings) {
trackOnboardingEvent("onboarding:auto-triggered", { trigger: "missing-provider" });
openSettings("authentication");
}
})