diff --git a/pkg/stripe/analytics_telemetry.go b/pkg/stripe/analytics_telemetry.go index ba4c22a61..69fbd7bab 100644 --- a/pkg/stripe/analytics_telemetry.go +++ b/pkg/stripe/analytics_telemetry.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "os" "runtime" "strconv" "strings" @@ -43,6 +44,7 @@ type CLIAnalyticsEventMetadata struct { CLIVersion string `url:"cli_version"` // the version of the CLI OS string `url:"os"` // the OS of the system GeneratedResource bool `url:"generated_resource"` // whether or not this was a generated resource + AIAgent string `url:"ai_agent,omitempty"` // the AI coding agent that invoked the CLI, if any } // TelemetryClient is an interface that can send two types of events: an API request, and just general events. @@ -72,6 +74,7 @@ func NewEventMetadata() *CLIAnalyticsEventMetadata { InvocationID: uuid.NewString(), CLIVersion: version.Version, OS: runtime.GOOS, + AIAgent: DetectAIAgent(os.Getenv), } } @@ -237,3 +240,30 @@ func TelemetryOptedOut(optoutVar string) bool { return optoutVar == "1" || optoutVar == "true" } + +// DetectAIAgent detects if the CLI was invoked by a coding agent, based on well-known env vars. +// It accepts an environment getter function to allow testing without modifying the actual environment. +func DetectAIAgent(getEnv func(string) string) string { + if getEnv("ANTIGRAVITY_CLI_ALIAS") != "" { + return "antigravity" + } + if getEnv("CLAUDECODE") != "" { + return "claude_code" + } + if getEnv("CLINE_ACTIVE") != "" { + return "cline" + } + if getEnv("CODEX_SANDBOX") != "" { + return "codex_cli" + } + if getEnv("CURSOR_AGENT") != "" { + return "cursor" + } + if getEnv("GEMINI_CLI") != "" { + return "gemini_cli" + } + if getEnv("OPENCODE") != "" { + return "open_code" + } + return "" +} diff --git a/pkg/stripe/analytics_telemetry_test.go b/pkg/stripe/analytics_telemetry_test.go index 6dc03b06d..2672a4a6e 100644 --- a/pkg/stripe/analytics_telemetry_test.go +++ b/pkg/stripe/analytics_telemetry_test.go @@ -96,6 +96,8 @@ func TestSendAPIRequestEvent(t *testing.T) { require.Contains(t, bodyString, "os=darwin") require.Contains(t, bodyString, "request_id=req_zzz") require.Contains(t, bodyString, "user_agent=Unit+Test") + // ai_agent should not be present when empty (omitempty) + require.NotContains(t, bodyString, "ai_agent") })) defer ts.Close() baseURL, _ := url.Parse(ts.URL) @@ -149,6 +151,8 @@ func TestSendEvent(t *testing.T) { require.Contains(t, bodyString, "merchant=acct_1234") require.Contains(t, bodyString, "os=darwin") require.Contains(t, bodyString, "user_agent=Unit+Test") + // ai_agent should not be present when empty (omitempty) + require.NotContains(t, bodyString, "ai_agent") })) defer ts.Close() baseURL, _ := url.Parse(ts.URL) @@ -167,6 +171,33 @@ func TestSendEvent(t *testing.T) { analyticsClient.SendEvent(processCtx, "foo", "bar") } +func TestSendEvent_WithAIAgent(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + bodyString := string(body) + require.Contains(t, bodyString, "ai_agent=cursor") + })) + defer ts.Close() + baseURL, _ := url.Parse(ts.URL) + + // Create metadata and manually set AIAgent field + telemetryMetadata := &stripe.CLIAnalyticsEventMetadata{ + InvocationID: "123456", + UserAgent: "Unit Test", + CLIVersion: "master", + OS: "darwin", + CommandPath: "stripe test", + Merchant: "acct_1234", + GeneratedResource: false, + AIAgent: "cursor", + } + + processCtx := stripe.WithEventMetadata(context.Background(), telemetryMetadata) + analyticsClient := stripe.AnalyticsTelemetryClient{BaseURL: baseURL, HTTPClient: &http.Client{}} + analyticsClient.SendEvent(processCtx, "foo", "bar") +} + func TestSkipsSendEventWhenMetadataIsEmpty(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Fail(t, "Did not expect to reach sendData") @@ -191,3 +222,67 @@ func TestTelemetryOptedOut(t *testing.T) { require.True(t, stripe.TelemetryOptedOut("True")) require.True(t, stripe.TelemetryOptedOut("TRUE")) } + +// AI Agent Detection Tests +func TestDetectAIAgent_WithClaudeCode(t *testing.T) { + // Mock env getter that returns CLAUDECODE + getEnv := func(key string) string { + if key == "CLAUDECODE" { + return "1" + } + return "" + } + result := stripe.DetectAIAgent(getEnv) + require.Equal(t, "claude_code", result) +} + +func TestDetectAIAgent_NoAgentDetected(t *testing.T) { + // Mock env getter that returns empty strings + getEnv := func(key string) string { + return "" + } + result := stripe.DetectAIAgent(getEnv) + require.Equal(t, "", result) +} + +func TestAIAgentDetection_AllAgents(t *testing.T) { + tests := []struct { + name string + envVar string + expected string + }{ + {"Antigravity", "ANTIGRAVITY_CLI_ALIAS", "antigravity"}, + {"Claude Code", "CLAUDECODE", "claude_code"}, + {"Cline", "CLINE_ACTIVE", "cline"}, + {"Codex CLI", "CODEX_SANDBOX", "codex_cli"}, + {"Cursor", "CURSOR_AGENT", "cursor"}, + {"Gemini CLI", "GEMINI_CLI", "gemini_cli"}, + {"Open Code", "OPENCODE", "open_code"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock env getter that returns only the specific env var being tested + getEnv := func(key string) string { + if key == tt.envVar { + return "true" + } + return "" + } + result := stripe.DetectAIAgent(getEnv) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestAIAgentDetection_Priority(t *testing.T) { + // Test that the first matching env var wins (antigravity comes before cursor) + getEnv := func(key string) string { + if key == "ANTIGRAVITY_CLI_ALIAS" || key == "CURSOR_AGENT" { + return "1" + } + return "" + } + result := stripe.DetectAIAgent(getEnv) + require.Equal(t, "antigravity", result) +}