Skip to content

Commit 03a2936

Browse files
committed
adding agent flag for agent telemtry
1 parent 65d1bc3 commit 03a2936

5 files changed

Lines changed: 433 additions & 15 deletions

File tree

cmd/analytics/analytics.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,74 @@
11
package analytics
22

33
import (
4+
"os"
5+
46
"github.com/launchdarkly/ldcli/cmd/cliflags"
57

68
"github.com/spf13/cobra"
79
"github.com/spf13/pflag"
810
"github.com/spf13/viper"
11+
"golang.org/x/term"
912
)
1013

14+
type envChecker interface {
15+
Getenv(key string) string
16+
IsTerminal(fd int) bool
17+
StdinFd() int
18+
StdoutFd() int
19+
}
20+
21+
type osEnvChecker struct{}
22+
23+
func (osEnvChecker) Getenv(key string) string { return os.Getenv(key) }
24+
func (osEnvChecker) IsTerminal(fd int) bool { return term.IsTerminal(fd) }
25+
func (osEnvChecker) StdinFd() int { return int(os.Stdin.Fd()) }
26+
func (osEnvChecker) StdoutFd() int { return int(os.Stdout.Fd()) }
27+
28+
type agentEnvVar struct {
29+
envVar string
30+
label string
31+
}
32+
33+
// Ordered list so detection priority is deterministic.
34+
var knownAgentEnvVars = []agentEnvVar{
35+
{"CURSOR_SESSION_ID", "cursor"},
36+
{"CURSOR_TRACE_ID", "cursor"},
37+
{"CLAUDE_CODE", "claude-code"},
38+
{"CLAUDE_CODE_SESSION", "claude-code"},
39+
{"CODEX_SESSION", "codex"},
40+
{"CODEX_SANDBOX_ID", "codex"},
41+
{"DEVIN_SESSION", "devin"},
42+
{"GITHUB_COPILOT", "copilot"},
43+
{"WINDSURF_SESSION", "windsurf"},
44+
{"CLINE_TASK_ID", "cline"},
45+
{"AIDER_MODEL", "aider"},
46+
}
47+
48+
// DetectAgentContext returns a label identifying the agent environment, or ""
49+
// if the CLI appears to be running in an interactive human terminal.
50+
func DetectAgentContext() string {
51+
return detectAgentContext(osEnvChecker{})
52+
}
53+
54+
func detectAgentContext(env envChecker) string {
55+
if v := env.Getenv("LD_CLI_AGENT"); v != "" {
56+
return "explicit:" + v
57+
}
58+
59+
for _, a := range knownAgentEnvVars {
60+
if env.Getenv(a.envVar) != "" {
61+
return a.label
62+
}
63+
}
64+
65+
if !env.IsTerminal(env.StdinFd()) && !env.IsTerminal(env.StdoutFd()) {
66+
return "no-tty"
67+
}
68+
69+
return ""
70+
}
71+
1172
func CmdRunEventProperties(
1273
cmd *cobra.Command,
1374
name string,

cmd/analytics/analytics_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package analytics
2+
3+
import (
4+
"testing"
5+
6+
"github.com/spf13/cobra"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
type mockEnvChecker struct {
11+
envVars map[string]string
12+
stdinTerminal bool
13+
stdoutTerminal bool
14+
}
15+
16+
func newMockEnv(envVars map[string]string, bothTerminal bool) mockEnvChecker {
17+
return mockEnvChecker{envVars: envVars, stdinTerminal: bothTerminal, stdoutTerminal: bothTerminal}
18+
}
19+
20+
func (m mockEnvChecker) Getenv(key string) string { return m.envVars[key] }
21+
func (m mockEnvChecker) IsTerminal(fd int) bool {
22+
if fd == 0 {
23+
return m.stdinTerminal
24+
}
25+
return m.stdoutTerminal
26+
}
27+
func (m mockEnvChecker) StdinFd() int { return 0 }
28+
func (m mockEnvChecker) StdoutFd() int { return 1 }
29+
30+
func TestDetectAgentContext(t *testing.T) {
31+
tests := []struct {
32+
name string
33+
env mockEnvChecker
34+
expected string
35+
}{
36+
{
37+
name: "explicit LD_CLI_AGENT",
38+
env: newMockEnv(map[string]string{"LD_CLI_AGENT": "my-skill"}, false),
39+
expected: "explicit:my-skill",
40+
},
41+
{
42+
name: "explicit LD_CLI_AGENT takes precedence over known env vars",
43+
env: newMockEnv(map[string]string{"LD_CLI_AGENT": "custom", "CURSOR_SESSION_ID": "abc"}, false),
44+
expected: "explicit:custom",
45+
},
46+
{
47+
name: "CURSOR_SESSION_ID detected",
48+
env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "abc"}, false),
49+
expected: "cursor",
50+
},
51+
{
52+
name: "CURSOR_TRACE_ID detected",
53+
env: newMockEnv(map[string]string{"CURSOR_TRACE_ID": "xyz"}, false),
54+
expected: "cursor",
55+
},
56+
{
57+
name: "CLAUDE_CODE detected",
58+
env: newMockEnv(map[string]string{"CLAUDE_CODE": "1"}, false),
59+
expected: "claude-code",
60+
},
61+
{
62+
name: "CLAUDE_CODE_SESSION detected",
63+
env: newMockEnv(map[string]string{"CLAUDE_CODE_SESSION": "sess"}, false),
64+
expected: "claude-code",
65+
},
66+
{
67+
name: "CODEX_SESSION detected",
68+
env: newMockEnv(map[string]string{"CODEX_SESSION": "s"}, false),
69+
expected: "codex",
70+
},
71+
{
72+
name: "CODEX_SANDBOX_ID detected",
73+
env: newMockEnv(map[string]string{"CODEX_SANDBOX_ID": "sb"}, false),
74+
expected: "codex",
75+
},
76+
{
77+
name: "DEVIN_SESSION detected",
78+
env: newMockEnv(map[string]string{"DEVIN_SESSION": "d"}, false),
79+
expected: "devin",
80+
},
81+
{
82+
name: "GITHUB_COPILOT detected",
83+
env: newMockEnv(map[string]string{"GITHUB_COPILOT": "1"}, false),
84+
expected: "copilot",
85+
},
86+
{
87+
name: "WINDSURF_SESSION detected",
88+
env: newMockEnv(map[string]string{"WINDSURF_SESSION": "w"}, false),
89+
expected: "windsurf",
90+
},
91+
{
92+
name: "CLINE_TASK_ID detected",
93+
env: newMockEnv(map[string]string{"CLINE_TASK_ID": "t"}, false),
94+
expected: "cline",
95+
},
96+
{
97+
name: "AIDER_MODEL detected",
98+
env: newMockEnv(map[string]string{"AIDER_MODEL": "gpt-4"}, false),
99+
expected: "aider",
100+
},
101+
{
102+
name: "no TTY and no env vars returns no-tty",
103+
env: newMockEnv(map[string]string{}, false),
104+
expected: "no-tty",
105+
},
106+
{
107+
name: "interactive terminal with no agent env vars returns empty",
108+
env: newMockEnv(map[string]string{}, true),
109+
expected: "",
110+
},
111+
{
112+
name: "first matching env var wins by priority order",
113+
env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "c", "CLAUDE_CODE": "1"}, false),
114+
expected: "cursor",
115+
},
116+
{
117+
name: "stdin is TTY but stdout is not still counts as interactive",
118+
env: mockEnvChecker{envVars: map[string]string{}, stdinTerminal: true, stdoutTerminal: false},
119+
expected: "",
120+
},
121+
{
122+
name: "stdout is TTY but stdin is not still counts as interactive",
123+
env: mockEnvChecker{envVars: map[string]string{}, stdinTerminal: false, stdoutTerminal: true},
124+
expected: "",
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
result := detectAgentContext(tt.env)
131+
assert.Equal(t, tt.expected, result)
132+
})
133+
}
134+
}
135+
136+
func TestCmdRunEventProperties(t *testing.T) {
137+
t.Run("does not set agent_context itself", func(t *testing.T) {
138+
cmd := &cobra.Command{Use: "test"}
139+
cmd.SetArgs([]string{})
140+
_ = cmd.Execute()
141+
142+
props := CmdRunEventProperties(cmd, "test-resource", nil)
143+
144+
_, hasAgentCtx := props["agent_context"]
145+
assert.False(t, hasAgentCtx, "agent_context is injected by sendEvent, not CmdRunEventProperties")
146+
})
147+
148+
t.Run("overrides can still set agent_context", func(t *testing.T) {
149+
cmd := &cobra.Command{Use: "test"}
150+
cmd.SetArgs([]string{})
151+
_ = cmd.Execute()
152+
153+
overrides := map[string]interface{}{"agent_context": "explicit:test-agent"}
154+
props := CmdRunEventProperties(cmd, "test-resource", overrides)
155+
156+
assert.Equal(t, "explicit:test-agent", props["agent_context"])
157+
})
158+
159+
t.Run("returns expected base properties", func(t *testing.T) {
160+
cmd := &cobra.Command{Use: "test"}
161+
cmd.SetArgs([]string{})
162+
_ = cmd.Execute()
163+
164+
props := CmdRunEventProperties(cmd, "flags", nil)
165+
166+
assert.Equal(t, "flags", props["name"])
167+
assert.Contains(t, props, "action")
168+
assert.Contains(t, props, "flags")
169+
})
170+
}

