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

135 lines
4.6 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"
"strings"
"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
// UUID, when non-empty, is sent as PostHog's top-level event `uuid`.
// PostHog deduplicates events sharing a uuid (best-effort), so a
// deterministic uuid makes re-sends idempotent. Empty → PostHog assigns
// its own (the default for every existing event).
UUID 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_ENVIRONMENT: production/staging/dev. Defaults from APP_ENV.
// - 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,
Environment: EnvironmentFromEnv(),
})
}
func isDisabled() bool {
v := os.Getenv("ANALYTICS_DISABLED")
return v == "true" || v == "1"
}
func EnvironmentFromEnv() string {
if v := normalizeEnvironment(os.Getenv("ANALYTICS_ENVIRONMENT")); v != "" {
return v
}
if v := normalizeEnvironment(os.Getenv("APP_ENV")); v != "" {
return v
}
return "dev"
}
func normalizeEnvironment(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "production", "prod":
return "production"
case "staging", "stage":
return "staging"
case "development", "dev", "test", "local":
return "dev"
default:
return ""
}
}
// 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() {}