From 1c37ae4e4e5dde72bb736dc4682904120a445ff3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 15 Jun 2026 14:31:48 +0300 Subject: [PATCH 1/7] feat(telemetry): one-time opt-out beacon + banner Settings deep-link (MCP-2482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B — server-side opt-out beacon: - Add internal/telemetry/optout.go: TelemetryDisableTransition (resolved-state compare, nil == enabled per MCP-2477), SendOptOutBeacon (reuses the existing /heartbeat ingest with event:"telemetry_disabled" + only the anonymous_id, no usage payload), and Service.NotifyConfigChanged. - Detect the enabled->disabled flip on config write and fire EXACTLY ONE best-effort, fire-and-forget beacon; latch optedOut so no further heartbeats are emitted. Re-enable clears the latch (exactly once per flip). - Wire NotifyConfigChanged into the daemon's REST apply path (runtime.ApplyConfig) and disk-reload path (ReloadConfiguration) so web UI + macOS app are covered, and into `mcpproxy telemetry disable` so the CLI path is covered (no fsnotify watcher means the daemon won't auto-reload the file). Send failure still disables; disabled->disabled / env-disabled emit nothing. Part A — banner deep-link + disclosure: - TelemetryBanner: add "Manage in Settings" link to /settings?focus=telemetry.enabled and a transparency note about the one-time opt-out signal. - Settings.vue: focusField() resolves a field key to its tab, opens the enclosing accordion, scrolls to and highlights the row. - Disclosure line added near the telemetry toggle (field help + confirm copy). Docs: document the one-time opt-out signal in docs/features/telemetry.md. Related #MCP-2482 --- cmd/mcpproxy/telemetry_cmd.go | 23 ++ docs/features/telemetry.md | 13 + frontend/src/components/TelemetryBanner.vue | 27 ++- frontend/src/views/Settings.vue | 49 +++- frontend/src/views/settings/fields.ts | 4 +- internal/runtime/lifecycle.go | 8 + internal/runtime/runtime.go | 8 + internal/telemetry/optout.go | 148 ++++++++++++ internal/telemetry/optout_test.go | 250 ++++++++++++++++++++ internal/telemetry/telemetry.go | 25 ++ 10 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 internal/telemetry/optout.go create mode 100644 internal/telemetry/optout_test.go diff --git a/cmd/mcpproxy/telemetry_cmd.go b/cmd/mcpproxy/telemetry_cmd.go index 88a74d427..5d14e629f 100644 --- a/cmd/mcpproxy/telemetry_cmd.go +++ b/cmd/mcpproxy/telemetry_cmd.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "path/filepath" "strings" @@ -267,6 +268,13 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to load config: %w", err) } + // Capture the resolved state BEFORE mutating, so we can detect a genuine + // enabled->disabled transition (MCP-2482). A second `disable` when already + // disabled must not emit another beacon. + wasEnabled := cfg.IsTelemetryEnabled() + anonID := cfg.GetAnonymousID() + endpoint := cfg.GetTelemetryEndpoint() + if cfg.Telemetry == nil { cfg.Telemetry = &config.TelemetryConfig{} } @@ -278,6 +286,21 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to save config: %w", err) } + // One-time opt-out beacon: best-effort, fire on the real enabled->disabled + // flip. When a daemon is running it does NOT auto-reload this file (there is + // no fsnotify watcher), so the CLI is responsible for the beacon in the + // CLI-driven path. If there is no anonymous ID there is nothing to dedup on. + if wasEnabled && anonID != "" { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := &http.Client{Timeout: 5 * time.Second} + if beaconErr := telemetry.SendOptOutBeacon(ctx, client, endpoint, anonID); beaconErr != nil { + // Best-effort only — telemetry is already disabled on disk. Surface + // at a low level so scripts aren't tripped up. + fmt.Println("Note: opt-out signal could not be delivered (telemetry is still disabled).") + } + } + fmt.Println("Telemetry disabled.") return nil } diff --git a/docs/features/telemetry.md b/docs/features/telemetry.md index 9fddcf5aa..f4351117e 100644 --- a/docs/features/telemetry.md +++ b/docs/features/telemetry.md @@ -44,6 +44,19 @@ The anonymous ID is a random UUID (v4) generated on first run. It has **no corre You can delete it by removing the `telemetry.anonymous_id` field from your config — a new random ID will be generated on next startup. +## One-time opt-out signal + +When telemetry transitions from **enabled to disabled** (via the CLI, the config +file, or the web UI / macOS app), MCPProxy sends **exactly one** final, anonymous +beacon — an `event: "telemetry_disabled"` carrying **only your anonymous install +ID** and **no usage data**. It lets us count how many installs opt out so we can +gauge how the feature is received. The send is best-effort: if it fails, +telemetry is still disabled. After it, **no further telemetry is emitted**. + +Disabling while already disabled (or reloading a config that is already +disabled) sends nothing. Setting `MCPPROXY_TELEMETRY=false` is treated as +"never enabled" and also sends nothing. + ## How to disable There are three ways to disable telemetry: diff --git a/frontend/src/components/TelemetryBanner.vue b/frontend/src/components/TelemetryBanner.vue index 59c52e0b7..29474dca0 100644 --- a/frontend/src/components/TelemetryBanner.vue +++ b/frontend/src/components/TelemetryBanner.vue @@ -1,5 +1,5 @@