Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
111 changes: 111 additions & 0 deletions common/commands/execution_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package commands

import (
"fmt"
"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"}},
Comment thread
fluxxBot marked this conversation as resolved.
{"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 ""
}

// EnrichUserAgent appends invoker context (agent and/or CI provider) to a base
// User-Agent string. Returns base unchanged when neither is detected.
// Examples: "jfrog-cli-go/2.x (claude)", "jfrog-cli-go/2.x (cursor; ci=github_actions)".
func EnrichUserAgent(base string) string {
ec := DetectExecutionContext()
Comment thread
fluxxBot marked this conversation as resolved.
Outdated
ciSystem := detectCISystem()
switch {
case ec.Agent != "" && ciSystem != "":
return fmt.Sprintf("%s (%s; ci=%s)", base, ec.Agent, ciSystem)
case ec.Agent != "":
return fmt.Sprintf("%s (%s)", base, ec.Agent)
case ciSystem != "":
return fmt.Sprintf("%s (ci=%s)", base, ciSystem)
}
return base
}
146 changes: 146 additions & 0 deletions common/commands/execution_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 TestEnrichUserAgent(t *testing.T) {
base := "jfrog-cli-go/2.103.0"

t.Run("none", func(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)
clearCIEnvVars(t)
assert.Equal(t, base, EnrichUserAgent(base))
})

t.Run("agent only", func(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)
clearCIEnvVars(t)
t.Setenv("CLAUDECODE", "1")
assert.Equal(t, base+" (claude)", EnrichUserAgent(base))
})

t.Run("ci only", func(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)
clearCIEnvVars(t)
t.Setenv("GITHUB_ACTIONS", "true")
assert.Equal(t, base+" (ci=github_actions)", EnrichUserAgent(base))
})

t.Run("agent and ci", func(t *testing.T) {
resetExecutionContextForTest(t)
clearAgentEnvVars(t)
clearCIEnvVars(t)
t.Setenv("CURSOR_AGENT", "1")
t.Setenv("GITHUB_ACTIONS", "true")
assert.Equal(t, base+" (cursor; ci=github_actions)", EnrichUserAgent(base))
})
}

func clearCIEnvVars(t *testing.T) {
t.Helper()
for _, e := range []string{
"JENKINS_URL", "TRAVIS", "CIRCLECI", "GITHUB_ACTIONS", "GITLAB_CI",
"BUILDKITE", "BAMBOO_BUILD_KEY", "TF_BUILD", "TEAMCITY_VERSION",
"DRONE", "BITBUCKET_BUILD_NUMBER", "CODEBUILD_BUILD_ID",
"CI", "CONTINUOUS_INTEGRATION", "BUILD_ID", "BUILD_NUMBER",
} {
t.Setenv(e, "")
}
}

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