mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
c6b20b5b87
commit
cf47d73126
4 changed files with 188 additions and 28 deletions
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue