Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
eeb26c4fc3 docs(analytics): document onboarding funnel events + $set person properties 2026-04-22 15:38:40 +08:00
Jiang Bohan
343d37fbdb feat(analytics): wire frontend onboarding events + completion_path
- captureEvent / setPersonProperties helpers in @multica/core/analytics,
  with the same pre-init buffering as identify/pageview so config races
  don't drop step transitions
- onboarding_runtime_path_selected fires from step-platform-fork for
  the three web-fork choices (download desktop / CLI / cloud waitlist),
  plus platform_preference on person properties for downstream splits
- completeOnboarding now takes an OnboardingCompletionPath; the
  onboarding shell derives full / runtime_skipped / cloud_waitlist
  from runtime + waitlist state (lifted to the shell so StepFirstIssue
  can see both), and handleWelcomeSkip passes skip_existing
- saveQuestionnaire mirrors team_size/role/use_case into person
  properties via $set so every event on this user becomes cohortable
- StepAgent sends the template slug, StarterContentPrompt passes
  workspace_id on dismiss so the server can mirror the branch label
2026-04-22 15:38:37 +08:00
Jiang Bohan
b26ab263ab feat(analytics): capture onboarding funnel events + person-property $set
Closes the visibility gap introduced by the Onboarding relaunch: the
five new steps between signup and workspace_created were invisible to
PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist
intent, or starter-content acceptance at all.

Server-side events (see docs/analytics.md for full contracts):
- onboarding_questionnaire_submitted — fires once when all three
  answers first land; also $set's role/use_case/team_size on the
  person so every subsequent event is cohortable
- agent_created — not onboarding-specific; is_first_agent_in_workspace
  isolates the Step 4 signal
- onboarding_completed — fires on the actual NULL → timestamp flip
  with completion_path (full / runtime_skipped / cloud_waitlist /
  skip_existing / unknown) + joined_cloud_waitlist
- cloud_waitlist_joined — sizes hosted-runtime interest
- starter_content_decided — imported vs dismissed, split by
  agent_guided / self_serve branch on both sides

Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so
the same events can carry mutable cohort signals without a separate
identify round-trip.
2026-04-22 15:38:26 +08:00
18 changed files with 654 additions and 22 deletions

View File

