|
| 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