Files
multica/server/internal/analytics/posthog.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

211 lines
5.0 KiB
Go

package analytics
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
)
const (
defaultQueueSize = 1024
defaultBatchSize = 64
defaultFlushEvery = 10 * time.Second
defaultFlushTimeout = 5 * time.Second
)
// PostHogConfig configures the live PostHog client.
type PostHogConfig struct {
APIKey string
Host string
// Optional overrides. Zero values fall back to sensible defaults.
QueueSize int
BatchSize int
FlushEvery time.Duration
HTTPClient *http.Client
}
// PostHogClient ships events to PostHog's /batch/ endpoint. It enqueues events
// into a bounded buffer (non-blocking Capture) and flushes them from a
// background worker.
type PostHogClient struct {
cfg PostHogConfig
ch chan Event
done chan struct{}
wg sync.WaitGroup
dropped atomic.Uint64 // events dropped because the queue was full
sent atomic.Uint64
failed atomic.Uint64
}
// NewPostHogClient starts the background flush worker. Caller must call Close
// on shutdown to drain pending events.
func NewPostHogClient(cfg PostHogConfig) *PostHogClient {
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultQueueSize
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = defaultBatchSize
}
if cfg.FlushEvery <= 0 {
cfg.FlushEvery = defaultFlushEvery
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: defaultFlushTimeout}
}
c := &PostHogClient{
cfg: cfg,
ch: make(chan Event, cfg.QueueSize),
done: make(chan struct{}),
}
c.wg.Add(1)
go c.run()
return c
}
// Capture enqueues an event. Returns immediately; on a full queue the event
// is dropped and counted. Analytics must never block a request handler.
func (c *PostHogClient) Capture(e Event) {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now().UTC()
}
select {
case c.ch <- e:
default:
n := c.dropped.Add(1)
// Log periodically — every 100 drops — so a broken pipe is visible but
// doesn't spam logs under sustained load.
if n%100 == 1 {
slog.Warn("analytics: queue full, dropping event", "event", e.Name, "total_dropped", n)
}
}
}
// Close stops accepting events and drains whatever is already queued.
func (c *PostHogClient) Close() {
close(c.done)
c.wg.Wait()
slog.Info("analytics: posthog client closed",
"sent", c.sent.Load(),
"dropped", c.dropped.Load(),
"failed", c.failed.Load(),
)
}
func (c *PostHogClient) run() {
defer c.wg.Done()
ticker := time.NewTicker(c.cfg.FlushEvery)
defer ticker.Stop()
batch := make([]Event, 0, c.cfg.BatchSize)
flush := func() {
if len(batch) == 0 {
return
}
c.send(batch)
batch = batch[:0]
}
for {
select {
case e := <-c.ch:
batch = append(batch, e)
if len(batch) >= c.cfg.BatchSize {
flush()
}
case <-ticker.C:
flush()
case <-c.done:
// Drain remaining events. The channel is not closed by Close() to
// avoid racing with Capture, so we loop until it's empty.
for {
select {
case e := <-c.ch:
batch = append(batch, e)
if len(batch) >= c.cfg.BatchSize {
flush()
}
default:
flush()
return
}
}
}
}
}
// capturePayload mirrors the PostHog /batch/ JSON shape.
type capturePayload struct {
APIKey string `json:"api_key"`
Batch []captureItem `json:"batch"`
}
type captureItem struct {
Event string `json:"event"`
DistinctID string `json:"distinct_id"`
Properties map[string]any `json:"properties"`
Timestamp string `json:"timestamp"`
}
func (c *PostHogClient) send(batch []Event) {
items := make([]captureItem, 0, len(batch))
for _, e := range batch {
props := make(map[string]any, len(e.Properties)+2)
for k, v := range e.Properties {
props[k] = v
}
if e.WorkspaceID != "" {
props["workspace_id"] = e.WorkspaceID
}
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,
Properties: props,
Timestamp: e.Timestamp.UTC().Format(time.RFC3339Nano),
})
}
body, err := json.Marshal(capturePayload{APIKey: c.cfg.APIKey, Batch: items})
if err != nil {
c.failed.Add(uint64(len(batch)))
slog.Error("analytics: marshal batch", "error", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), defaultFlushTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/batch/", bytes.NewReader(body))
if err != nil {
c.failed.Add(uint64(len(batch)))
slog.Error("analytics: build request", "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
c.failed.Add(uint64(len(batch)))
slog.Warn("analytics: send batch failed", "error", err, "events", len(batch))
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
c.failed.Add(uint64(len(batch)))
slog.Warn("analytics: posthog rejected batch", "status", resp.StatusCode, "events", len(batch))
return
}
c.sent.Add(uint64(len(batch)))
}