Skip to content

Commit 0c7ba6c

Browse files
committed
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
1 parent 9794dfe commit 0c7ba6c

10 files changed

Lines changed: 543 additions & 12 deletions

File tree

cmd/mcpproxy/telemetry_cmd.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"net/http"
78
"os"
89
"path/filepath"
910
"strings"
@@ -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 resolved state BEFORE mutating, so we can detect a genuine
272+
// enabled->disabled transition (MCP-2482). A second `disable` when already
273+
// disabled must not emit another beacon.
274+
wasEnabled := cfg.IsTelemetryEnabled()
275+
anonID := cfg.GetAnonymousID()
276+
endpoint := cfg.GetTelemetryEndpoint()
277+
270278
if cfg.Telemetry == nil {
271279
cfg.Telemetry = &config.TelemetryConfig{}
272280
}
@@ -278,6 +286,21 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
278286
return fmt.Errorf("failed to save config: %w", err)
279287
}
280288

289+
// One-time opt-out beacon: best-effort, fire on the real enabled->disabled
290+
// flip. When a daemon is running it does NOT auto-reload this file (there is
291+
// no fsnotify watcher), so the CLI is responsible for the beacon in the
292+
// CLI-driven path. If there is no anonymous ID there is nothing to dedup on.
293+
if wasEnabled && anonID != "" {
294+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
295+
defer cancel()
296+
client := &http.Client{Timeout: 5 * time.Second}
297+
if beaconErr := telemetry.SendOptOutBeacon(ctx, client, endpoint, anonID); beaconErr != nil {
298+
// Best-effort only — telemetry is already disabled on disk. Surface
299+
// at a low level so scripts aren't tripped up.
300+
fmt.Println("Note: opt-out signal could not be delivered (telemetry is still disabled).")
301+
}
302+
}
303+
281304
fmt.Println("Telemetry disabled.")
282305
return nil
283306
}

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, onMounted, onUnmounted } from 'vue'
178-
import { RouterLink } from 'vue-router'
177+
import { ref, reactive, computed, 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'
@@ -199,6 +199,7 @@ import api from '@/services/api'
199199
200200
const serversStore = useServersStore()
201201
const systemStore = useSystemStore()
202+
const route = useRoute()
202203
203204
const securityFields = SECURITY_FIELDS
204205
const generalFields = GENERAL_FIELDS
@@ -418,6 +419,41 @@ function handleConfigSaved() {
418419
loadConfig()
419420
}
420421
422+
// Deep-link support (MCP-2482): the telemetry consent banner links to
423+
// /settings?focus=telemetry.enabled. Map any field key to the tab that renders
424+
// it, switch to that tab, open the enclosing accordion if it lives under
425+
// Advanced, then scroll to and briefly highlight the field row.
426+
function tabForFieldKey(key: string): string {
427+
if (securityFields.some((f) => f.key === key)) return 'security'
428+
if (generalFields.some((f) => f.key === key)) return 'general'
429+
if (advancedAccordions.value.some((a) => a.fields.some((f) => f.key === key))) return 'advanced'
430+
if (serverEditionFields.some((f) => f.key === key)) return 'teams'
431+
return activeTab.value
432+
}
433+
434+
async function focusField(key: string) {
435+
// Clear any active search so the tabbed layout (not search results) renders.
436+
search.value = ''
437+
activeTab.value = tabForFieldKey(key)
438+
await nextTick()
439+
440+
// Advanced settings live inside collapsed <details>; open the one holding it.
441+
const acc = advancedAccordions.value.find((a) => a.fields.some((f) => f.key === key))
442+
if (acc) {
443+
const summary = document.querySelector(`[data-test="settings-accordion-${acc.id}"]`)
444+
const det = summary?.closest('details') as HTMLDetailsElement | null
445+
if (det) det.open = true
446+
await nextTick()
447+
}
448+
449+
const row = document.querySelector(`[data-test="setting-row-${key}"]`) as HTMLElement | null
450+
if (!row) return
451+
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
452+
const highlight = ['ring-2', 'ring-primary', 'ring-offset-2']
453+
row.classList.add(...highlight)
454+
setTimeout(() => row.classList.remove(...highlight), 2500)
455+
}
456+
421457
// Fetch the resolved built-in MCP instructions default for the textarea
422458
// placeholder (MCP-2175). Non-fatal: a failure or an older core just leaves the
423459
// static catalogue placeholder in place.
@@ -432,10 +468,15 @@ async function loadDefaultInstructions() {
432468
}
433469
}
434470
435-
onMounted(() => {
436-
loadConfig()
471+
onMounted(async () => {
437472
loadDefaultInstructions()
438473
window.addEventListener('mcpproxy:config-saved', handleConfigSaved)
474+
await loadConfig()
475+
const focus = route.query.focus
476+
if (typeof focus === 'string' && focus) {
477+
await nextTick()
478+
await focusField(focus)
479+
}
439480
})
440481
onUnmounted(() => {
441482
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
@@ -250,13 +250,13 @@ export const GENERAL_FIELDS: SettingField[] = [
250250
key: 'telemetry.enabled',
251251
docs: '/features/telemetry',
252252
label: 'Anonymous usage telemetry',
253-
help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time.',
253+
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.',
254254
control: 'toggle',
255255
danger: {
256256
confirmValue: false,
257257
tone: 'info',
258258
message:
259-
'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?',
259+
'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?',
260260
},
261261
},
262262
{ 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 {

internal/telemetry/optout.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package telemetry
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
11+
"go.uber.org/zap"
12+
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
14+
)
15+
16+
// OptOutEvent is the distinguishing field value carried by the one-time opt-out
17+
// beacon. Receivers route a payload to the opt-out funnel by the presence of
18+
// this `event` value on the existing /heartbeat ingest path — no new endpoint
19+
// is required (MCP-2482).
20+
const OptOutEvent = "telemetry_disabled"
21+
22+
// OptOutBeacon is the minimal payload sent exactly once when a user disables
23+
// telemetry. It carries ONLY the anonymous install ID (for unique opt-out
24+
// counting / dedup) and the distinguishing event marker. It deliberately omits
25+
// every usage field — sending data after an opt-out is only defensible because
26+
// this payload contains nothing but the dedup ID.
27+
type OptOutBeacon struct {
28+
Event string `json:"event"`
29+
AnonymousID string `json:"anonymous_id"`
30+
}
31+
32+
// TelemetryDisableTransition reports whether the resolved telemetry state moved
33+
// from enabled to disabled between prior and next. "Resolved" follows
34+
// Config.IsTelemetryEnabled — a nil Telemetry block or a nil Enabled pointer
35+
// resolves to enabled (telemetry is opt-out), per MCP-2477. This is the single
36+
// source of truth for the enabled->disabled flip, shared by the running daemon
37+
// (REST / reload paths) and the `mcpproxy telemetry disable` CLI.
38+
func TelemetryDisableTransition(prior, next *config.Config) bool {
39+
if prior == nil || next == nil {
40+
return false
41+
}
42+
return prior.IsTelemetryEnabled() && !next.IsTelemetryEnabled()
43+
}
44+
45+
// SendOptOutBeacon posts a single opt-out beacon to the telemetry endpoint,
46+
// reusing the existing /heartbeat ingest path. It is best-effort: callers MUST
47+
// disable telemetry regardless of the returned error. The caller supplies the
48+
// context (with its own short timeout) so the send never blocks a config save.
49+
func SendOptOutBeacon(ctx context.Context, client *http.Client, endpoint, anonymousID string) error {
50+
if anonymousID == "" {
51+
// Nothing to dedup on — never send an identity-less beacon.
52+
return errors.New("opt-out beacon skipped: no anonymous_id")
53+
}
54+
if client == nil {
55+
client = http.DefaultClient
56+
}
57+
58+
beacon := OptOutBeacon{Event: OptOutEvent, AnonymousID: anonymousID}
59+
data, err := json.Marshal(beacon)
60+
if err != nil {
61+
return fmt.Errorf("marshal opt-out beacon: %w", err)
62+
}
63+
64+
// Defense-in-depth: the same anonymity scanner that guards heartbeats also
65+
// guards the beacon. The payload is a constant + a UUID, so this is belt-
66+
// and-suspenders, but it keeps a single invariant for everything we emit.
67+
if scanErr := ScanForPII(data); scanErr != nil {
68+
return fmt.Errorf("opt-out beacon failed anonymity scan: %w", scanErr)
69+
}
70+
71+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/heartbeat", bytes.NewReader(data))
72+
if err != nil {
73+
return fmt.Errorf("build opt-out request: %w", err)
74+
}
75+
req.Header.Set("Content-Type", "application/json")
76+
77+
resp, err := client.Do(req)
78+
if err != nil {
79+
return fmt.Errorf("send opt-out beacon: %w", err)
80+
}
81+
defer resp.Body.Close()
82+
if resp.StatusCode/100 != 2 {
83+
return fmt.Errorf("opt-out beacon rejected with status %d", resp.StatusCode)
84+
}
85+
return nil
86+
}
87+
88+
// NotifyConfigChanged informs the telemetry service that the live configuration
89+
// has been swapped. It is the single server-side hook for the opt-out beacon:
90+
// the running daemon calls it from every config-write path (REST apply, disk
91+
// reload) so web UI, macOS app, and CLI-driven changes are all covered by one
92+
// implementation.
93+
//
94+
// On a resolved enabled->disabled transition it:
95+
// 1. immediately marks telemetry opted-out (no further heartbeats are sent),
96+
// 2. fires exactly one fire-and-forget opt-out beacon with a short timeout.
97+
//
98+
// The send is best-effort: a failure does not re-enable telemetry. On a
99+
// disabled->enabled transition it clears the opt-out latch so a later disable
100+
// flip emits its own beacon (exactly once per flip). It never blocks the caller.
101+
func (s *Service) NotifyConfigChanged(newCfg *config.Config) {
102+
if newCfg == nil {
103+
return
104+
}
105+
106+
s.mu.Lock()
107+
prior := s.resolvedEnabled
108+
next := newCfg.IsTelemetryEnabled()
109+
s.resolvedEnabled = next
110+
// Keep the service's live config/endpoint current. ApplyConfig swaps the
111+
// runtime's *config.Config pointer wholesale, so without this the service
112+
// would read a stale snapshot (see the stale-config-snapshot pitfall).
113+
s.config = newCfg
114+
s.endpoint = newCfg.GetTelemetryEndpoint()
115+
transition := prior && !next
116+
s.mu.Unlock()
117+
118+
if !transition {
119+
// Re-enabling clears the latch so a future disable flip can fire again.
120+
if !prior && next {
121+
s.optedOut.Store(false)
122+
}
123+
return
124+
}
125+
126+
// Stop all further telemetry immediately, before the (slower) network send.
127+
s.optedOut.Store(true)
128+
129+
// Dev builds never emit telemetry; don't emit a beacon for them either.
130+
if !isValidSemver(s.version) {
131+
return
132+
}
133+
anonID := newCfg.GetAnonymousID()
134+
if anonID == "" {
135+
return
136+
}
137+
138+
endpoint := s.endpoint
139+
client := s.client
140+
logger := s.logger
141+
go func() {
142+
ctx, cancel := context.WithTimeout(context.Background(), optOutBeaconTimeout)
143+
defer cancel()
144+
if err := SendOptOutBeacon(ctx, client, endpoint, anonID); err != nil {
145+
logger.Debug("opt-out beacon send failed (telemetry still disabled)", zap.Error(err))
146+
}
147+
}()
148+
}

0 commit comments

Comments
 (0)