feat(onboarding): instrument brain onboarding analytics (#1099)

Wire PostHog funnel events (started, step viewed/completed, mode, workspace, sources, ingest, team, completed) across the brain onboarding flow, and drop stale pre-brain event defs from analytics.ts.

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/5b48bbe4-422a-4577-b6bf-fd9416a6846b)
- Requested by: Sreeram Sreedhar (sreeram@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
MaheshtheDev 2026-06-20 07:48:14 +00:00
parent c6b20b5b87
commit cf47d73126
4 changed files with 188 additions and 28 deletions

View file

@ -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" && (
<StepAbout
mode={mode}
onModeChange={setMode}
onModeChange={(m) => {
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")}
/>

View file

@ -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) {
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
<button
type="button"
onClick={onContinue}
onClick={handleSkip}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
onClick={handleContinue}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue

View file

@ -108,6 +108,7 @@ import {
} from "@lib/constants"
import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import type { BrainMode } from "./types"
type SourceId =
@ -213,6 +214,9 @@ const PLAN_CARDS: PlanCardDefinition[] = [
const PLAN_CARD_SCROLL_STEP = 406
const countsAsConnected = (state: SourceState | undefined) =>
state === "connected" || state === "waitlist"
export interface SourcesValues {
connected: Partial<Record<SourceId, SourceState>>
driveScope: DriveScope
@ -309,6 +313,7 @@ export function StepSources({
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => {
analytics.onboardingIntegrationClicked({ integration: provider })
setState(id, "connecting")
try {
const metadata: Record<string, string> = {}
@ -338,10 +343,16 @@ export function StepSources({
}
const openExternal = (id: SourceId, url: string) => {
analytics.onboardingIntegrationClicked({ integration: id })
window.open(url, "_blank", "noopener,noreferrer")
setState(id, "connected")
}
const requestWaitlist = (id: SourceId) => {
analytics.onboardingIntegrationClicked({ integration: id })
setState(id, "waitlist")
}
const guard = (
plan: RequiredPlan | undefined,
title: string,
@ -359,9 +370,14 @@ export function StepSources({
}
const connectedCount = Object.values(values.connected).filter(
(s) => s === "connected" || s === "waitlist",
countsAsConnected,
).length
const handleContinue = () => {
analytics.onboardingSourcesCompleted({ connected_count: connectedCount })
onContinue()
}
return (
<div className="mx-auto w-full max-w-[1400px] pb-10">
<section className="relative min-h-[calc(100dvh-136px)] py-4">
@ -454,7 +470,7 @@ export function StepSources({
</button>
<SourceActions
connectedCount={connectedCount}
onContinue={onContinue}
onContinue={handleContinue}
className="mt-0 px-0"
/>
</div>
@ -467,8 +483,8 @@ export function StepSources({
onChange={onChange}
isLocked={isLocked}
guard={guard}
setState={setState}
openExternal={openExternal}
requestWaitlist={requestWaitlist}
connectRealProvider={connectRealProvider}
/>
</div>
@ -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"))}
/>
<SourceCard
title="GitHub"
@ -1042,7 +1058,7 @@ function MoreSourcesGrid({
"READMEs and docs indexed",
"Stays in sync with new activity",
]}
onConnect={guard("max", "GitHub", () => setState("github", "waitlist"))}
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
/>
<SourceCard
title="Granola"
@ -1057,9 +1073,10 @@ function MoreSourcesGrid({
"Decisions and action items extracted",
"Synced after every meeting",
]}
onConnect={guard("max", "Granola", () =>
toast.info("Granola is coming soon."),
)}
onConnect={guard("max", "Granola", () => {
analytics.onboardingIntegrationClicked({ integration: "granola" })
toast.info("Granola is coming soon.")
})}
/>
</>
)

View file

@ -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<string, unknown>
}> = []
let flushTimer: ReturnType<typeof setInterval> | undefined
let flushTimeout: ReturnType<typeof setTimeout> | 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<string, unknown>,
) => {
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