mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-10 16:22:26 +02:00
* 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>
130 lines
4.9 KiB
Go
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")
|
|
}
|
|
}
|