diff --git a/go.mod b/go.mod index 9f51e6a..e9497ee 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.3 require ( github.com/gofrs/flock v0.13.0 + github.com/posthog/posthog-go v1.12.6 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 @@ -24,8 +25,10 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index c74d58b..f82d09d 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -23,6 +25,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -36,6 +40,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.12.6 h1:N+FrKWY6DOuDhV2OMgvtKAKDYGTdtS9/nuvr0BTyBp0= +github.com/posthog/posthog-go v1.12.6/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/telemetry/deferred.go b/telemetry/deferred.go index b16f490..5b8ed16 100644 --- a/telemetry/deferred.go +++ b/telemetry/deferred.go @@ -1,4 +1,5 @@ -// Package telemetry provides small utilities around OpenTelemetry. +// Package telemetry provides small utilities around OpenTelemetry and anonymous +// application telemetry. package telemetry import ( diff --git a/telemetry/posthog.go b/telemetry/posthog.go new file mode 100644 index 0000000..b70fb06 --- /dev/null +++ b/telemetry/posthog.go @@ -0,0 +1,458 @@ +package telemetry + +import ( + "errors" + "math" + "net/http" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/posthog/posthog-go" +) + +const ( + // DefaultPostHogEndpoint is PostHog's US ingest endpoint. + DefaultPostHogEndpoint = "https://us.i.posthog.com" + // GenericTelemetryEnabledEnv disables telemetry for callers that honor the + // conventional unprefixed variable. + GenericTelemetryEnabledEnv = "TELEMETRY_ENABLED" +) + +// ErrUnsupportedTelemetryEvent is returned when an event is not in a reporter's +// event allowlist. +var ErrUnsupportedTelemetryEvent = errors.New("unsupported telemetry event") + +var postHogTelemetryDisabled atomic.Bool + +// TelemetryPropertyFilter validates and returns a safe event property value. +type TelemetryPropertyFilter func(any) (any, bool) + +// AllowedTelemetryProperty configures one safe property for an allowed event. +type AllowedTelemetryProperty struct { + name string + filter TelemetryPropertyFilter +} + +// PostHogOption customizes a PostHog telemetry reporter. +type PostHogOption interface { + applyPostHogOption(*postHogReporterConfig) +} + +// PostHogClient is the daemon-facing telemetry reporter contract. +type PostHogClient interface { + Capture(event string, properties map[string]any) error + Close() error + Enabled() bool +} + +// PostHogOptions configures a PostHog telemetry reporter. +type PostHogOptions struct { + // APIKey is the PostHog project API key. It is a public ingest identifier, + // but callers must still pass it explicitly so kit never embeds app keys. + APIKey string + // Endpoint defaults to DefaultPostHogEndpoint when empty. + Endpoint string + // Application is included on every event and cannot be overridden by Capture. + Application string + // EnvPrefix enables PREFIX_TELEMETRY_ENABLED=0 opt-out handling. The generic + // TELEMETRY_ENABLED=0 variable is also honored. + EnvPrefix string + // DistinctID must be an anonymous stable installation or instance ID. + DistinctID string + Version string + Commit string + // Source defaults to "daemon" when empty. + Source string +} + +// PostHogReporter sanitizes and submits anonymous telemetry events to PostHog. +type PostHogReporter struct { + mu sync.Mutex + client postHogEnqueueCloser + distinctID string + version string + commit string + application string + source string + allowedEvents map[string]map[string]TelemetryPropertyFilter + enabled bool +} + +type postHogEnqueueCloser interface { + Enqueue(posthog.Message) error + Close() error +} + +type postHogClientFactory func(apiKey string, config posthog.Config) (postHogEnqueueCloser, error) + +type postHogReporterConfig struct { + allowedEvents map[string]map[string]TelemetryPropertyFilter +} + +type postHogOptionFunc func(*postHogReporterConfig) + +func (f postHogOptionFunc) applyPostHogOption(config *postHogReporterConfig) { + f(config) +} + +// AllowTelemetryProperty creates an allowlisted property entry for WithAllowedEvent. +func AllowTelemetryProperty(name string, filter TelemetryPropertyFilter) AllowedTelemetryProperty { + return AllowedTelemetryProperty{ + name: strings.TrimSpace(name), + filter: filter, + } +} + +// WithAllowedEvent allows event and the listed sanitized properties. +func WithAllowedEvent(event string, properties ...AllowedTelemetryProperty) PostHogOption { + return postHogOptionFunc(func(config *postHogReporterConfig) { + if config == nil { + return + } + event = strings.TrimSpace(event) + if event == "" { + return + } + if config.allowedEvents == nil { + config.allowedEvents = map[string]map[string]TelemetryPropertyFilter{} + } + allowedProperties := config.allowedEvents[event] + if allowedProperties == nil { + allowedProperties = map[string]TelemetryPropertyFilter{} + config.allowedEvents[event] = allowedProperties + } + for _, property := range properties { + if property.name == "" || property.filter == nil { + continue + } + allowedProperties[property.name] = property.filter + } + }) +} + +// PostHogTelemetryEnabledFromEnv reports whether telemetry is enabled for envPrefix. +func PostHogTelemetryEnabledFromEnv(envPrefix string) bool { + if PostHogTelemetryDisabled() { + return false + } + if strings.TrimSpace(os.Getenv(GenericTelemetryEnabledEnv)) == "0" { + return false + } + if env := PrefixedTelemetryEnabledEnv(envPrefix); env != "" { + return strings.TrimSpace(os.Getenv(env)) != "0" + } + return true +} + +// PrefixedTelemetryEnabledEnv returns the telemetry opt-out environment variable +// for prefix, such as KATA_TELEMETRY_ENABLED. +func PrefixedTelemetryEnabledEnv(prefix string) string { + prefix = strings.Trim(strings.ToUpper(strings.TrimSpace(prefix)), "_") + if prefix == "" { + return "" + } + return prefix + "_TELEMETRY_ENABLED" +} + +// DisablePostHogTelemetry disables PostHog telemetry for this process. +// +// Callers can use this from their own build-tagged file, for example: +// +// //go:build kata_test +// package telemetry +// +// import kittelemetry "go.kenn.io/kit/telemetry" +// +// func init() { +// kittelemetry.DisablePostHogTelemetry() +// } +func DisablePostHogTelemetry() { + postHogTelemetryDisabled.Store(true) +} + +// PostHogTelemetryDisabled reports whether telemetry was disabled for this process. +func PostHogTelemetryDisabled() bool { + return postHogTelemetryDisabled.Load() +} + +// NewPostHogReporter builds an enabled reporter or returns a disabled reporter +// when telemetry is opted out by build tag or environment variable. +func NewPostHogReporter(opts PostHogOptions, options ...PostHogOption) (*PostHogReporter, error) { + return newPostHogReporter(opts, func(apiKey string, config posthog.Config) (postHogEnqueueCloser, error) { + return posthog.NewWithConfig(apiKey, config) + }, options...) +} + +func newPostHogReporter(opts PostHogOptions, newClient postHogClientFactory, options ...PostHogOption) (*PostHogReporter, error) { + if !PostHogTelemetryEnabledFromEnv(opts.EnvPrefix) { + return DisabledPostHogReporter(), nil + } + if newClient == nil { + return nil, errors.New("posthog client factory is required") + } + if strings.TrimSpace(opts.APIKey) == "" { + return nil, errors.New("posthog api key is required") + } + if strings.TrimSpace(opts.Application) == "" { + return nil, errors.New("telemetry application is required") + } + if strings.TrimSpace(opts.EnvPrefix) == "" { + return nil, errors.New("telemetry env prefix is required") + } + if strings.TrimSpace(opts.DistinctID) == "" { + return nil, errors.New("telemetry distinct id is required") + } + + config := postHogReporterConfig{} + for _, option := range options { + if option != nil { + option.applyPostHogOption(&config) + } + } + allowedEvents := cloneAllowedTelemetryEvents(config.allowedEvents) + if len(allowedEvents) == 0 { + return nil, errors.New("telemetry allowed events are required") + } + + endpoint := strings.TrimSpace(opts.Endpoint) + if endpoint == "" { + endpoint = DefaultPostHogEndpoint + } + disableGeoIP := true + client, err := newClient(strings.TrimSpace(opts.APIKey), posthog.Config{ + Endpoint: endpoint, + DisableGeoIP: &disableGeoIP, + Transport: postHogDisableTransport{}, + }) + if err != nil { + return nil, err + } + + return &PostHogReporter{ + client: client, + distinctID: strings.TrimSpace(opts.DistinctID), + version: opts.Version, + commit: opts.Commit, + application: strings.TrimSpace(opts.Application), + source: defaultString(strings.TrimSpace(opts.Source), "daemon"), + allowedEvents: allowedEvents, + enabled: true, + }, nil +} + +// DisabledPostHogReporter returns a reporter that drops events without network calls. +func DisabledPostHogReporter() *PostHogReporter { + return &PostHogReporter{} +} + +// Enabled reports whether the reporter can submit telemetry events. +func (r *PostHogReporter) Enabled() bool { + if r == nil { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + return r.activeLocked() && !PostHogTelemetryDisabled() +} + +// EventAllowed reports whether event is included in the reporter's allowlist. +func (r *PostHogReporter) EventAllowed(event string) bool { + if r == nil { + return false + } + _, ok := r.allowedEvents[strings.TrimSpace(event)] + return ok +} + +// SanitizeProperties returns only allowlisted properties for event. +func (r *PostHogReporter) SanitizeProperties(event string, properties map[string]any) (map[string]any, error) { + if r == nil { + return nil, ErrUnsupportedTelemetryEvent + } + allowedProperties, ok := r.allowedEvents[strings.TrimSpace(event)] + if !ok { + return nil, ErrUnsupportedTelemetryEvent + } + + safeProperties := map[string]any{} + for key, value := range properties { + key = strings.TrimSpace(key) + filter, ok := allowedProperties[key] + if !ok { + continue + } + if safeValue, ok := filter(value); ok { + safeProperties[key] = safeValue + } + } + r.addDefaultProperties(safeProperties) + return safeProperties, nil +} + +// Capture sanitizes and queues an anonymous telemetry event. +func (r *PostHogReporter) Capture(event string, properties map[string]any) error { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + if !r.activeLocked() || PostHogTelemetryDisabled() { + return nil + } + event = strings.TrimSpace(event) + if event == "" { + return errors.New("telemetry event is required") + } + + props, err := r.SanitizeProperties(event, properties) + if err != nil { + return err + } + + return r.client.Enqueue(posthog.Capture{ + DistinctId: r.distinctID, + Event: event, + Timestamp: time.Now().UTC(), + Properties: posthog.Properties(props), + }) +} + +// Close stops the underlying telemetry client when the reporter is enabled. +// Reporter-created PostHog clients use a process-disable-aware transport, so +// Close can drain the SDK locally without network sends after telemetry is +// disabled for the process. +func (r *PostHogReporter) Close() error { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + if !r.activeLocked() { + return nil + } + if err := r.client.Close(); err != nil { + return err + } + r.deactivateLocked() + return nil +} + +func (r *PostHogReporter) activeLocked() bool { + return r.enabled && r.client != nil +} + +func (r *PostHogReporter) deactivateLocked() { + r.enabled = false + r.client = nil +} + +type postHogDisableTransport struct { + base http.RoundTripper +} + +func (t postHogDisableTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if PostHogTelemetryDisabled() { + return &http.Response{ + StatusCode: http.StatusNoContent, + Status: "204 " + http.StatusText(http.StatusNoContent), + Header: make(http.Header), + Body: http.NoBody, + Request: req, + }, nil + } + base := t.base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(req) +} + +func (r *PostHogReporter) addDefaultProperties(props map[string]any) { + props["$process_person_profile"] = false + props["$geoip_disable"] = true + props["application"] = r.application + props["source"] = r.source + props["version"] = r.version + props["commit"] = r.commit + props["goos"] = runtime.GOOS + props["goarch"] = runtime.GOARCH +} + +// AllowTelemetryNumber accepts finite numeric telemetry values. +func AllowTelemetryNumber(value any) (any, bool) { + switch v := value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return v, true + case float32: + if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) { + return nil, false + } + return v, true + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return nil, false + } + return v, true + default: + return nil, false + } +} + +// AllowTelemetryBool accepts boolean telemetry values. +func AllowTelemetryBool(value any) (any, bool) { + v, ok := value.(bool) + return v, ok +} + +// AllowTelemetryStringValues accepts only the listed string values. +func AllowTelemetryStringValues(values ...string) TelemetryPropertyFilter { + allowed := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + allowed[value] = struct{}{} + } + } + return func(value any) (any, bool) { + v, ok := value.(string) + if !ok { + return nil, false + } + v = strings.TrimSpace(v) + if _, ok := allowed[v]; !ok { + return nil, false + } + return v, true + } +} + +func cloneAllowedTelemetryEvents(events map[string]map[string]TelemetryPropertyFilter) map[string]map[string]TelemetryPropertyFilter { + cloned := make(map[string]map[string]TelemetryPropertyFilter, len(events)) + for event, properties := range events { + event = strings.TrimSpace(event) + if event == "" { + continue + } + clonedProperties := map[string]TelemetryPropertyFilter{} + for property, filter := range properties { + property = strings.TrimSpace(property) + if property == "" || filter == nil { + continue + } + clonedProperties[property] = filter + } + cloned[event] = clonedProperties + } + return cloned +} + +func defaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/telemetry/posthog_disabled.go b/telemetry/posthog_disabled.go new file mode 100644 index 0000000..8b6c940 --- /dev/null +++ b/telemetry/posthog_disabled.go @@ -0,0 +1,7 @@ +//go:build kit_posthog_disabled + +package telemetry + +func init() { + DisablePostHogTelemetry() +} diff --git a/telemetry/posthog_example_test.go b/telemetry/posthog_example_test.go new file mode 100644 index 0000000..4c9b97a --- /dev/null +++ b/telemetry/posthog_example_test.go @@ -0,0 +1,46 @@ +package telemetry_test + +import ( + "log" + "os" + + "go.kenn.io/kit/telemetry" +) + +func ExamplePostHogReporter_Capture_daemonActive() { + // Examples disable telemetry so `go test` never submits events. + // Real callers should omit this when telemetry is allowed. + if err := os.Setenv("KATA_TELEMETRY_ENABLED", "0"); err != nil { + log.Fatal(err) + } + defer func() { + if err := os.Unsetenv("KATA_TELEMETRY_ENABLED"); err != nil { + log.Fatal(err) + } + }() + + reporter, err := telemetry.NewPostHogReporter(telemetry.PostHogOptions{ + APIKey: "caller-owned-posthog-project-api-key", + Application: "kata", + EnvPrefix: "KATA", + DistinctID: "anonymous-instance-id", + Version: "v1.2.3", + Commit: "abc1234", + }, telemetry.WithAllowedEvent("daemon_active", + telemetry.AllowTelemetryProperty("project_count", telemetry.AllowTelemetryNumber), + )) + if err != nil { + log.Fatal(err) + } + defer func() { + if err := reporter.Close(); err != nil { + log.Fatal(err) + } + }() + + if err := reporter.Capture("daemon_active", map[string]any{ + "project_count": 3, + }); err != nil { + log.Fatal(err) + } +} diff --git a/telemetry/posthog_test.go b/telemetry/posthog_test.go new file mode 100644 index 0000000..cdec9b1 --- /dev/null +++ b/telemetry/posthog_test.go @@ -0,0 +1,427 @@ +package telemetry + +import ( + "net/http" + "net/http/httptest" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/posthog/posthog-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakePostHogClient struct { + message posthog.Message + closed bool +} + +func (f *fakePostHogClient) Enqueue(message posthog.Message) error { + f.message = message + return nil +} + +func (f *fakePostHogClient) Close() error { + f.closed = true + return nil +} + +type blockingPostHogClient struct { + message posthog.Message + enqueueStarted chan struct{} + unblockEnqueue chan struct{} + closeCalled atomic.Bool +} + +func (b *blockingPostHogClient) Enqueue(message posthog.Message) error { + b.message = message + close(b.enqueueStarted) + <-b.unblockEnqueue + return nil +} + +func (b *blockingPostHogClient) Close() error { + b.closeCalled.Store(true) + return nil +} + +func TestPrefixedTelemetryEnabledEnv(t *testing.T) { + assert.Equal(t, "KATA_TELEMETRY_ENABLED", PrefixedTelemetryEnabledEnv(" kata ")) + assert.Equal(t, "ROBOREV_TELEMETRY_ENABLED", PrefixedTelemetryEnabledEnv("ROBOREV")) + assert.Empty(t, PrefixedTelemetryEnabledEnv("")) +} + +func TestPostHogTelemetryEnabledFromEnvHonorsPrefixAndGenericDisable(t *testing.T) { + enablePostHogTelemetryForTest() + t.Cleanup(enablePostHogTelemetryForTest) + + t.Setenv("KATA_TELEMETRY_ENABLED", "0") + assert.False(t, PostHogTelemetryEnabledFromEnv("kata")) + + t.Setenv("KATA_TELEMETRY_ENABLED", "1") + assert.True(t, PostHogTelemetryEnabledFromEnv("kata")) + + t.Setenv(GenericTelemetryEnabledEnv, "0") + assert.False(t, PostHogTelemetryEnabledFromEnv("kata")) +} + +func TestPostHogTelemetryEnabledFromEnvHonorsProcessDisable(t *testing.T) { + DisablePostHogTelemetry() + t.Cleanup(enablePostHogTelemetryForTest) + + t.Setenv("KATA_TELEMETRY_ENABLED", "1") + t.Setenv(GenericTelemetryEnabledEnv, "1") + + assert.False(t, PostHogTelemetryEnabledFromEnv("kata")) +} + +func TestNewPostHogReporterDisabledByEnvSkipsRequiredFields(t *testing.T) { + enablePostHogTelemetryForTest() + t.Cleanup(enablePostHogTelemetryForTest) + t.Setenv(GenericTelemetryEnabledEnv, "0") + + reporter, err := NewPostHogReporter(PostHogOptions{}) + + require.NoError(t, err) + assert.False(t, reporter.Enabled()) +} + +func TestNewPostHogReporterRequiresCallerOwnedConfigurationWhenEnabled(t *testing.T) { + enablePostHogTelemetryForTest() + t.Cleanup(enablePostHogTelemetryForTest) + t.Setenv(GenericTelemetryEnabledEnv, "1") + t.Setenv("KATA_TELEMETRY_ENABLED", "1") + + _, err := newPostHogReporter(PostHogOptions{ + Application: "kata", + EnvPrefix: "KATA", + DistinctID: "anonymous-instance-id", + }, func(string, posthog.Config) (postHogEnqueueCloser, error) { + return &fakePostHogClient{}, nil + }, testAllowedTelemetryOptions()...) + + require.Error(t, err) + assert.Contains(t, err.Error(), "api key") +} + +func TestNewPostHogReporterPassesMandatoryAPIKeyAndEndpointToPostHog(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + enablePostHogTelemetryForTest() + t.Cleanup(enablePostHogTelemetryForTest) + t.Setenv(GenericTelemetryEnabledEnv, "1") + t.Setenv("KATA_TELEMETRY_ENABLED", "1") + + var gotAPIKey string + var gotConfig posthog.Config + reporter, err := newPostHogReporter(PostHogOptions{ + APIKey: "caller-owned-key", + Endpoint: "https://posthog.example.test", + Application: "kata", + EnvPrefix: "KATA", + DistinctID: "anonymous-instance-id", + }, func(apiKey string, config posthog.Config) (postHogEnqueueCloser, error) { + gotAPIKey = apiKey + gotConfig = config + return &fakePostHogClient{}, nil + }, testAllowedTelemetryOptions()...) + + require.NoError(err) + require.True(reporter.Enabled()) + assert.Equal("caller-owned-key", gotAPIKey) + assert.Equal("https://posthog.example.test", gotConfig.Endpoint) + require.NotNil(gotConfig.DisableGeoIP) + assert.True(*gotConfig.DisableGeoIP) +} + +func TestPostHogReporterCaptureUsesAnonymousDistinctIDAndPrivacyDefaults(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + client := &fakePostHogClient{} + reporter := &PostHogReporter{ + client: client, + distinctID: "anonymous-instance-id", + application: "kata", + version: "v-test", + commit: "abc123", + source: "daemon", + allowedEvents: testAllowedTelemetryEvents(), + enabled: true, + } + + err := reporter.Capture("daemon_started", map[string]any{ + "$geoip_disable": false, + "$process_person_profile": true, + "application": "caller-app", + "distinct_id": "user-provided", + "path": "/Users/example/private", + "project_count": 3, + "sync_enabled": true, + "view": "dashboard", + }) + + require.NoError(err) + capture, ok := client.message.(posthog.Capture) + require.True(ok) + assert.Equal("anonymous-instance-id", capture.DistinctId) + assert.Equal("daemon_started", capture.Event) + assert.Equal(3, capture.Properties["project_count"]) + assert.Equal(true, capture.Properties["sync_enabled"]) + assert.NotContains(capture.Properties, "distinct_id") + assert.NotContains(capture.Properties, "path") + assert.NotContains(capture.Properties, "view") + assert.False(capture.Properties["$process_person_profile"].(bool)) + assert.True(capture.Properties["$geoip_disable"].(bool)) + assert.Equal("kata", capture.Properties["application"]) + assert.Equal("v-test", capture.Properties["version"]) + assert.Equal("abc123", capture.Properties["commit"]) + assert.Equal(runtime.GOOS, capture.Properties["goos"]) + assert.Equal(runtime.GOARCH, capture.Properties["goarch"]) + assert.Equal("daemon", capture.Properties["source"]) +} + +func TestPostHogReporterCaptureRejectsUnsupportedEvents(t *testing.T) { + reporter := &PostHogReporter{ + client: &fakePostHogClient{}, + distinctID: "anonymous-instance-id", + application: "kata", + allowedEvents: testAllowedTelemetryEvents(), + enabled: true, + } + + err := reporter.Capture("issue_created", map[string]any{"project_count": 1}) + + require.ErrorIs(t, err, ErrUnsupportedTelemetryEvent) +} + +func TestPostHogReporterCaptureDropsUnsafePropertyValues(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + client := &fakePostHogClient{} + reporter := &PostHogReporter{ + client: client, + distinctID: "anonymous-instance-id", + application: "kata", + source: "daemon", + allowedEvents: testAllowedTelemetryEvents(), + enabled: true, + } + + err := reporter.Capture("daemon_active", map[string]any{ + "project_count": "private-project-name", + "sync_enabled": "yes", + "view": "bad/path", + }) + + require.NoError(err) + capture, ok := client.message.(posthog.Capture) + require.True(ok) + assert.NotContains(capture.Properties, "project_count") + assert.NotContains(capture.Properties, "sync_enabled") + assert.NotContains(capture.Properties, "view") +} + +func TestPostHogReporterAllowsDefaultPropertiesOnlyEvents(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + enablePostHogTelemetryForTest() + t.Cleanup(enablePostHogTelemetryForTest) + t.Setenv(GenericTelemetryEnabledEnv, "1") + t.Setenv("KATA_TELEMETRY_ENABLED", "1") + + client := &fakePostHogClient{} + reporter, err := newPostHogReporter(PostHogOptions{ + APIKey: "caller-owned-key", + Application: "kata", + EnvPrefix: "KATA", + DistinctID: "anonymous-instance-id", + Version: "v-test", + Commit: "abc123", + }, func(string, posthog.Config) (postHogEnqueueCloser, error) { + return client, nil + }, WithAllowedEvent("event_without_properties")) + require.NoError(err) + + err = reporter.Capture("event_without_properties", map[string]any{ + "private_path": "/Users/example/private", + }) + require.NoError(err) + + capture, ok := client.message.(posthog.Capture) + require.True(ok) + assert.Equal("event_without_properties", capture.Event) + assert.NotContains(capture.Properties, "private_path") + assert.Equal("kata", capture.Properties["application"]) + assert.Equal("daemon", capture.Properties["source"]) + assert.Equal("v-test", capture.Properties["version"]) + assert.Equal("abc123", capture.Properties["commit"]) + assert.False(capture.Properties["$process_person_profile"].(bool)) + assert.True(capture.Properties["$geoip_disable"].(bool)) +} + +func TestPostHogReporterCaptureHonorsProcessDisableAfterCreation(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + client := &fakePostHogClient{} + reporter := &PostHogReporter{ + client: client, + distinctID: "anonymous-instance-id", + application: "kata", + allowedEvents: testAllowedTelemetryEvents(), + enabled: true, + } + require.True(reporter.Enabled()) + + DisablePostHogTelemetry() + t.Cleanup(enablePostHogTelemetryForTest) + + assert.False(reporter.Enabled()) + err := reporter.Capture("daemon_active", map[string]any{"project_count": 1}) + require.NoError(err) + assert.Nil(client.message) + + require.NoError(reporter.Close()) + assert.True(client.closed) + assert.False(reporter.Enabled()) +} + +func TestPostHogDisableTransportNoOpsRequestsAfterProcessDisable(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + enablePostHogTelemetryForTest() + t.Cleanup(enablePostHogTelemetryForTest) + + baseCalled := false + transport := postHogDisableTransport{ + base: roundTripFunc(func(*http.Request) (*http.Response, error) { + baseCalled = true + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }), + } + + DisablePostHogTelemetry() + + resp, err := transport.RoundTrip(httptest.NewRequest(http.MethodPost, "https://posthog.example.test/batch", nil)) + + require.NoError(err) + require.NotNil(resp) + assert.Equal(http.StatusNoContent, resp.StatusCode) + assert.False(baseCalled) +} + +func TestPostHogReporterCloseAfterProcessDisableDoesNotFlushQueuedEvent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + client := &fakePostHogClient{} + reporter := &PostHogReporter{ + client: client, + distinctID: "anonymous-instance-id", + application: "kata", + allowedEvents: testAllowedTelemetryEvents(), + enabled: true, + } + + require.NoError(reporter.Capture("daemon_active", map[string]any{"project_count": 1})) + require.NotNil(client.message) + + DisablePostHogTelemetry() + t.Cleanup(enablePostHogTelemetryForTest) + + require.NoError(reporter.Close()) + assert.True(client.closed) + assert.False(reporter.Enabled()) +} + +func TestPostHogReporterCloseWaitsForInFlightCapture(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + client := &blockingPostHogClient{ + enqueueStarted: make(chan struct{}), + unblockEnqueue: make(chan struct{}), + } + reporter := &PostHogReporter{ + client: client, + distinctID: "anonymous-instance-id", + application: "kata", + allowedEvents: testAllowedTelemetryEvents(), + enabled: true, + } + + captureErr := make(chan error, 1) + go func() { + captureErr <- reporter.Capture("daemon_active", map[string]any{"project_count": 1}) + }() + <-client.enqueueStarted + + closeErr := make(chan error, 1) + go func() { + closeErr <- reporter.Close() + }() + + assert.Never(client.closeCalled.Load, 50*time.Millisecond, 5*time.Millisecond) + + close(client.unblockEnqueue) + require.NoError(<-captureErr) + require.NoError(<-closeErr) + assert.True(client.closeCalled.Load()) + assert.False(reporter.Enabled()) +} + +func TestAllowTelemetryStringValues(t *testing.T) { + filter := AllowTelemetryStringValues("pulls.list") + + value, ok := filter(" pulls.list ") + require.True(t, ok) + assert.Equal(t, "pulls.list", value) + + _, ok = filter("private-project-name") + assert.False(t, ok) +} + +func testAllowedTelemetryOptions() []PostHogOption { + return []PostHogOption{ + WithAllowedEvent("daemon_active", + AllowTelemetryProperty("project_count", AllowTelemetryNumber), + AllowTelemetryProperty("sync_enabled", AllowTelemetryBool), + AllowTelemetryProperty("view", AllowTelemetryStringValues("dashboard", "summary")), + ), + WithAllowedEvent("daemon_started", + AllowTelemetryProperty("project_count", AllowTelemetryNumber), + AllowTelemetryProperty("sync_enabled", AllowTelemetryBool), + ), + } +} + +func testAllowedTelemetryEvents() map[string]map[string]TelemetryPropertyFilter { + return map[string]map[string]TelemetryPropertyFilter{ + "daemon_active": { + "project_count": AllowTelemetryNumber, + "sync_enabled": AllowTelemetryBool, + "view": AllowTelemetryStringValues("dashboard", "summary"), + }, + "daemon_started": { + "project_count": AllowTelemetryNumber, + "sync_enabled": AllowTelemetryBool, + }, + } +} + +func enablePostHogTelemetryForTest() { + postHogTelemetryDisabled.Store(false) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +}