diff --git a/cmd/mcpproxy/telemetry_cmd.go b/cmd/mcpproxy/telemetry_cmd.go index 88a74d42..8623c6d3 100644 --- a/cmd/mcpproxy/telemetry_cmd.go +++ b/cmd/mcpproxy/telemetry_cmd.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "go.etcd.io/bbolt" + "go.uber.org/zap" clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" @@ -267,6 +268,13 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to load config: %w", err) } + // Capture the EFFECTIVE resolved state BEFORE mutating, so we only beacon on + // a genuine enabled->disabled transition (MCP-2482). Effective resolution + // includes env overrides (DO_NOT_TRACK / CI), so an install where telemetry + // was never actually enabled emits nothing. A second `disable` when already + // disabled also emits nothing (wasEnabled == false). + wasEnabled := telemetry.EffectiveTelemetryEnabled(cfg) + if cfg.Telemetry == nil { cfg.Telemetry = &config.TelemetryConfig{} } @@ -278,7 +286,23 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to save config: %w", err) } + // The disable is now persisted and effective — confirm immediately so the + // command never appears to hang on the (best-effort) beacon below. fmt.Println("Telemetry disabled.") + + // One-time opt-out beacon. 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. Route it through the SAME guarded server-side + // entry point (EmitOptOutBeacon applies the dev-build/semver, env, and + // anon-id guards and owns the single send) rather than duplicating the send + // or bypassing a guard. A short timeout keeps this from blocking on a slow + // endpoint; the CLI is short-lived so the send must complete before exit. + if wasEnabled { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + beaconSvc := telemetry.New(cfg, "", version, Edition, zap.NewNop()) + beaconSvc.EmitOptOutBeacon(ctx) + } return nil } diff --git a/docs/features/telemetry.md b/docs/features/telemetry.md index 9fddcf5a..f4351117 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 59c52e0b..29474dca 100644 --- a/frontend/src/components/TelemetryBanner.vue +++ b/frontend/src/components/TelemetryBanner.vue @@ -1,5 +1,5 @@