Skip to content

Commit ff6906a

Browse files
committed
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
1 parent 3c67b29 commit ff6906a

4 files changed

Lines changed: 214 additions & 38 deletions

File tree

cmd/mcpproxy/telemetry_cmd.go

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,12 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
268268
return fmt.Errorf("failed to load config: %w", err)
269269
}
270270

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()
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)
276277

277278
if cfg.Telemetry == nil {
278279
cfg.Telemetry = &config.TelemetryConfig{}
@@ -285,24 +286,23 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
285286
return fmt.Errorf("failed to save config: %w", err)
286287
}
287288

288-
// One-time opt-out beacon: best-effort, fire on the real enabled->disabled
289-
// flip. When a daemon is running it does NOT auto-reload this file (there is
290-
// no fsnotify watcher), so the CLI is responsible for the beacon in the
291-
// CLI-driven path. If there is no anonymous ID there is nothing to dedup on.
292-
if wasEnabled && anonID != "" {
293-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
289+
// The disable is now persisted and effective — confirm immediately so the
290+
// command never appears to hang on the (best-effort) beacon below.
291+
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)
294302
defer cancel()
295-
// Reuse the telemetry service's own sender so the destination is the
296-
// resolved telemetry endpoint (never an arbitrary caller-supplied URL).
297-
beaconSvc := telemetry.New(cfg, "", "", "", zap.NewNop())
298-
if beaconErr := beaconSvc.SendOptOutBeacon(ctx); beaconErr != nil {
299-
// Best-effort only — telemetry is already disabled on disk. Surface
300-
// at a low level so scripts aren't tripped up.
301-
fmt.Println("Note: opt-out signal could not be delivered (telemetry is still disabled).")
302-
}
303+
beaconSvc := telemetry.New(cfg, "", version, Edition, zap.NewNop())
304+
beaconSvc.EmitOptOutBeacon(ctx)
303305
}
304-
305-
fmt.Println("Telemetry disabled.")
306306
return nil
307307
}
308308

internal/telemetry/optout.go

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,61 @@ type OptOutBeacon struct {
3131
AnonymousID string `json:"anonymous_id"`
3232
}
3333

34+
// EffectiveTelemetryEnabled resolves whether telemetry is ACTUALLY active for
35+
// cfg, honoring both the config bool (IsTelemetryEnabled — nil means enabled per
36+
// MCP-2477) AND the startup env overrides (DO_NOT_TRACK / CI / MCPPROXY_TELEMETRY
37+
// via IsDisabledByEnv). This must match the same effective resolution startup
38+
// uses: telemetry that was never enabled (e.g. DO_NOT_TRACK=1 or CI=true) must
39+
// never be considered "enabled", so disabling it produces no opt-out beacon.
40+
func EffectiveTelemetryEnabled(cfg *config.Config) bool {
41+
if cfg == nil {
42+
return false
43+
}
44+
if disabled, _ := IsDisabledByEnv(); disabled {
45+
return false
46+
}
47+
return cfg.IsTelemetryEnabled()
48+
}
49+
3450
// TelemetryDisableTransition reports whether the resolved telemetry state moved
35-
// from enabled to disabled between prior and next. "Resolved" follows
36-
// Config.IsTelemetryEnabled — a nil Telemetry block or a nil Enabled pointer
37-
// resolves to enabled (telemetry is opt-out), per MCP-2477. This is the single
51+
// from enabled to disabled between prior and next, using the SAME effective
52+
// resolution as startup (config bool + env overrides). This is the single
3853
// source of truth for the enabled->disabled flip, shared by the running daemon
3954
// (REST / reload paths) and the `mcpproxy telemetry disable` CLI.
4055
func TelemetryDisableTransition(prior, next *config.Config) bool {
4156
if prior == nil || next == nil {
4257
return false
4358
}
44-
return prior.IsTelemetryEnabled() && !next.IsTelemetryEnabled()
59+
return EffectiveTelemetryEnabled(prior) && !EffectiveTelemetryEnabled(next)
60+
}
61+
62+
// EmitOptOutBeacon applies the shared eligibility guards and, if eligible, sends
63+
// the opt-out beacon synchronously. It is the single guarded send entry point
64+
// used by BOTH the daemon (fire-and-forget in a goroutine from
65+
// NotifyConfigChanged) and the `mcpproxy telemetry disable` CLI — so neither
66+
// duplicates the send nor bypasses a guard.
67+
//
68+
// Guards (all must hold, mirroring the heartbeat eligibility):
69+
// - valid semver build (dev builds never emit telemetry),
70+
// - telemetry not disabled by environment (DO_NOT_TRACK / CI / MCPPROXY_TELEMETRY),
71+
// - an anonymous install ID exists (nothing to dedup on otherwise).
72+
//
73+
// Returns true if a send was attempted (regardless of HTTP outcome). Best-effort:
74+
// callers disable telemetry whether or not this succeeds.
75+
func (s *Service) EmitOptOutBeacon(ctx context.Context) bool {
76+
if !isValidSemver(s.version) {
77+
return false
78+
}
79+
if disabled, _ := IsDisabledByEnv(); disabled {
80+
return false
81+
}
82+
if s.config.GetAnonymousID() == "" {
83+
return false
84+
}
85+
if err := s.SendOptOutBeacon(ctx); err != nil {
86+
s.logger.Debug("opt-out beacon send failed (telemetry still disabled)", zap.Error(err))
87+
}
88+
return true
4589
}
4690

