The sample_rate common dimension was set once at service creation time and never updated when SetSampleRate was called later. This caused commands like 'gh skill publish' that override the sample rate via PersistentPreRunE to report the wrong sample_rate in telemetry events (e.g. 1 instead of 100). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
723 lines
20 KiB
Go
723 lines
20 KiB
Go
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 = "<unknown>"
|
|
}
|
|
|
|
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, "<unknown>", 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)
|
|
}
|