-
Notifications
You must be signed in to change notification settings - Fork 129
sends posthog events in a detached mode #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1fc9e4c
sends posthog events in a detached mode
gtrrz-victor 23a9c9d
fix lint
gtrrz-victor 3d0f9ee
fix test
gtrrz-victor 8c0db60
add no-op spawnDetachedAnalytics for non-unix platforms
gtrrz-victor 1e37755
fix lint
gtrrz-victor 32611c1
APIKey and Endpoint fields have been removed from EventPayload
gtrrz-victor 65fa564
go back to use go run
Soph 732e342
fix: point stdout to is.Discard
gtrrz-victor File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| package telemetry | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "runtime" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/denisbrodbeck/machineid" | ||
| "github.com/posthog/posthog-go" | ||
| "github.com/spf13/cobra" | ||
| "github.com/spf13/pflag" | ||
| ) | ||
|
|
||
| var ( | ||
| // PostHogAPIKey is set at build time for production | ||
| PostHogAPIKey = "phc_development_key" | ||
| // PostHogEndpoint is set at build time for production | ||
| PostHogEndpoint = "https://eu.i.posthog.com" | ||
| ) | ||
|
|
||
| // EventPayload represents the data passed to the detached subprocess. | ||
| // Note: APIKey and Endpoint are intentionally excluded to avoid exposing | ||
| // them in process listings (ps/top). SendEvent reads them from package-level vars. | ||
| type EventPayload struct { | ||
| Event string `json:"event"` | ||
| DistinctID string `json:"distinct_id"` | ||
| Properties map[string]any `json:"properties"` | ||
| Timestamp time.Time `json:"timestamp"` | ||
| } | ||
|
|
||
| // silentLogger suppresses PostHog log output - expected for CLI best-effort telemetry | ||
| type silentLogger struct{} | ||
|
|
||
| func (silentLogger) Logf(_ string, _ ...interface{}) {} | ||
| func (silentLogger) Debugf(_ string, _ ...interface{}) {} | ||
| func (silentLogger) Warnf(_ string, _ ...interface{}) {} | ||
| func (silentLogger) Errorf(_ string, _ ...interface{}) {} | ||
|
|
||
| // BuildEventPayload constructs the event payload for tracking. | ||
| // Exported for testing. Returns nil if the payload cannot be built. | ||
| func BuildEventPayload(cmd *cobra.Command, strategy, agent string, isEntireEnabled bool, version string) *EventPayload { | ||
| if cmd == nil { | ||
| return nil | ||
| } | ||
|
|
||
| // Get machine ID for distinct_id | ||
| machineID, err := machineid.ProtectedID("entire-cli") | ||
| if err != nil { | ||
| return nil | ||
| } | ||
|
|
||
| // Collect flag names (not values) for privacy | ||
| var flags []string | ||
| cmd.Flags().Visit(func(flag *pflag.Flag) { | ||
| flags = append(flags, flag.Name) | ||
| }) | ||
|
|
||
| selectedAgent := agent | ||
| if selectedAgent == "" { | ||
| selectedAgent = "auto" | ||
| } | ||
|
|
||
| properties := map[string]any{ | ||
| "command": cmd.CommandPath(), | ||
| "strategy": strategy, | ||
| "agent": selectedAgent, | ||
| "isEntireEnabled": isEntireEnabled, | ||
| "cli_version": version, | ||
| "os": runtime.GOOS, | ||
| "arch": runtime.GOARCH, | ||
| } | ||
|
|
||
| if len(flags) > 0 { | ||
| properties["flags"] = strings.Join(flags, ",") | ||
| } | ||
|
|
||
| return &EventPayload{ | ||
| Event: "cli_command_executed", | ||
| DistinctID: machineID, | ||
| Properties: properties, | ||
| Timestamp: time.Now(), | ||
| } | ||
| } | ||
|
|
||
| // TrackCommandDetached tracks a command execution by spawning a detached subprocess. | ||
| // This returns immediately without blocking the CLI. | ||
| func TrackCommandDetached(cmd *cobra.Command, strategy, agent string, isEntireEnabled bool, version string) { | ||
| // Check opt-out environment variables | ||
| if os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" { | ||
| return | ||
| } | ||
|
|
||
| if cmd == nil { | ||
| return | ||
| } | ||
|
|
||
| // Skip hidden commands | ||
| if cmd.Hidden { | ||
| return | ||
| } | ||
|
|
||
| payload := BuildEventPayload(cmd, strategy, agent, isEntireEnabled, version) | ||
| if payload == nil { | ||
| return | ||
| } | ||
|
|
||
| if payloadJSON, err := json.Marshal(payload); err == nil { | ||
| spawnDetachedAnalytics(string(payloadJSON)) | ||
| } | ||
| } | ||
|
|
||
| // SendEvent processes an event payload in the detached subprocess. | ||
| // This is called by the hidden __send_analytics command. | ||
| func SendEvent(payloadJSON string) { | ||
| var payload EventPayload | ||
| if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { | ||
| return | ||
| } | ||
|
|
||
| // Create PostHog client - no need for fast timeouts since we're detached | ||
| // Read API key and endpoint from package-level vars (not passed via argv for security) | ||
| client, err := posthog.NewWithConfig(PostHogAPIKey, posthog.Config{ | ||
| Endpoint: PostHogEndpoint, | ||
| Logger: silentLogger{}, | ||
| DisableGeoIP: posthog.Ptr(true), | ||
| }) | ||
| if err != nil { | ||
| return | ||
| } | ||
| defer func() { | ||
| _ = client.Close() | ||
| }() | ||
|
|
||
| // Build properties | ||
| props := posthog.NewProperties() | ||
| for k, v := range payload.Properties { | ||
| props.Set(k, v) | ||
| } | ||
|
|
||
| //nolint:errcheck // Best effort telemetry - don't block on result | ||
| _ = client.Enqueue(posthog.Capture{ | ||
| DistinctId: payload.DistinctID, | ||
| Event: payload.Event, | ||
| Properties: props, | ||
| Timestamp: payload.Timestamp, | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| //go:build !unix | ||
|
|
||
| package telemetry | ||
|
|
||
| // spawnDetachedAnalytics is a no-op on non-Unix platforms. | ||
| // Windows support for detached processes would require different syscall flags | ||
| // (CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS), but telemetry is best-effort | ||
| // so we simply skip it on unsupported platforms. | ||
| func spawnDetachedAnalytics(string) { | ||
| // No-op: detached subprocess spawning not implemented for this platform | ||
| } | ||
gtrrz-victor marked this conversation as resolved.
Show resolved
Hide resolved
gtrrz-victor marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| package telemetry | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func TestEventPayloadSerialization(t *testing.T) { | ||
| payload := EventPayload{ | ||
| Event: "cli_command_executed", | ||
| DistinctID: "test-machine-id", | ||
| Properties: map[string]any{ | ||
| "command": "entire status", | ||
| "strategy": "manual-commit", | ||
| "agent": "claude-code", | ||
| "isEntireEnabled": true, | ||
| "cli_version": "1.0.0", | ||
| "os": "darwin", | ||
| "arch": "arm64", | ||
| }, | ||
| Timestamp: time.Date(2026, 1, 28, 12, 0, 0, 0, time.UTC), | ||
| } | ||
|
|
||
| // Serialize | ||
| data, err := json.Marshal(payload) | ||
| if err != nil { | ||
| t.Fatalf("Failed to marshal EventPayload: %v", err) | ||
| } | ||
|
|
||
| // Deserialize | ||
| var decoded EventPayload | ||
| if err := json.Unmarshal(data, &decoded); err != nil { | ||
| t.Fatalf("Failed to unmarshal EventPayload: %v", err) | ||
| } | ||
|
|
||
| // Verify fields | ||
| if decoded.Event != payload.Event { | ||
| t.Errorf("Event = %q, want %q", decoded.Event, payload.Event) | ||
| } | ||
| if decoded.DistinctID != payload.DistinctID { | ||
| t.Errorf("DistinctID = %q, want %q", decoded.DistinctID, payload.DistinctID) | ||
| } | ||
| if !decoded.Timestamp.Equal(payload.Timestamp) { | ||
| t.Errorf("Timestamp = %v, want %v", decoded.Timestamp, payload.Timestamp) | ||
| } | ||
|
|
||
| // Verify properties | ||
| if cmd, ok := decoded.Properties["command"].(string); !ok || cmd != "entire status" { | ||
| t.Errorf("Properties[command] = %v, want %q", decoded.Properties["command"], "entire status") | ||
| } | ||
| } | ||
|
|
||
| func TestTrackCommandDetachedSkipsNilCommand(_ *testing.T) { | ||
| // Should not panic with nil command | ||
| TrackCommandDetached(nil, "manual-commit", "claude-code", true, "1.0.0") | ||
| } | ||
|
|
||
| func TestTrackCommandDetachedSkipsHiddenCommands(_ *testing.T) { | ||
| hiddenCmd := &cobra.Command{ | ||
| Use: "__send_analytics", | ||
| Hidden: true, | ||
| } | ||
|
|
||
| // Should not panic and should skip hidden commands | ||
| TrackCommandDetached(hiddenCmd, "manual-commit", "claude-code", true, "1.0.0") | ||
| } | ||
|
|
||
| func TestTrackCommandDetachedRespectsOptOut(t *testing.T) { | ||
| t.Setenv("ENTIRE_TELEMETRY_OPTOUT", "1") | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "status", | ||
| } | ||
|
|
||
| // Should not panic and should respect opt-out | ||
| TrackCommandDetached(cmd, "manual-commit", "claude-code", true, "1.0.0") | ||
| } | ||
|
|
||
| func TestBuildEventPayloadAgent(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| inputAgent string | ||
| expectedAgent string | ||
| }{ | ||
| {"defaults empty to auto", "", "auto"}, | ||
| {"preserves explicit agent", "claude-code", "claude-code"}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| cmd := &cobra.Command{Use: "test"} | ||
| payload := BuildEventPayload(cmd, "manual-commit", tt.inputAgent, true, "1.0.0") | ||
| if payload == nil { | ||
| t.Fatal("Expected non-nil payload") | ||
| return | ||
| } | ||
|
|
||
| agent, ok := payload.Properties["agent"].(string) | ||
| if !ok { | ||
| t.Fatal("Expected agent property to be a string") | ||
| return | ||
| } | ||
| if agent != tt.expectedAgent { | ||
| t.Errorf("agent = %q, want %q", agent, tt.expectedAgent) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestSendEventHandlesInvalidJSON(_ *testing.T) { | ||
| // Should not panic with invalid JSON | ||
| SendEvent("invalid json") | ||
| SendEvent("") | ||
| SendEvent("{}") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| //go:build unix | ||
|
|
||
| package telemetry | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "syscall" | ||
| ) | ||
|
|
||
| // spawnDetachedAnalytics spawns a detached subprocess to send analytics. | ||
| // On Unix, this uses process group detachment so the subprocess continues | ||
| // after the parent exits. | ||
| func spawnDetachedAnalytics(payloadJSON string) { | ||
| executable, err := os.Executable() | ||
| if err != nil { | ||
| return | ||
| } | ||
|
|
||
| //nolint:gosec // G204: payloadJSON is controlled internally, not user input | ||
| cmd := exec.CommandContext(context.Background(), executable, "__send_analytics", payloadJSON) | ||
|
|
||
| // Detach from parent process group so subprocess survives parent exit | ||
| cmd.SysProcAttr = &syscall.SysProcAttr{ | ||
| Setpgid: true, | ||
| } | ||
|
|
||
| // Don't hold the working directory | ||
| cmd.Dir = "/" | ||
|
|
||
| // Inherit environment (may be needed for network config) | ||
| cmd.Env = os.Environ() | ||
|
|
||
| // Discard stdout/stderr to prevent output leaking to parent's terminal | ||
| cmd.Stdout = io.Discard | ||
| cmd.Stderr = io.Discard | ||
|
|
||
| // Start the process (non-blocking) | ||
| if err := cmd.Start(); err != nil { | ||
| return | ||
| } | ||
|
|
||
| // Release the process so it can run independently | ||
| //nolint:errcheck // Best effort - process should continue regardless | ||
| _ = cmd.Process.Release() | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant analytics command check with misleading comment
Low Severity
The check
cmd.Name() == "__send_analytics"is redundant becauseTrackCommandDetachedalready skips hidden commands, and__send_analyticsis defined withHidden: true. The comment states this "prevents recursion" but the actual recursion prevention happens inTrackCommandDetachedvia thecmd.Hiddencheck. This creates confusion about where the safeguard actually lives and adds unnecessary code. The only benefit is avoiding aLoadEntireSettingscall for the analytics command, but this optimization intent isn't documented.