diff --git a/cli/telemetry/detached.go b/cli/telemetry/detached.go index df606ad..67bf0fc 100644 --- a/cli/telemetry/detached.go +++ b/cli/telemetry/detached.go @@ -1,13 +1,15 @@ package telemetry import ( + "crypto/rand" + "encoding/hex" "encoding/json" "os" + "path/filepath" "runtime" "strings" "time" - "github.com/denisbrodbeck/machineid" "github.com/posthog/posthog-go" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -31,6 +33,55 @@ type EventPayload struct { Timestamp time.Time `json:"timestamp"` } +// userConfigDir returns the base user config directory. It is a var so +// tests can isolate the anonymous ID file from the real user config dir. +var userConfigDir = os.UserConfigDir + +// anonymousID returns a stable per-install anonymous identifier. The ID is a +// random 128-bit value generated on first use and cached in the user config +// dir. A random ID is used instead of a hardware-derived machine ID so +// telemetry events cannot be correlated to a physical machine. +func anonymousID() (string, error) { + dir, err := userConfigDir() + if err != nil { + return "", err + } + dir = filepath.Join(dir, "trace") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + path := filepath.Join(dir, "telemetry-id") + if b, err := os.ReadFile(path); err == nil { + if id := strings.TrimSpace(string(b)); id != "" { + return id, nil + } + } + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", err + } + id := hex.EncodeToString(raw) + if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil { + return "", err + } + return id, nil +} + +// telemetryEnabled reports whether telemetry is permitted. Telemetry is +// opt-in: nothing is sent unless TRACE_TELEMETRY_OPTIN=1 is set. The legacy +// TRACE_TELEMETRY_OPTOUT variable continues to force-disable even when +// opt-in is enabled. +func telemetryEnabled() bool { + if os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" { + return false + } + return os.Getenv("TRACE_TELEMETRY_OPTIN") == "1" +} + +// sendDetached dispatches a payload to the detached analytics subprocess. +// It is a var so tests can spy on whether telemetry was dispatched. +var sendDetached = spawnDetachedAnalytics + // silentLogger suppresses PostHog log output - expected for CLI best-effort telemetry type silentLogger struct{} @@ -46,8 +97,8 @@ func BuildEventPayload(cmd *cobra.Command, agent string, isTraceEnabled bool, ve return nil } - // Get machine ID for distinct_id - machineID, err := machineid.ProtectedID("trace-cli") + // Get an anonymous per-install ID for distinct_id + machineID, err := anonymousID() if err != nil { return nil } @@ -87,11 +138,12 @@ func BuildEventPayload(cmd *cobra.Command, agent string, isTraceEnabled bool, ve // TrackCommandDetached tracks a command execution by spawning a detached subprocess. // This returns immediately without blocking the CLI. // -// Telemetry can be disabled by setting the TRACE_TELEMETRY_OPTOUT environment variable -// to any non-empty value (e.g., TRACE_TELEMETRY_OPTOUT=1). +// Telemetry is opt-in: it is only sent when TRACE_TELEMETRY_OPTIN=1 is set. +// The legacy TRACE_TELEMETRY_OPTOUT environment variable (any non-empty +// value) force-disables telemetry regardless. func TrackCommandDetached(cmd *cobra.Command, agent string, isTraceEnabled bool, version string) { - // Check opt-out environment variables - if os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" { + // Opt-in gate: nothing is sent unless explicitly enabled. + if !telemetryEnabled() { return } @@ -109,14 +161,14 @@ func TrackCommandDetached(cmd *cobra.Command, agent string, isTraceEnabled bool, } if payloadJSON, err := json.Marshal(payload); err == nil { - spawnDetachedAnalytics(string(payloadJSON)) + sendDetached(string(payloadJSON)) } } // TrackPluginDetached tracks a plugin invocation by spawning a detached subprocess. // This returns immediately without blocking the CLI. func TrackPluginDetached(pluginName string, isTraceEnabled bool, version string) { - if os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" { + if !telemetryEnabled() { return } @@ -126,7 +178,7 @@ func TrackPluginDetached(pluginName string, isTraceEnabled bool, version string) } if payloadJSON, err := json.Marshal(payload); err == nil { - spawnDetachedAnalytics(string(payloadJSON)) + sendDetached(string(payloadJSON)) } } diff --git a/cli/telemetry/detached_test.go b/cli/telemetry/detached_test.go index 8035a2b..1199997 100644 --- a/cli/telemetry/detached_test.go +++ b/cli/telemetry/detached_test.go @@ -8,6 +8,24 @@ import ( "github.com/spf13/cobra" ) +// spySendDetached swaps the dispatch hook and returns a counter. +func spySendDetached(t *testing.T) *int { + t.Helper() + calls := 0 + orig := sendDetached + sendDetached = func(string) { calls++ } + t.Cleanup(func() { sendDetached = orig }) + return &calls +} + +// isolateConfigDir redirects the anonymous ID file into a temp dir. +func isolateConfigDir(t *testing.T) { + t.Helper() + orig := userConfigDir + userConfigDir = func() (string, error) { return t.TempDir(), nil } + t.Cleanup(func() { userConfigDir = orig }) +} + func TestEventPayloadSerialization(t *testing.T) { payload := EventPayload{ Event: "cli_command_executed", @@ -69,14 +87,62 @@ func TestTrackCommandDetachedSkipsHiddenCommands(_ *testing.T) { } func TestTrackCommandDetachedRespectsOptOut(t *testing.T) { + t.Setenv("TRACE_TELEMETRY_OPTIN", "1") t.Setenv("TRACE_TELEMETRY_OPTOUT", "1") + calls := spySendDetached(t) cmd := &cobra.Command{ Use: "status", } - // Should not panic and should respect opt-out + // Opt-out must win over opt-in: nothing is dispatched. + TrackCommandDetached(cmd, "claude-code", true, "1.0.0") + if *calls != 0 { + t.Errorf("expected 0 dispatches when opt-out is set, got %d", *calls) + } +} + +func TestTrackCommandDetachedDisabledByDefault(t *testing.T) { + // Without TRACE_TELEMETRY_OPTIN, telemetry is off even with no opt-out. + calls := spySendDetached(t) + cmd := &cobra.Command{Use: "status"} + TrackCommandDetached(cmd, "claude-code", true, "1.0.0") + if *calls != 0 { + t.Errorf("expected 0 dispatches by default (opt-in), got %d", *calls) + } +} + +func TestTrackCommandDetachedEnabledWithOptIn(t *testing.T) { + t.Setenv("TRACE_TELEMETRY_OPTIN", "1") + isolateConfigDir(t) + + calls := spySendDetached(t) + cmd := &cobra.Command{Use: "status"} + + TrackCommandDetached(cmd, "claude-code", true, "1.0.0") + if *calls != 1 { + t.Errorf("expected 1 dispatch with opt-in, got %d", *calls) + } +} + +func TestTrackPluginDetachedDisabledByDefault(t *testing.T) { + calls := spySendDetached(t) + TrackPluginDetached("my-plugin", true, "1.0.0") + if *calls != 0 { + t.Errorf("expected 0 dispatches by default (opt-in), got %d", *calls) + } +} + +func TestTrackPluginDetachedEnabledWithOptIn(t *testing.T) { + t.Setenv("TRACE_TELEMETRY_OPTIN", "1") + isolateConfigDir(t) + + calls := spySendDetached(t) + TrackPluginDetached("my-plugin", true, "1.0.0") + if *calls != 1 { + t.Errorf("expected 1 dispatch with opt-in, got %d", *calls) + } } func TestBuildEventPayloadAgent(t *testing.T) { diff --git a/go.mod b/go.mod index 52b35e6..88e70da 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/betterleaks/betterleaks v1.4.1 github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.24 - github.com/denisbrodbeck/machineid v1.0.1 github.com/entireio/auth-go v0.4.0 github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 github.com/go-git/go-git/v6 v6.0.0-alpha.4 diff --git a/go.sum b/go.sum index 22111d9..879c576 100644 --- a/go.sum +++ b/go.sum @@ -99,8 +99,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= -github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=