mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 10:02:36 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb26c4fc3 | ||
|
|
343d37fbdb | ||
|
|
b26ab263ab |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type {
|
||||
OnboardingStep,
|
||||
OnboardingCompletionPath,
|
||||
QuestionnaireAnswers,
|
||||
TeamSize,
|
||||
Role,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user