mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Compare commits
2 Commits
agent/j/46
...
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. |
|
| `workspace_id` | string (UUID) | Present only when the user already has a workspace. |
|
||||||
| `source` | string | Always `onboarding`. |
|
| `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`
|
### `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. |
|
| `team_size_has_other` | bool | `true` when the user filled the Q1 free-text escape. |
|
||||||
| `role_has_other` | bool | Ditto Q2. |
|
| `role_has_other` | bool | Ditto Q2. |
|
||||||
| `use_case_has_other` | bool | Ditto Q3. |
|
| `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
|
Person properties set with `$set` (not once — users can go back and
|
||||||
change answers before submitting again):
|
change answers before submitting again):
|
||||||
@@ -424,6 +447,7 @@ which exit the user took.
|
|||||||
| `workspace_id` | string (UUID) | Present for workspace-linked onboarding completions. |
|
| `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. |
|
| `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. |
|
| `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`:
|
Person properties set with `$set_once`:
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// backend returns an empty key and this module stays inert.
|
// backend returns an empty key and this module stays inert.
|
||||||
|
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
import { getOnboardingSessionId } from "../onboarding/session";
|
||||||
|
|
||||||
export const EVENT_SCHEMA_VERSION = 2;
|
export const EVENT_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
@@ -283,6 +284,14 @@ function withClientEventProperties(
|
|||||||
if (next.is_demo === undefined) {
|
if (next.is_demo === undefined) {
|
||||||
next.is_demo = false;
|
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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export class ApiClient {
|
|||||||
async markOnboardingComplete(payload?: {
|
async markOnboardingComplete(payload?: {
|
||||||
completion_path?: OnboardingCompletionPath;
|
completion_path?: OnboardingCompletionPath;
|
||||||
workspace_id?: string;
|
workspace_id?: string;
|
||||||
|
onboarding_session_id?: string;
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
return this.fetch("/api/me/onboarding/complete", {
|
return this.fetch("/api/me/onboarding/complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -411,6 +412,7 @@ export class ApiClient {
|
|||||||
|
|
||||||
async patchOnboarding(payload: {
|
async patchOnboarding(payload: {
|
||||||
questionnaire?: Record<string, unknown>;
|
questionnaire?: Record<string, unknown>;
|
||||||
|
onboarding_session_id?: string;
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
return this.fetch("/api/me/onboarding", {
|
return this.fetch("/api/me/onboarding", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -13,3 +13,8 @@ export {
|
|||||||
} from "./store";
|
} from "./store";
|
||||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||||
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
|
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 { api } from "../api";
|
||||||
import { useAuthStore } from "../auth";
|
import { useAuthStore } from "../auth";
|
||||||
import { setPersonProperties } from "../analytics";
|
import { setPersonProperties } from "../analytics";
|
||||||
|
import {
|
||||||
|
clearOnboardingSession,
|
||||||
|
getOnboardingSessionId,
|
||||||
|
} from "./session";
|
||||||
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +20,11 @@ import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
|||||||
export async function saveQuestionnaire(
|
export async function saveQuestionnaire(
|
||||||
answers: Partial<QuestionnaireAnswers>,
|
answers: Partial<QuestionnaireAnswers>,
|
||||||
): Promise<void> {
|
): 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);
|
useAuthStore.getState().setUser(user);
|
||||||
// Mirror the three cohort signals into person properties so every
|
// Mirror the three cohort signals into person properties so every
|
||||||
// PostHog event on this user can be broken down by role / use_case /
|
// PostHog event on this user can be broken down by role / use_case /
|
||||||
@@ -44,11 +52,20 @@ export async function completeOnboarding(
|
|||||||
completionPath?: OnboardingCompletionPath,
|
completionPath?: OnboardingCompletionPath,
|
||||||
workspaceId?: string,
|
workspaceId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.markOnboardingComplete(
|
const onboardingSessionId = getOnboardingSessionId() ?? undefined;
|
||||||
completionPath || workspaceId
|
const payload =
|
||||||
? { completion_path: completionPath, workspace_id: workspaceId }
|
completionPath || workspaceId || onboardingSessionId
|
||||||
: undefined,
|
? {
|
||||||
);
|
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();
|
await useAuthStore.getState().refreshMe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { captureEvent } from "@multica/core/analytics";
|
|||||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||||
import { useAuthStore } from "@multica/core/auth";
|
import { useAuthStore } from "@multica/core/auth";
|
||||||
import {
|
import {
|
||||||
|
clearOnboardingSession,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
ONBOARDING_STEP_ORDER,
|
ONBOARDING_STEP_ORDER,
|
||||||
saveQuestionnaire,
|
saveQuestionnaire,
|
||||||
|
startOnboardingSession,
|
||||||
type OnboardingCompletionPath,
|
type OnboardingCompletionPath,
|
||||||
type OnboardingStep,
|
type OnboardingStep,
|
||||||
type QuestionnaireAnswers,
|
type QuestionnaireAnswers,
|
||||||
@@ -98,6 +100,10 @@ export function OnboardingFlow({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedEmittedRef.current || !workspacesFetched) return;
|
if (startedEmittedRef.current || !workspacesFetched) return;
|
||||||
startedEmittedRef.current = true;
|
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", {
|
captureEvent("onboarding_started", {
|
||||||
source: "onboarding",
|
source: "onboarding",
|
||||||
...(existingWorkspace ? { workspace_id: existingWorkspace.id } : {}),
|
...(existingWorkspace ? { workspace_id: existingWorkspace.id } : {}),
|
||||||
@@ -122,6 +128,12 @@ export function OnboardingFlow({
|
|||||||
// StarterContentPrompt dialog on arrival — which is correct, since
|
// StarterContentPrompt dialog on arrival — which is correct, since
|
||||||
// they never got a starter project and may want one now.
|
// they never got a starter project and may want one now.
|
||||||
const handleWelcomeSkip = useCallback(async () => {
|
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 {
|
try {
|
||||||
await completeOnboarding("skip_existing", workspaces[0]?.id);
|
await completeOnboarding("skip_existing", workspaces[0]?.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -398,18 +398,22 @@ func TeamInviteAccepted(inviteeID, workspaceID string, daysSinceInvite int64) Ev
|
|||||||
// teamSizeOther / roleOther / useCaseOther are presence booleans only —
|
// teamSizeOther / roleOther / useCaseOther are presence booleans only —
|
||||||
// the free-text content is kept in the DB for product research but not
|
// the free-text content is kept in the DB for product research but not
|
||||||
// broadcast via analytics (PII risk + low cardinality ask).
|
// 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{
|
return Event{
|
||||||
Name: EventOnboardingQuestionnaireSubmit,
|
Name: EventOnboardingQuestionnaireSubmit,
|
||||||
DistinctID: userID,
|
DistinctID: userID,
|
||||||
Properties: withCoreProperties(map[string]any{
|
Properties: withCoreProperties(props, CoreProperties{
|
||||||
"team_size": teamSize,
|
|
||||||
"role": role,
|
|
||||||
"use_case": useCase,
|
|
||||||
"team_size_has_other": teamSizeOther,
|
|
||||||
"role_has_other": roleOther,
|
|
||||||
"use_case_has_other": useCaseOther,
|
|
||||||
}, CoreProperties{
|
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Source: SourceOnboarding,
|
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
|
// onboardedAt is an RFC3339 timestamp set $set_once on the person so
|
||||||
// "onboarded before date X" cohorts are queryable directly from
|
// "onboarded before date X" cohorts are queryable directly from
|
||||||
// person_properties without re-emitting per-event.
|
// 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{
|
return Event{
|
||||||
Name: EventOnboardingCompleted,
|
Name: EventOnboardingCompleted,
|
||||||
DistinctID: userID,
|
DistinctID: userID,
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
Properties: withCoreProperties(map[string]any{
|
Properties: withCoreProperties(props, CoreProperties{
|
||||||
"completion_path": completionPath,
|
|
||||||
"joined_cloud_waitlist": joinedCloudWaitlist,
|
|
||||||
}, CoreProperties{
|
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
Source: SourceOnboarding,
|
Source: SourceOnboarding,
|
||||||
|
|||||||
@@ -478,12 +478,18 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
if onboardedUser.OnboardedAt.Valid {
|
if onboardedUser.OnboardedAt.Valid {
|
||||||
onboardedAt = onboardedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
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(
|
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||||
userID,
|
userID,
|
||||||
wsID,
|
wsID,
|
||||||
analytics.OnboardingPathInviteAccept,
|
analytics.OnboardingPathInviteAccept,
|
||||||
onboardedAt,
|
onboardedAt,
|
||||||
onboardedUser.CloudWaitlistEmail.Valid,
|
onboardedUser.CloudWaitlistEmail.Valid,
|
||||||
|
"",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ const (
|
|||||||
// legacy clients still complete the flow cleanly, just without a
|
// legacy clients still complete the flow cleanly, just without a
|
||||||
// funnel-ready label.
|
// funnel-ready label.
|
||||||
type completeOnboardingRequest struct {
|
type completeOnboardingRequest struct {
|
||||||
CompletionPath string `json:"completion_path,omitempty"`
|
CompletionPath string `json:"completion_path,omitempty"`
|
||||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||||
|
OnboardingSessionID string `json:"onboarding_session_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var validCompletionPaths = map[string]struct{}{
|
var validCompletionPaths = map[string]struct{}{
|
||||||
@@ -111,6 +112,7 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
|||||||
path,
|
path,
|
||||||
onboardedAt,
|
onboardedAt,
|
||||||
user.CloudWaitlistEmail.Valid,
|
user.CloudWaitlistEmail.Valid,
|
||||||
|
req.OnboardingSessionID,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,8 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type patchOnboardingRequest struct {
|
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`
|
// questionnaireAnswers mirrors the frontend's `QuestionnaireAnswers`
|
||||||
@@ -191,6 +194,7 @@ func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
|
|||||||
after.TeamSizeOther != "",
|
after.TeamSizeOther != "",
|
||||||
after.RoleOther != "",
|
after.RoleOther != "",
|
||||||
after.UseCaseOther != "",
|
after.UseCaseOther != "",
|
||||||
|
req.OnboardingSessionID,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user