Skip to content
Open
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
93 changes: 93 additions & 0 deletions common/commands/execution_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package commands

import (
"os"
"sync"

clientlog "github.com/jfrog/jfrog-client-go/utils/log"
)

// AgentUnknown is returned when a generic AGENT env var is set but its value
// does not match any known agent. We don't propagate the raw value to keep
// metric cardinality bounded.
const AgentUnknown = "unknown"

// ExecutionContext describes how a CLI invocation was launched.
type ExecutionContext struct {
Agent string // e.g. "claude", "cursor", "gemini", "unknown" or "" if none
IsAgent bool
IsInteractive bool // stdout is a TTY
TraceID string // propagated trace ID (e.g. CURSOR_TRACE_ID), empty if none
}

// agentDetector maps an agent name to env vars whose presence proves the agent
// invoked the CLI.
type agentDetector struct {
name string
envs []string
}

// agentEnvDetectors is the agent detection table. First match wins.
var agentEnvDetectors = []agentDetector{
{"claude", []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
{"gemini", []string{"GEMINI_CLI"}},
{"goose", []string{"GOOSE_TERMINAL"}},
{"cursor", []string{"CURSOR_AGENT", "CURSOR_CLI", "CURSOR_TRACE_ID"}},
Comment thread
fluxxBot marked this conversation as resolved.
{"copilot", []string{"COPILOT_CLI"}},
{"kilocode", []string{"KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD"}},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in description table I see replit, windsurf, and aider entries but here I am not seeing them implemented

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah needed to drop it, no official documentation regarding any env from them

{"roo_code", []string{"ROO_CODE_IPC_SOCKET_PATH"}},
{"codex", []string{"CODEX_CI"}},
}

// DetectExecutionContext captures signals about who executed the CLI.
// Memoized for the process lifetime so independent call sites (metrics
// collector, trace-ID setup, User-Agent enrichment) cannot diverge if a
// later caller mutates the environment.
func DetectExecutionContext() ExecutionContext {
executionContextOnce.Do(func() {
cachedExecutionContext = computeExecutionContext()
})
return cachedExecutionContext
}

var (
executionContextOnce sync.Once
cachedExecutionContext ExecutionContext
)

func computeExecutionContext() ExecutionContext {
ec := ExecutionContext{
IsInteractive: clientlog.IsStdOutTerminal(),
}
ec.Agent = detectAgent()
ec.IsAgent = ec.Agent != ""
ec.TraceID = detectAgentTraceID(ec.Agent)
return ec
}

func detectAgent() string {
for _, d := range agentEnvDetectors {
for _, e := range d.envs {
if os.Getenv(e) != "" {
return d.name
}
}
}
// Generic AGENT env var (goose convention, codex pending). Don't propagate the
// raw value into metrics — collapse to "unknown" to keep cardinality bounded.
if os.Getenv("AGENT") != "" {
return AgentUnknown
}
return ""
}

// detectAgentTraceID returns a trace ID propagated by the parent agent, if any.
// Gated on agent identity to prevent stale values leaked from an outer shell
// (e.g. CURSOR_TRACE_ID present while the actual invoker is Claude Code).
// Empty result means the CLI should generate its own trace ID.
func detectAgentTraceID(agent string) string {
if agent == "cursor" {
return os.Getenv("CURSOR_TRACE_ID")
}
return ""
}
99 changes: 99 additions & 0 deletions common/commands/execution_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package commands

import (
"sync"
"testing"

"github.com/stretchr/testify/assert"
)

func TestDetectAgent_FromTable(t *testing.T) {
for _, d := range agentEnvDetectors {
for _, env := range d.envs {
t.Run(env, func(t *testing.T) {
clearAgentEnvVars(t)
t.Setenv(env, "1")
assert.Equal(t, d.name, detectAgent())
})
}
}
}

func TestDetectAgent_GenericAgentEnvCollapsesToUnknown(t *testing.T) {
clearAgentEnvVars(t)
t.Setenv("AGENT", "some_random_value")
assert.Equal(t, AgentUnknown, detectAgent())
}

func TestDetectAgent_None(t *testing.T) {
clearAgentEnvVars(t)
assert.Equal(t, "", detectAgent())
}

func TestDetectAgentTraceID(t *testing.T) {
t.Setenv("CURSOR_TRACE_ID", "trace-abc")
assert.Equal(t, "trace-abc", detectAgentTraceID("cursor"))
// Trace ID gated on agent identity: a leaked CURSOR_TRACE_ID from an outer
// shell must not be reused when the real invoker is a different agent.
assert.Equal(t, "", detectAgentTraceID("claude"))
assert.Equal(t, "", detectAgentTraceID(""))
}

func TestDetectExecutionContext_Agent(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)
t.Setenv("CLAUDECODE", "1")

ec := DetectExecutionContext()
assert.True(t, ec.IsAgent)
assert.Equal(t, "claude", ec.Agent)
}

