package telemetry import ( "bytes" "errors" "maps" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func stubStateDir(dir string) func() { orig := stateDirFunc stateDirFunc = func() string { return dir } return func() { stateDirFunc = orig } } func stubDeviceID(id string) func() { orig := deviceIDFunc deviceIDFunc = func() (string, error) { return id, nil } return func() { deviceIDFunc = orig } } func stubDeviceIDError(err error) func() { orig := deviceIDFunc deviceIDFunc = func() (string, error) { return "", err } return func() { deviceIDFunc = orig } } func stubLookupEnv(fn func(string) (string, bool)) func() { orig := lookupEnvFunc lookupEnvFunc = fn return func() { lookupEnvFunc = orig } } // newService is a test helper that constructs the internal service struct // directly, bypassing the config/env parsing of NewService but still // resolving common dimensions like device_id and invocation_id. func newService(flusher func(SendTelemetryPayload), additionalDimensions ghtelemetry.Dimensions) *service { deviceID, err := deviceIDFunc() if err != nil { deviceID = "" } commonDimensions := ghtelemetry.Dimensions{ "device_id": deviceID, "invocation_id": uuid.NewString(), } maps.Copy(commonDimensions, additionalDimensions) return &service{ flush: flusher, commonDimensions: commonDimensions, } } func TestGetOrCreateDeviceID(t *testing.T) { t.Run("creates new ID on first call", func(t *testing.T) { tmpDir := t.TempDir() t.Cleanup(stubStateDir(tmpDir)) id, err := getOrCreateDeviceID() require.NoError(t, err) require.NotEmpty(t, id) data, err := os.ReadFile(filepath.Join(tmpDir, deviceIDFileName)) require.NoError(t, err) assert.Equal(t, id, string(data)) }) t.Run("returns same ID on subsequent calls", func(t *testing.T) { tmpDir := t.TempDir() t.Cleanup(stubStateDir(tmpDir)) id1, err := getOrCreateDeviceID() require.NoError(t, err) id2, err := getOrCreateDeviceID() require.NoError(t, err) assert.Equal(t, id1, id2) }) t.Run("trims whitespace from stored ID", func(t *testing.T) { tmpDir := t.TempDir() t.Cleanup(stubStateDir(tmpDir)) err := os.WriteFile(filepath.Join(tmpDir, deviceIDFileName), []byte(" some-device-id\n"), 0o600) require.NoError(t, err) id, err := getOrCreateDeviceID() require.NoError(t, err) assert.Equal(t, "some-device-id", id) }) t.Run("returns error for non-ErrNotExist read failures", func(t *testing.T) { tmpDir := t.TempDir() t.Cleanup(stubStateDir(tmpDir)) // Create device-id as a directory so ReadFile fails with a non-ErrNotExist error. err := os.Mkdir(filepath.Join(tmpDir, deviceIDFileName), 0o755) require.NoError(t, err) _, err = getOrCreateDeviceID() require.Error(t, err) assert.False(t, errors.Is(err, os.ErrNotExist)) }) t.Run("creates state directory if missing", func(t *testing.T) { tmpDir := t.TempDir() nestedDir := filepath.Join(tmpDir, "nested", "state") t.Cleanup(stubStateDir(nestedDir)) id, err := getOrCreateDeviceID() require.NoError(t, err) require.NotEmpty(t, id) data, err := os.ReadFile(filepath.Join(nestedDir, deviceIDFileName)) require.NoError(t, err) assert.Equal(t, id, string(data)) }) t.Run("concurrent callers converge on the same ID", func(t *testing.T) { tmpDir := t.TempDir() t.Cleanup(stubStateDir(tmpDir)) const goroutines = 10 ids := make([]string, goroutines) errs := make([]error, goroutines) var wg sync.WaitGroup wg.Add(goroutines) for i := range goroutines { go func() { defer wg.Done() ids[i], errs[i] = getOrCreateDeviceID() }() } wg.Wait() for i := range goroutines { require.NoError(t, errs[i]) } for i := 1; i < goroutines; i++ { assert.Equal(t, ids[0], ids[i], "goroutine %d returned a different ID", i) } }) } func TestParseTelemetryState(t *testing.T) { envSet := func(val string) func(string) (string, bool) { return func(string) (string, bool) { return val, true } } envUnset := func(string) (string, bool) { return "", false } // envMap allows setting multiple environment variables for testing DO_NOT_TRACK + GH_TELEMETRY interactions. envMap := func(m map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { val, ok := m[key] return val, ok } } tests := []struct { name string lookupEnv func(string) (string, bool) configValue string want TelemetryState }{ { name: "env unset, config empty string disables", lookupEnv: envUnset, configValue: "", want: Disabled, }, { name: "env unset, config enabled", lookupEnv: envUnset, configValue: "enabled", want: Enabled, }, { name: "env unset, config disabled", lookupEnv: envUnset, configValue: "disabled", want: Disabled, }, { name: "env unset, config log", lookupEnv: envUnset, configValue: "log", want: Logged, }, { name: "env unset, config false", lookupEnv: envUnset, configValue: "false", want: Disabled, }, { name: "env unset, config any truthy value", lookupEnv: envUnset, configValue: "anything", want: Enabled, }, { name: "env enabled takes precedence over config disabled", lookupEnv: envSet("enabled"), configValue: "disabled", want: Enabled, }, { name: "env disabled takes precedence over config enabled", lookupEnv: envSet("disabled"), configValue: "enabled", want: Disabled, }, { name: "env log takes precedence over config enabled", lookupEnv: envSet("log"), configValue: "enabled", want: Logged, }, { name: "env false disables", lookupEnv: envSet("false"), configValue: "enabled", want: Disabled, }, { name: "env empty string disables", lookupEnv: envSet(""), configValue: "enabled", want: Disabled, }, { name: "env any truthy value enables", lookupEnv: envSet("yes"), configValue: "disabled", want: Enabled, }, { name: "env FALSE (uppercase) disables", lookupEnv: envSet("FALSE"), configValue: "enabled", want: Disabled, }, { name: "env LOG (uppercase) logs", lookupEnv: envSet("LOG"), configValue: "enabled", want: Logged, }, { name: "env value with whitespace is trimmed", lookupEnv: envSet(" false "), configValue: "enabled", want: Disabled, }, { name: "DO_NOT_TRACK=1 disables telemetry", lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), configValue: "enabled", want: Disabled, }, { name: "DO_NOT_TRACK=true disables telemetry", lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "true"}), configValue: "enabled", want: Disabled, }, { name: "DO_NOT_TRACK=TRUE disables telemetry (case insensitive)", lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "TRUE"}), configValue: "enabled", want: Disabled, }, { name: "DO_NOT_TRACK=0 does not disable telemetry", lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "0"}), configValue: "enabled", want: Enabled, }, { name: "DO_NOT_TRACK with whitespace is trimmed", lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": " 1 "}), configValue: "enabled", want: Disabled, }, { name: "GH_TELEMETRY takes precedence over DO_NOT_TRACK", lookupEnv: envMap(map[string]string{"GH_TELEMETRY": "enabled", "DO_NOT_TRACK": "1"}), configValue: "", want: Enabled, }, { name: "DO_NOT_TRACK takes precedence over config", lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), configValue: "log", want: Disabled, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Cleanup(stubLookupEnv(tt.lookupEnv)) got := ParseTelemetryState(tt.configValue) assert.Equal(t, tt.want, got) }) } } func TestNewServiceLogModeFlushesToWriter(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer svc := NewService(LogFlusher(&buf, false)) svc.Record(ghtelemetry.Event{ Type: "test_event", Dimensions: map[string]string{"key": "value"}, }) svc.Flush() output := buf.String() assert.Contains(t, output, "Telemetry payload:") assert.Contains(t, output, "test_event") assert.Contains(t, output, `"key"`) assert.Contains(t, output, `"value"`) } func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer svc := NewService(LogFlusher(&buf, true)) svc.Record(ghtelemetry.Event{Type: "color_event"}) svc.Flush() output := buf.String() assert.Contains(t, output, "color_event") // Verify ANSI color codes are present in the output assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled") } func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { t.Run("no color", func(t *testing.T) { var buf bytes.Buffer LogFlusher(&buf, false)(SendTelemetryPayload{}) assert.Equal(t, "Telemetry payload: none\n", buf.String()) }) t.Run("with color", func(t *testing.T) { var buf bytes.Buffer LogFlusher(&buf, true)(SendTelemetryPayload{}) output := buf.String() assert.Contains(t, output, "Telemetry payload:") assert.Contains(t, output, "none") assert.Contains(t, output, "\x1b") // ANSI escape char for color codes }) } func TestServiceDeviceIDFallback(t *testing.T) { t.Cleanup(stubDeviceIDError(errors.New("no device id"))) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() require.Len(t, captured.Events, 1) assert.Equal(t, "", captured.Events[0].Dimensions["device_id"]) } func TestServiceFlush(t *testing.T) { t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload called := false svc := newService(func(p SendTelemetryPayload) { called = true captured = p }, nil) svc.Flush() assert.True(t, called, "flusher should be called even with no events so log mode can surface the absence") assert.Empty(t, captured.Events, "payload should have no events") }) t.Run("flushes events with merged dimensions", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"version": "2.45.0"}) svc.Record(ghtelemetry.Event{ Type: "command_invocation", Dimensions: map[string]string{"command": "gh pr list"}, Measures: map[string]int64{"duration_ms": 150}, }) svc.Flush() require.Len(t, captured.Events, 1) event := captured.Events[0] assert.Equal(t, "command_invocation", event.Type) assert.Equal(t, "gh pr list", event.Dimensions["command"]) assert.Equal(t, "2.45.0", event.Dimensions["version"]) assert.Equal(t, "test-device", event.Dimensions["device_id"]) assert.NotEmpty(t, event.Dimensions["timestamp"]) assert.NotEmpty(t, event.Dimensions["invocation_id"]) assert.Equal(t, int64(150), event.Measures["duration_ms"]) }) t.Run("flushes multiple events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) svc.Record(ghtelemetry.Event{Type: "event1"}) svc.Record(ghtelemetry.Event{Type: "event2"}) svc.Flush() require.Len(t, captured.Events, 2) assert.Equal(t, "event1", captured.Events[0].Type) assert.Equal(t, "event2", captured.Events[1].Type) }) t.Run("is idempotent", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) callCount := 0 svc := newService(func(SendTelemetryPayload) { callCount++ }, nil) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() svc.Flush() svc.Flush() assert.Equal(t, 1, callCount, "flusher should only be called once") }) t.Run("event dimensions override common dimensions", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"shared": "common"}) svc.Record(ghtelemetry.Event{ Type: "test", Dimensions: map[string]string{"shared": "event-level"}, }) svc.Flush() require.Len(t, captured.Events, 1) // Event dimensions are copied last via maps.Copy, so they override common assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"]) }) t.Run("timestamps reflect record time not flush time", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) svc.Record(ghtelemetry.Event{Type: "early"}) time.Sleep(50 * time.Millisecond) svc.Record(ghtelemetry.Event{Type: "late"}) svc.Flush() require.Len(t, captured.Events, 2) ts1 := captured.Events[0].Dimensions["timestamp"] ts2 := captured.Events[1].Dimensions["timestamp"] require.NotEmpty(t, ts1) require.NotEmpty(t, ts2) t1, err := time.Parse("2006-01-02T15:04:05.000Z", ts1) require.NoError(t, err) t2, err := time.Parse("2006-01-02T15:04:05.000Z", ts2) require.NoError(t, err) assert.True(t, t2.After(t1), "second event timestamp %s should be after first %s", ts2, ts1) }) } func TestServiceSampling(t *testing.T) { t.Run("sampleRate 0 sends all events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) svc.sampleRate = 0 svc.sampleBucket = 99 svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() require.Len(t, captured.Events, 1) }) t.Run("sampleRate 100 sends all events regardless of bucket", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) svc.sampleRate = 100 svc.sampleBucket = 99 svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() require.Len(t, captured.Events, 1) }) t.Run("bucket below sampleRate sends events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) svc.sampleRate = 50 svc.sampleBucket = 49 // below rate, should be included svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() require.Len(t, captured.Events, 1) }) t.Run("bucket at sampleRate drops events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) called := false svc := newService(func(SendTelemetryPayload) { called = true }, nil) svc.sampleRate = 50 svc.sampleBucket = 50 // at rate boundary, should be excluded svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() assert.False(t, called, "flusher should not be called when bucket >= sampleRate") }) t.Run("bucket above sampleRate drops events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) called := false svc := newService(func(SendTelemetryPayload) { called = true }, nil) svc.sampleRate = 1 svc.sampleBucket = 50 svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() assert.False(t, called, "flusher should not be called when bucket >= sampleRate") }) t.Run("SetSampleRate changes flush behavior", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) called := false svc := newService(func(SendTelemetryPayload) { called = true }, nil) svc.sampleBucket = 50 // Initially rate=0, which sends everything svc.SetSampleRate(10) // Now bucket=50 >= rate=10, should drop svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() assert.False(t, called, "flusher should not be called after SetSampleRate reduced the rate") }) t.Run("SetSampleRate updates sample_rate dimension", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{ "sample_rate": "1", }) svc.sampleRate = 1 svc.sampleBucket = 0 svc.SetSampleRate(100) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() require.Len(t, captured.Events, 1) assert.Equal(t, "100", captured.Events[0].Dimensions["sample_rate"]) }) t.Run("WithSampleRate option sets rate on construction", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) called := false svc := NewService(func(SendTelemetryPayload) { called = true }, WithSampleRate(1)) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() // We can't control the bucket from NewService, so we just verify // the service was created without error and Flush doesn't panic. // The actual sampling behavior is tested via direct struct manipulation above. _ = called }) } func TestWithAdditionalCommonDimensions(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload svc := NewService( func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ "version": "2.45.0", "agent": "none", }), ) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() require.Len(t, captured.Events, 1) assert.Equal(t, "2.45.0", captured.Events[0].Dimensions["version"]) assert.Equal(t, "none", captured.Events[0].Dimensions["agent"]) // Standard common dimensions should also be present assert.Equal(t, "test-device", captured.Events[0].Dimensions["device_id"]) assert.NotEmpty(t, captured.Events[0].Dimensions["invocation_id"]) assert.NotEmpty(t, captured.Events[0].Dimensions["os"]) assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) } func TestServiceDisable(t *testing.T) { t.Run("drops recorded events from flushed payload", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload called := false svc := newService(func(p SendTelemetryPayload) { called = true captured = p }, nil) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Disable() svc.Flush() assert.True(t, called, "flusher should still be called so log mode can surface the absence of events") assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") }) t.Run("drops events even with multiple recorded events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload called := false svc := newService(func(p SendTelemetryPayload) { called = true captured = p }, nil) svc.Record(ghtelemetry.Event{Type: "event1"}) svc.Record(ghtelemetry.Event{Type: "event2"}) svc.Record(ghtelemetry.Event{Type: "event3"}) svc.Disable() svc.Flush() assert.True(t, called, "flusher should still be called") assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") }) t.Run("can be called before any events are recorded", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload called := false svc := newService(func(p SendTelemetryPayload) { called = true captured = p }, nil) svc.Disable() svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() assert.True(t, called, "flusher should still be called") assert.Empty(t, captured.Events, "events recorded after Disable() should be dropped") }) } func TestNoOpService(t *testing.T) { svc := &NoOpService{} // All methods should be safe to call without panicking svc.Record(ghtelemetry.Event{Type: "test"}) svc.Disable() svc.SetSampleRate(50) svc.Flush() } func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) { // Build a payload larger than maxPayloadSize (16KB) largeDimensions := map[string]string{ "data": strings.Repeat("x", maxPayloadSize), } payload := SendTelemetryPayload{ Events: []PayloadEvent{ {Type: "test", Dimensions: largeDimensions}, }, } // This should not panic or spawn a process - it silently returns. // We can't easily assert the subprocess wasn't started, but we verify // the function doesn't crash. SpawnSendTelemetry("/nonexistent/binary", payload) }