Files
multica/docs/analytics.md
devv-eve 637bdc8eb3 feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping

Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.

Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.

* feat(server): emit signup and workspace_created analytics events

Wires analytics.Client through handler.New and main, then emits the first
two funnel events:

- signup fires from findOrCreateUser (which now reports isNew), covering
  both the verification-code and Google OAuth entry points — a single
  emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
  with is_first_workspace computed from a post-commit ListWorkspaces count
  so we can distinguish fresh-user activation from returning-user
  expansion.

Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.

* refactor(analytics): drop is_first_workspace from workspace_created

Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.

PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.

* docs(analytics): document self-host safety defaults

Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.

* feat(analytics): emit runtime_registered, issue_executed, team_invite_*

Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:

- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
  reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
  and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
  UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
  first_executed_at IS NULL flips the column for the first time. Retries,
  re-assignments, and comment-triggered follow-up tasks hit the WHERE
  clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
  buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
  from AcceptInvitation, closing the expansion funnel.

Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.

* feat(config): surface PostHog key via /api/config

Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.

* feat(analytics): wire posthog-js identify + UTM capture on the client

Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.

captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.

Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
  cookie and token paths. A getConfig/getMe race is handled by buffering
  a pending identify inside the analytics module and flushing it once
  initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
  loginWithToken and resetAnalytics() on logout so the next login merges
  cleanly without bleeding events.

* docs(analytics): describe runtime_registered, issue_executed, invite events

Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.

* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed

Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.

Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.

* fix(analytics): wire $pageview and harden signup_source cookie payload

Two frontend fixes from the PR review:

- PageviewTracker, mounted under WebProviders, fires capturePageview on
  every Next.js App Router path / query-string change. Without this the
  capturePageview helper in @multica/core/analytics was never called and
  the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
  *before* JSON.stringify, and drops the whole cookie when the serialised
  payload still exceeds 512 chars. Previously the overall slice(0, 256)
  could leave a half-JSON string on the wire that neither the backend nor
  PostHog could parse.

Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.

* fix(analytics): URL-decode signup_source cookie on read

Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.

* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding

Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.

---------

Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-04-21 14:42:52 +08:00

9.1 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.example ships POSTHOG_API_KEY= empty. The Docker self-host compose does not set a default either.
  • With the key unset, NewFromEnv returns NoopClient and logs analytics: POSTHOG_API_KEY not set, using noop client at startup — a visible confirmation that nothing is shipped.
  • Operators who want their own analytics can set POSTHOG_API_KEY and POSTHOG_HOST to 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.Capture is 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 via slog + the dropped counter on shutdown).
  • Batches flush either when BatchSize is reached or every FlushEvery (default 10 s), whichever comes first.
  • Close() drains remaining events during graceful shutdown. Called from server/cmd/server/main.go via defer.

Identity model

  • distinct_id — always the user's UUID for logged-in events. The frontend's posthog.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_once so it's available for individual debugging but not broadcast with every event.

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.

Frontend-only events

  • $pageview — fired by apps/web/components/pageview-tracker.tsx on every Next.js App Router path or query-string change. The tracker 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.
  • 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 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 before JSON.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:

  1. Update this document first.
  2. Update server/internal/analytics/events.go constants and helpers to match.
  3. PR description must state which existing funnel / insight is affected.