Skip to content

Commit 3beb908

Browse files
feat(telemetry): emit cli_context property on main events (#269)
1 parent cc2c293 commit 3beb908

4 files changed

Lines changed: 215 additions & 1 deletion

File tree

‎pkg/telemetry/clicontext.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"regexp"
6+
"strings"
7+
8+
"github.com/algolia/cli/pkg/utils"
9+
)
10+
11+
const (
12+
ContextHuman = "human"
13+
ContextAgentUnknown = "agent:unknown"
14+
)
15+
16+
var agentEnvVars = []struct {
17+
envVar string
18+
name string
19+
}{
20+
{"CLAUDECODE", "claude-code"},
21+
{"CLAUDE_CODE", "claude-code"},
22+
{"CLAUDE_CODE_SESSION_ID", "claude-code"},
23+
{"CURSOR_AGENT", "cursor"},
24+
{"CODEX_THREAD_ID", "codex"},
25+
{"CODEX_SANDBOX", "codex"},
26+
{"CODEX_CI", "codex"},
27+
{"GEMINI_CLI", "gemini"},
28+
{"COPILOT_CLI", "github-copilot"},
29+
{"OPENCODE", "opencode"},
30+
{"OPENCODE_CLIENT", "opencode"},
31+
{"AMP_CURRENT_THREAD_ID", "amp"},
32+
{"AUGMENT_AGENT", "augment"},
33+
{"GOOSE_TERMINAL", "goose"},
34+
{"CLINE_ACTIVE", "cline"},
35+
{"ANTIGRAVITY_AGENT", "antigravity"},
36+
{"PI_CODING_AGENT", "pi"},
37+
{"KIRO_AGENT_PATH", "kiro"},
38+
}
39+
40+
var genericAgentNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
41+
42+
var booleanishValues = map[string]bool{
43+
"true": true,
44+
"false": true,
45+
"yes": true,
46+
"no": true,
47+
"on": true,
48+
"off": true,
49+
}
50+
51+
func DetectCLIContext() string {
52+
return detectCLIContext(
53+
os.Getenv,
54+
utils.IsTerminal(os.Stdin),
55+
utils.IsTerminal(os.Stdout),
56+
utils.IsCI(),
57+
)
58+
}
59+
60+
func detectCLIContext(getenv func(string) string, stdinTTY, stdoutTTY, isCI bool) string {
61+
for _, agent := range agentEnvVars {
62+
if getenv(agent.envVar) != "" {
63+
return "agent:" + agent.name
64+
}
65+
}
66+
for _, key := range []string{"AI_AGENT", "AGENT"} {
67+
v, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(getenv(key))), "_")
68+
if genericAgentNameRe.MatchString(v) && !booleanishValues[v] {
69+
return "agent:" + v
70+
}
71+
}
72+
if !stdinTTY && !stdoutTTY && !isCI {
73+
return ContextAgentUnknown
74+
}
75+
return ContextHuman
76+
}

‎pkg/telemetry/clicontext_test.go‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package telemetry
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func fakeEnv(vars map[string]string) func(string) string {
10+
return func(key string) string { return vars[key] }
11+
}
12+
13+
func TestDetectCLIContext_KnownAgents(t *testing.T) {
14+
cases := []struct {
15+
envVar string
16+
want string
17+
}{
18+
{"CLAUDECODE", "agent:claude-code"},
19+
{"CLAUDE_CODE", "agent:claude-code"},
20+
{"CLAUDE_CODE_SESSION_ID", "agent:claude-code"},
21+
{"CURSOR_AGENT", "agent:cursor"},
22+
{"CODEX_THREAD_ID", "agent:codex"},
23+
{"CODEX_SANDBOX", "agent:codex"},
24+
{"CODEX_CI", "agent:codex"},
25+
{"GEMINI_CLI", "agent:gemini"},
26+
{"COPILOT_CLI", "agent:github-copilot"},
27+
{"OPENCODE", "agent:opencode"},
28+
{"OPENCODE_CLIENT", "agent:opencode"},
29+
{"AMP_CURRENT_THREAD_ID", "agent:amp"},
30+
{"AUGMENT_AGENT", "agent:augment"},
31+
{"GOOSE_TERMINAL", "agent:goose"},
32+
{"CLINE_ACTIVE", "agent:cline"},
33+
{"ANTIGRAVITY_AGENT", "agent:antigravity"},
34+
{"PI_CODING_AGENT", "agent:pi"},
35+
{"KIRO_AGENT_PATH", "agent:kiro"},
36+
}
37+
for _, c := range cases {
38+
t.Run(c.envVar, func(t *testing.T) {
39+
got := detectCLIContext(fakeEnv(map[string]string{c.envVar: "1"}), true, true, false)
40+
assert.Equal(t, c.want, got)
41+
})
42+
}
43+
}
44+
45+
func TestDetectCLIContext_GenericAIAgentVar(t *testing.T) {
46+
got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "SomeAgent"}), true, true, false)
47+
assert.Equal(t, "agent:someagent", got)
48+
}
49+
50+
func TestDetectCLIContext_GenericAgentVar(t *testing.T) {
51+
got := detectCLIContext(fakeEnv(map[string]string{"AGENT": "goose"}), true, true, false)
52+
assert.Equal(t, "agent:goose", got)
53+
}
54+
55+
func TestDetectCLIContext_AIAgentWinsOverAgent(t *testing.T) {
56+
env := fakeEnv(map[string]string{"AI_AGENT": "v0", "AGENT": "goose"})
57+
assert.Equal(t, "agent:v0", detectCLIContext(env, true, true, false))
58+
}
59+
60+
func TestDetectCLIContext_WhitespaceOnlyGenericVar(t *testing.T) {
61+
got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": " "}), true, true, false)
62+
assert.Equal(t, "human", got)
63+
}
64+
65+
func TestDetectCLIContext_VersionStampedGenericVar(t *testing.T) {
66+
got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "claude-code_2-1-210_agent"}), true, true, false)
67+
assert.Equal(t, "agent:claude-code", got)
68+
}
69+
70+
func TestDetectCLIContext_LeadingUnderscoreGenericVar(t *testing.T) {
71+
got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "_foo"}), true, true, false)
72+
assert.Equal(t, "human", got)
73+
}
74+
75+
func TestDetectCLIContext_NumericGenericVar(t *testing.T) {
76+
got := detectCLIContext(fakeEnv(map[string]string{"AGENT": "1"}), true, true, false)
77+
assert.Equal(t, "human", got)
78+
}
79+
80+
func TestDetectCLIContext_BooleanishGenericVar(t *testing.T) {
81+
got := detectCLIContext(fakeEnv(map[string]string{"AGENT": "true"}), true, true, false)
82+
assert.Equal(t, "human", got)
83+
}
84+
85+
func TestDetectCLIContext_OverlongGenericVar(t *testing.T) {
86+
got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "a-very-long-agent-name-exceeding-32-characters"}), true, true, false)
87+
assert.Equal(t, "human", got)
88+
}
89+
90+
func TestDetectCLIContext_NamedVarWinsOverGeneric(t *testing.T) {
91+
env := fakeEnv(map[string]string{"CLAUDECODE": "1", "AI_AGENT": "other"})
92+
assert.Equal(t, "agent:claude-code", detectCLIContext(env, true, true, false))
93+
}
94+
95+
func TestDetectCLIContext_NoTTYNoCI(t *testing.T) {
96+
assert.Equal(t, "agent:unknown", detectCLIContext(fakeEnv(nil), false, false, false))
97+
}
98+
99+
func TestDetectCLIContext_NoTTYInCI(t *testing.T) {
100+
assert.Equal(t, "human", detectCLIContext(fakeEnv(nil), false, false, true))
101+
}
102+
103+
func TestDetectCLIContext_PipedOutputOnly(t *testing.T) {
104+
assert.Equal(t, "human", detectCLIContext(fakeEnv(nil), true, false, false))
105+
}
106+
107+
func TestDetectCLIContext_Interactive(t *testing.T) {
108+
assert.Equal(t, "human", detectCLIContext(fakeEnv(nil), true, true, false))
109+
}