func TestDetectExecutionContext_NoEnv(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)

ec := DetectExecutionContext()
assert.False(t, ec.IsAgent)
assert.Equal(t, "", ec.Agent)
assert.Equal(t, "", ec.TraceID)
}

func TestDetectExecutionContext_IsMemoized(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)
t.Setenv("CLAUDECODE", "1")
first := DetectExecutionContext()

// Mutate env after first call; result must not change without reset.
t.Setenv("CLAUDECODE", "")
t.Setenv("CURSOR_AGENT", "1")
second := DetectExecutionContext()

assert.Equal(t, first, second)
assert.Equal(t, "claude", second.Agent)
}

// resetExecutionContextForTest forces the next DetectExecutionContext call to
// re-evaluate env vars. Restores the memoization state after the test.
func resetExecutionContextForTest(t *testing.T) {
t.Helper()
prevOnce, prevCache := executionContextOnce, cachedExecutionContext
executionContextOnce = sync.Once{}
cachedExecutionContext = ExecutionContext{}
t.Cleanup(func() {
executionContextOnce, cachedExecutionContext = prevOnce, prevCache
})
}


func clearAgentEnvVars(t *testing.T) {
t.Helper()
for _, d := range agentEnvDetectors {
for _, e := range d.envs {
t.Setenv(e, "")
}
}
t.Setenv("AGENT", "")
t.Setenv("CURSOR_TRACE_ID", "")
}
37 changes: 20 additions & 17 deletions common/commands/metrics_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,31 @@ var globalMetricsCollector = &metricsCollector{
// CollectMetrics stores enhanced metrics information for a command execution.
// Collects system information, CI environment details, and container detection.
func CollectMetrics(commandName string, flags []string) {
// Compute detection outside the lock; these are pure env reads and don't
// touch shared state. Keeps the critical section minimal.
ec := DetectExecutionContext()
ciSystem := detectCISystem()
isContainer := isRunningInContainer()

globalMetricsCollector.mu.Lock()
defer globalMetricsCollector.mu.Unlock()

ciSystem := detectCISystem()
isCI := ciSystem != ""

pkgAliasTool := globalMetricsCollector.packageAliasContext
globalMetricsCollector.packageAliasContext = ""

metricsData := &MetricsData{
Flags: flags,
Platform: runtime.GOOS,
Architecture: runtime.GOARCH,
IsCI: isCI,
CISystem: func() string {
if isCI {
return ciSystem
}
return ""
}(),
IsContainer: isRunningInContainer(),
globalMetricsCollector.metricsData[commandName] = &MetricsData{
Flags: flags,
Platform: runtime.GOOS,
Architecture: runtime.GOARCH,
IsCI: ciSystem != "",
CISystem: ciSystem,
IsContainer: isContainer,
IsAgent: ec.IsAgent,
Agent: ec.Agent,
Comment thread
fluxxBot marked this conversation as resolved.
IsInteractive: ec.IsInteractive,
PackageAlias: pkgAliasTool != "",
PackageManager: pkgAliasTool,
}

globalMetricsCollector.metricsData[commandName] = metricsData
}

// GetCollectedMetrics retrieves collected metrics for a command.
Expand All @@ -73,6 +72,9 @@ func GetCollectedMetrics(commandName string) *MetricsData {
IsCI: metrics.IsCI,
CISystem: metrics.CISystem,
IsContainer: metrics.IsContainer,
IsAgent: metrics.IsAgent,
Agent: metrics.Agent,
IsInteractive: metrics.IsInteractive,
PackageAlias: metrics.PackageAlias,
PackageManager: metrics.PackageManager,
}
Expand All @@ -93,6 +95,7 @@ func detectCISystem() string {
"DRONE": "drone",
"BITBUCKET_BUILD_NUMBER": "bitbucket",
"CODEBUILD_BUILD_ID": "aws_codebuild",
"HARNESS_BUILD_ID": "harness",
}

for envVar, system := range ciEnvVars {
Expand Down
3 changes: 3 additions & 0 deletions utils/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ type MetricsData struct {
IsCI bool `json:"is_ci,omitempty"`
CISystem string `json:"ci_system,omitempty"`
IsContainer bool `json:"is_container,omitempty"`
IsAgent bool `json:"is_agent,omitempty"`
Agent string `json:"agent,omitempty"`
IsInteractive bool `json:"is_interactive,omitempty"`
PackageAlias bool `json:"package_alias,omitempty"`
PackageManager string `json:"package_manager,omitempty"`
}
Loading