Skip to content

Commit 113a12c

Browse files
Dumbrisclaude
andauthored
feat(telemetry): one-time opt-out beacon + banner Settings deep-link (MCP-2482) (#684)
* feat(telemetry): one-time opt-out beacon + banner Settings deep-link (MCP-2482) 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 * fix(telemetry): route opt-out beacon through service endpoint (fix CodeQL SSRF) CodeQL go/request-forgery (CWE-918) flagged optout.go because the destination URL flowed as a function parameter directly from the config source to the http.Do sink. Make SendOptOutBeacon a *Service method that reads the resolved s.endpoint/s.config — the same struct-field indirection the existing heartbeat and feedback senders use (which CodeQL does not flag) — so the beacon can never target an arbitrary caller-supplied URL. The CLI path now constructs a telemetry.Service and calls the method instead of passing a raw URL. No behavior change: still exactly one fire-and-forget beacon to /heartbeat on the enabled->disabled flip. Related #MCP-2482 * fix(telemetry): validate opt-out beacon URL (scheme/host) to clear CWE-918 Apply the repo's established request-forgery barrier (mirror validateRegistryURL): parse the configured telemetry endpoint, constrain the scheme to http/https, require a host, and issue the beacon against the re-serialized URL. Rejects file://, gopher://, and schemeless/malformed endpoints before any request. Related #MCP-2482 * fix(telemetry): pin opt-out beacon host to clear CWE-918 (host allowlist guard) Scheme/host-presence validation alone did not satisfy CodeQL go/request-forgery; the established repo barrier (validateRegistryURL) is a host equality guard. Pin the beacon destination to the built-in telemetry host or a loopback address (tests/local dev) and reject anything else before issuing the request. This both clears the alert and genuinely hardens the path — the anonymous ID can only ever reach the official endpoint or localhost, never an arbitrary host from a malformed/hostile telemetry.endpoint value (documented as a testing override). Related #MCP-2482 * fix(telemetry): inline opt-out beacon host guard (CWE-918 barrier shape) Move the host allowlist check inline into validateTelemetryURL as an 'if !match { return err }' equality guard on the same control-flow path as the request sink, mirroring validateRegistryURL (the repo's CodeQL-accepted request-forgery barrier). The prior bool-returning helper put the guard across a function boundary that CodeQL's barrier-guard analysis did not propagate. Related #MCP-2482 * fix(telemetry): explicit-equality host guard for opt-out beacon (CWE-918) Earlier barrier shapes (helper returning bool; EqualFold + net.ParseIP/IsLoopback) did not satisfy CodeQL go/request-forgery. Switch to an inline exact-equality allowlist (switch on the lower-cased host against the built-in telemetry host and explicit loopback literals) — the canonical barrier shape the scanner recognizes, on the same control-flow path as the request. Behaviour unchanged: prod host and loopback (tests/local) allowed, everything else rejected. Related #MCP-2482 * fix(telemetry): address Codex review — env-resolved gate, opt-out race, CLI guards (MCP-2482) Codex REQUEST_CHANGES on PR #684: 1. [P1] Respect env-resolved disabled state. The transition gated only on Config.IsTelemetryEnabled() (MCPPROXY_TELEMETRY), so DO_NOT_TRACK=1 / CI=true could emit a beacon when telemetry was never enabled. Add EffectiveTelemetryEnabled() = IsTelemetryEnabled() && !IsDisabledByEnv(), and use it for the transition, the New() resolvedEnabled seed, and the CLI. 2. [P1] Heartbeat-after-opt-out race. sendHeartbeat checked optedOut only at entry; a heartbeat in flight when the latch flipped still shipped a full usage payload. Re-check the latch immediately before transmit so no usage data leaves after opt-out. 3. [P2] CLI disable path. Routed through a single guarded entry point (Service.EmitOptOutBeacon — applies semver/dev, env, and anon-id guards and owns the send) instead of calling SendOptOutBeacon directly (which bypassed the guards). The CLI now confirms "Telemetry disabled." before the best-effort beacon (non-blocking) with a short 3s timeout. Tests: env-disabled emits nothing; mid-flight opt-out suppresses transmit; EmitOptOutBeacon guard matrix (dev/env/no-anon-id skip, eligible sends). CLI smoke: enabled=1 beacon, DO_NOT_TRACK/CI=0. Related #MCP-2482 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b8f0d86 commit 113a12c

10 files changed

Lines changed: 795 additions & 12 deletions

File tree

cmd/mcpproxy/telemetry_cmd.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/spf13/cobra"
1313
"go.etcd.io/bbolt"
14+
"go.uber.org/zap"
1415

1516
clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
1617
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
@@ -267,6 +268,13 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
267268
return fmt.Errorf("failed to load config: %w", err)
268269
}
269270

