* feat(analytics): capture onboarding funnel events + person-property $set Closes the visibility gap introduced by the Onboarding relaunch: the five new steps between signup and workspace_created were invisible to PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist intent, or starter-content acceptance at all. Server-side events (see docs/analytics.md for full contracts): - onboarding_questionnaire_submitted — fires once when all three answers first land; also $set's role/use_case/team_size on the person so every subsequent event is cohortable - agent_created — not onboarding-specific; is_first_agent_in_workspace isolates the Step 4 signal - onboarding_completed — fires on the actual NULL → timestamp flip with completion_path (full / runtime_skipped / cloud_waitlist / skip_existing / unknown) + joined_cloud_waitlist - cloud_waitlist_joined — sizes hosted-runtime interest - starter_content_decided — imported vs dismissed, split by agent_guided / self_serve branch on both sides Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so the same events can carry mutable cohort signals without a separate identify round-trip. * feat(analytics): wire frontend onboarding events + completion_path - captureEvent / setPersonProperties helpers in @multica/core/analytics, with the same pre-init buffering as identify/pageview so config races don't drop step transitions - onboarding_runtime_path_selected fires from step-platform-fork for the three web-fork choices (download desktop / CLI / cloud waitlist), plus platform_preference on person properties for downstream splits - completeOnboarding now takes an OnboardingCompletionPath; the onboarding shell derives full / runtime_skipped / cloud_waitlist from runtime + waitlist state (lifted to the shell so StepFirstIssue can see both), and handleWelcomeSkip passes skip_existing - saveQuestionnaire mirrors team_size/role/use_case into person properties via $set so every event on this user becomes cohortable - StepAgent sends the template slug, StarterContentPrompt passes workspace_id on dismiss so the server can mirror the branch label * docs(analytics): document onboarding funnel events + $set person properties
14 KiB
Product Analytics
This document is the source of truth for the analytics events Multica ships to PostHog. Events feed the acquisition → activation → expansion funnel that drives our weekly Active Workspaces (WAW) north-star metric.
See MUL-1122 for the design context.
Configuration
All analytics shipping is toggled by environment variables (see .env.example):
| Variable | Meaning | Default |
|---|---|---|
POSTHOG_API_KEY |
PostHog project API key. Empty = no events are shipped. | "" |
POSTHOG_HOST |
PostHog host (US or EU cloud, or self-hosted URL). | https://us.i.posthog.com |
ANALYTICS_DISABLED |
Set to true/1 to force the no-op client even when POSTHOG_API_KEY is set. |
"" |
Local dev and self-hosted instances run with POSTHOG_API_KEY="", so no
events leave the process unless the operator explicitly opts in.
Self-hosted instances
Self-hosters should never inherit a Multica-issued POSTHOG_API_KEY —
that would route their users' behavior to our analytics project. The
defaults guarantee this:
.env.exampleshipsPOSTHOG_API_KEY=empty. The Docker self-host compose does not set a default either.- With the key unset,
NewFromEnvreturnsNoopClientand logsanalytics: POSTHOG_API_KEY not set, using noop clientat startup — a visible confirmation that nothing is shipped. - Operators who want their own analytics can set
POSTHOG_API_KEYandPOSTHOG_HOSTto point at their own PostHog project (Cloud or self-hosted PostHog). - The frontend receives the key via
/api/config(planned for PR 2), so self-hosts' blank server config also disables frontend event shipping automatically — no separate frontend opt-out plumbing required.
Architecture
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
│
▼
bounded queue (1024 events)
│
▼
background worker: batch + POST /batch/
│
▼
PostHog
analytics.Captureis never allowed to block a request handler. A broken backend must not degrade the product — when the queue is full, events are dropped and counted (visible viaslog+ thedroppedcounter on shutdown).- Batches flush either when
BatchSizeis reached or everyFlushEvery(default 10 s), whichever comes first. Close()drains remaining events during graceful shutdown. Called fromserver/cmd/server/main.goviadefer.
Identity model
distinct_id— always the user's UUID for logged-in events. The frontend'sposthog.identify(user.id)merges any prior anonymous events under the same identity, so acquisition attribution (UTM / referrer) stays intact across signup.workspace_id— added to every event as a property when present. v1 uses event property filtering (free tier) rather than PostHog Groups Analytics (paid) to compute workspace-level metrics.- PII — events carry
email_domain(e.g.gmail.com), not the full email. Full email is stored once in person properties via$set_onceso 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.Seton the backend maps to$set; the frontend helper issetPersonProperties()in@multica/core/analytics. Use$set_onceonly for values that must never be overwritten (email, initial attribution, first-completion timestamp).
Event contract
signup
Fires when a new user is created. Covers both verification-code and Google
OAuth entry points (findOrCreateUser is the single emission site).
| Property | Type | Description |
|---|---|---|
email_domain |
string | Lower-cased domain portion of the user's email. |
signup_source |
string | Opaque attribution bundle from the frontend cookie multica_signup_source (UTM + referrer). Empty when the cookie is absent. |
auth_method |
string | Optional. "google" for Google OAuth signups. Absent for verification-code signups. |
Person properties set with $set_once:
| Property | Type | Description |
|---|---|---|
email |
string | Full email. Never broadcast per-event. |
signup_source |
string | Same as above; kept on the person for later segmentation. |
workspace_created
Fires after a CreateWorkspace transaction commits successfully.
| Property | Type | Description |
|---|---|---|
workspace_id |
string (UUID) | Added globally; present here for clarity. |
Note on "first workspace" segmentation — we deliberately do not stamp
an is_first_workspace boolean at emit time. Computing it correctly would
require an extra column or transaction-scoped logic that still races under
concurrent creates. Instead, PostHog answers the same question exactly by
looking at whether the user has a prior workspace_created event (use a
funnel with "first time user does X" or a cohort on
person_properties.$initial_event). No information is lost.
runtime_registered
Fires the first time a (workspace_id, daemon_id, provider) tuple is
upserted. Heartbeats and repeat registrations never re-emit. First-time
detection uses Postgres xmax = 0 on the upsert RETURNING clause — no
extra query, no race.
| Property | Type | Description |
|---|---|---|
runtime_id |
string (UUID) | The newly created agent_runtime row id. |
provider |
string | e.g. "codex", "claude". |
runtime_version |
string | Version of the agent runtime binary. |
cli_version |
string | Version of the multica CLI that registered it. |
distinct_id is the authenticated owner's user id when the daemon was
registered via a member's JWT/PAT; daemon-token registrations fall back to
workspace:<workspace_id> so PostHog doesn't bucket unrelated daemons
under a single "anonymous" person.
issue_executed
Fires at most once per issue — when the first task on that issue
reaches terminal done state. Backed by an atomic
UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *;
retries, re-assignments, and comment-triggered follow-up tasks all hit the
WHERE clause and no-op, so the ≥1 / ≥2 / ≥5 / ≥10 funnel buckets count
distinct issues, not tasks.
| Property | Type | Description |
|---|---|---|
issue_id |
string (UUID) | |
task_duration_ms |
int64 | Wall-clock time between task.started_at and task.completed_at. Zero when the task was created in a completed state (rare). |
distinct_id prefers the issue's human creator so agent-executed events
flow into the issue-author's person profile (same place signup and
workspace_created land). Agent-created issues prefix with agent: to
keep PostHog from merging the agent into a user record.
Note on workspace-Nth ordinals — we deliberately do not stamp
nth_issue_for_workspace at emit time. Computing it correctly would
require either a serialised transaction or an advisory lock per workspace;
two concurrent first-completions could otherwise both read count=1 and
emit n=1. PostHog answers the same question at query time via
row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp),
and funnel steps of the form "workspace has had ≥2 issue_executed
events" are expressible without the property. No information is lost.
team_invite_sent
Fires from CreateInvitation after the DB row is written.
| Property | Type | Description |
|---|---|---|
invited_email_domain |
string | Lower-cased domain; full email lives in the invitation row, not the event. |
invite_method |
string | Currently always "email". Future non-email invite flows (share link, SCIM) should pass their own value. |
distinct_id is the inviter's user id.
team_invite_accepted
Fires from AcceptInvitation after both the invitation row is marked
accepted and the member row is inserted in the same transaction.
| Property | Type | Description |
|---|---|---|
days_since_invite |
int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
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 byapps/web/components/pageview-tracker.tsxon every Next.js App Router path or query-string change. The tracker mounts once underWebProvidersand drives the acquisition funnel's/ → signupstep. posthog-js's automatic pageview capture is disabled ininitAnalyticsso we own the event shape.onboarding_runtime_path_selected— fired frompackages/views/onboarding/steps/step-platform-fork.tsxwhen 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 writesplatform_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_sourcecookie on the first anonymous pageview and read by the backend'ssignupemission. The cookie carries a JSON payload URL-encoded at write time (encodeURIComponent) and URL-decoded at read time (url.QueryUnescape) — the JSON is never mid-truncated; individual values are capped at 96 chars beforeJSON.stringify, and the entire payload is dropped if it still exceeds 512 chars. That way PostHog sees either intact JSON or nothing at all.
Governance
Before adding, renaming, or removing any event:
- Update this document first.
- Update
server/internal/analytics/events.goconstants and helpers to match. - PR description must state which existing funnel / insight is affected.