cmd/root.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,9 @@ func Execute(version string) {
242242
}
243243
configService := config.NewService(resources.NewClient(version))
244244
trackerFn := analytics.ClientFn{
245-
ID: uuid.New().String(),
246-
Version: version,
245+
ID: uuid.New().String(),
246+
Version: version,
247+
AgentContext: cmdAnalytics.DetectAgentContext(),
247248
}
248249
rootCmd, err := NewRootCommand(
249250
configService,

internal/analytics/client.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import (
1212
)
1313

1414
type ClientFn struct {
15-
ID string
16-
Version string
15+
ID string
16+
Version string
17+
AgentContext string
1718
}
1819

1920
func (fn ClientFn) Tracker(accessToken string, baseURI string, optOut bool) Tracker {
@@ -25,25 +26,30 @@ func (fn ClientFn) Tracker(accessToken string, baseURI string, optOut bool) Trac
2526
httpClient: &http.Client{
2627
Timeout: time.Second * 3,
2728
},
28-
id: fn.ID,
29-
version: fn.Version,
30-
accessToken: accessToken,
31-
baseURI: baseURI,
29+
id: fn.ID,
30+
version: fn.Version,
31+
accessToken: accessToken,
32+
baseURI: baseURI,
33+
agentContext: fn.AgentContext,
3234
}
3335
}
3436

3537
type Client struct {
36-
accessToken string
37-
baseURI string
38-
httpClient *http.Client
39-
id string
40-
version string
41-
wg sync.WaitGroup
38+
accessToken string
39+
agentContext string
40+
baseURI string
41+
httpClient *http.Client
42+
id string
43+
version string
44+
wg sync.WaitGroup
4245
}
4346

44-
// SendEvent makes an async request to track the given event with properties.
47+
// sendEvent makes an async request to track the given event with properties.
4548
func (c *Client) sendEvent(eventName string, properties map[string]interface{}) {
4649
properties["id"] = c.id
50+
if c.agentContext != "" {
51+
properties["agent_context"] = c.agentContext
52+
}
4753
input := struct {
4854
Event string `json:"event"`
4955
Properties map[string]interface{} `json:"properties"`

0 commit comments

Comments
 (0)