Files
multica/server/internal/handler/source_telemetry_test.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

130 lines
4.9 KiB
Go

package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/sourcebeacon"
)
// captureRecorder is a fake analytics.Client that records every captured
// event for assertions.
type captureRecorder struct{ events []analytics.Event }
func (c *captureRecorder) Capture(e analytics.Event) { c.events = append(c.events, e) }
func (c *captureRecorder) Close() {}
func postBeacon(t *testing.T, h *Handler, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/telemetry/self-host-source", strings.NewReader(body))
rec := httptest.NewRecorder()
h.HandleSelfHostSourceBeacon(rec, req)
return rec
}
func TestSelfHostSourceBeacon_ValidPayload(t *testing.T) {
rec := &captureRecorder{}
h := &Handler{Analytics: rec}
uid := sourcebeacon.HashUID("salt", "user-1")
inst := sourcebeacon.HashInstance("salt")
resp := postBeacon(t, h, `{"v":1,"channels":["social_youtube","search"],"uid_hash":"`+uid+`","instance_hash":"`+inst+`"}`)
if resp.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", resp.Code, resp.Body.String())
}
if len(rec.events) != 2 {
t.Fatalf("captured %d events, want 2", len(rec.events))
}
for i, ch := range []string{"social_youtube", "search"} {
e := rec.events[i]
if e.Name != analytics.EventSelfHostSourceChannel {
t.Errorf("event[%d].Name = %q", i, e.Name)
}
if e.DistinctID != "selfhost:"+uid {
t.Errorf("event[%d].DistinctID = %q", i, e.DistinctID)
}
if !strings.Contains(e.DistinctID, ":") {
t.Errorf("distinct_id must contain ':' to suppress user_id derivation")
}
if e.UUID != sourcebeacon.EventUUID(inst, uid, ch) {
t.Errorf("event[%d].UUID = %q, want deterministic", i, e.UUID)
}
if e.Properties["source"] != ch {
t.Errorf("event[%d].source = %v, want %q", i, e.Properties["source"], ch)
}
if e.Properties["deployment"] != "self_host" {
t.Errorf("event[%d].deployment = %v", i, e.Properties["deployment"])
}
if e.Properties["instance_hash"] != inst {
t.Errorf("event[%d].instance_hash = %v", i, e.Properties["instance_hash"])
}
if v, ok := e.Properties["$process_person_profile"].(bool); !ok || v {
t.Errorf("event[%d] must set $process_person_profile=false, got %v", i, e.Properties["$process_person_profile"])
}
}
}
func TestSelfHostSourceBeacon_DropsUnknownChannelsKeepsValid(t *testing.T) {
rec := &captureRecorder{}
h := &Handler{Analytics: rec}
uid := sourcebeacon.HashUID("salt", "u")
inst := sourcebeacon.HashInstance("salt")
resp := postBeacon(t, h, `{"v":1,"channels":["bogus","search"],"uid_hash":"`+uid+`","instance_hash":"`+inst+`"}`)
if resp.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", resp.Code)
}
if len(rec.events) != 1 || rec.events[0].Properties["source"] != "search" {
t.Fatalf("expected only the valid channel captured, got %d events", len(rec.events))
}
}
func TestSelfHostSourceBeacon_Rejections(t *testing.T) {
uid := sourcebeacon.HashUID("salt", "u")
inst := sourcebeacon.HashInstance("salt")
cases := []struct {
name string
body string
}{
// The privacy red-line: any extra field (identity / source_other /
// anything) is rejected, never logged or forwarded.
{"unknown field", `{"v":1,"channels":["search"],"uid_hash":"` + uid + `","instance_hash":"` + inst + `","email":"a@b.com"}`},
{"source_other field", `{"v":1,"channels":["other"],"uid_hash":"` + uid + `","instance_hash":"` + inst + `","source_other":"a podcast"}`},
{"wrong version", `{"v":2,"channels":["search"],"uid_hash":"` + uid + `","instance_hash":"` + inst + `"}`},
{"all invalid channels", `{"v":1,"channels":["nope"],"uid_hash":"` + uid + `","instance_hash":"` + inst + `"}`},
{"empty channels", `{"v":1,"channels":[],"uid_hash":"` + uid + `","instance_hash":"` + inst + `"}`},
{"invalid uid hash", `{"v":1,"channels":["search"],"uid_hash":"NOT-HEX","instance_hash":"` + inst + `"}`},
{"malformed json", `{"v":1,`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := &captureRecorder{}
h := &Handler{Analytics: rec}
resp := postBeacon(t, h, tc.body)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", resp.Code, resp.Body.String())
}
if len(rec.events) != 0 {
t.Fatalf("rejected payload must capture 0 events, got %d", len(rec.events))
}
})
}
}
func TestSelfHostSourceBeacon_OversizedBody(t *testing.T) {
rec := &captureRecorder{}
h := &Handler{Analytics: rec}
big := strings.Repeat("a", sourcebeacon.MaxBodyBytes+1)
resp := postBeacon(t, h, `{"v":1,"channels":["search"],"uid_hash":"`+big+`","instance_hash":"x"}`)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for oversized body", resp.Code)
}
if len(rec.events) != 0 {
t.Fatal("oversized body must capture 0 events")
}
}