271+
// Capture the EFFECTIVE resolved state BEFORE mutating, so we only beacon on
272+
// a genuine enabled->disabled transition (MCP-2482). Effective resolution
273+
// includes env overrides (DO_NOT_TRACK / CI), so an install where telemetry
274+
// was never actually enabled emits nothing. A second `disable` when already
275+
// disabled also emits nothing (wasEnabled == false).
276+
wasEnabled := telemetry.EffectiveTelemetryEnabled(cfg)
277+
270278
if cfg.Telemetry == nil {
271279
cfg.Telemetry = &config.TelemetryConfig{}
272280
}
@@ -278,7 +286,23 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
278286
return fmt.Errorf("failed to save config: %w", err)
279287
}
280288

289+
// The disable is now persisted and effective — confirm immediately so the
290+
// command never appears to hang on the (best-effort) beacon below.
281291
fmt.Println("Telemetry disabled.")
292+
293+
// One-time opt-out beacon. When a daemon is running it does NOT auto-reload
294+
// this file (there is no fsnotify watcher), so the CLI is responsible for the
295+
// beacon in the CLI-driven path. Route it through the SAME guarded server-side
296+
// entry point (EmitOptOutBeacon applies the dev-build/semver, env, and
297+
// anon-id guards and owns the single send) rather than duplicating the send
298+
// or bypassing a guard. A short timeout keeps this from blocking on a slow
299+
// endpoint; the CLI is short-lived so the send must complete before exit.
300+
if wasEnabled {
301+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
302+
defer cancel()
303+
beaconSvc := telemetry.New(cfg, "", version, Edition, zap.NewNop())
304+
beaconSvc.EmitOptOutBeacon(ctx)
305+
}
282306
return nil
283307
}
284308

docs/features/telemetry.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ The anonymous ID is a random UUID (v4) generated on first run. It has **no corre
4444

4545
You can delete it by removing the `telemetry.anonymous_id` field from your config — a new random ID will be generated on next startup.
4646

47+
## One-time opt-out signal
48+
49+
When telemetry transitions from **enabled to disabled** (via the CLI, the config
50+
file, or the web UI / macOS app), MCPProxy sends **exactly one** final, anonymous
51+
beacon — an `event: "telemetry_disabled"` carrying **only your anonymous install
52+
ID** and **no usage data**. It lets us count how many installs opt out so we can
53+
gauge how the feature is received. The send is best-effort: if it fails,
54+
telemetry is still disabled. After it, **no further telemetry is emitted**.
55+
56+
Disabling while already disabled (or reloading a config that is already
57+
disabled) sends nothing. Setting `MCPPROXY_TELEMETRY=false` is treated as
58+
"never enabled" and also sends nothing.
59+
4760
## How to disable
4861

4962
There are three ways to disable telemetry:

