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

152 lines
5.9 KiB
Go

package handler
import (
"net/http"
"net/url"
"os"
"strings"
"github.com/multica-ai/multica/server/internal/analytics"
)
type AppConfig struct {
CdnDomain string `json:"cdn_domain"`
// CdnSigned tells clients that the CDN domain above serves PRIVATE
// content through time-bounded signed URLs (CloudFront signing is
// enabled). When true, a raw storage URL on the CDN domain is NOT
// publicly fetchable — renderers must not pick it as a native
// <img>/<video> source and should fall back to the per-attachment
// API endpoint or a freshly signed download_url instead (MUL-3254).
// Omitted when false so older clients see the previous shape.
CdnSigned bool `json:"cdn_signed,omitempty"`
// Public auth config consumed by the web app at runtime so self-hosted
// deployments do not need to rebuild the frontend image when operators
// toggle signup or wire Google OAuth.
AllowSignup bool `json:"allow_signup"`
GoogleClientID string `json:"google_client_id,omitempty"`
// WorkspaceCreationDisabled mirrors the server-side
// DISABLE_WORKSPACE_CREATION env var so the UI can hide every
// "Create workspace" affordance on self-hosted instances. Omitted
// from the JSON when false to keep responses identical to the
// previous shape for the common managed-cloud case (#3433).
WorkspaceCreationDisabled bool `json:"workspace_creation_disabled,omitempty"`
// Public daemon setup config consumed by the web app at runtime so
// self-hosted instances can show `multica setup self-host` commands
// with the operator's own domains instead of Multica Cloud defaults.
DaemonServerURL string `json:"daemon_server_url,omitempty"`
DaemonAppURL string `json:"daemon_app_url,omitempty"`
// PostHog public config for the frontend. The key is the same Project
// API Key the backend uses; returning it here (instead of baking it
// into the frontend bundle via NEXT_PUBLIC_*) means self-hosted
// instances — whose server returns an empty key — automatically
// disable frontend event shipping too.
PosthogKey string `json:"posthog_key"`
PosthogHost string `json:"posthog_host"`
AnalyticsEnvironment string `json:"analytics_environment"`
// SelfHostSourceNotice is true when this deployment will ship the
// anonymous onboarding source beacon (MUL-3708) — i.e. a production
// self-host. The onboarding source step shows the "anonymous
// collection" notice only when this is true. Omitted (false) for the
// common official-cloud case so responses stay identical there.
SelfHostSourceNotice bool `json:"self_host_source_notice,omitempty"`
}
// GetConfig is mounted on the public (unauthenticated) route group because
// the web app calls it before login to decide whether to render the Google
// sign-in button and signup UI. Only add fields here that are safe to expose
// to anonymous callers — never user- or tenant-scoped data.
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
config := AppConfig{
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
WorkspaceCreationDisabled: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
}
if h.Storage != nil {
config.CdnDomain = h.Storage.CdnDomain()
}
config.CdnSigned = h.CFSigner != nil
config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv()
// Mirror the source-beacon enablement so the onboarding source step
// shows the anonymous-collection notice exactly when (and only when)
// this deployment will actually ship the beacon. Nil-safe.
config.SelfHostSourceNotice = h.SourceBeacon.Enabled()
// Re-read from env on every request so operators can rotate keys via
// secret refresh without a server restart.
if v := os.Getenv("ANALYTICS_DISABLED"); v != "true" && v != "1" {
config.PosthogKey = os.Getenv("POSTHOG_API_KEY")
config.PosthogHost = os.Getenv("POSTHOG_HOST")
config.AnalyticsEnvironment = analytics.EnvironmentFromEnv()
if config.PosthogHost == "" && config.PosthogKey != "" {
config.PosthogHost = "https://us.i.posthog.com"
}
}
writeJSON(w, http.StatusOK, config)
}
func daemonSetupURLsFromEnv() (string, string) {
serverURL := normalizePublicURL(os.Getenv("MULTICA_PUBLIC_URL"))
appURL := normalizePublicURL(os.Getenv("MULTICA_APP_URL"))
if appURL == "" {
appURL = normalizePublicURL(os.Getenv("FRONTEND_ORIGIN"))
}
if appURL == "" {
return "", ""
}
if serverURL == "" {
serverURL = appURL
}
if isOfficialCloudDaemonConfig(appURL) {
return "", ""
}
return serverURL, appURL
}
func normalizePublicURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
// isOfficialCloudDaemonConfig reports whether this deployment is the official
// Multica Cloud, identified by its frontend host alone (multica.ai /
// app.multica.ai). The daemon setup for the managed cloud is always
// `multica setup` (which hardcodes api.multica.ai), so the per-deployment URLs
// must be omitted from /api/config even when MULTICA_PUBLIC_URL is unset or
// misconfigured. Previously this also required serverURL==api.multica.ai, so a
// cloud deployment that forgot MULTICA_PUBLIC_URL fell through and emitted a
// `setup self-host --server-url https://multica.ai` command — pointing the
// daemon's backend at the frontend (no /health, no WebSocket proxy).
func isOfficialCloudDaemonConfig(appURL string) bool {
return urlHostEquals(appURL, "multica.ai") || urlHostEquals(appURL, "app.multica.ai")
}
func urlHostEquals(raw, want string) bool {
host := canonicalURLHost(raw)
if host == "" {
return false
}
want = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(want)), ".")
return host == want
}
func canonicalURLHost(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil {
return ""
}
host := u.Hostname()
if host == "" && !strings.Contains(raw, "://") {
u, err = url.Parse("https://" + raw)
if err != nil {
return ""
}
host = u.Hostname()
}
return strings.TrimSuffix(strings.ToLower(host), ".")
}