4791
// SendOptOutBeacon posts a single opt-out beacon to the configured telemetry
@@ -160,7 +204,10 @@ func (s *Service) NotifyConfigChanged(newCfg *config.Config) {
160204

161205
s.mu.Lock()
162206
prior := s.resolvedEnabled
163-
next := newCfg.IsTelemetryEnabled()
207+
// Use the SAME effective resolution as startup (config bool + env overrides)
208+
// so an env-disabled install (DO_NOT_TRACK / CI) — which never emitted
209+
// telemetry — never produces an opt-out beacon on a config flip.
210+
next := EffectiveTelemetryEnabled(newCfg)
164211
s.resolvedEnabled = next
165212
// Keep the service's live config/endpoint current. ApplyConfig swaps the
166213
// runtime's *config.Config pointer wholesale, so without this the service
@@ -181,19 +228,11 @@ func (s *Service) NotifyConfigChanged(newCfg *config.Config) {
181228
// Stop all further telemetry immediately, before the (slower) network send.
182229
s.optedOut.Store(true)
183230

184-
// Dev builds never emit telemetry; don't emit a beacon for them either.
185-
if !isValidSemver(s.version) {
186-
return
187-
}
188-
if newCfg.GetAnonymousID() == "" {
189-
return
190-
}
191-
231+
// Fire-and-forget through the shared guarded entry point (semver / env /
232+
// anon-id guards live there). Never blocks the caller (the config save).
192233
go func() {
193234
ctx, cancel := context.WithTimeout(context.Background(), optOutBeaconTimeout)
194235
defer cancel()
195-
if err := s.SendOptOutBeacon(ctx); err != nil {
196-
s.logger.Debug("opt-out beacon send failed (telemetry still disabled)", zap.Error(err))
197-
}
236+
s.EmitOptOutBeacon(ctx)
198237
}()
199238
}

internal/telemetry/optout_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,131 @@ func TestOptOut_StopsFurtherHeartbeats(t *testing.T) {
272272
t.Fatalf("heartbeat emitted after opt-out: hits went %d -> %d", hitsAfterBeacon, got)
273273
}
274274
}
275+
276+
// hookStats is a RuntimeStats whose GetServerCount fires a hook, letting a test
277+
// flip state DURING buildHeartbeat to exercise the in-flight opt-out race.
278+
type hookStats struct {
279+
onServerCount func()
280+
}
281+
282+
func (h *hookStats) GetServerCount() int {
283+
if h.onServerCount != nil {
284+
h.onServerCount()
285+
}
286+
return 1
287+
}
288+
func (h *hookStats) GetConnectedServerCount() int { return 0 }
289+
func (h *hookStats) GetToolCount() int { return 0 }
290+
func (h *hookStats) GetRoutingMode() string { return "retrieve_tools" }
291+
func (h *hookStats) IsQuarantineEnabled() bool { return false }
292+
func (h *hookStats) IsDockerAvailable() bool { return false }
293+
func (h *hookStats) GetDockerIsolatedServerCount() int { return 0 }
294+
295+
// TestSendHeartbeat_RechecksOptOutBeforeTransmit covers Codex fix #2: a heartbeat
296+
// already in flight when the opt-out latch flips must NOT transmit a usage
297+
// payload. The latch is flipped mid-buildHeartbeat (so the entry check has
298+
// already passed); the pre-transmit re-check must suppress the send.
299+
func TestSendHeartbeat_RechecksOptOutBeforeTransmit(t *testing.T) {
300+
clearTelemetryEnv(t)
301+
302+
var hits atomic.Int32
303+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
304+
hits.Add(1)
305+
w.WriteHeader(http.StatusOK)
306+
}))
307+
defer server.Close()
308+
309+
cfg := &config.Config{Telemetry: &config.TelemetryConfig{
310+
Enabled: boolPtr(true), AnonymousID: "anon-xyz", Endpoint: server.URL,
311+
}}
312+
svc := New(cfg, "", "v1.2.3", "personal", zap.NewNop())
313+
// Flip the opt-out latch while the payload is being built (after the entry
314+
// guard, before transmit).
315+
svc.SetRuntimeStats(&hookStats{onServerCount: func() { svc.optedOut.Store(true) }})
316+
317+
svc.sendHeartbeat(context.Background())
318+
319+
if got := hits.Load(); got != 0 {
320+
t.Fatalf("usage heartbeat transmitted after mid-flight opt-out: %d sends", got)
321+
}
322+
}
323+
324+
// TestNotifyConfigChanged_NoBeaconWhenEnvDisabled covers Codex fix #1: when
325+
// telemetry is disabled by environment (DO_NOT_TRACK / CI), it was never
326+
// enabled, so a config enabled->disabled flip must emit NO opt-out beacon.
327+
func TestNotifyConfigChanged_NoBeaconWhenEnvDisabled(t *testing.T) {
328+
clearTelemetryEnv(t)
329+
t.Setenv("DO_NOT_TRACK", "1") // env-disabled: telemetry never ran
330+
331+
received := make(chan struct{}, 4)
332+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
333+
received <- struct{}{}
334+
w.WriteHeader(http.StatusOK)
335+
}))
336+
defer server.Close()
337+
338+
enabled := &config.Config{Telemetry: &config.TelemetryConfig{
339+
Enabled: boolPtr(true), AnonymousID: "anon-xyz", Endpoint: server.URL,
340+
}}
341+
disabled := &config.Config{Telemetry: &config.TelemetryConfig{
342+
Enabled: boolPtr(false), AnonymousID: "anon-xyz", Endpoint: server.URL,
343+
}}
344+
345+
svc := New(enabled, "", "v1.2.3", "personal", zap.NewNop())
346+
svc.NotifyConfigChanged(disabled)
347+
348+
select {
349+
case <-received:
350+
t.Fatal("no beacon expected when telemetry was env-disabled (never enabled)")
351+
case <-time.After(300 * time.Millisecond):
352+
}
353+
}
354+
355+
// TestEmitOptOutBeacon_Guards covers Codex fix #3: the single guarded send entry
356+
// (used by both the daemon and the CLI) must skip on a dev/non-semver build, when
357+
// env-disabled, and when there is no anonymous ID — and send only when eligible.
358+
func TestEmitOptOutBeacon_Guards(t *testing.T) {
359+
newSvc := func(version, anonID string, endpoint string) *Service {
360+
return New(&config.Config{Telemetry: &config.TelemetryConfig{
361+
AnonymousID: anonID, Endpoint: endpoint,
362+
}}, "", version, "personal", zap.NewNop())
363+
}
364+
365+
t.Run("dev_build_skips", func(t *testing.T) {
366+
clearTelemetryEnv(t)
367+
if newSvc("dev", "anon", "http://127.0.0.1:0").EmitOptOutBeacon(context.Background()) {
368+
t.Fatal("dev (non-semver) build must not attempt a beacon")
369+
}
370+
})
371+
372+
t.Run("env_disabled_skips", func(t *testing.T) {
373+
clearTelemetryEnv(t)
374+
t.Setenv("CI", "true")
375+
if newSvc("v1.2.3", "anon", "http://127.0.0.1:0").EmitOptOutBeacon(context.Background()) {
376+
t.Fatal("env-disabled (CI) build must not attempt a beacon")
377+
}
378+
})
379+
380+
t.Run("no_anon_id_skips", func(t *testing.T) {
381+
clearTelemetryEnv(t)
382+
if newSvc("v1.2.3", "", "http://127.0.0.1:0").EmitOptOutBeacon(context.Background()) {
383+
t.Fatal("missing anonymous ID must not attempt a beacon")
384+
}
385+
})
386+
387+
t.Run("eligible_sends", func(t *testing.T) {
388+
clearTelemetryEnv(t)
389+
var hits atomic.Int32
390+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
391+
hits.Add(1)
392+
w.WriteHeader(http.StatusOK)
393+
}))
394+
defer server.Close()
395+
if !newSvc("v1.2.3", "anon", server.URL).EmitOptOutBeacon(context.Background()) {
396+
t.Fatal("eligible service should attempt a beacon")
397+
}
398+
if hits.Load() != 1 {
399+
t.Fatalf("expected exactly one beacon, got %d", hits.Load())
400+
}
401+
})
402+
}

internal/telemetry/telemetry.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ func New(cfg *config.Config, cfgPath, version, edition string, logger *zap.Logge
255255
envDisabledReason: envReason,
256256
initialDelay: 5 * time.Minute,
257257
heartbeatInterval: 24 * time.Hour,
258-
resolvedEnabled: cfg.IsTelemetryEnabled(),
258+
resolvedEnabled: EffectiveTelemetryEnabled(cfg),
259259
}
260260
}
261261

@@ -472,6 +472,15 @@ func (s *Service) sendHeartbeat(ctx context.Context) {
472472
}
473473
req.Header.Set("Content-Type", "application/json")
474474

475+
// MCP-2482: re-check the opt-out latch immediately before transmit. The
476+
// entry check above can pass for a heartbeat already in flight when the user
477+
// opts out mid-build; without this second check that heartbeat would still
478+
// ship a full usage payload AFTER the opt-out. No usage data leaves once the
479+
// latch is set.
480+
if s.optedOut.Load() {
481+
return
482+
}
483+
475484
resp, err := s.client.Do(req)
476485
if err != nil {
477486
s.logger.Debug("Failed to send heartbeat", zap.Error(err))

0 commit comments

Comments
 (0)