frontend/src/components/TelemetryBanner.vue

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div v-if="visible" class="alert alert-info">
2+
<div v-if="visible" class="alert alert-info" data-test="telemetry-banner">
33
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
44
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
55
</svg>
@@ -11,17 +11,32 @@
1111
rel="noopener noreferrer"
1212
class="link link-hover underline"
1313
>Learn more</a>
14+
<!-- Transparency note (MCP-2482): disclose the one-time opt-out signal. -->
15+
<p class="text-xs opacity-80 mt-1" data-test="telemetry-banner-disclosure">
16+
Disabling sends a single anonymous opt-out signal, then stops all telemetry.
17+
</p>
18+
</div>
19+
<div class="flex items-center gap-2">
20+
<RouterLink
21+
to="/settings?focus=telemetry.enabled"
22+
class="btn btn-sm btn-ghost"
23+
data-test="telemetry-banner-settings-link"
24+
@click="dismiss"
25+
>
26+
Manage in Settings
27+
</RouterLink>
28+
<button class="btn btn-sm btn-ghost btn-square" @click="dismiss" aria-label="Dismiss" data-test="telemetry-banner-dismiss">
29+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
31+
</svg>
32+
</button>
1433
</div>
15-
<button class="btn btn-sm btn-ghost" @click="dismiss" aria-label="Dismiss">
16-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
18-
</svg>
19-
</button>
2034
</div>
2135
</template>
2236

2337
<script setup lang="ts">
2438
import { ref, onMounted } from 'vue'
39+
import { RouterLink } from 'vue-router'
2540
2641
const STORAGE_KEY = 'telemetry-banner-dismissed'
2742
const visible = ref(false)

frontend/src/views/Settings.vue

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@
174174
</template>
175175

