Skip to content

Commit fe2457c

Browse files
authored
feat: log AI coding agents in user-agent, telemetry, and tracing (#550)
* feat: detect AI coding agents and append identifier to user-agent When the Slack CLI is invoked by an AI coding agent (Claude Code, Codex, Gemini CLI, Cline, Cursor, Goose, Amp, or others), detect the agent via environment variables and append an AI-Agent product token to the HTTP user-agent header. Also propagates the agent name to telemetry events and Jaeger tracing spans. User-Agent format: slack-cli/2.38.1 (os: darwin) AI-Agent (name=claude-code, entry=cli) * refactor: extract DetectName() into useragent package Removes duplicate detectAIAgentName/detectAgentName helpers from main.go and tracking.go in favor of useragent.DetectName(). * docs: add godoc comments to useragent package functions * fix: align test function names with Test_Package_Method convention * chore: fix gofmt formatting in import blocks * feat: print user-agent in verbose debug output during startup * refactor: rename logstash field from agent to ai_agent for consistency * chore: alphabetize test functions and rename logstash field to ai_agent * style: remove stray blank line in raw_request.go import block * style: format AI-Agent user-agent properties with colons Use "name: value" / "entry: value" to match the existing "(os: linux)" segment instead of "name=value" / "entry=value". * docs: add full user-agent examples to BuildUserAgent godoc Show concrete User-Agent strings for both the no-agent and AI-agent-detected cases so readers can see the full assembled header at a glance. * refactor: rename useragent.Detect/DetectName to GetAIAgent/GetAIAgentName The package was renamed from agent to useragent and generalized to build the full User-Agent string. The old Detect / DetectName names were ambiguous in the new package context (detect what?). Rename to make their purpose explicit, and rename internal "agent" variables to "aiAgent" to avoid shadowing the broader user-agent concept.
1 parent 5845bf6 commit fe2457c

11 files changed

Lines changed: 273 additions & 17 deletions

File tree

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import (
5252
"github.com/slackapi/slack-cli/internal/slackerror"
5353
"github.com/slackapi/slack-cli/internal/style"
5454
"github.com/slackapi/slack-cli/internal/update"
55+
"github.com/slackapi/slack-cli/internal/useragent"
5556
"github.com/slackapi/slack-cli/internal/version"
5657
"github.com/spf13/cobra"
5758
"github.com/spf13/pflag"
@@ -286,6 +287,7 @@ func InitConfig(ctx context.Context, clients *shared.ClientFactory, rootCmd *cob
286287
rootCmd.SetContext(ctx)
287288
// Debug logging
288289
clients.IO.PrintDebug(ctx, "system_id: %s", clients.Config.SystemID)
290+
clients.IO.PrintDebug(ctx, "user_agent: %s", useragent.BuildUserAgent(version.Raw()))
289291
}
290292

291293
// Init Project ID, if current directory is a project

internal/api/client.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
"io"
2323
"net/http"
2424
"net/url"
25-
"runtime"
2625
"strconv"
2726
"strings"
2827
"time"
@@ -35,6 +34,7 @@ import (
3534
"github.com/slackapi/slack-cli/internal/slackcontext"
3635
"github.com/slackapi/slack-cli/internal/slackdeps"
3736
"github.com/slackapi/slack-cli/internal/slackerror"
37+
"github.com/slackapi/slack-cli/internal/useragent"
3838
"github.com/uber/jaeger-client-go"
3939
)
4040

@@ -128,10 +128,9 @@ func (c *Client) postForm(ctx context.Context, endpoint string, formValues url.V
128128
if err != nil {
129129
return nil, err
130130
}
131-
var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)
132131

133132
request.Header.Add("content-type", "application/x-www-form-urlencoded")
134-
request.Header.Add("User-Agent", userAgent)
133+
request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion))
135134
if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok {
136135
request.Header.Add("x-b3-sampled", "0")
137136
request.Header.Add("x-b3-spanid", jaegerSpanContext.SpanID().String())
@@ -179,8 +178,7 @@ func (c *Client) postJSON(ctx context.Context, endpoint, token string, cookie st
179178
if err != nil {
180179
return nil, err
181180
}
182-
var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)
183-
request.Header.Add("User-Agent", userAgent)
181+
request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion))
184182
if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok {
185183
request.Header.Add("x-b3-sampled", "0")
186184
request.Header.Add("x-b3-spanid", jaegerSpanContext.SpanID().String())
@@ -232,9 +230,8 @@ func (c *Client) get(ctx context.Context, endpoint, token string, cookie string)
232230
if err != nil {
233231
return nil, err
234232
}
235-
var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)
236233

