Skip to content

Commit 14c1cc9

Browse files
Dumbrisclaude
andauthored
feat: anonymous telemetry and in-app feedback (Spec 036) (#345)
* docs: add telemetry and feedback design spec (036) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: expand spec with CLI toggle details, documentation deliverables - Detail telemetry enable/disable CLI behavior and output - Add documentation deliverables: CLAUDE.md, docs/features/telemetry.md, configuration.md, swagger.yaml - Clarify CLI feedback uses local API via socket Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(frontend): add Feedback page and TelemetryBanner for telemetry/feedback feature Add frontend components for Spec 036 (telemetry and feedback): - Feedback.vue: form with category, message, email fields posting to /api/v1/feedback - TelemetryBanner.vue: dismissible info banner on Dashboard (localStorage-persisted) - Route /feedback added to router - Feedback link added to SidebarNav and NavBar menus - submitFeedback method added to API service Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: implement telemetry service, feedback handler, and CLI commands (036) Backend implementation for anonymous usage telemetry and feedback: - TelemetryConfig in config with IsTelemetryEnabled(), env var override - Telemetry service: daily heartbeat, 5min startup delay, fire-and-forget - Feedback: validation, rate limiting (5/hr), proxy to Cloudflare Worker - HTTP API: POST /api/v1/feedback endpoint - CLI: mcpproxy telemetry status/enable/disable, mcpproxy feedback - Runtime wiring: RuntimeStats interface, background goroutine launch - Tests: 19 new tests covering all paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add telemetry documentation and update CLAUDE.md - Create docs/features/telemetry.md with full privacy policy, data collected/excluded, and three ways to disable - Add telemetry/feedback CLI commands to CLAUDE.md - Add MCPPROXY_TELEMETRY env var to CLAUDE.md - Add POST /api/v1/feedback to API endpoints table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: propagate context to heartbeat HTTP requests for clean shutdown Pass context through to http.NewRequestWithContext so in-flight heartbeat requests are cancelled immediately on app shutdown instead of blocking up to 10s on the client timeout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate OpenAPI spec after rebase Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent b48acb9 commit 14c1cc9

26 files changed

Lines changed: 2413 additions & 10 deletions

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,22 @@ mcpproxy token regenerate deploy-bot # Regenerate token secret
205205

206206
See [docs/features/agent-tokens.md](docs/features/agent-tokens.md) for complete reference.
207207

208+
### Telemetry CLI
209+
```bash
210+
mcpproxy telemetry status # Show telemetry status and anonymous ID
211+
mcpproxy telemetry enable # Enable anonymous usage telemetry
212+
mcpproxy telemetry disable # Disable telemetry (no data sent)
213+
```
214+
215+
### Feedback CLI
216+
```bash
217+
mcpproxy feedback "message" # Submit bug report
218+
mcpproxy feedback --category feature "Add SAML" # Feature request
219+
mcpproxy feedback --category bug --email me@x.com "Crash" # With contact email
220+
```
221+
222+
See [docs/features/telemetry.md](docs/features/telemetry.md) for telemetry details and privacy policy.
223+
208224
### CLI Output Formatting
209225
```bash
210226
mcpproxy upstream list -o json # JSON output for scripting
@@ -289,6 +305,7 @@ See [docs/socket-communication.md](docs/socket-communication.md) for details.
289305
- `MCPPROXY_LISTEN` - Override network binding (e.g., `127.0.0.1:8080`)
290306
- `MCPPROXY_API_KEY` - Set API key for REST API authentication
291307
- `MCPPROXY_DEBUG` - Enable debug mode
308+
- `MCPPROXY_TELEMETRY` - Set to `false` to disable anonymous telemetry (overrides config)
292309
- `HEADLESS` - Run in headless mode (no browser launching)
293310

294311
See [docs/configuration.md](docs/configuration.md) for complete reference.
@@ -339,6 +356,7 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
339356
| `POST /api/v1/servers/{id}/tools/approve` | Approve pending/changed tools (Spec 032) |
340357
| `GET /api/v1/servers/{id}/tools/{tool}/diff` | View tool description/schema changes (Spec 032) |
341358
| `GET /api/v1/servers/{id}/tools/export` | Export tool approval records (Spec 032) |
359+
| `POST /api/v1/feedback` | Submit feedback/bug report (proxied to GitHub Issues) |
342360
| `GET /events` | SSE stream for live updates |
343361

344362
**Authentication**: Use `X-API-Key` header or `?apikey=` query parameter.

cmd/mcpproxy/feedback_cmd.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
"go.uber.org/zap"
11+
12+
clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
14+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
15+
)
16+
17+
var (
18+
feedbackCategory string
19+
feedbackEmail string
20+
)
21+
22+
// GetFeedbackCommand returns the feedback submission command.
23+
func GetFeedbackCommand() *cobra.Command {
24+
cmd := &cobra.Command{
25+
Use: "feedback \"message\"",
26+
Short: "Submit feedback (bug report, feature request, or general)",
27+
Long: `Submit feedback to the MCPProxy team. Your message is sent to the
28+
telemetry endpoint along with anonymous system context (version, OS, etc.).
29+
30+
Examples:
31+
mcpproxy feedback "The search results could be more relevant" --category feature
32+
mcpproxy feedback "Server crashes when adding OAuth server" --category bug
33+
mcpproxy feedback "Great tool, thanks!" --category other --email me@example.com`,
34+
Args: cobra.ExactArgs(1),
35+
RunE: runFeedback,
36+
}
37+
38+
cmd.Flags().StringVar(&feedbackCategory, "category", "other", "Feedback category: bug, feature, other")
39+
cmd.Flags().StringVar(&feedbackEmail, "email", "", "Optional email for follow-up")
40+
41+
return cmd
42+
}
43+
44+
func runFeedback(cmd *cobra.Command, args []string) error {
45+
message := args[0]
46+
47+
// Validate inputs before sending
48+
if !telemetry.ValidateCategory(feedbackCategory) {
49+
return fmt.Errorf("invalid category %q: must be bug, feature, or other", feedbackCategory)
50+
}
51+
if err := telemetry.ValidateMessage(message); err != nil {
52+
return fmt.Errorf("invalid message: %w", err)
53+
}
54+
55+
cfg, err := loadFeedbackConfig()
56+
if err != nil {
57+
return fmt.Errorf("failed to load config: %w", err)
58+
}
59+
60+
logger, _ := zap.NewProduction()
61+
defer logger.Sync()
62+
63+
cfgPath := config.GetConfigPath(cfg.DataDir)
64+
svc := telemetry.New(cfg, cfgPath, version, Edition, logger)
65+
66+
req := &telemetry.FeedbackRequest{
67+
Category: feedbackCategory,
68+
Message: message,
69+
Email: feedbackEmail,
70+
}
71+
72+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
73+
defer cancel()
74+
75+
resp, err := svc.SubmitFeedback(ctx, req)
76+
if err != nil {
77+
return fmt.Errorf("failed to submit feedback: %w", err)
78+
}
79+
80+
format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
81+
switch format {
82+
case "json":
83+
data, err := json.MarshalIndent(resp, "", " ")
84+
if err != nil {
85+
return err
86+
}
87+
fmt.Println(string(data))
88+
default:
89+
if resp.Success {
90+
fmt.Println("Feedback submitted successfully!")
91+
if resp.IssueURL != "" {
92+
fmt.Printf("Track your feedback: %s\n", resp.IssueURL)
93+
}
94+
} else {
95+
fmt.Printf("Feedback submission failed: %s\n", resp.Error)
96+
}
97+
}
98+
99+
return nil
100+
}
101+
102+
func loadFeedbackConfig() (*config.Config, error) {
103+
if configFile != "" {
104+
cfg, err := config.LoadFromFile(configFile)
105+
if err != nil {
106+
return nil, err
107+
}
108+
if dataDir != "" {
109+
cfg.DataDir = dataDir
110+
}
111+
return cfg, nil
112+
}
113+
cfg, err := config.Load()
114+
if err != nil {
115+
return nil, err
116+
}
117+
if dataDir != "" {
118+
cfg.DataDir = dataDir
119+
}
120+
return cfg, nil
121+
}

cmd/mcpproxy/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ func main() {
172172
// Add token command (Spec 028: Agent tokens)
173173
tokenCmd := GetTokenCommand()
174174

175+
// Add telemetry command (Spec 036)
176+
telemetryCmd := GetTelemetryCommand()
177+
178+
// Add feedback command (Spec 036)
179+
feedbackCmd := GetFeedbackCommand()
180+
175181
// Add commands to root
176182
rootCmd.AddCommand(serverCmd)
177183
rootCmd.AddCommand(searchCmd)
@@ -187,6 +193,8 @@ func main() {
187193
rootCmd.AddCommand(tuiCmd)
188194
rootCmd.AddCommand(statusCmd)
189195
rootCmd.AddCommand(tokenCmd)
196+
rootCmd.AddCommand(telemetryCmd)
197+
rootCmd.AddCommand(feedbackCmd)
190198

191199
// Setup --help-json for machine-readable help discovery
192200
// This must be called AFTER all commands are added

cmd/mcpproxy/telemetry_cmd.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
10+
clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
12+
)
13+
14+
// TelemetryStatus holds status data for display.
15+
type TelemetryStatus struct {
16+
Enabled bool `json:"enabled"`
17+
AnonymousID string `json:"anonymous_id,omitempty"`
18+
Endpoint string `json:"endpoint"`
19+
EnvOverride bool `json:"env_override,omitempty"`
20+
}
21+
22+
// GetTelemetryCommand returns the telemetry management command.
23+
func GetTelemetryCommand() *cobra.Command {
24+
telemetryCmd := &cobra.Command{
25+
Use: "telemetry",
26+
Short: "Manage anonymous usage telemetry",
27+
Long: `Manage anonymous usage telemetry for MCPProxy.
28+
29+
Telemetry sends anonymous, non-identifiable usage statistics to help
30+
improve MCPProxy. No personal data, tool names, or server details are
31+
ever transmitted.
32+
33+
Examples:
34+
mcpproxy telemetry status # Show telemetry status
35+
mcpproxy telemetry enable # Enable telemetry
36+
mcpproxy telemetry disable # Disable telemetry`,
37+
}
38+
39+
telemetryCmd.AddCommand(getTelemetryStatusCommand())
40+
telemetryCmd.AddCommand(getTelemetryEnableCommand())
41+
telemetryCmd.AddCommand(getTelemetryDisableCommand())
42+
43+
return telemetryCmd
44+
}
45+
46+
func getTelemetryStatusCommand() *cobra.Command {
47+
return &cobra.Command{
48+
Use: "status",
49+
Short: "Show telemetry status",
50+
RunE: runTelemetryStatus,
51+
}
52+
}
53+
54+
func getTelemetryEnableCommand() *cobra.Command {
55+
return &cobra.Command{
56+
Use: "enable",
57+
Short: "Enable anonymous telemetry",
58+
RunE: runTelemetryEnable,
59+
}
60+
}
61+
62+
func getTelemetryDisableCommand() *cobra.Command {
63+
return &cobra.Command{
64+
Use: "disable",
65+
Short: "Disable anonymous telemetry",
66+
RunE: runTelemetryDisable,
67+
}
68+
}
69+
70+
func runTelemetryStatus(cmd *cobra.Command, _ []string) error {
71+
cfg, err := loadTelemetryConfig()
72+
if err != nil {
73+
return fmt.Errorf("failed to load config: %w", err)
74+
}
75+
76+
status := TelemetryStatus{
77+
Enabled: cfg.IsTelemetryEnabled(),
78+
Endpoint: cfg.GetTelemetryEndpoint(),
79+
}
80+
81+
if id := cfg.GetAnonymousID(); id != "" {
82+
status.AnonymousID = id
83+
}
84+
85+
if os.Getenv("MCPPROXY_TELEMETRY") == "false" {
86+
status.EnvOverride = true
87+
}
88+
89+
format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
90+
switch format {
91+
case "json":
92+
data, err := json.MarshalIndent(status, "", " ")
93+
if err != nil {
94+
return err
95+
}
96+
fmt.Println(string(data))
97+
case "yaml":
98+
formatter, err := clioutput.NewFormatter("yaml")
99+
if err != nil {
100+
return err
101+
}
102+
output, err := formatter.Format(status)
103+
if err != nil {
104+
return err
105+
}
106+
fmt.Println(output)
107+
default:
108+
fmt.Println("Telemetry Status")
109+
enabledStr := "Enabled"
110+
if !status.Enabled {
111+
enabledStr = "Disabled"
112+
}
113+
fmt.Printf(" %-14s %s\n", "Status:", enabledStr)
114+
if status.EnvOverride {
115+
fmt.Printf(" %-14s %s\n", "Override:", "MCPPROXY_TELEMETRY=false")
116+
}
117+
if status.AnonymousID != "" {
118+
fmt.Printf(" %-14s %s\n", "Anonymous ID:", status.AnonymousID)
119+
}
120+
fmt.Printf(" %-14s %s\n", "Endpoint:", status.Endpoint)
121+
}
122+
123+
return nil
124+
}
125+
126+
func runTelemetryEnable(cmd *cobra.Command, _ []string) error {
127+
cfg, err := loadTelemetryConfig()
128+
if err != nil {
129+
return fmt.Errorf("failed to load config: %w", err)
130+
}
131+
132+
if cfg.Telemetry == nil {
133+
cfg.Telemetry = &config.TelemetryConfig{}
134+
}
135+
enabled := true
136+
cfg.Telemetry.Enabled = &enabled
137+
138+
configPath := config.GetConfigPath(cfg.DataDir)
139+
if err := config.SaveConfig(cfg, configPath); err != nil {
140+
return fmt.Errorf("failed to save config: %w", err)
141+
}
142+
143+
fmt.Println("Telemetry enabled.")
144+
if os.Getenv("MCPPROXY_TELEMETRY") == "false" {
145+
fmt.Println("Warning: MCPPROXY_TELEMETRY=false environment variable is set and will override this setting.")
146+
}
147+
return nil
148+
}
149+
150+
func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
151+
cfg, err := loadTelemetryConfig()
152+
if err != nil {
153+
return fmt.Errorf("failed to load config: %w", err)
154+
}
155+
156+
if cfg.Telemetry == nil {
157+
cfg.Telemetry = &config.TelemetryConfig{}
158+
}
159+
disabled := false
160+
cfg.Telemetry.Enabled = &disabled
161+
162+
configPath := config.GetConfigPath(cfg.DataDir)
163+
if err := config.SaveConfig(cfg, configPath); err != nil {
164+
return fmt.Errorf("failed to save config: %w", err)
165+
}
166+
167+
fmt.Println("Telemetry disabled.")
168+
return nil
169+
}
170+
171+
func loadTelemetryConfig() (*config.Config, error) {
172+
if configFile != "" {
173+
cfg, err := config.LoadFromFile(configFile)
174+
if err != nil {
175+
return nil, err
176+
}
177+
if dataDir != "" {
178+
cfg.DataDir = dataDir
179+
}
180+
return cfg, nil
181+
}
182+
cfg, err := config.Load()
183+
if err != nil {
184+
return nil, err
185+
}
186+
if dataDir != "" {
187+
cfg.DataDir = dataDir
188+
}
189+
return cfg, nil
190+
}

0 commit comments

Comments
 (0)