mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +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>
194 lines
6.6 KiB
Go
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):
|
|
}
|
|
}
|