‎pkg/telemetry/telemetry.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ type CLIAnalyticsEventMetadata struct {
141141
CommandFlags []string // the command flags is the full list of flags passed to the command
142142
CLIVersion string // the version of the CLI
143143
OS string // the OS of the system
144+
CLIContext string
144145
}
145146

146147
// NewEventMetadata initializes an instance of CLIAnalyticsEventContext
@@ -150,6 +151,7 @@ func NewEventMetadata() *CLIAnalyticsEventMetadata {
150151
InvocationID: uuid.NewRandom().String(),
151152
CLIVersion: version.Version,
152153
OS: runtime.GOOS,
154+
CLIContext: DetectCLIContext(),
153155
}
154156
}
155157

@@ -263,7 +265,7 @@ func (a *AnalyticsTelemetryClient) Track(
263265
) error {
264266
metadata := GetEventMetadata(ctx)
265267

266-
props := make(map[string]any, len(properties)+5)
268+
props := make(map[string]any, len(properties)+6)
267269
for k, v := range properties {
268270
props[k] = v
269271
}
@@ -273,6 +275,7 @@ func (a *AnalyticsTelemetryClient) Track(
273275
props["command"] = metadata.CommandPath
274276
props["flags"] = metadata.CommandFlags
275277
props["sequence"] = a.sequence.Add(1)
278+
props["cli_context"] = metadata.CLIContext
276279

277280
track := analytics.Track{
278281
Event: event,

‎pkg/telemetry/telemetry_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package telemetry
33
import (
44
"context"
55
"net/http"
6+
"strings"
67
"sync"
78
"testing"
89

@@ -215,6 +216,31 @@ func TestTrack_MergesCustomProperties(t *testing.T) {
215216
assert.Equal(t, "app-id", track.Properties["app_id"])
216217
}
217218

219+
func TestTrack_IncludesCLIContext(t *testing.T) {
220+
fake := &fakeAnalyticsClient{}
221+
client := &AnalyticsTelemetryClient{client: fake}
222+
223+
metadata := NewEventMetadata()
224+
metadata.CLIContext = "agent:claude-code"
225+
ctx := WithEventMetadata(context.Background(), metadata)
226+
227+
require.NoError(t, client.Track(ctx, "Command Invoked", nil))
228+
require.Len(t, fake.messages, 1)
229+
230+
track, ok := fake.messages[0].(analytics.Track)
231+
require.True(t, ok)
232+
assert.Equal(t, "agent:claude-code", track.Properties["cli_context"])
233+
}
234+
235+
func TestNewEventMetadata_DetectsCLIContext(t *testing.T) {
236+
metadata := NewEventMetadata()
237+
assert.NotEmpty(t, metadata.CLIContext)
238+
valid := metadata.CLIContext == ContextHuman ||
239+
metadata.CLIContext == ContextAgentUnknown ||
240+
strings.HasPrefix(metadata.CLIContext, "agent:")
241+
assert.True(t, valid)
242+
}
243+
218244
func TestTrack_SequenceIsMonotonic(t *testing.T) {
219245
fake := &fakeAnalyticsClient{}
220246
client := &AnalyticsTelemetryClient{client: fake}

0 commit comments

Comments
 (0)