Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions cmd/entire/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,25 @@ func NewRootCmd() *cobra.Command {
HiddenDefaultCmd: true,
},
PersistentPostRun: func(cmd *cobra.Command, _ []string) {
// Skip analytics for the analytics command itself (prevents recursion)
if cmd.Name() == "__send_analytics" {
return
}
Copy link

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 because TrackCommandDetached already skips hidden commands, and __send_analytics is defined with Hidden: true. The comment states this "prevents recursion" but the actual recursion prevention happens in TrackCommandDetached via the cmd.Hidden check. This creates confusion about where the safeguard actually lives and adds unnecessary code. The only benefit is avoiding a LoadEntireSettings call for the analytics command, but this optimization intent isn't documented.

Fix in Cursor Fix in Web


// Load telemetry preference from settings (ignore errors - nil defaults to disabled)
var telemetryEnabled *bool
settings, err := LoadEntireSettings()
if err == nil {
telemetryEnabled = settings.Telemetry
}

// Initialize telemetry client and add to context
telemetryClient := telemetry.NewClient(Version, telemetryEnabled)
defer telemetryClient.Close()
telemetryClient.TrackCommand(cmd, settings.Strategy, settings.Agent, settings.Enabled)
// Check if telemetry is enabled
if telemetryEnabled == nil || !*telemetryEnabled {
return
}

// Use detached tracking (non-blocking)
telemetry.TrackCommandDetached(cmd, settings.Strategy, settings.Agent, settings.Enabled, Version)
},
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
Expand All @@ -70,6 +78,7 @@ func NewRootCmd() *cobra.Command {
cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newExplainCmd())
cmd.AddCommand(newDebugCmd())
cmd.AddCommand(newSendAnalyticsCmd())

// Replace default help command with custom one that supports -t flag
cmd.SetHelpCommand(NewHelpCmd(cmd))
Expand All @@ -88,3 +97,16 @@ func newVersionCmd() *cobra.Command {
},
}
}

// newSendAnalyticsCmd creates the hidden command for sending analytics from a detached subprocess.
// This command is invoked by TrackCommandDetached and should not be called directly by users.
func newSendAnalyticsCmd() *cobra.Command {
return &cobra.Command{
Use: "__send_analytics",
Hidden: true,
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
telemetry.SendEvent(args[0])
},
}
}
149 changes: 149 additions & 0 deletions cmd/entire/cli/telemetry/detached.go
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,
})
}
11 changes: 11 additions & 0 deletions cmd/entire/cli/telemetry/detached_other.go
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
}
118 changes: 118 additions & 0 deletions cmd/entire/cli/telemetry/detached_test.go
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("{}")
}
48 changes: 48 additions & 0 deletions cmd/entire/cli/telemetry/detached_unix.go
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()
}
Loading