mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d38396f59 | ||
|
|
a033725506 |
@@ -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`:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
59
packages/core/onboarding/session.test.ts
Normal file
59
packages/core/onboarding/session.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
63
packages/core/onboarding/session.ts
Normal file
63
packages/core/onboarding/session.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
"",
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user