237-
request.Header.Add("User-Agent", userAgent)
234+
request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion))
238235
if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok {
239236
request.Header.Add("x-b3-sampled", "0")
240237
request.Header.Add("x-b3-spanid", jaegerSpanContext.SpanID().String())

internal/api/docs.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ import (
2020
"fmt"
2121
"net/http"
2222
"net/url"
23-
"runtime"
2423

2524
"github.com/opentracing/opentracing-go"
2625
"github.com/slackapi/slack-cli/internal/slackcontext"
26+
"github.com/slackapi/slack-cli/internal/useragent"
2727
)
2828

2929
var docsBaseURL = "https://docs.slack.dev"
@@ -59,7 +59,7 @@ func buildDocsSearchRequest(ctx context.Context, urlStr, cliVersion string) (*ht
5959
if err != nil {
6060
return nil, err
6161
}
62-
req.Header.Add("User-Agent", fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS))
62+
req.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion))
6363
return req, nil
6464
}
6565

internal/api/icon.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ import (
2424
"net/http"
2525
"net/textproto"
2626
"net/url"
27-
"runtime"
2827

2928
"github.com/opentracing/opentracing-go"
3029
"github.com/slackapi/slack-cli/internal/image"
3130
"github.com/slackapi/slack-cli/internal/slackcontext"
3231
"github.com/slackapi/slack-cli/internal/slackerror"
32+
"github.com/slackapi/slack-cli/internal/useragent"
3333
"github.com/spf13/afero"
3434
)
3535

@@ -132,8 +132,7 @@ func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, icon
132132
if err != nil {
133133
return IconResult{}, err
134134
}
135-
var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)
136-
request.Header.Add("User-Agent", userAgent)
135+
request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion))
137136

138137
c.io.PrintDebug(ctx, "HTTP Request: %v %v %v", request.Method, request.URL, request.Proto)
139138
c.io.PrintDebug(ctx, "HTTP Request User-Agent: %s", request.Header.Get("User-Agent"))

internal/api/raw_request.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import (
2121
"io"
2222
"net/http"
2323
"net/url"
24-
"runtime"
2524
"time"
2625

2726
"github.com/opentracing/opentracing-go"
2827
"github.com/slackapi/slack-cli/internal/slackcontext"
28+
"github.com/slackapi/slack-cli/internal/useragent"
2929
"github.com/uber/jaeger-client-go"
3030
)
3131

@@ -67,7 +67,7 @@ func (c *Client) RawRequest(ctx context.Context, httpMethod, endpoint, token str
6767
if err != nil {
6868
return nil, err
6969
}
70-
request.Header.Set("User-Agent", fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS))
70+
request.Header.Set("User-Agent", useragent.BuildUserAgent(cliVersion))
7171

7272
if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok {
7373
request.Header.Set("x-b3-sampled", "0")

internal/api/s3_upload.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ import (
2222
"mime/multipart"
2323
"net/http"
2424
"net/textproto"
25-
"runtime"
2625

2726
"github.com/opentracing/opentracing-go"
2827
"github.com/slackapi/slack-cli/internal/slackcontext"
2928
"github.com/slackapi/slack-cli/internal/slackerror"
29+
"github.com/slackapi/slack-cli/internal/useragent"
3030
"github.com/spf13/afero"
3131
)
3232

@@ -100,8 +100,7 @@ func (c *Client) UploadPackageToS3(ctx context.Context, fs afero.Fs, appID strin
100100
if err != nil {
101101
return fileName, err
102102
}
103-
var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)
104-
request.Header.Add("User-Agent", userAgent)
103+
request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion))
105104

