Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
8d38396f59 fix(onboarding): clear session before skip_existing completion (MUL-2166)
Emacs review: handleWelcomeSkip ran after startOnboardingSession had
already fired during the shell's mount effect, so the skip_existing
onboarding_completed event was carrying the session id we documented
it would omit. Clear the session before completing so the event
matches the doc and HogQL IS NOT NULL filter isolates real funnel
completions cleanly.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:51:11 +08:00
Jiayuan Zhang
a033725506 feat(onboarding): correlate funnel events with onboarding_session_id (MUL-2166)
Issue funnel attribution: `onboarding_completed` previously joined to
`onboarding_started` on `distinct_id` alone, which collapsed
skip_existing / invite_accept completions into the same bucket as
real funnel completions. PostHog's 30-day backfill could only link
20 / 152 completions back to a start.

Generate an `onboarding_session_id` on `onboarding_started`, persist
it to client storage so it survives reloads, attach it as a property
on every onboarding event (client and server), and clear it on
`onboarding_completed`. Skip / invite paths never receive a session
id; HogQL funnels filter `onboarding_session_id IS NOT NULL` to
isolate real funnel completions from soft completions.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:43:59 +08:00
11 changed files with 235 additions and 23 deletions

View File

@@ -366,6 +366,28 @@ not have a workspace yet.
|---|---|---|
| `workspace_id` | string (UUID) | Present only when the user already has a workspace. |
| `source` | string | Always `onboarding`. |
| `onboarding_session_id` | string (UUID) | Issued on this event and persisted to client storage. Stamped on every onboarding_* event until the funnel terminates. Lets HogQL correlate a full funnel back to a single start, even when `distinct_id` is shared across multiple sessions or skip paths. |
## `onboarding_session_id`
All in-product onboarding events carry an `onboarding_session_id` so the funnel
can be reconstructed without joining on `distinct_id` alone. The id is generated
client-side at `onboarding_started`, persisted across reloads, attached to every
subsequent onboarding event, and cleared on `onboarding_completed`.
Paths that bypass the in-product funnel emit `onboarding_completed` with the
property omitted:
- `skip_existing` from Welcome — the Welcome step clears the session before
completing, since the user never entered any real onboarding step. Their
earlier `onboarding_started` *does* carry a session id, but their completion
does not.
- `invite_accept` — server-side completion path that never receives a session
id from the client.
Funnel queries should filter `onboarding_session_id IS NOT NULL` on
`onboarding_completed` to isolate real funnel completions from these
soft-completions.
### `onboarding_questionnaire_submitted`
@@ -382,6 +404,7 @@ re-emit — the funnel counts users, not edits.
| `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. |
| `onboarding_session_id` | string (UUID) | Forwarded from the client; lets the questionnaire submission join back to its `onboarding_started`. |
Person properties set with `$set` (not once — users can go back and
change answers before submitting again):
@@ -424,6 +447,7 @@ which exit the user took.
| `workspace_id` | string (UUID) | Present for workspace-linked onboarding completions. |
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `invite_accept` / `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. |
| `onboarding_session_id` | string (UUID) | Present for `full` / `runtime_skipped` / `cloud_waitlist` paths (the in-product funnel). Omitted for `skip_existing` / `invite_accept` because those bypass the funnel and never received a session. |
Person properties set with `$set_once`:

View File

@@ -13,6 +13,7 @@
// backend returns an empty key and this module stays inert.
import posthog from "posthog-js";
import { getOnboardingSessionId } from "../onboarding/session";
export const EVENT_SCHEMA_VERSION = 2;
@@ -283,6 +284,14 @@ function withClientEventProperties(
if (next.is_demo === undefined) {
next.is_demo = false;
}
// Attach the active onboarding session id when one is in progress.
// Stamped on every client event (not just onboarding_*) so a stray
// event fired mid-funnel can still be joined back; HogQL filters by
// event name when it cares.
if (next.onboarding_session_id === undefined) {
const sessionId = getOnboardingSessionId();
if (sessionId) next.onboarding_session_id = sessionId;
}
return next;
}

View File

