Skip to content

Commit a7206fd

Browse files
authored
feat(REL-12753): adding agent flag for agent telemetry (#659)
* adding agent flag for agent telemtry * pr feedback, splitting no-tty
1 parent 65d1bc3 commit a7206fd

6 files changed

Lines changed: 497 additions & 15 deletions

File tree

cmd/analytics/analytics.go

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

33
import (
4+
_ "embed"
5+
"encoding/json"
6+
"os"
7+
48
"github.com/launchdarkly/ldcli/cmd/cliflags"
59

610
"github.com/spf13/cobra"
711
"github.com/spf13/pflag"
812
"github.com/spf13/viper"
13+
"golang.org/x/term"
14+
)
15+
16+
type envChecker interface {
17+
Getenv(key string) string
18+
IsTerminal(fd int) bool
19+
StdinFd() int
20+
StdoutFd() int
21+
}
22+
23+
type osEnvChecker struct{}
24+
25+
func (osEnvChecker) Getenv(key string) string { return os.Getenv(key) }
26+
func (osEnvChecker) IsTerminal(fd int) bool { return term.IsTerminal(fd) }
27+
func (osEnvChecker) StdinFd() int { return int(os.Stdin.Fd()) }
28+
func (osEnvChecker) StdoutFd() int { return int(os.Stdout.Fd()) }
29+
30+
//go:embed known_agents.json
31+
var knownAgentsJSON []byte
32+
33+
type agentEnvVar struct {
34+
EnvVar string `json:"env_var"`
35+
Label string `json:"label"`
36+
}
37+
38+
type knownAgentsConfig struct {
39+
Agents []agentEnvVar `json:"agents"`
40+
CIEnvVars []string `json:"ci_env_vars"`
41+
}
42+
43+
var (
44+
knownAgentEnvVars []agentEnvVar
45+
knownCIEnvVars []string
946
)
1047

48+
func init() {
49+
var cfg knownAgentsConfig
50+
if err := json.Unmarshal(knownAgentsJSON, &cfg); err != nil {
51+
panic("failed to parse embedded known_agents.json: " + err.Error())
52+
}
53+
knownAgentEnvVars = cfg.Agents
54+
knownCIEnvVars = cfg.CIEnvVars
55+
}
56+
57+
// DetectAgentContext returns a label identifying the agent environment, or ""
58+
// if the CLI appears to be running in an interactive human terminal.
59+
func DetectAgentContext() string {
60+
return detectAgentContext(osEnvChecker{})
61+
}
62+
63+
func detectAgentContext(env envChecker) string {
64+
if v := env.Getenv("LD_CLI_AGENT"); v != "" {
65+
return "explicit:" + v
66+
}
67+
68+
for _, a := range knownAgentEnvVars {
69+
if env.Getenv(a.EnvVar) != "" {
70+
return a.Label
71+
}
72+
}
73+
74+
if env.IsTerminal(env.StdinFd()) || env.IsTerminal(env.StdoutFd()) {
75+
return ""
76+
}
77+
78+
for _, ciVar := range knownCIEnvVars {
79+
if env.Getenv(ciVar) != "" {
80+
return "ci"
81+
}
82+
}
83+
84+
return "unknown-non-interactive"
85+
}
86+
1187
func CmdRunEventProperties(
1288
cmd *cobra.Command,
1389
name string,

cmd/analytics/analytics_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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 unknown-non-interactive",
103+
env: newMockEnv(map[string]string{}, false),
104+
expected: "unknown-non-interactive",
105+
},
106+
{
107+
name: "CI env var returns ci",
108+
env: newMockEnv(map[string]string{"CI": "true"}, false),
109+
expected: "ci",
110+
},
111+
{
112+
name: "GITHUB_ACTIONS env var returns ci",
113+
env: newMockEnv(map[string]string{"GITHUB_ACTIONS": "true"}, false),
114+
expected: "ci",
115+
},
116+
{
117+
name: "GITLAB_CI env var returns ci",
118+
env: newMockEnv(map[string]string{"GITLAB_CI": "true"}, false),
119+
expected: "ci",
120+
},
121+
{
122+
name: "agent env var takes precedence over CI env var",
123+
env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "abc", "CI": "true"}, false),
124+
expected: "cursor",
125+
},
126+
{
127+
name: "interactive terminal with no agent env vars returns empty",
128+
env: newMockEnv(map[string]string{}, true),
129+
expected: "",
130+
},
131+
{
132+
name: "CI env var in interactive terminal returns empty",
133+
env: newMockEnv(map[string]string{"CI": "true"}, true),
134+
expected: "",
135+
},
136+
{
137+
name: "first matching env var wins by priority order",
138+
env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "c", "CLAUDE_CODE": "1"}, false),
139+
expected: "cursor",
140+
},
141+
{
142+
name: "stdin is TTY but stdout is not still counts as interactive",
143+
env: mockEnvChecker{envVars: map[string]string{}, stdinTerminal: true, stdoutTerminal: false},
144+
expected: "",
145+
},
146+
{
147+
name: "stdout is TTY but stdin is not still counts as interactive",
148+
env: mockEnvChecker{envVars: map[string]string{}, stdinTerminal: false, stdoutTerminal: true},
149+
expected: "",
150+
},
151+
}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
result := detectAgentContext(tt.env)
156+
assert.Equal(t, tt.expected, result)
157+
})
158+
}
159+
}
160+
161+
func TestCmdRunEventProperties(t *testing.T) {
162+
t.Run("does not set agent_context itself", func(t *testing.T) {
163+
cmd := &cobra.Command{Use: "test"}
164+
cmd.SetArgs([]string{})
165+
_ = cmd.Execute()
166+
167+
props := CmdRunEventProperties(cmd, "test-resource", nil)
168+
169+
_, hasAgentCtx := props["agent_context"]
170+
assert.False(t, hasAgentCtx, "agent_context is injected by sendEvent, not CmdRunEventProperties")
171+
})
172+
173+
t.Run("overrides can still set agent_context", func(t *testing.T) {
174+
cmd := &cobra.Command{Use: "test"}
175+
cmd.SetArgs([]string{})
176+
_ = cmd.Execute()
177+
178+
overrides := map[string]interface{}{"agent_context": "explicit:test-agent"}
179+
props := CmdRunEventProperties(cmd, "test-resource", overrides)
180+
181+
assert.Equal(t, "explicit:test-agent", props["agent_context"])
182+
})
183+
184+
t.Run("returns expected base properties", func(t *testing.T) {
185+
cmd := &cobra.Command{Use: "test"}
186+
cmd.SetArgs([]string{})
187+
_ = cmd.Execute()
188+
189+
props := CmdRunEventProperties(cmd, "flags", nil)
190+
191+
assert.Equal(t, "flags", props["name"])
192+
assert.Contains(t, props, "action")
193+
assert.Contains(t, props, "flags")
194+
})
195+
}

cmd/analytics/known_agents.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"agents": [
3+
{"env_var": "CURSOR_SESSION_ID", "label": "cursor"},
4+
{"env_var": "CURSOR_TRACE_ID", "label": "cursor"},
5+
{"env_var": "CLAUDE_CODE", "label": "claude-code"},
6+
{"env_var": "CLAUDE_CODE_SESSION", "label": "claude-code"},
7+
{"env_var": "CODEX_SESSION", "label": "codex"},
8+
{"env_var": "CODEX_SANDBOX_ID", "label": "codex"},
9+
{"env_var": "DEVIN_SESSION", "label": "devin"},
10+
{"env_var": "GITHUB_COPILOT", "label": "copilot"},
11+
{"env_var": "WINDSURF_SESSION", "label": "windsurf"},
12+
{"env_var": "CLINE_TASK_ID", "label": "cline"},
13+
{"env_var": "AIDER_MODEL", "label": "aider"}
14+
],
15+
"ci_env_vars": [
16+
"CI",
17+
"GITHUB_ACTIONS",
18+
"JENKINS_URL",
19+
"CIRCLECI",
20+
"BUILDKITE",
21+
"GITLAB_CI",
22+
"TF_BUILD"
23+
]
24+
}

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)