@@ -74,6 +74,13 @@ handler → analytics.Client.Capture(Event) ← non-blocking, returns immediat
email. Full email is stored once in person properties via `$set_once` so
it's available for individual debugging but not broadcast with every
event.
- **Person properties (`$set`)** — use for mutable cohort signals
(role, use_case, team_size, platform_preference) that a user can
legitimately change during onboarding. `Event.Set` on the backend
maps to `$set`; the frontend helper is
`setPersonProperties()` in `@multica/core/analytics`. Use
`$set_once` only for values that must never be overwritten (email,
initial attribution, first-completion timestamp).
## Event contract
@@ -181,6 +188,103 @@ accepted and the member row is inserted in the same transaction.
`distinct_id` is the invitee's user id — this is the event that closes the
expansion funnel.
### `onboarding_questionnaire_submitted`
Fires on the first PatchOnboarding that transitions the user's
questionnaire JSONB from "at least one slot empty" to "all three
filled" (team_size, role, use_case). Revisions past that point don't
re-emit — the funnel counts users, not edits.
| Property | Type | Description |
|---|---|---|
| `team_size` | string | `solo` / `team` / `other`. |
| `role` | string | `developer` / `product_lead` / `writer` / `founder` / `other`. |
| `use_case` | string | `coding` / `planning` / `writing_research` / `explore` / `other`. |
| `team_size_has_other` | bool | `true` when the user filled the Q1 free-text escape. |
| `role_has_other` | bool | Ditto Q2. |
| `use_case_has_other` | bool | Ditto Q3. |
Person properties set with `$set` (not once — users can go back and
change answers before submitting again):
| Property | Type | Description |
|---|---|---|
| `team_size` | string | Mirrors the event property for cohort queries. |
| `role` | string | Same. |
| `use_case` | string | Same. |
`distinct_id` is the user's id. No workspace_id — the questionnaire is
per-user, not per-workspace.
### `agent_created`
Fires on every successful `POST /api/workspaces/:id/agents`. Not
onboarding-specific — the `is_first_agent_in_workspace` property
isolates the Step 4 signal from later agent additions.
| Property | Type | Description |
|---|---|---|
| `agent_id` | string (UUID) | |
| `provider` | string | Runtime provider the agent is bound to (`claude`, `codex`, etc). |
| `template` | string | Template slug used to seed the agent (`coding` / `planning` / `writing` / `assistant`). Empty when the caller didn't come from a template picker. |
| `is_first_agent_in_workspace` | bool | `true` when the workspace had zero agents before this insert. |
`distinct_id` is the authenticated owner's user id.
### `onboarding_completed`
Fires from CompleteOnboarding on the first call that actually flips
`user.onboarded_at` from NULL. Retries are idempotent server-side but
deliberately do NOT re-emit, so the funnel counts first-completions
only. The client sends `completion_path` in the POST body to label
which exit the user took.
| Property | Type | Description |
|---|---|---|
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
Person properties set with `$set_once`:
| Property | Type | Description |
|---|---|---|
| `onboarded_at` | string (RFC3339) | Timestamp the first completion landed. Enables cohort queries like "users onboarded before X" directly from person_properties. |
`completion_path` values:
- `full` — Reached Step 5 (first_issue) with a runtime connected.
- `runtime_skipped` — Completed without connecting a runtime (user hit Skip in Step 3).
- `cloud_waitlist` — Submitted the cloud waitlist form and skipped Step 3.
- `skip_existing` — "I've done this before" from Welcome. The user already had a workspace.
- `unknown` — Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
### `cloud_waitlist_joined`
Fires from JoinCloudWaitlist whenever a user submits the Step 3 cloud
waitlist form. Not a completion signal — it's orthogonal to the main
funnel and used to size hosted-runtime interest.
| Property | Type | Description |
|---|---|---|
| `has_reason` | bool | Presence flag for the free-text reason field. The free text stays in the DB; we don't broadcast it. |
`distinct_id` is the user's id.
### `starter_content_decided`
Fires on the atomic NULL → terminal state transition in both
ImportStarterContent and DismissStarterContent. The `branch` property
mirrors what ImportStarterContent would emit for the same workspace,
so import-vs-dismiss rates split cleanly by branch.
| Property | Type | Description |
|---|---|---|
| `decision` | string | `imported` or `dismissed`. |
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
`distinct_id` is the user's id; `workspace_id` is attached from the
request payload.
### Frontend-only events
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
@@ -188,6 +292,14 @@ expansion funnel.
mounts once under `WebProviders` and drives the acquisition funnel's
`/ → signup` step. posthog-js's automatic pageview capture is
disabled in `initAnalytics` so we own the event shape.
- `onboarding_runtime_path_selected` — fired from
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
user clicks one of the three Step 3 fork cards (before any server
call happens, so it's frontend-only). Properties: `path`
(`download_desktop` / `cli` / `cloud_waitlist`), `is_mac`. Also
writes `platform_preference` (`web` / `desktop`) to person properties
so every subsequent event on the user can be broken down by chosen
platform.
- Attribution is NOT a separate event; UTM + referrer origin are written
to the `multica_signup_source` cookie on the first anonymous pageview
and read by the backend's `signup` emission. The cookie carries a JSON

View File

@@ -39,6 +39,15 @@ let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null
// config fetch resolves. We keep the first pending pageview so that step
// doesn't silently drop.
let pendingPageview: string | undefined | null = null;
// Frontend-emitted events (captureEvent) and person-property updates
// (setPersonProperties) can also arrive before init — same config-race as
// identify/pageview. We replay them in order once init succeeds. These
// only ever carry user-triggered signals on identified users, so the
// buffer stays small (~one step-transition worth).
type PendingOp =
| { kind: "event"; name: string; props?: Record<string, unknown> }
| { kind: "set"; props: Record<string, unknown> };
const pendingOps: PendingOp[] = [];
export interface AnalyticsConfig {
key: string;
@@ -90,6 +99,18 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
pendingPageview = null;
}
// Replay buffered events / person-property updates in their original
// order — funnel correctness depends on sequence (e.g. a user submits
// the questionnaire and then finishes onboarding within the same
// config-race window).
while (pendingOps.length > 0) {
const op = pendingOps.shift()!;
if (op.kind === "event") {
posthog.capture(op.name, op.props);
} else {
capturePersonSet(op.props);
}
}
return true;
}
@@ -117,10 +138,57 @@ export function identify(userId: string, userProperties?: Record<string, unknown
export function resetAnalytics(): void {
pendingIdentify = null;
pendingPageview = null;
pendingOps.length = 0;
if (!initialized) return;
posthog.reset();
}
/**
* Capture a frontend-emitted event. Most funnel events fire server-side
* (see `server/internal/analytics`); this wrapper is reserved for the
* handful of signals the backend can't see — primarily the Step 3
* platform-fork choice on web, where the user's click never round-trips
* to a handler.
*
* Calls before initAnalytics() buffer in order so a late-arriving config
* doesn't silently swallow a step transition.
*/
export function captureEvent(
name: string,
props?: Record<string, unknown>,
): void {
if (!initialized) {
pendingOps.push({ kind: "event", name, props });
return;
}
posthog.capture(name, props);
}
/**
* Set (overwrite) person properties on the currently identified user.
* Mirrors the backend's `Event.Set` path — keep these aligned so the
* same cohort signals (role, use_case, platform_preference) are
* queryable regardless of which side emitted last. Use for mutable
* signals; use `identify(userId, { $set_once: {...} })` style for
* attribution fields that must never be overwritten.
*/
export function setPersonProperties(props: Record<string, unknown>): void {
if (!initialized) {
pendingOps.push({ kind: "set", props });
return;
}
capturePersonSet(props);
}
// The public wire-level contract for `$set` is a no-op event carrying a
// `$set` property. Wrapping it here (rather than calling
// `posthog.setPersonProperties` directly) keeps us version-independent —
// older posthog-js builds expose the same protocol under `posthog.people.set`,
// and the capture form works uniformly.
function capturePersonSet(props: Record<string, unknown>): void {
posthog.capture("$set", { $set: props });
}
/**
* Capture a page view. Call once per client-side navigation. We disable
* posthog's automatic pageview tracking in init() so this module owns the

View File

@@ -68,6 +68,7 @@ import type {
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
@@ -288,8 +289,13 @@ export class ApiClient {
return this.fetch("/api/me");
}
async markOnboardingComplete(): Promise<User> {
return this.fetch("/api/me/onboarding/complete", { method: "POST" });
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
async joinCloudWaitlist(payload: {
@@ -330,9 +336,12 @@ export class ApiClient {
});
}
async dismissStarterContent(): Promise<User> {
async dismissStarterContent(payload?: {
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/starter-content/dismiss", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}

View File

@@ -1,5 +1,6 @@
export type {
OnboardingStep,
OnboardingCompletionPath,
QuestionnaireAnswers,
TeamSize,
Role,

View File

@@ -1,6 +1,7 @@
import { api } from "../api";
import { useAuthStore } from "../auth";
import type { QuestionnaireAnswers } from "./types";
import { setPersonProperties } from "../analytics";
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
/**
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
@@ -17,15 +18,34 @@ export async function saveQuestionnaire(
): Promise<void> {
const user = await api.patchOnboarding({ questionnaire: answers });
useAuthStore.getState().setUser(user);
// Mirror the three cohort signals into person properties so every
// PostHog event on this user can be broken down by role / use_case /
// team_size without re-joining the DB. Matches the $set block the
// server writes alongside `onboarding_questionnaire_submitted`.
if (answers.team_size || answers.role || answers.use_case) {
setPersonProperties({
...(answers.team_size ? { team_size: answers.team_size } : {}),
...(answers.role ? { role: answers.role } : {}),
...(answers.use_case ? { use_case: answers.use_case } : {}),
});
}
}
/**
* Finalize onboarding. POST /complete marks `onboarded_at` atomically
* (COALESCE-guarded for idempotency). We then refresh the auth store
* so every gate sees the updated user.
*
* `completionPath` is the client's view of which Step-3 exit the user
* took; the server funnel-splits `onboarding_completed` on this value.
* Legacy callers that don't pass a path get recorded as `unknown`.
*/
export async function completeOnboarding(): Promise<void> {
await api.markOnboardingComplete();
export async function completeOnboarding(
completionPath?: OnboardingCompletionPath,
): Promise<void> {
await api.markOnboardingComplete(
completionPath ? { completion_path: completionPath } : undefined,
);
await useAuthStore.getState().refreshMe();
}

View File

@@ -6,6 +6,18 @@ export type OnboardingStep =
| "agent"
| "first_issue";
/**
* Exit path from the onboarding flow. Sent to
* POST /api/me/onboarding/complete and mirrored on the PostHog
* `onboarding_completed` event. Must stay in sync with the
* `OnboardingPath*` constants in `server/internal/analytics/events.go`.
*/
export type OnboardingCompletionPath =
| "full" // Reached Step 5 (first_issue) with a runtime connected
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
| "skip_existing"; // "I've done this before" from Welcome
export type TeamSize = "solo" | "team" | "other";
export type Role =

View File

@@ -75,6 +75,9 @@ export interface CreateAgentRequest {
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
model?: string;
/** Optional template slug used by the onboarding agent picker. Surfaced
* as the `template` property on the `agent_created` PostHog event. */
template?: string;
}
export interface UpdateAgentRequest {

View File

@@ -106,7 +106,7 @@ export function StarterContentPrompt() {
if (submitting) return;
setSubmitting("dismiss");
try {
await api.dismissStarterContent();
await api.dismissStarterContent({ workspace_id: workspace.id });
await refreshMe();
} catch (err) {
toast.error(

View File

@@ -9,6 +9,7 @@ import {
completeOnboarding,
ONBOARDING_STEP_ORDER,
saveQuestionnaire,
type OnboardingCompletionPath,
type OnboardingStep,
type QuestionnaireAnswers,
} from "@multica/core/onboarding";
@@ -69,6 +70,11 @@ export function OnboardingFlow({
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [runtime, setRuntime] = useState<AgentRuntime | null>(null);
const [, setAgent] = useState<Agent | null>(null);
// Sticky flag: Step 3's cloud-waitlist dialog only lives inside
// StepPlatformFork's local state, so the completion path for
// `runtime=null && waitlist submitted` would be invisible here without
// a shell-level record. One way latch; never cleared once set.
const [waitlistSubmitted, setWaitlistSubmitted] = useState(false);
// Fetched at Step 0 + Step 2. Step 2 uses it to detect a pre-existing
// workspace from an earlier abandoned onboarding (so StepWorkspace shows
@@ -97,7 +103,7 @@ export function OnboardingFlow({
// they never got a starter project and may want one now.
const handleWelcomeSkip = useCallback(async () => {
try {
await completeOnboarding();
await completeOnboarding("skip_existing");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to finish onboarding",
@@ -194,6 +200,7 @@ export function OnboardingFlow({
wsId={workspace.id}
onNext={handleRuntimeNext}
onBack={() => handleBack("runtime")}
onWaitlistSubmitted={() => setWaitlistSubmitted(true)}
/>
);
}
@@ -203,6 +210,7 @@ export function OnboardingFlow({
onNext={handleRuntimeNext}
onBack={() => handleBack("runtime")}
cliInstructions={runtimeInstructions}
onWaitlistSubmitted={() => setWaitlistSubmitted(true)}
/>
);
}
@@ -225,6 +233,18 @@ export function OnboardingFlow({
);
}
// Derive the completion-path label for Step 5 here — runtime +
// waitlist state both live in this shell, StepFirstIssue doesn't
// have the visibility to compute it itself.
// runtime set → "full"
// no runtime + waitlist → "cloud_waitlist"
// no runtime, no waitlist → "runtime_skipped"
const completionPath: OnboardingCompletionPath = runtime
? "full"
: waitlistSubmitted
? "cloud_waitlist"
: "runtime_skipped";
return (
<div className="animate-onboarding-enter flex min-h-full flex-col">
<DragStrip />
@@ -232,7 +252,10 @@ export function OnboardingFlow({
<div className="flex w-full max-w-xl flex-col gap-8">
<StepHeader currentStep={step} />
{step === "first_issue" && (
<StepFirstIssue onFinished={handleFinished} />
<StepFirstIssue
onFinished={handleFinished}
completionPath={completionPath}
/>
)}
</div>
</div>

View File

@@ -125,6 +125,7 @@ export function StepAgent({
instructions: template.instructions,
runtime_id: runtime.id,
visibility: "workspace",
template: templateId,
};
const agent = await api.createAgent(req);
await onCreated(agent);

View File

@@ -4,7 +4,10 @@ import { useEffect, useRef, useState } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { completeOnboarding } from "@multica/core/onboarding";
import {
completeOnboarding,
type OnboardingCompletionPath,
} from "@multica/core/onboarding";
/**
* Step 5 — the final onboarding beat.
@@ -25,23 +28,30 @@ import { completeOnboarding } from "@multica/core/onboarding";
*/
export function StepFirstIssue({
onFinished,
completionPath,
}: {
/** Called after `onboarded_at` is set server-side. Parent handles
* navigation to the workspace landing page. */
onFinished: () => void;
/** Which exit label the server should record on `onboarding_completed`.
* Computed in the parent shell where runtime + waitlist state are
* both in scope. */
completionPath: OnboardingCompletionPath;
}) {
const [error, setError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const started = useRef(false);
const onFinishedRef = useRef(onFinished);
onFinishedRef.current = onFinished;
const completionPathRef = useRef(completionPath);
completionPathRef.current = completionPath;
useEffect(() => {
if (started.current) return;
started.current = true;
(async () => {
try {
await completeOnboarding();
await completeOnboarding(completionPathRef.current);
onFinishedRef.current();
} catch (err) {
setError(
@@ -56,7 +66,7 @@ export function StepFirstIssue({
setRetrying(true);
setError(null);
try {
await completeOnboarding();
await completeOnboarding(completionPathRef.current);
onFinishedRef.current();
} catch (err) {
setError(err instanceof Error ? err.message : "Retry failed");

View File

@@ -2,6 +2,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ArrowLeft, ArrowRight, Download } from "lucide-react";
import { captureEvent, setPersonProperties } from "@multica/core/analytics";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -59,12 +60,17 @@ export function StepPlatformFork({
onNext,
onBack,
cliInstructions,
onWaitlistSubmitted,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Platform-specific CLI install card, rendered inside the CLI dialog. */
cliInstructions?: ReactNode;
/** Parent-level latch used to label the onboarding completion path
* as `cloud_waitlist` when the user ends up skipping Step 3 after
* submitting the waitlist form. */
onWaitlistSubmitted?: () => void;
}) {
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
@@ -90,6 +96,28 @@ export function StepPlatformFork({
const pickDesktop = () => {
window.open(DESKTOP_DOWNLOAD_URL, "_blank", "noopener,noreferrer");
setDownloaded(true);
captureEvent("onboarding_runtime_path_selected", {
path: "download_desktop",
is_mac: isMac,
});
setPersonProperties({ platform_preference: "desktop" });
};
const handleOpenCli = () => {
setDialog("cli");
captureEvent("onboarding_runtime_path_selected", {
path: "cli",
is_mac: isMac,
});
setPersonProperties({ platform_preference: "web" });
};
const handleOpenCloud = () => {
setDialog("cloud");
captureEvent("onboarding_runtime_path_selected", {
path: "cloud_waitlist",
is_mac: isMac,
});
};
const handleCliConnect = () => {
@@ -163,7 +191,7 @@ export function StepPlatformFork({
title="Install the CLI"
subtitle="Run the Multica daemon yourself — a couple of terminal commands."
actionLabel="Show steps"
onAction={() => setDialog("cli")}
onAction={handleOpenCli}
/>
<ForkAlt
@@ -172,7 +200,7 @@ export function StepPlatformFork({
actionLabel={
waitlistSubmitted ? "On the list" : "Join waitlist"
}
onAction={() => setDialog("cloud")}
onAction={handleOpenCloud}
/>
</div>
</div>
@@ -219,7 +247,10 @@ export function StepPlatformFork({
open={dialog === "cloud"}
onClose={() => setDialog(null)}
submitted={waitlistSubmitted}
onSubmitted={() => setWaitlistSubmitted(true)}
onSubmitted={() => {
setWaitlistSubmitted(true);
onWaitlistSubmitted?.();
}}
/>
</div>
);

View File

@@ -41,10 +41,15 @@ export function StepRuntimeConnect({
wsId,
onNext,
onBack,
onWaitlistSubmitted,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Parent-level latch used to label the onboarding completion path
* as `cloud_waitlist` when the user ends up skipping this step
* after submitting the waitlist form. */
onWaitlistSubmitted?: () => void;
}) {
const { runtimes, selected, selectedId, setSelectedId } =
useRuntimePicker(wsId);
@@ -57,6 +62,7 @@ export function StepRuntimeConnect({
setSelectedId={setSelectedId}
onNext={onNext}
onBack={onBack}
onWaitlistSubmitted={onWaitlistSubmitted}
/>
);
}
@@ -77,6 +83,7 @@ function FancyView({
setSelectedId,
onNext,
onBack,
onWaitlistSubmitted,
}: {
runtimes: AgentRuntime[];
selected: AgentRuntime | null;
@@ -84,6 +91,7 @@ function FancyView({
setSelectedId: (id: string) => void;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
onWaitlistSubmitted?: () => void;
}) {
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
@@ -199,7 +207,10 @@ function FancyView({
{phase === "empty" && (
<EmptyView
waitlistSubmitted={waitlistSubmitted}
onWaitlistSubmitted={() => setWaitlistSubmitted(true)}
onWaitlistSubmitted={() => {
setWaitlistSubmitted(true);
onWaitlistSubmitted?.();
}}
onSkip={() => onNext(null)}
/>
)}

View File

@@ -44,6 +44,11 @@ type Event struct {
// (initial_utm_source, etc.) so later events don't overwrite the origin.
SetOnce map[string]any
// Set properties attach to the person record and overwrite on every write.
// Use this for mutable cohort signals (role, use_case, platform_preference)
// that users can legitimately change during onboarding.
Set map[string]any
// Timestamp is optional; when zero the client fills in time.Now().
Timestamp time.Time
}

View File

@@ -4,12 +4,34 @@ import "strings"
// Event names. Keep in sync with docs/analytics.md.
const (
EventSignup = "signup"
EventWorkspaceCreated = "workspace_created"
EventRuntimeRegistered = "runtime_registered"
EventIssueExecuted = "issue_executed"
EventTeamInviteSent = "team_invite_sent"
EventTeamInviteAccepted = "team_invite_accepted"
EventSignup = "signup"
EventWorkspaceCreated = "workspace_created"
EventRuntimeRegistered = "runtime_registered"
EventIssueExecuted = "issue_executed"
EventTeamInviteSent = "team_invite_sent"
EventTeamInviteAccepted = "team_invite_accepted"
EventOnboardingQuestionnaireSubmit = "onboarding_questionnaire_submitted"
EventAgentCreated = "agent_created"
EventOnboardingCompleted = "onboarding_completed"
EventCloudWaitlistJoined = "cloud_waitlist_joined"
EventStarterContentDecided = "starter_content_decided"
)
// Onboarding completion paths. Keep in sync with docs/analytics.md.
const (
OnboardingPathFull = "full" // reached first_issue end of flow
OnboardingPathRuntimeSkipped = "runtime_skipped" // completed without connecting a runtime
OnboardingPathCloudWaitlist = "cloud_waitlist" // completed via cloud waitlist soft exit
OnboardingPathSkipExisting = "skip_existing" // "I've done this before" from welcome
OnboardingPathUnknown = "unknown" // fallback when the server can't derive the path
)
// Starter content branches. Matches the server-authoritative decision in
// ImportStarterContent (hasAgent ? agent_guided : self_serve). DismissStarter
// carries the same branch so acceptance rates split cleanly.
const (
StarterContentBranchAgentGuided = "agent_guided"
StarterContentBranchSelfServe = "self_serve"
)
// Platform is used as the "platform" event property so funnels can split by
@@ -132,6 +154,114 @@ func TeamInviteAccepted(inviteeID, workspaceID string, daysSinceInvite int64) Ev
}
}
// OnboardingQuestionnaireSubmitted fires the first time a user's
// `user.onboarding_questionnaire` transitions from empty (or partial) to
// all three answers present. The handler drives this transition — we
// emit from PatchOnboarding so the single emission site stays honest
// even if the frontend retries.
//
// The three answers are also mirrored into person properties via $set
// so cohorting by role / use_case / team_size works across every event
// on the same user without re-joining back to the DB.
//
// teamSizeOther / roleOther / useCaseOther are presence booleans only —
// the free-text content is kept in the DB for product research but not
// broadcast via analytics (PII risk + low cardinality ask).
func OnboardingQuestionnaireSubmitted(userID, teamSize, role, useCase string, teamSizeOther, roleOther, useCaseOther bool) Event {
return Event{
Name: EventOnboardingQuestionnaireSubmit,
DistinctID: userID,
Properties: map[string]any{
"team_size": teamSize,
"role": role,
"use_case": useCase,
"team_size_has_other": teamSizeOther,
"role_has_other": roleOther,
"use_case_has_other": useCaseOther,
},
Set: map[string]any{
"team_size": teamSize,
"role": role,
"use_case": useCase,
},
}
}
// AgentCreated fires whenever a new agent is added to a workspace — not
// just inside onboarding. `isFirstAgentInWorkspace` lets the funnel
// isolate the Step 4 signal from later agent additions.
//
// template is the template slug the frontend used to seed the agent
// (e.g. "coding", "planning", "writing", "assistant") — empty when the
// caller didn't come from a template picker.
func AgentCreated(actorID, workspaceID, agentID, provider, template string, isFirstAgentInWorkspace bool) Event {
return Event{
Name: EventAgentCreated,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"agent_id": agentID,
"provider": provider,
"template": template,
"is_first_agent_in_workspace": isFirstAgentInWorkspace,
},
}
}
// OnboardingCompleted fires from CompleteOnboarding. `completionPath`
// is derived server-side from the state the user arrived in (see the
// OnboardingPath* constants above). `joinedCloudWaitlist` is true when
// the user submitted the waitlist form at any point during the flow —
// it's orthogonal to `completion_path`; a user may submit the form and
// still pick CLI, so we keep both signals.
//
// onboardedAt is an RFC3339 timestamp set $set_once on the person so
// "onboarded before date X" cohorts are queryable directly from
// person_properties without re-emitting per-event.
func OnboardingCompleted(userID, completionPath, onboardedAt string, joinedCloudWaitlist bool) Event {
return Event{
Name: EventOnboardingCompleted,
DistinctID: userID,
Properties: map[string]any{
"completion_path": completionPath,
"joined_cloud_waitlist": joinedCloudWaitlist,
},
SetOnce: map[string]any{
"onboarded_at": onboardedAt,
},
}
}
// CloudWaitlistJoined fires when a user submits the Step 3 cloud
// waitlist form. `hasReason` is a presence bool — the free-text reason
// stays in the DB for product research.
func CloudWaitlistJoined(userID string, hasReason bool) Event {
return Event{
Name: EventCloudWaitlistJoined,
DistinctID: userID,
Properties: map[string]any{
"has_reason": hasReason,
},
}
}
// StarterContentDecided fires on the atomic NULL -> terminal state
// transition in both ImportStarterContent and DismissStarterContent.
// branch carries agent_guided / self_serve for BOTH decisions — the
// dismiss handler resolves it from the current ListAgents state so
// acceptance rates split cleanly by branch.
func StarterContentDecided(userID, workspaceID, decision, branch string) Event {
return Event{
Name: EventStarterContentDecided,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"decision": decision,
"branch": branch,
},
}
}
func emailDomain(email string) string {
at := strings.LastIndex(email, "@")
if at < 0 || at == len(email)-1 {

View File

@@ -166,6 +166,9 @@ func (c *PostHogClient) send(batch []Event) {
if len(e.SetOnce) > 0 {
props["$set_once"] = e.SetOnce
}
if len(e.Set) > 0 {
props["$set"] = e.Set
}
items = append(items, captureItem{
Event: e.Name,
DistinctID: e.DistinctID,

View File

@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
@@ -281,6 +282,12 @@ type CreateAgentRequest struct {
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
Model string `json:"model"`
// Template records which template slug was used to seed this agent
// (e.g. "coding" / "planning" / "writing" / "assistant"). Empty when
// the caller didn't come from a template picker — the `agent_created`
// event still fires with `template=""`, which is the correct signal
// for "manually authored agent".
Template string `json:"template"`
}
func decodeJSONBodyWithRawFields(body io.Reader, dst any) (map[string]json.RawMessage, error) {
@@ -343,6 +350,16 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
return
}
// Probe workspace agent count BEFORE the insert so the funnel has a
// clean "first agent ever in this workspace" signal — Step 4 of
// onboarding always lands in this branch. A non-fatal read: if the
// list fails we fall through with isFirstAgent=false rather than
// blocking creation, since the primary DB operation is the insert.
isFirstAgent := false
if existing, listErr := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID)); listErr == nil {
isFirstAgent = len(existing) == 0
}
rc, _ := json.Marshal(req.RuntimeConfig)
if req.RuntimeConfig == nil {
rc = []byte("{}")
@@ -402,6 +419,16 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
resp := agentToResponse(agent)
actorType, actorID := h.resolveActor(r, ownerID, workspaceID)
h.publish(protocol.EventAgentCreated, workspaceID, actorType, actorID, map[string]any{"agent": resp})
h.Analytics.Capture(analytics.AgentCreated(
ownerID,
workspaceID,
uuidToString(agent.ID),
runtime.Provider,
req.Template,
isFirstAgent,
))
writeJSON(w, http.StatusCreated, resp)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -35,19 +36,81 @@ const (
importStarterContentBodyLimit = 64 * 1024
)
// completeOnboardingRequest carries the client's view of which exit the
// user took from the flow. The client is the only place that knows
// whether Step 3's runtime connect was skipped, whether the cloud
// waitlist form was submitted, or whether Welcome's "I've done this
// before" path was used. Unknown/missing → OnboardingPathUnknown so
// legacy clients still complete the flow cleanly, just without a
// funnel-ready label.
type completeOnboardingRequest struct {
CompletionPath string `json:"completion_path,omitempty"`
}
var validCompletionPaths = map[string]struct{}{
analytics.OnboardingPathFull: {},
analytics.OnboardingPathRuntimeSkipped: {},
analytics.OnboardingPathCloudWaitlist: {},
analytics.OnboardingPathSkipExisting: {},
}
// CompleteOnboarding marks the authenticated user as having completed
// onboarding. Idempotent: the underlying query uses COALESCE so the
// original timestamp is preserved if called more than once.
//
// Emits `onboarding_completed` exactly once — the first call that
// actually flips `onboarded_at` from NULL. Subsequent calls are still
// 200 OK (for client-side retries) but skip the event so the funnel
// counts honest first-completion.
func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Body is optional — an empty body is a legal legacy call.
var req completeOnboardingRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
// Read the prior state so we can detect "was this call the one that
// actually completed onboarding?" — MarkUserOnboarded uses COALESCE
// and returns the preserved timestamp on repeat calls, which is not
// the signal we need for the funnel.
before, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
firstCompletion := !before.OnboardedAt.Valid
user, err := h.Queries.MarkUserOnboarded(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
return
}
if firstCompletion {
path := req.CompletionPath
if _, ok := validCompletionPaths[path]; !ok {
path = analytics.OnboardingPathUnknown
}
onboardedAt := ""
if user.OnboardedAt.Valid {
onboardedAt = user.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
}
h.Analytics.Capture(analytics.OnboardingCompleted(
userID,
path,
onboardedAt,
user.CloudWaitlistEmail.Valid,
))
}
writeJSON(w, http.StatusOK, userToResponse(user))
}
@@ -55,10 +118,31 @@ type patchOnboardingRequest struct {
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
}
// questionnaireAnswers mirrors the frontend's `QuestionnaireAnswers`
// shape. Only the first-time submission — every slot filled — is a
// funnel signal; partial saves are allowed but never emit.
type questionnaireAnswers struct {
TeamSize string `json:"team_size"`
TeamSizeOther string `json:"team_size_other"`
Role string `json:"role"`
RoleOther string `json:"role_other"`
UseCase string `json:"use_case"`
UseCaseOther string `json:"use_case_other"`
}
func (q questionnaireAnswers) complete() bool {
return q.TeamSize != "" && q.Role != "" && q.UseCase != ""
}
// PatchOnboarding persists the user's questionnaire answers. The
// field is optional; an omitted questionnaire is preserved. Which
// step the user is on is deliberately not persisted — every
// onboarding entry starts at Welcome.
//
// Emits `onboarding_questionnaire_submitted` exactly once per user:
// the first PATCH that transitions the answers from "at least one
// slot empty" to "all three filled". Revisions past that point don't
// re-emit — the funnel counts users, not edits.
func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -73,6 +157,16 @@ func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Read prior answers so we can detect the NULL/partial → complete
// transition after the update. An errored decode on the prior row
// is treated as "incomplete" — worst case we emit once more than
// we should, never twice for the same transition.
var before questionnaireAnswers
if beforeUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID)); err == nil {
_ = json.Unmarshal(beforeUser.OnboardingQuestionnaire, &before)
}
params := db.PatchUserOnboardingParams{ID: parseUUID(userID)}
if req.Questionnaire != nil {
params.Questionnaire = []byte(*req.Questionnaire)
@@ -82,6 +176,21 @@ func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to update onboarding")
return
}
var after questionnaireAnswers
_ = json.Unmarshal(user.OnboardingQuestionnaire, &after)
if after.complete() && !before.complete() {
h.Analytics.Capture(analytics.OnboardingQuestionnaireSubmitted(
userID,
after.TeamSize,
after.Role,
after.UseCase,
after.TeamSizeOther != "",
after.RoleOther != "",
after.UseCaseOther != "",
))
}
writeJSON(w, http.StatusOK, userToResponse(user))
}
@@ -142,6 +251,8 @@ func (h *Handler) JoinCloudWaitlist(w http.ResponseWriter, r *http.Request) {
return
}
h.Analytics.Capture(analytics.CloudWaitlistJoined(userID, reason != ""))
writeJSON(w, http.StatusOK, userToResponse(user))
}
@@ -498,6 +609,12 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinWelcomeIssueForEvent)})
}
starterBranch := analytics.StarterContentBranchSelfServe
if hasAgent {
starterBranch = analytics.StarterContentBranchAgentGuided
}
h.Analytics.Capture(analytics.StarterContentDecided(userID, req.WorkspaceID, "imported", starterBranch))
writeJSON(w, http.StatusOK, importStarterContentResponse{
User: userToResponse(updatedUser),
ProjectID: uuidToString(project.ID),
@@ -505,14 +622,39 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
})
}
type dismissStarterContentRequest struct {
// WorkspaceID is optional but strongly preferred — when present the
// server derives the starter branch (agent_guided / self_serve) by
// looking at the workspace's current agent list, so analytics can
// split dismiss rate by branch the same way import is split.
// Without it, branch defaults to self_serve (the zero-agent case).
WorkspaceID string `json:"workspace_id,omitempty"`
}
// DismissStarterContent records the user's decision to skip starter
// content. Like Import, this is a NULL -> terminal transition; a
// second call returns 409 with the current state.
//
// Emits `starter_content_decided` with `decision=dismissed`. The
// `branch` property mirrors what ImportStarterContent would have
// written for the same workspace, so the two-sided funnel (import vs
// dismiss by branch) stays directly comparable.
func (h *Handler) DismissStarterContent(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Body is optional for backward-compat with callers that pre-date
// the workspace-id addition. An empty body is a legal dismiss.
var req dismissStarterContentRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
@@ -525,6 +667,27 @@ func (h *Handler) DismissStarterContent(w http.ResponseWriter, r *http.Request)
})
return
}
// Resolve branch before the update so the analytics event mirrors
// the import-side logic exactly. An unresolvable workspace (malformed
// UUID, user not a member, or empty body) falls back to self_serve —
// the conservative default that matches what Import would emit when
// ListAgents returns empty.
branch := analytics.StarterContentBranchSelfServe
if req.WorkspaceID != "" {
if _, err := uuid.Parse(req.WorkspaceID); err == nil {
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: parseUUID(req.WorkspaceID),
}); err == nil {
agents, err := h.Queries.ListAgents(r.Context(), parseUUID(req.WorkspaceID))
if err == nil && len(agents) > 0 {
branch = analytics.StarterContentBranchAgentGuided
}
}
}
}
updated, err := h.Queries.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
ID: parseUUID(userID),
StarterContentState: pgtype.Text{String: "dismissed", Valid: true},
@@ -533,6 +696,9 @@ func (h *Handler) DismissStarterContent(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, "failed to record dismiss")
return
}
h.Analytics.Capture(analytics.StarterContentDecided(userID, req.WorkspaceID, "dismissed", branch))
writeJSON(w, http.StatusOK, userToResponse(updated))
}