@@ -392,6 +392,7 @@ export class ApiClient {
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
workspace_id?: string;
onboarding_session_id?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
@@ -411,6 +412,7 @@ export class ApiClient {
async patchOnboarding(payload: {
questionnaire?: Record<string, unknown>;
onboarding_session_id?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding", {
method: "PATCH",

View File

@@ -13,3 +13,8 @@ export {
} from "./store";
export { ONBOARDING_STEP_ORDER } from "./step-order";
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
export {
startOnboardingSession,
getOnboardingSessionId,
clearOnboardingSession,
} from "./session";

View File

@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
async function loadModule() {
// Reset module cache so each test starts with a clean in-memory id.
const vitest = await import("vitest");
vitest.vi.resetModules();
return import("./session");
}
beforeEach(() => {
// Provide a minimal in-memory localStorage so defaultStorage's
// typeof-window check passes and we exercise the persistence path.
const store = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {},
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
configurable: true,
writable: true,
});
});
afterEach(() => {
// @ts-expect-error — test-only cleanup
delete globalThis.window;
// @ts-expect-error — test-only cleanup
delete globalThis.localStorage;
});
describe("onboarding session", () => {
it("startOnboardingSession returns a stable id within the same funnel", async () => {
const session = await loadModule();
const id1 = session.startOnboardingSession();
const id2 = session.startOnboardingSession();
expect(id1).toBe(id2);
expect(session.getOnboardingSessionId()).toBe(id1);
});
it("clearOnboardingSession resets so the next start gets a fresh id", async () => {
const session = await loadModule();
const first = session.startOnboardingSession();
session.clearOnboardingSession();
expect(session.getOnboardingSessionId()).toBeNull();
const second = session.startOnboardingSession();
expect(second).not.toBe(first);
});
it("getOnboardingSessionId returns null when no session has been started", async () => {
const session = await loadModule();
expect(session.getOnboardingSessionId()).toBeNull();
});
});

View File

@@ -0,0 +1,63 @@
// Onboarding session identifier — issued once per funnel entry and attached
// to every onboarding event so PostHog can correlate the full funnel back to
// a single `onboarding_started`. Solves the prior funnel-attribution gap
// where `onboarding_completed` events fired from skip/invite paths (no
// `onboarding_started` in their lineage) were indistinguishable from real
// funnel completions when joining on `distinct_id` alone.
//
// The id is persisted to client storage because the funnel spans page
// reloads (especially on web — desktop bundle install, OAuth redirects).
// It's cleared on completion; entering onboarding again starts a fresh
// session and a fresh id.
import { createSafeId } from "../utils";
import { defaultStorage } from "../platform/storage";
const STORAGE_KEY = "multica_onboarding_session_id";
// In-memory cache so the analytics wrapper doesn't hit storage on every
// event. Storage is read once on first access and on every start/clear.
let cached: string | null | undefined;
function read(): string | null {
if (cached !== undefined) return cached;
cached = defaultStorage.getItem(STORAGE_KEY);
return cached;
}
/**
* Generate a new session id and persist it. Idempotent — calling twice in
* the same funnel returns the same id, so a re-mount of the onboarding
* shell can't accidentally split one funnel across two sessions.
*
* The expected fire site is the same place that emits `onboarding_started`.
*/
export function startOnboardingSession(): string {
const existing = read();
if (existing) return existing;
const id = createSafeId();
cached = id;
defaultStorage.setItem(STORAGE_KEY, id);
return id;
}
/**
* Read the current session id. Returns null when no onboarding session is
* in progress — the analytics wrapper omits the property in that case
* rather than emitting an empty string, so HogQL queries can filter
* `onboarding_session_id IS NOT NULL` to isolate real funnel events from
* skip/invite paths that legitimately have no session.
*/
export function getOnboardingSessionId(): string | null {
return read();
}
/**
* Clear the session. Called at the funnel terminus (after a successful
* `onboarding_completed`) so a returning user who somehow re-enters
* onboarding starts a fresh session.
*/
export function clearOnboardingSession(): void {
cached = null;
defaultStorage.removeItem(STORAGE_KEY);
}

View File

@@ -1,6 +1,10 @@
import { api } from "../api";
import { useAuthStore } from "../auth";
import { setPersonProperties } from "../analytics";
import {
clearOnboardingSession,
getOnboardingSessionId,
} from "./session";
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
/**
@@ -16,7 +20,11 @@ import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
export async function saveQuestionnaire(
answers: Partial<QuestionnaireAnswers>,
): Promise<void> {
const user = await api.patchOnboarding({ questionnaire: answers });
const onboardingSessionId = getOnboardingSessionId() ?? undefined;
const user = await api.patchOnboarding({
questionnaire: answers,
onboarding_session_id: onboardingSessionId,
});
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 /
@@ -44,11 +52,20 @@ export async function completeOnboarding(
completionPath?: OnboardingCompletionPath,
workspaceId?: string,
): Promise<void> {
await api.markOnboardingComplete(
completionPath || workspaceId
? { completion_path: completionPath, workspace_id: workspaceId }
: undefined,
);
const onboardingSessionId = getOnboardingSessionId() ?? undefined;
const payload =
completionPath || workspaceId || onboardingSessionId
? {
completion_path: completionPath,
workspace_id: workspaceId,
onboarding_session_id: onboardingSessionId,
}
: undefined;
await api.markOnboardingComplete(payload);
// Clear the session AFTER the server records it. The funnel terminus
// is over — any subsequent re-entry into onboarding starts a fresh
// session (and a fresh id), which is what we want.
clearOnboardingSession();
await useAuthStore.getState().refreshMe();
}

View File

@@ -7,9 +7,11 @@ import { captureEvent } from "@multica/core/analytics";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import {
clearOnboardingSession,
completeOnboarding,
ONBOARDING_STEP_ORDER,
saveQuestionnaire,
startOnboardingSession,
type OnboardingCompletionPath,
type OnboardingStep,
type QuestionnaireAnswers,
@@ -98,6 +100,10 @@ export function OnboardingFlow({
useEffect(() => {
if (startedEmittedRef.current || !workspacesFetched) return;
startedEmittedRef.current = true;
// Issue the funnel session id BEFORE emitting `onboarding_started`
// so the analytics wrapper picks it up and stamps it on the event
// (and every subsequent onboarding_* event until completion).
startOnboardingSession();
captureEvent("onboarding_started", {
source: "onboarding",
...(existingWorkspace ? { workspace_id: existingWorkspace.id } : {}),
@@ -122,6 +128,12 @@ export function OnboardingFlow({
// StarterContentPrompt dialog on arrival — which is correct, since
// they never got a starter project and may want one now.
const handleWelcomeSkip = useCallback(async () => {
// skip_existing is NOT a real funnel completion — the user bounced at
// Welcome without entering any onboarding step. Drop the session id
// first so `completeOnboarding` doesn't forward it to the server,
// which keeps `onboarding_session_id` absent on these soft completions
// and lets HogQL filter `IS NOT NULL` to isolate real funnel exits.
clearOnboardingSession();
try {
await completeOnboarding("skip_existing", workspaces[0]?.id);
} catch (err) {

View File

@@ -398,18 +398,22 @@ func TeamInviteAccepted(inviteeID, workspaceID string, daysSinceInvite int64) Ev
// 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 {
func OnboardingQuestionnaireSubmitted(userID, teamSize, role, useCase string, teamSizeOther, roleOther, useCaseOther bool, onboardingSessionID string) Event {
props := 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,
}
if onboardingSessionID != "" {
props["onboarding_session_id"] = onboardingSessionID
}
return Event{
Name: EventOnboardingQuestionnaireSubmit,
DistinctID: userID,
Properties: withCoreProperties(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,
}, CoreProperties{
Properties: withCoreProperties(props, CoreProperties{
UserID: userID,
Source: SourceOnboarding,
}),
@@ -460,15 +464,22 @@ func AgentCreated(actorID, workspaceID, agentID, provider, runtimeMode, template
// 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, workspaceID, completionPath, onboardedAt string, joinedCloudWaitlist bool) Event {
func OnboardingCompleted(userID, workspaceID, completionPath, onboardedAt string, joinedCloudWaitlist bool, onboardingSessionID string) Event {
props := map[string]any{
"completion_path": completionPath,
"joined_cloud_waitlist": joinedCloudWaitlist,
}
// Skip / invite paths legitimately have no session — the property is
// omitted there so HogQL can isolate real funnel completions with
// `onboarding_session_id IS NOT NULL`.
if onboardingSessionID != "" {
props["onboarding_session_id"] = onboardingSessionID
}
return Event{
Name: EventOnboardingCompleted,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"completion_path": completionPath,
"joined_cloud_waitlist": joinedCloudWaitlist,
}, CoreProperties{
Properties: withCoreProperties(props, CoreProperties{
UserID: userID,
WorkspaceID: workspaceID,
Source: SourceOnboarding,

View File

@@ -478,12 +478,18 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
if onboardedUser.OnboardedAt.Valid {
onboardedAt = onboardedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
}
// Invite-accept is not part of the in-product onboarding funnel,
// so no onboarding_session_id is available. PostHog will see an
// empty session id on this event; HogQL funnels filter on
// `onboarding_session_id IS NOT NULL` to keep real funnel
// completions separate from these soft-completions.
h.Analytics.Capture(analytics.OnboardingCompleted(
userID,
wsID,
analytics.OnboardingPathInviteAccept,
onboardedAt,
onboardedUser.CloudWaitlistEmail.Valid,
"",
))
}

View File

@@ -44,8 +44,9 @@ const (
// legacy clients still complete the flow cleanly, just without a
// funnel-ready label.
type completeOnboardingRequest struct {
CompletionPath string `json:"completion_path,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
CompletionPath string `json:"completion_path,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
OnboardingSessionID string `json:"onboarding_session_id,omitempty"`
}
var validCompletionPaths = map[string]struct{}{
@@ -111,6 +112,7 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
path,
onboardedAt,
user.CloudWaitlistEmail.Valid,
req.OnboardingSessionID,
))
}
@@ -118,7 +120,8 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
}
type patchOnboardingRequest struct {
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
OnboardingSessionID string `json:"onboarding_session_id,omitempty"`
}
// questionnaireAnswers mirrors the frontend's `QuestionnaireAnswers`
@@ -191,6 +194,7 @@ func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
after.TeamSizeOther != "",
after.RoleOther != "",
after.UseCaseOther != "",
req.OnboardingSessionID,
))
}