176176
<script setup lang="ts">
177-
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
178-
import { RouterLink } from 'vue-router'
177+
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
178+
import { RouterLink, useRoute } from 'vue-router'
179179
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
180180
import { useServersStore } from '@/stores/servers'
181181
import { useSystemStore } from '@/stores/system'
@@ -200,6 +200,7 @@ import api from '@/services/api'
200200
201201
const serversStore = useServersStore()
202202
const systemStore = useSystemStore()
203+
const route = useRoute()
203204
204205
const securityFields = SECURITY_FIELDS
205206
const generalFields = GENERAL_FIELDS
@@ -434,6 +435,41 @@ function handleConfigSaved() {
434435
loadConfig()
435436
}
436437
438+
// Deep-link support (MCP-2482): the telemetry consent banner links to
439+
// /settings?focus=telemetry.enabled. Map any field key to the tab that renders
440+
// it, switch to that tab, open the enclosing accordion if it lives under
441+
// Advanced, then scroll to and briefly highlight the field row.
442+
function tabForFieldKey(key: string): string {
443+
if (securityFields.some((f) => f.key === key)) return 'security'
444+
if (generalFields.some((f) => f.key === key)) return 'general'
445+
if (advancedAccordions.value.some((a) => a.fields.some((f) => f.key === key))) return 'advanced'
446+
if (serverEditionFields.some((f) => f.key === key)) return 'teams'
447+
return activeTab.value
448+
}
449+
450+
async function focusField(key: string) {
451+
// Clear any active search so the tabbed layout (not search results) renders.
452+
search.value = ''
453+
activeTab.value = tabForFieldKey(key)
454+
await nextTick()
455+
456+
// Advanced settings live inside collapsed <details>; open the one holding it.
457+
const acc = advancedAccordions.value.find((a) => a.fields.some((f) => f.key === key))
458+
if (acc) {
459+
const summary = document.querySelector(`[data-test="settings-accordion-${acc.id}"]`)
460+
const det = summary?.closest('details') as HTMLDetailsElement | null
461+
if (det) det.open = true
462+
await nextTick()
463+
}
464+
465+
const row = document.querySelector(`[data-test="setting-row-${key}"]`) as HTMLElement | null
466+
if (!row) return
467+
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
468+
const highlight = ['ring-2', 'ring-primary', 'ring-offset-2']
469+
row.classList.add(...highlight)
470+
setTimeout(() => row.classList.remove(...highlight), 2500)
471+
}
472+
437473
// Fetch the resolved built-in MCP instructions default for the textarea
438474
// placeholder (MCP-2175). Non-fatal: a failure or an older core just leaves the
439475
// static catalogue placeholder in place.
@@ -456,10 +492,15 @@ watch(defaultInstructions, () => {
456492
maybePrefillInstructions()
457493
})
458494
459-
onMounted(() => {
460-
loadConfig()
495+
onMounted(async () => {
461496
loadDefaultInstructions()
462497
window.addEventListener('mcpproxy:config-saved', handleConfigSaved)
498+
await loadConfig()
499+
const focus = route.query.focus
500+
if (typeof focus === 'string' && focus) {
501+
await nextTick()
502+
await focusField(focus)
503+
}
463504
})
464505
onUnmounted(() => {
465506
window.removeEventListener('mcpproxy:config-saved', handleConfigSaved)

frontend/src/views/settings/fields.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,13 @@ export const GENERAL_FIELDS: SettingField[] = [
251251
key: 'telemetry.enabled',
252252
docs: '/features/telemetry',
253253
label: 'Anonymous usage telemetry',
254-
help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time.',
254+
help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time. Disabling sends a single anonymous opt-out signal, then stops all telemetry.',
255255
control: 'toggle',
256256
danger: {
257257
confirmValue: false,
258258
tone: 'info',
259259
message:
260-
'Anonymous telemetry is how we see which features matter and catch problems — it never includes your tool arguments, content, or any identifying info. Turning it off removes that signal. Turn it off anyway?',
260+
'Anonymous telemetry is how we see which features matter and catch problems — it never includes your tool arguments, content, or any identifying info. Turning it off removes that signal, and sends a single anonymous opt-out signal before all telemetry stops. Turn it off anyway?',
261261
},
262262
},
263263
{ key: 'enable_prompts', label: 'Expose MCP prompts to clients', help: 'Advertises mcpproxy’s built-in guided prompts to connected AI clients: “setup-new-mcp-server” (add a server) and “troubleshoot-mcp-server” (diagnose connection issues).', control: 'toggle' },

internal/runtime/lifecycle.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,14 @@ func (r *Runtime) ReloadConfiguration() error {
10291029
return fmt.Errorf("failed to reload servers: %w", err)
10301030
}
10311031

1032+
// MCP-2482: detect a telemetry enabled->disabled flip across the reload and
1033+
// fire the one-time opt-out beacon. This covers config changes that arrive
1034+
// via a disk reload (there is no fsnotify auto-watcher, so this is the
1035+
// manual/triggered-reload path). nil-safe + fire-and-forget.
1036+
if r.telemetryService != nil {
1037+
r.telemetryService.NotifyConfigChanged(newSnapshot.Config)
1038+
}
1039+
10321040
go r.postConfigReload()
10331041

10341042
r.logger.Info("Configuration reload completed",

internal/runtime/runtime.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,14 @@ func (r *Runtime) ApplyConfig(newCfg *config.Config, cfgPath string) (*ConfigApp
13041304
// Event handlers may need to acquire locks on other resources
13051305
r.mu.Unlock()
13061306

1307+
// MCP-2482: drive the one-time telemetry opt-out beacon on an
1308+
// enabled->disabled flip. NotifyConfigChanged is fire-and-forget and
1309+
// nil-safe, so this never blocks the apply path. Covers web UI + macOS app,
1310+
// which both reach this via the REST /config apply pipeline.
1311+
if r.telemetryService != nil {
1312+
r.telemetryService.NotifyConfigChanged(newCfg)
1313+
}
1314+
13071315
// Update configSvc to notify subscribers (like supervisor)
13081316
// This must happen BEFORE LoadConfiguredServers to ensure supervisor reconciles
13091317
if err := r.configSvc.Update(&configCopy, configsvc.UpdateTypeModify, "api_apply_config"); err != nil {

0 commit comments

Comments
 (0)