106105
var s3span = opentracing.StartSpan("apiclient.UploadPackageToS3.FileUpload", opentracing.ChildOf(span.Context()))
107106
s3span.SetTag("app", appID)

internal/tracking/logstash_event.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type EventData struct {
4848

4949
// EventContext contains information / metadata about the CLI session
5050
type EventContext struct {
51+
AIAgent string `json:"ai_agent,omitempty"`
5152
Arch string `json:"arch"`
5253
Binary string `json:"bin"`
5354
CLIVersion string `json:"cli_version"`

internal/tracking/tracking.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"github.com/slackapi/slack-cli/internal/ioutils"
3333
"github.com/slackapi/slack-cli/internal/slackcontext"
3434
"github.com/slackapi/slack-cli/internal/style"
35+
"github.com/slackapi/slack-cli/internal/useragent"
3536
)
3637

3738
// TrackingManager is an interface for tracking metrics and events related to CLI activity
@@ -194,6 +195,7 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config,
194195
Timestamp: time.Now().UnixMilli(),
195196
Data: eventData,
196197
Context: EventContext{
198+
AIAgent: useragent.GetAIAgentName(),
197199
CLIVersion: versionString,
198200
Host: ioutils.GetHostname(),
199201
OS: runtime.GOOS,

internal/useragent/useragent.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package useragent
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"runtime"
21+
"strings"
22+
)
23+
24+
// AIAgent represents a detected AI coding agent that invoked the CLI.
25+
type AIAgent struct {
26+
Name string
27+
Entry string
28+
}
29+
30+
// GetAIAgent checks environment variables to determine if the CLI is being run
31+
// by an AI coding agent. Returns nil if no agent is detected. Detection
32+
// priority: CLAUDECODE > CODEX_CI > GEMINI_CLI > CLINE_ACTIVE > CURSOR_AGENT >
33+
// AGENT.
34+
func GetAIAgent() *AIAgent {
35+
switch {
36+
case os.Getenv("CLAUDECODE") == "1":
37+
return &AIAgent{
38+
Name: "claude-code",
39+
Entry: os.Getenv("CLAUDE_CODE_ENTRYPOINT"),
40+
}
41+
case os.Getenv("CODEX_CI") == "1":
42+
return &AIAgent{Name: "codex"}
43+
case os.Getenv("GEMINI_CLI") == "1":
44+
return &AIAgent{Name: "gemini-cli"}
45+
case os.Getenv("CLINE_ACTIVE") == "true":
46+
return &AIAgent{Name: "cline"}
47+
case os.Getenv("CURSOR_AGENT") == "1":
48+
return &AIAgent{Name: "cursor"}
49+
case os.Getenv("AGENT") != "":
50+
return &AIAgent{Name: os.Getenv("AGENT")}
51+
default:
52+
return nil
53+
}
54+
}
55+
56+
// GetAIAgentName returns the normalized name of the detected AI agent, or an
57+
// empty string if no agent is detected.
58+
func GetAIAgentName() string {
59+
if aiAgent := GetAIAgent(); aiAgent != nil {
60+
return aiAgent.Name
61+
}
62+
return ""
63+
}
64+
65+
// BuildUserAgent constructs the HTTP User-Agent header value for the CLI.
66+
// The base format is "slack-cli/<version> (os: <goos>)". If an AI agent is
67+
// detected, an " AI-Agent (name: ..., entry: ...)" suffix is appended.
68+
//
69+
// Examples:
70+
//
71+
// slack-cli/2.38.1 (os: darwin)
72+
// slack-cli/2.38.1 (os: darwin) AI-Agent (name: claude-code, entry: cli)
73+
// slack-cli/2.38.1 (os: linux) AI-Agent (name: cursor)
74+
func BuildUserAgent(cliVersion string) string {
75+
ua := fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)
76+
if aiAgent := GetAIAgent(); aiAgent != nil {
77+
var parts []string
78+
parts = append(parts, "name: "+aiAgent.Name)
79+
if aiAgent.Entry != "" {
80+
parts = append(parts, "entry: "+aiAgent.Entry)
81+
}
82+
ua += " AI-Agent (" + strings.Join(parts, ", ") + ")"
83+
}
84+
return ua
85+
}

0 commit comments

Comments
 (0)