Files
multica/server/internal/sourcebeacon/sourcebeacon_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

194 lines
6.6 KiB
Go

package sourcebeacon
import (
"encoding/json"
"io"
"net/http"
"testing"
"time"
)
func TestShouldSend(t *testing.T) {
cases := []struct {
name string
in ShouldSendInput
want bool
}{
{"production self-host", ShouldSendInput{Environment: "production", AppHost: "acme.example.com"}, true},
{"analytics disabled", ShouldSendInput{AnalyticsDisabled: true, Environment: "production", AppHost: "acme.example.com"}, false},
{"not production", ShouldSendInput{Environment: "staging", AppHost: "acme.example.com"}, false},
{"dev", ShouldSendInput{Environment: "dev", AppHost: "acme.example.com"}, false},
{"localhost", ShouldSendInput{Environment: "production", AppHost: "localhost"}, false},
{"empty host", ShouldSendInput{Environment: "production", AppHost: ""}, false},
{"official multica.ai", ShouldSendInput{Environment: "production", AppHost: "multica.ai"}, false},
{"official app subdomain", ShouldSendInput{Environment: "production", AppHost: "app.multica.ai"}, false},
{"official staging subdomain", ShouldSendInput{Environment: "production", AppHost: "staging.multica.ai"}, false},
{"official api subdomain", ShouldSendInput{Environment: "production", AppHost: "api.multica.ai"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ShouldSend(tc.in); got != tc.want {
t.Fatalf("ShouldSend(%+v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestCanonicalHost(t *testing.T) {
cases := map[string]string{
"https://acme.example.com": "acme.example.com",
"http://localhost:3000": "localhost",
"acme.example.com": "acme.example.com",
"https://APP.MULTICA.AI/path": "app.multica.ai",
"": "",
"https://acme.example.com:8443": "acme.example.com",
}
for raw, want := range cases {
if got := canonicalHost(raw); got != want {
t.Errorf("canonicalHost(%q) = %q, want %q", raw, got, want)
}
}
}
func TestHashingIsDeterministicAndSalted(t *testing.T) {
uid1 := HashUID("salt-a", "user-1")
if uid1 != HashUID("salt-a", "user-1") {
t.Fatal("HashUID not deterministic")
}
if len(uid1) != 32 {
t.Fatalf("uid hash length = %d, want 32", len(uid1))
}
// Different salt (different instance) → different hash for same user.
if uid1 == HashUID("salt-b", "user-1") {
t.Fatal("same user across different salts must hash differently")
}
// Different user, same salt → different hash.
if uid1 == HashUID("salt-a", "user-2") {
t.Fatal("different users must hash differently")
}
// instance hash is stable and distinct from a uid hash.
if HashInstance("salt-a") != HashInstance("salt-a") {
t.Fatal("HashInstance not deterministic")
}
if HashInstance("salt-a") == uid1 {
t.Fatal("instance hash must differ from uid hash")
}
}
func TestEventUUIDDeterministicPerChannel(t *testing.T) {
a := EventUUID("inst", "uid", "social_youtube")
if a != EventUUID("inst", "uid", "social_youtube") {
t.Fatal("EventUUID not deterministic")
}
if a == EventUUID("inst", "uid", "search") {
t.Fatal("different channels must yield different event uuids")
}
if a == EventUUID("inst2", "uid", "social_youtube") {
t.Fatal("different instances must yield different event uuids")
}
if len(a) != 36 { // canonical UUID string
t.Fatalf("event uuid not canonical: %q", a)
}
}
func TestFilterValidChannels(t *testing.T) {
got := FilterValidChannels([]string{"social_youtube", "bogus", "search", "social_youtube", "", "other"})
want := []string{"social_youtube", "search", "other"}
if len(got) != len(want) {
t.Fatalf("FilterValidChannels = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("FilterValidChannels = %v, want %v", got, want)
}
}
if len(FilterValidChannels([]string{"nope", "also_nope"})) != 0 {
t.Fatal("all-invalid input must yield empty slice")
}
}
func TestIsValidHash(t *testing.T) {
if !IsValidHash(HashUID("s", "u")) {
t.Fatal("a real hash must validate")
}
for _, bad := range []string{"", "xyz", "SHORT", "ABCDEF0123456789", "g123456789012345"} {
if IsValidHash(bad) {
t.Errorf("IsValidHash(%q) = true, want false", bad)
}
}
}
// roundTripFunc adapts a function to http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSenderEnabledGating(t *testing.T) {
if NewSender(SenderConfig{Enabled: false, Salt: "s"}).Enabled() {
t.Fatal("Enabled:false must be disabled")
}
if NewSender(SenderConfig{Enabled: true, Salt: ""}).Enabled() {
t.Fatal("empty salt must be disabled")
}
if !NewSender(SenderConfig{Enabled: true, Salt: "s"}).Enabled() {
t.Fatal("enabled + salt must be enabled")
}
var nilSender *Sender
if nilSender.Enabled() {
t.Fatal("nil sender must be disabled")
}
}
func TestSenderMaybeSendPostsPayload(t *testing.T) {
got := make(chan Payload, 1)
gotURL := make(chan string, 1)
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(r.Body)
var p Payload
_ = json.Unmarshal(body, &p)
got <- p
gotURL <- r.URL.String()
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
})}
s := NewSender(SenderConfig{Enabled: true, Salt: "salt-x", UpstreamURL: "https://ingest.example", HTTPClient: client})
s.MaybeSend("user-1", []string{"social_youtube", "bogus", "search"})
select {
case p := <-got:
if p.V != SchemaVersion {
t.Errorf("v = %d, want %d", p.V, SchemaVersion)
}
wantChannels := []string{"social_youtube", "search"}
if len(p.Channels) != len(wantChannels) {
t.Fatalf("channels = %v, want %v", p.Channels, wantChannels)
}
if p.UIDHash != HashUID("salt-x", "user-1") || p.InstanceHash != HashInstance("salt-x") {
t.Error("payload hashes do not match expected salt hashing")
}
if u := <-gotURL; u != "https://ingest.example/api/telemetry/self-host-source" {
t.Errorf("url = %q", u)
}
case <-time.After(2 * time.Second):
t.Fatal("expected a beacon POST, got none")
}
}
func TestSenderMaybeSendNoOpCases(t *testing.T) {
calls := make(chan struct{}, 1)
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls <- struct{}{}
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
})}
s := NewSender(SenderConfig{Enabled: true, Salt: "salt-x", HTTPClient: client})
s.MaybeSend("user-1", []string{"bogus"}) // no valid channel
s.MaybeSend("", []string{"search"}) // no user id
select {
case <-calls:
t.Fatal("no beacon should have been sent")
case <-time.After(150 * time.Millisecond):
}
}