diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 64ee0299..8ee25591 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation" import { toast } from "sonner" import { useAuth } from "@lib/auth-context" import { authClient } from "@lib/auth" +import { analytics } from "@/lib/analytics" import { BrainShell } from "@/components/onboarding-brain/shell" import { StepAbout, @@ -34,6 +35,9 @@ import { const STORAGE_KEY = "supermemory-brain-onboarding-v1" +const countsAsConnectedSource = (state: unknown) => + state === "connected" || state === "waitlist" + export default function BrainOnboardingPage() { const router = useRouter() const params = useSearchParams() @@ -103,8 +107,40 @@ export default function BrainOnboardingPage() { } catch {} }, [mode, about, sources, team]) + const navTrigger = useRef<"user" | "auto">("auto") + const startedRef = useRef(false) + useEffect(() => { + if (startedRef.current) return + startedRef.current = true + analytics.onboardingStarted({ + mode: detectedMode, + entry_step: initialStep, + }) + analytics.onboardingStepViewed({ + step: initialStep, + index: BRAIN_STEPS.indexOf(initialStep), + trigger: "auto", + }) + }, [detectedMode, initialStep]) + + const firstStepRender = useRef(true) + useEffect(() => { + // Skip the mount run — the gated effect above fires the initial view. + if (firstStepRender.current) { + firstStepRender.current = false + return + } + analytics.onboardingStepViewed({ + step, + index: BRAIN_STEPS.indexOf(step), + trigger: navTrigger.current, + }) + navTrigger.current = "auto" + }, [step]) + const setStepAndUrl = useCallback( (next: BrainStep) => { + navTrigger.current = "user" setStep(next) const url = new URL(window.location.href) url.searchParams.set("step", next) @@ -128,14 +164,23 @@ export default function BrainOnboardingPage() { }, [org]) const finish = useCallback(async () => { + analytics.onboardingCompleted({ + mode, + steps_completed: BRAIN_STEPS.length, + sources_connected: Object.values(sources.connected).filter( + countsAsConnectedSource, + ).length, + invites_sent: team.invites.filter((i) => i.email.trim()).length, + }) try { localStorage.removeItem(STORAGE_KEY) } catch {} router.push("/?onboarded=1") - }, [router]) + }, [router, mode, sources, team]) const goNext = useCallback(() => { const idx = BRAIN_STEPS.indexOf(step) + analytics.onboardingStepCompleted({ step, index: idx }) const next = BRAIN_STEPS[idx + 1] if (!next) { finish() @@ -175,6 +220,11 @@ export default function BrainOnboardingPage() { }) } await refetchOrganizations() + analytics.onboardingWorkspaceCreated({ + mode, + has_about: Boolean(about.about.trim()), + has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)), + }) }, [ organizations, about, @@ -195,6 +245,9 @@ export default function BrainOnboardingPage() { goNext() } catch (e) { console.error("Failed to create organization:", e) + analytics.onboardingWorkspaceCreateFailed({ + error: e instanceof Error ? e.message : String(e), + }) toast.error("Couldn't create your workspace. Please try again.") } finally { creatingOrgRef.current = false @@ -209,6 +262,7 @@ export default function BrainOnboardingPage() { if (sendingInvitesRef.current) return const pending = team.invites.filter((i) => i.email.trim()) if (pending.length === 0) { + analytics.onboardingTeamSkipped() goNext() return } @@ -231,6 +285,10 @@ export default function BrainOnboardingPage() { r.status === "rejected" || (r.status === "fulfilled" && Boolean(r.value?.error)), ).length + analytics.onboardingInvitesSent({ + sent: pending.length - failed, + failed, + }) if (failed > 0) { toast.error( `${failed} of ${pending.length} invite${pending.length === 1 ? "" : "s"} couldn't be sent.`, @@ -260,7 +318,10 @@ export default function BrainOnboardingPage() { {step === "about" && ( { + analytics.onboardingModeSelected({ mode: m }) + setMode(m) + }} domain={domain} suggestedWorkspaceName={suggestedWorkspaceName} defaultName={user?.name ?? ""} @@ -290,7 +351,10 @@ export default function BrainOnboardingPage() { values={team} onChange={setTeam} onContinue={handleTeamContinue} - onSkip={goNext} + onSkip={() => { + analytics.onboardingTeamSkipped() + goNext() + }} submitting={sendingInvites} onUpgrade={() => router.push("/settings/billing")} /> diff --git a/apps/web/components/onboarding-brain/step-ingest.tsx b/apps/web/components/onboarding-brain/step-ingest.tsx index 90a43377..3f795157 100644 --- a/apps/web/components/onboarding-brain/step-ingest.tsx +++ b/apps/web/components/onboarding-brain/step-ingest.tsx @@ -11,6 +11,7 @@ import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { toast } from "sonner" import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view" import { PLUGIN_CATALOG } from "@/lib/plugin-catalog" +import { analytics } from "@/lib/analytics" interface Props { mcpUrl: string @@ -120,9 +121,20 @@ export function StepIngest({ mcpUrl, onContinue }: Props) { }, [selectedAgent, setMcpClient]) const selectAgent = (agent: Agent) => { + analytics.onboardingAgentSelected({ agent: agent.key }) setSelectedKey(agent.key) } + const handleContinue = () => { + analytics.onboardingIngestCompleted() + onContinue() + } + + const handleSkip = () => { + analytics.onboardingIngestSkipped() + onContinue() + } + const filtered = AGENTS.filter((a) => a.category === activeCategory) return ( @@ -176,14 +188,14 @@ export function StepIngest({ mcpUrl, onContinue }: Props) {
@@ -467,8 +483,8 @@ export function StepSources({ onChange={onChange} isLocked={isLocked} guard={guard} - setState={setState} openExternal={openExternal} + requestWaitlist={requestWaitlist} connectRealProvider={connectRealProvider} /> @@ -917,8 +933,8 @@ function MoreSourcesGrid({ onChange, isLocked, guard, - setState, openExternal, + requestWaitlist, connectRealProvider, }: { mode: BrainMode @@ -930,8 +946,8 @@ function MoreSourcesGrid({ title: string, fn: () => void, ) => () => void - setState: (id: SourceId, state: SourceState) => void openExternal: (id: SourceId, url: string) => void + requestWaitlist: (id: SourceId) => void connectRealProvider: ( provider: "google-drive" | "notion" | "onedrive", id: SourceId, @@ -1027,7 +1043,7 @@ function MoreSourcesGrid({ "Decisions and follow-ups surfaced", "You control which labels sync", ]} - onConnect={guard("max", "Gmail", () => setState("gmail", "waitlist"))} + onConnect={guard("max", "Gmail", () => requestWaitlist("gmail"))} /> setState("github", "waitlist"))} + onConnect={guard("max", "GitHub", () => requestWaitlist("github"))} /> - toast.info("Granola is coming soon."), - )} + onConnect={guard("max", "Granola", () => { + analytics.onboardingIntegrationClicked({ integration: "granola" }) + toast.info("Granola is coming soon.") + })} /> ) diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 71987d22..ae78e306 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -1,16 +1,53 @@ import posthog from "posthog-js" +import type { BrainStep } from "@/components/onboarding-brain/types" -export type OnboardingStep = "profile_input" | "processing" | "done" | "error" -export type OnboardingSource = "x" | "linkedin" | "resume" +const pendingEvents: Array<{ + eventName: string + properties?: Record +}> = [] +let flushTimer: ReturnType | undefined +let flushTimeout: ReturnType | undefined + +const flushPendingEvents = () => { + if (!posthog.__loaded) return + while (pendingEvents.length > 0) { + const event = pendingEvents.shift() + if (!event) return + posthog.capture(event.eventName, event.properties) + } + if (flushTimer) { + clearInterval(flushTimer) + flushTimer = undefined + } + if (flushTimeout) { + clearTimeout(flushTimeout) + flushTimeout = undefined + } +} + +const scheduleFlush = () => { + if (flushTimer) return + flushTimer = setInterval(flushPendingEvents, 200) + flushTimeout = setTimeout(() => { + if (!flushTimer) return + clearInterval(flushTimer) + flushTimer = undefined + flushTimeout = undefined + pendingEvents.length = 0 + }, 10000) +} -// Helper function to safely capture events const safeCapture = ( eventName: string, properties?: Record, ) => { if (posthog.__loaded) { + flushPendingEvents() posthog.capture(eventName, properties) + return } + pendingEvents.push({ eventName, properties }) + scheduleFlush() } export const analytics = { @@ -82,32 +119,62 @@ export const analytics = { addDocumentModalOpened: () => safeCapture("add_document_modal_opened"), // onboarding analytics + onboardingStarted: (props: { mode: string; entry_step: BrainStep }) => + safeCapture("onboarding_started", props), + onboardingStepViewed: (props: { - step: OnboardingStep + step: BrainStep + index: number trigger: "user" | "auto" }) => safeCapture("onboarding_step_viewed", props), - onboardingProfileSubmitted: (props: { source: OnboardingSource }) => - safeCapture("onboarding_profile_submitted", props), + onboardingStepCompleted: (props: { step: BrainStep; index: number }) => + safeCapture("onboarding_step_completed", props), + + onboardingModeSelected: (props: { mode: string }) => + safeCapture("onboarding_mode_selected", props), + + onboardingWorkspaceCreated: (props: { + mode: string + has_about: boolean + has_domain: boolean + }) => safeCapture("onboarding_workspace_created", props), + + onboardingWorkspaceCreateFailed: (props: { error: string }) => + safeCapture("onboarding_workspace_create_failed", props), onboardingIntegrationClicked: (props: { integration: string }) => safeCapture("onboarding_integration_clicked", props), + onboardingSourcesCompleted: (props: { connected_count: number }) => + safeCapture("onboarding_sources_completed", props), + + onboardingAgentSelected: (props: { agent: string }) => + safeCapture("onboarding_agent_selected", props), + + onboardingIngestCompleted: () => safeCapture("onboarding_ingest_completed"), + + onboardingIngestSkipped: () => safeCapture("onboarding_ingest_skipped"), + + onboardingInvitesSent: (props: { sent: number; failed: number }) => + safeCapture("onboarding_invites_sent", props), + + onboardingTeamSkipped: () => safeCapture("onboarding_team_skipped"), + onboardingChromeExtensionClicked: (props: { source: "onboarding" | "settings" | "integrations" }) => safeCapture("onboarding_chrome_extension_clicked", props), onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"), - onboardingXBookmarksDetailOpened: () => - safeCapture("onboarding_x_bookmarks_detail_opened"), - - onboardingSkipped: (props: { from_step: OnboardingStep }) => + onboardingSkipped: (props: { from_step: BrainStep }) => safeCapture("onboarding_skipped", props), - onboardingCompleted: (props?: { - source?: OnboardingSource - memories_count?: number + onboardingCompleted: (props: { + mode: string + steps_completed: number + sources_connected: number + invites_sent: number }) => safeCapture("onboarding_completed", props), // main app analytics