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
102 changes: 96 additions & 6 deletions cmd/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
_ "embed"
"encoding/json"
"os"
"strings"

"github.com/launchdarkly/ldcli/cmd/cliflags"

Expand All @@ -30,26 +31,56 @@ func (osEnvChecker) StdoutFd() int { return int(os.Stdout.Fd()) }
//go:embed known_agents.json
var knownAgentsJSON []byte

// agentEnvVar maps an environment variable to an agent label. If Equals or
// Contains is set, the value must also match. Otherwise any non-empty value
// matches.
// agentEnvVar maps an environment variable to an agent label. If Equals or
// Contains is set, the value must also match. Otherwise any non-empty value
// matches.
type agentEnvVar struct {
EnvVar string `json:"env_var"`
EnvVar string `json:"env_var"`
Equals string `json:"equals,omitempty"`
Contains string `json:"contains,omitempty"`
Label string `json:"label"`
}

func (a agentEnvVar) matches(value string) bool {
switch {
case value == "":
return false
case a.Equals != "":
return value == a.Equals
case a.Contains != "":
return strings.Contains(value, a.Contains)
default:
return true
}
}

// aiAgentPrefix maps the start of an AI_AGENT value to an agent label.
type aiAgentPrefix struct {
Prefix string `json:"prefix"`
Label string `json:"label"`
}

type knownAgentsConfig struct {
Agents []agentEnvVar `json:"agents"`
CIEnvVars []string `json:"ci_env_vars"`
AIAgentPrefixes []aiAgentPrefix `json:"ai_agent_prefixes"`
Agents []agentEnvVar `json:"agents"`
CIEnvVars []string `json:"ci_env_vars"`
}

var (
knownAgentEnvVars []agentEnvVar
knownCIEnvVars []string
knownAIAgentPrefixes []aiAgentPrefix
knownAgentEnvVars []agentEnvVar
knownCIEnvVars []string
)

func init() {
var cfg knownAgentsConfig
if err := json.Unmarshal(knownAgentsJSON, &cfg); err != nil {
panic("failed to parse embedded known_agents.json: " + err.Error())
}
knownAIAgentPrefixes = cfg.AIAgentPrefixes
knownAgentEnvVars = cfg.Agents
knownCIEnvVars = cfg.CIEnvVars
}
Expand All @@ -65,8 +96,12 @@ func detectAgentContext(env envChecker) string {
return "explicit:" + v
}

if label := aiAgentLabel(env.Getenv("AI_AGENT")); label != "" {
return label
}

for _, a := range knownAgentEnvVars {
if env.Getenv(a.EnvVar) != "" {
if a.matches(env.Getenv(a.EnvVar)) {
return a.Label
}
}
Expand All @@ -84,6 +119,61 @@ func detectAgentContext(env envChecker) string {
return "unknown-non-interactive"
}

// maxAIAgentNameLen limits the length of an unknown AI_AGENT name in a label.
const maxAIAgentNameLen = 32

// aiAgentLabel converts an AI_AGENT value to a label. AI_AGENT is a shared
// convention: the value starts with the agent name and can have a version
// after it, for example "claude-code_2-1-295_agent" or "devin@1". A known
// name gets its label. An unknown name gets "ai-agent:<name>", without the
// version. The function returns "" if the value has no usable name.
func aiAgentLabel(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}

for _, p := range knownAIAgentPrefixes {
if hasNamePrefix(value, p.Prefix) {
return p.Label
}
}

name, _, _ := strings.Cut(value, "@")
name, _, _ = strings.Cut(name, "_")
name = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return -1
}, name)
if len(name) > maxAIAgentNameLen {
name = name[:maxAIAgentNameLen]
}
if name == "" {
return ""
}

return "ai-agent:" + name
}

// hasNamePrefix reports whether value starts with prefix and the prefix ends at
// a name boundary. For example, "amp" matches "amp" and "amp_1" but not "ampere".
func hasNamePrefix(value, prefix string) bool {
if !strings.HasPrefix(value, prefix) {
return false
}
if len(value) == len(prefix) {
return true
}
switch value[len(prefix)] {
case '_', '@', '-':
return true
default:
return false
}
}

func CmdRunEventProperties(
cmd *cobra.Command,
name string,
Expand Down
Loading
Loading