Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/features/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,26 @@ transmissible if telemetry is re-enabled later.
session id, the number of tools in the response, or any part of the suggestion
string the agent saw.

## Feedback submissions

`mcpproxy feedback` and the Web UI feedback page send feedback through the same
path. Each submission files a public GitHub issue on
`smart-mcp-proxy/mcpproxy-go` containing your message and this context:
version, edition, OS/arch, Go version, server and tool counts, routing mode and
(from the running daemon) uptime.

While telemetry is enabled, the submission also carries the same
`anonymous_id` and `machine_id` the heartbeat reports, plus the heartbeat's
enum-only diagnostics (`last_startup_outcome`, `previous_shutdown`,
`last_error_code`, and the `MCPX_*` error-code counts). That lets a maintainer
match a report to your install's telemetry. The ids and your optional email are
stored privately by the telemetry service (pruned after 180 days) and never
appear in the public issue; the issue shows only a reference such as
`fb-123`. With telemetry disabled, the ids and diagnostics are not sent.

The wire format is pinned by `internal/telemetry/testdata/feedback_contract.json`,
which the telemetry worker keeps a copy of.

## One-time opt-out signal

When telemetry transitions from **enabled to disabled** (via the CLI, the config
Expand Down
90 changes: 81 additions & 9 deletions internal/telemetry/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,35 @@ type FeedbackRequest struct {
}

// FeedbackContext provides automatic system context alongside feedback.
//
// The wire shape is a contract with the telemetry worker's /v1/feedback
// handler, pinned by testdata/feedback_contract.json (see
// feedback_contract_test.go). Add a field there before adding it here.
type FeedbackContext struct {
Version string `json:"version"`
Edition string `json:"edition"`
OS string `json:"os"`
Arch string `json:"arch"`
GoVersion string `json:"go_version,omitempty"`
ServerCount int `json:"server_count"`
ConnectedServerCount int `json:"connected_server_count"`
ToolCount int `json:"tool_count"`
RoutingMode string `json:"routing_mode"`
// UptimeMinutes is the daemon's uptime. Omitted from short-lived CLI
// submissions, where the process uptime says nothing about the daemon.
UptimeMinutes int `json:"uptime_minutes,omitempty"`

// Everything below is sent only while telemetry is enabled. AnonymousID and
// MachineID are the same values the heartbeat reports, so a maintainer can
// join a report to its install's telemetry; the worker stores them
// privately and never prints them in the public issue. The diagnostics are
// the heartbeat's enum-only fields (MCPX_* codes, fixed outcome words).
AnonymousID string `json:"anonymous_id,omitempty"`
MachineID string `json:"machine_id,omitempty"`
LastStartupOutcome string `json:"last_startup_outcome,omitempty"`
PreviousShutdown string `json:"previous_shutdown,omitempty"`
LastErrorCode string `json:"last_error_code,omitempty"`
Diagnostics *DiagnosticsCounters `json:"diagnostics,omitempty"`
}

// FeedbackResponse is the response from the telemetry backend.
Expand Down Expand Up @@ -116,15 +137,12 @@ func (s *Service) SubmitFeedback(ctx context.Context, req *FeedbackRequest) (*Fe
return nil, fmt.Errorf("invalid message: %w", err)
}

// Auto-populate context
req.Context.Version = s.version
req.Context.Edition = s.edition
req.Context.OS = runtime.GOOS
req.Context.Arch = runtime.GOARCH
if s.stats != nil {
req.Context.ServerCount = s.stats.GetServerCount()
req.Context.ConnectedServerCount = s.stats.GetConnectedServerCount()
req.Context.RoutingMode = s.stats.GetRoutingMode()
req.Context = s.feedbackContext()
// Building the context reads BBolt; an opt-out can land meanwhile. Recheck
// consent at the last moment so a report sent after opting out never
// carries the install's ids or diagnostics.
if !s.feedbackTelemetryConsent() {
req.Context.withoutTelemetry()
}

// Marshal and send
Expand Down Expand Up @@ -163,3 +181,57 @@ func (s *Service) SubmitFeedback(ctx context.Context, req *FeedbackRequest) (*Fe

return &result, nil
}

// feedbackContext builds the automatic context for a feedback submission. The
// telemetry correlation ids and diagnostics are attached only while telemetry
// is enabled: a user who opted out has not agreed to have a report linked to
// their install.
func (s *Service) feedbackContext() FeedbackContext {
fc := FeedbackContext{
Version: s.version,
Edition: s.edition,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
GoVersion: runtime.Version(),
}
if s.stats != nil {
fc.ServerCount = s.stats.GetServerCount()
fc.ConnectedServerCount = s.stats.GetConnectedServerCount()
fc.ToolCount = s.stats.GetToolCount()
fc.RoutingMode = s.stats.GetRoutingMode()
// Runtime stats are wired only inside the daemon, so this is its uptime.
fc.UptimeMinutes = int(time.Since(s.startTime).Minutes())
}

if !s.feedbackTelemetryConsent() {
return fc
}
anonID, _, _, lastStartupOutcome := s.telemetryCursor(s.liveConfig())
if anonID == "" {
anonID, _ = s.fallbackIdentity()
}
fc.AnonymousID = anonID
fc.MachineID = resolveMachineID()
fc.LastStartupOutcome = lastStartupOutcome
fc.PreviousShutdown = s.previousShutdown
fc.LastErrorCode = s.lastErrorCode()
fc.Diagnostics = s.diagnosticsSnapshot()
return fc
}

// feedbackTelemetryConsent reports whether a feedback submission may carry the
// install's telemetry ids and diagnostics: telemetry resolves as enabled (config
// and env) and no opt-out has latched — the same gates the heartbeat honours.
func (s *Service) feedbackTelemetryConsent() bool {
return !s.optedOut.Load() && s.telemetryEnabledLive()
}

// withoutTelemetry clears every field that is sent only with telemetry consent.
func (fc *FeedbackContext) withoutTelemetry() {
fc.AnonymousID = ""
fc.MachineID = ""
fc.LastStartupOutcome = ""
fc.PreviousShutdown = ""
fc.LastErrorCode = ""
fc.Diagnostics = nil
}
Loading
Loading