Files
multica/server/internal/analytics/posthog.go
Naiyuan Qing 63eb6f73ad feat(analytics): anonymous self-host onboarding source beacon (MUL-3708) (#4691)
* feat(analytics): anonymous self-host onboarding source beacon (MUL-3708)

Production self-host servers now report the anonymous onboarding "how did
you hear about us" channel to Multica's public write-only ingest, so the
self-host source distribution becomes visible alongside official cloud.
Official cloud keeps its existing PostHog capture unchanged; this is a
submit-time beacon, not a background telemetry pipeline.

- server/internal/sourcebeacon: ShouldSend gate (production + non-local +
  non-*.multica.ai app host, fail-closed — judged by the app/frontend host,
  not the backend URL, which official often leaves unset), per-instance
  salted hashing, deterministic event uuid, fire-and-forget sender.
- POST /api/telemetry/self-host-source: public, write-only, per-IP
  rate-limited, 4 KiB body cap, channel allowlist, strict unknown-field
  rejection. Lands in PostHog as self_host_source_channel with a
  deterministic uuid (best-effort dedup), $process_person_profile=false,
  and deployment=self_host — a distinct event name so it never pollutes the
  official onboarding funnel.
- Hook in PatchOnboarding fires once when the source is first set; never
  blocks onboarding. Only channel enum(s) + two per-instance hashes leave
  the box — never user_id/email/name/workspace/org/domain/role/use_case/the
  source_other free-text/IP.
- migration 128: system_settings singleton holding instance_salt.
- frontend: self-host-only anonymous-collection notice on the source step,
  gated by a new /api/config self_host_source_notice flag (en/zh-Hans/ko/ja).
- analytics.Event gains an optional top-level uuid; docs/analytics.md,
  SELF_HOSTING.md and .env.example document exactly what is/isn't sent and
  how to disable it (ANALYTICS_DISABLED). Also fixes the long-standing
  team_size→source drift in docs/analytics.md.

Verified locally: go build/vet, go test (sourcebeacon, analytics, handler),
pnpm typecheck (all packages), locale parity (157), step-source (6) + core
config/schema (69) vitest, lint (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): wire self-host source beacon through metrics, guard nil pool (MUL-3708)

Addresses Howard CI blockers on #4691 (no product-direction change):

- loadInstanceSalt returns "" on nil pool; salt is only loaded when
  ShouldSendFromEnv() is true, via a bounded (5s) context — restores the
  "router constructible without a DB" invariant (nil-pool routing tests).
- Add multica_self_host_source_channel_total counter (by source) + an
  IncForEvent case, so every analytics event is paired with a Prometheus
  counter. NormalizeSourceChannel reuses sourcebeacon allowlist (no 3rd copy).
- Beacon handler now builds the event via the analytics.SelfHostSourceChannel
  helper and ships it through obsmetrics.RecordEvent (no naked Capture); not
  IsMetricsOnly, so it still reaches PostHog.
- Prime the new family in the registry-families test.

Verified: go build/vet, go test ./internal/metrics ./internal/sourcebeacon
./internal/handler ./cmd/server (incl. the 3 named blockers + registry +
record-event-helper lints) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 15:56:16 +08:00

229 lines
5.6 KiB
Go

package analytics
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"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
Environment 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}
}
if cfg.Environment == "" {
cfg.Environment = EnvironmentFromEnv()
}
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"`
// UUID is PostHog's top-level event id; set only when the caller
// supplied a deterministic one (for dedup). Omitted otherwise so
// PostHog assigns its own, preserving existing behaviour.
UUID string `json:"uuid,omitempty"`
}
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
}
props["event_schema_version"] = EventSchemaVersion
props["environment"] = c.cfg.Environment
if _, ok := props["is_demo"]; !ok {
props["is_demo"] = false
}
if _, ok := props["user_id"]; !ok && e.DistinctID != "" && !strings.Contains(e.DistinctID, ":") {
props["user_id"] = e.DistinctID
}
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),
UUID: e.UUID,
})
}
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)))
}