Files
multica/server/internal/analytics/client.go
Bohan Jiang 936df59fa1 feat(analytics): instrument onboarding funnel (MUL-1250) (#1489)
* 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
2026-04-22 16:28:08 +08:00

100 lines
3.7 KiB
Go

// Package analytics ships product telemetry events to an external analytics
// backend (PostHog). Events feed the acquisition → activation → expansion
// funnel — see docs/analytics.md for the event contract.
//
// Design:
// - Capture is non-blocking. Request handlers must never wait on analytics
// network I/O, so we enqueue into a bounded channel and a background
// worker flushes to PostHog in batches.
// - When the queue is full events are dropped (and counted). A broken
// analytics backend must never degrade the product.
// - When POSTHOG_API_KEY is empty the package runs a no-op client, which
// keeps local dev and self-hosted instances friction-free.
package analytics
import (
"log/slog"
"os"
"time"
)
// Event is a single analytics capture. Fields mirror PostHog's /capture/ shape
// but are framework-agnostic so alternate backends can plug in later.
type Event struct {
// Name of the event (e.g. "signup", "workspace_created").
Name string
// DistinctID identifies the person this event belongs to. For logged-in
// users this is user.id; for anonymous events it should be the anon_id
// that was previously used on the frontend so identity merging works.
DistinctID string
// WorkspaceID scopes the event to a workspace. Required when the event is
// about a workspace-level action (workspace_created, issue_executed, ...).
// Empty is allowed for pre-workspace events (signup).
WorkspaceID string
// Properties is the free-form bag of event attributes. Only serialisable
// values (string, number, bool, nested maps/slices of the same) should
// go here. Never put raw PII like full emails here — use email_domain.
Properties map[string]any
// SetOnce properties attach to the person record and are only written the
// first time they appear. Use this for acquisition attribution
// (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
}
// Client is the narrow surface the rest of the codebase depends on. Handlers
// call Capture and move on; the implementation is responsible for buffering,
// batching, and shipping.
type Client interface {
Capture(e Event)
// Close drains pending events. Call once during graceful shutdown.
Close()
}
// NewFromEnv returns a Client configured from environment variables:
//
// - POSTHOG_API_KEY: project API key. Empty → no-op client.
// - POSTHOG_HOST: API host (default https://us.i.posthog.com).
// - ANALYTICS_DISABLED: set to "true"/"1" to force a no-op client even
// when POSTHOG_API_KEY is set (useful for CI and self-hosted opt-out).
func NewFromEnv() Client {
if isDisabled() {
slog.Info("analytics disabled via ANALYTICS_DISABLED")
return NoopClient{}
}
key := os.Getenv("POSTHOG_API_KEY")
if key == "" {
slog.Info("analytics: POSTHOG_API_KEY not set, using noop client")
return NoopClient{}
}
host := os.Getenv("POSTHOG_HOST")
if host == "" {
host = "https://us.i.posthog.com"
}
slog.Info("analytics: posthog client enabled", "host", host)
return NewPostHogClient(PostHogConfig{APIKey: key, Host: host})
}
func isDisabled() bool {
v := os.Getenv("ANALYTICS_DISABLED")
return v == "true" || v == "1"
}
// NoopClient silently drops all events. Used in tests, in local dev when
// POSTHOG_API_KEY is unset, and in self-hosted instances that opt out.
type NoopClient struct{}
func (NoopClient) Capture(Event) {}
func (NoopClient) Close() {}