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: 30 additions & 0 deletions pkg/stripe/analytics_telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -72,6 +74,7 @@ func NewEventMetadata() *CLIAnalyticsEventMetadata {
InvocationID: uuid.NewString(),
CLIVersion: version.Version,
OS: runtime.GOOS,
AIAgent: DetectAIAgent(os.Getenv),
}
}

Expand Down Expand Up @@ -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 ""
}
95 changes: 95 additions & 0 deletions pkg/stripe/analytics_telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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)
}