diff --git a/src/Aspire.Cli/Telemetry/AspireCliTelemetry.cs b/src/Aspire.Cli/Telemetry/AspireCliTelemetry.cs index a6313a06003..80e49cb4613 100644 --- a/src/Aspire.Cli/Telemetry/AspireCliTelemetry.cs +++ b/src/Aspire.Cli/Telemetry/AspireCliTelemetry.cs @@ -46,6 +46,7 @@ internal sealed class AspireCliTelemetry : IHostedService private readonly ActivitySource _reportedActivitySource; private readonly IMachineInformationProvider _machineInformationProvider; private readonly ICIEnvironmentDetector _ciEnvironmentDetector; + private readonly ICodingAgentDetector _codingAgentDetector; private readonly ILogger _logger; private readonly List> _tagsList = []; @@ -57,8 +58,9 @@ internal sealed class AspireCliTelemetry : IHostedService /// The logger instance for recording errors. /// The machine information provider. /// The CI environment detector. - public AspireCliTelemetry(ILogger logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector) - : this(logger, machineInformationProvider, ciEnvironmentDetector, ReportedActivitySourceName, DiagnosticsActivitySourceName) + /// The coding agent detector. + public AspireCliTelemetry(ILogger logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, ICodingAgentDetector codingAgentDetector) + : this(logger, machineInformationProvider, ciEnvironmentDetector, codingAgentDetector, ReportedActivitySourceName, DiagnosticsActivitySourceName) { } @@ -69,13 +71,15 @@ public AspireCliTelemetry(ILogger logger, IMachineInformatio /// The logger instance for recording errors. /// The machine information provider. /// The CI environment detector. + /// The coding agent detector. /// The name for the reported activity source. /// The name for the diagnostics activity source. - internal AspireCliTelemetry(ILogger logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, string reportedSourceName, string diagnosticsSourceName) + internal AspireCliTelemetry(ILogger logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, ICodingAgentDetector codingAgentDetector, string reportedSourceName, string diagnosticsSourceName) { _logger = logger; _machineInformationProvider = machineInformationProvider; _ciEnvironmentDetector = ciEnvironmentDetector; + _codingAgentDetector = codingAgentDetector; _reportedActivitySource = new ActivitySource(reportedSourceName); _diagnosticsActivitySource = new ActivitySource(diagnosticsSourceName); } @@ -225,6 +229,12 @@ internal async Task InitializeAsync() _tagsList.Add(new(TelemetryConstants.Tags.CliVersion, GetCliVersion())); _tagsList.Add(new(TelemetryConstants.Tags.CliBuildId, GetCliBuildId())); + var codingAgent = _codingAgentDetector.GetCodingAgent(); + if (codingAgent is not null) + { + _tagsList.Add(new(TelemetryConstants.Tags.CodingAgent, codingAgent)); + } + _tagsList.Add(new(TelemetryConstants.Tags.DeploymentEnvironmentName, _ciEnvironmentDetector.IsCIEnvironment() ? "ci" : "local")); _tagsList.Add(new(TelemetryConstants.Tags.OsName, GetOsName())); diff --git a/src/Aspire.Cli/Telemetry/CodingAgentDetector.cs b/src/Aspire.Cli/Telemetry/CodingAgentDetector.cs new file mode 100644 index 00000000000..690081976d3 --- /dev/null +++ b/src/Aspire.Cli/Telemetry/CodingAgentDetector.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Aspire.Cli.Telemetry; + +/// +/// Detects coding agents from known environment variables. +/// +internal sealed class CodingAgentDetector(IConfiguration configuration) : ICodingAgentDetector +{ + // Keep this in sync with the dotnet CLI's LLMEnvironmentDetectorForTelemetry detection + // order so Aspire reports the same agent names when the same environment variables are set. + // https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/Telemetry/LLMEnvironmentDetectorForTelemetry.cs + private static readonly DetectionRule[] s_detectionRules = + [ + new("cowork", ["CLAUDE_CODE_IS_COWORK"]), + new("claude", ["CLAUDECODE", "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT"]), + new("cursor", ["CURSOR_EDITOR", "CURSOR_AI", "CURSOR_TRACE_ID", "CURSOR_AGENT"]), + new("gemini", ["GEMINI_CLI"]), + // GitHub Copilot CLI (legacy gh extension: GITHUB_COPILOT_CLI_MODE; new Copilot CLI: GH_COPILOT_WORKING_DIRECTORY, COPILOT_CLI, COPILOT_MODEL, COPILOT_ALLOW_ALL, or COPILOT_GITHUB_TOKEN is set). + new("copilot-cli", ["COPILOT_CLI", "GITHUB_COPILOT_CLI_MODE", "GH_COPILOT_WORKING_DIRECTORY", "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]), + // GitHub Copilot agent mode in VS Code, which sets AI_AGENT=github_copilot_vscode_agent and COPILOT_AGENT=1 on the terminals it runs commands in. + // See https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts + new("copilot-vscode", ["AI_AGENT"], "github_copilot_vscode_agent"), + new("copilot-vscode", ["COPILOT_AGENT"]), + new("codex", ["CODEX_CLI", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]), + new("aider", ["OR_APP_NAME"], "Aider"), + new("plandex", ["OR_APP_NAME"], "plandex"), + new("amp", ["AMP_HOME"]), + new("qwen", ["QWEN_CODE"]), + new("droid", ["DROID_CLI"]), + new("opencode", ["OPENCODE_AI"]), + new("zed", ["ZED_ENVIRONMENT", "ZED_TERM"]), + new("kimi", ["KIMI_CLI"]), + new("openhands", ["OR_APP_NAME"], "OpenHands"), + new("goose", ["GOOSE_TERMINAL", "GOOSE_PROVIDER"]), + new("cline", ["CLINE_TASK_ID"]), + new("roo", ["ROO_CODE_TASK_ID"]), + new("windsurf", ["WINDSURF_SESSION"]), + new("replit", ["REPL_ID"]), + new("augment", ["AUGMENT_AGENT"]), + new("antigravity", ["ANTIGRAVITY_AGENT"]), + new("generic_agent", ["AGENT_CLI"]) + ]; + + private readonly IConfiguration _configuration = configuration; + + /// + public string? GetCodingAgent() + { + List? agentNames = null; + + foreach (var rule in s_detectionRules) + { + if (rule.IsMatch(_configuration)) + { + agentNames ??= []; + agentNames.Add(rule.AgentName); + } + } + + return agentNames is { Count: > 0 } ? string.Join(", ", agentNames) : null; + } + + private sealed class DetectionRule(string agentName, string[] variableNames, string? expectedValue = null) + { + private readonly string[] _variableNames = variableNames; + private readonly string? _expectedValue = expectedValue; + + public string AgentName { get; } = agentName; + + public bool IsMatch(IConfiguration configuration) + { + foreach (var variableName in _variableNames) + { + var value = configuration[variableName]; + if (_expectedValue is null) + { + if (!string.IsNullOrEmpty(value)) + { + return true; + } + } + else if (string.Equals(value, _expectedValue, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Aspire.Cli/Telemetry/ICodingAgentDetector.cs b/src/Aspire.Cli/Telemetry/ICodingAgentDetector.cs new file mode 100644 index 00000000000..b552ab754f3 --- /dev/null +++ b/src/Aspire.Cli/Telemetry/ICodingAgentDetector.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Telemetry; + +/// +/// Detects whether the CLI is running under a known coding agent. +/// +internal interface ICodingAgentDetector +{ + /// + /// Gets the detected coding agent name, or names, for the current environment. + /// + /// The detected coding agent names, or when none are detected. + string? GetCodingAgent(); +} diff --git a/src/Aspire.Cli/Telemetry/TelemetryConstants.cs b/src/Aspire.Cli/Telemetry/TelemetryConstants.cs index f52808223c8..2f20f443c7c 100644 --- a/src/Aspire.Cli/Telemetry/TelemetryConstants.cs +++ b/src/Aspire.Cli/Telemetry/TelemetryConstants.cs @@ -73,6 +73,11 @@ internal static class Tags /// public const string CliBuildId = "aspire.cli.build_id"; + /// + /// Tag for the detected coding agent that invoked the CLI process. + /// + public const string CodingAgent = "process.coding_agent"; + /// /// Tag for the deployment environment name ("ci" or "local"). /// diff --git a/src/Aspire.Cli/Telemetry/TelemetryServiceCollectionExtensions.cs b/src/Aspire.Cli/Telemetry/TelemetryServiceCollectionExtensions.cs index ef96eef5319..44d260bd430 100644 --- a/src/Aspire.Cli/Telemetry/TelemetryServiceCollectionExtensions.cs +++ b/src/Aspire.Cli/Telemetry/TelemetryServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static IServiceCollection AddTelemetryServices(this IServiceCollection se } services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs b/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs index d39fc709f6d..9a9772df1fe 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.InternalTesting; using System.Diagnostics; using Aspire.Cli.Telemetry; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -238,6 +239,53 @@ public void InitializeAsync_AddsOsInformationTags() Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.OsType && (string?)t.Value == expectedOsType); } + [Fact] + public void InitializeAsync_AddsCodingAgentTag_WhenCodingAgentIsDetected() + { + var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector + { + CodingAgent = "copilot" + }; + using var fixture = new TelemetryFixture(codingAgentDetector: codingAgentDetector, sampleResult: ActivitySamplingResult.AllData); + + using var activity = fixture.Telemetry.StartReportedActivity(TelemetryConstants.Activities.Main); + + Assert.NotNull(activity); + Assert.Equal("copilot", activity.GetTagItem(TelemetryConstants.Tags.CodingAgent)); + } + + [Fact] + public void InitializeAsync_DoesNotAddCodingAgentTag_WhenCodingAgentIsNotDetected() + { + using var fixture = new TelemetryFixture(); + + var tags = fixture.Telemetry.GetDefaultTags(); + + Assert.DoesNotContain(tags, t => t.Key == TelemetryConstants.Tags.CodingAgent); + } + + [Theory] + [MemberData(nameof(CodingAgentTelemetryTestCases))] + public void CodingAgentDetector_DetectsKnownCodingAgents((string, string?)[] environmentVariables, string? expectedCodingAgent) + { + var configurationValues = new Dictionary(); + foreach (var environmentVariable in environmentVariables) + { + if (environmentVariable.Item1.Length > 0) + { + configurationValues.Add(environmentVariable.Item1, environmentVariable.Item2); + } + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues) + .Build(); + + var detector = new CodingAgentDetector(configuration); + + Assert.Equal(expectedCodingAgent, detector.GetCodingAgent()); + } + [Fact] public void StartReportedActivity_IncludesAllDefaultTags() { @@ -267,7 +315,8 @@ public void StartReportedActivity_ThrowsIfNotInitialized() { var provider = new TelemetryFixture.TestMachineInformationProvider(); var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector(); - var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector); + var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector(); + var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector, codingAgentDetector); var exception = Assert.Throws(() => telemetry.StartReportedActivity("test")); Assert.Contains("not been initialized", exception.Message); @@ -278,7 +327,8 @@ public async Task InitializeAsync_IsIdempotent() { var provider = new TelemetryFixture.TestMachineInformationProvider(); var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector(); - var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector); + var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector(); + var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector, codingAgentDetector); await telemetry.InitializeAsync().DefaultTimeout(); var tagsAfterFirstInit = telemetry.GetDefaultTags().Count; @@ -287,4 +337,67 @@ public async Task InitializeAsync_IsIdempotent() var tags = telemetry.GetDefaultTags(); Assert.Equal(tagsAfterFirstInit, tags.Count); // Should have the same number of tags after second init } + + public static TheoryData<(string, string?)[], string?> CodingAgentTelemetryTestCases => new() + { + { [("CLAUDECODE", "1")], "claude" }, + { [("CLAUDE_CODE", "1")], "claude" }, + { [("CLAUDE_CODE_ENTRYPOINT", "some_value")], "claude" }, + { [("CLAUDE_CODE_IS_COWORK", "1")], "cowork" }, + { [("CURSOR_EDITOR", "1")], "cursor" }, + { [("CURSOR_AI", "1")], "cursor" }, + { [("CURSOR_TRACE_ID", "abc")], "cursor" }, + { [("CURSOR_AGENT", "1")], "cursor" }, + { [("GEMINI_CLI", "true")], "gemini" }, + { [("GEMINI_CLI", "0")], "gemini" }, + { [("GITHUB_COPILOT_CLI_MODE", "true")], "copilot-cli" }, + { [("GH_COPILOT_WORKING_DIRECTORY", "/repo")], "copilot-cli" }, + { [("COPILOT_CLI", "1")], "copilot-cli" }, + { [("COPILOT_MODEL", "gpt")], "copilot-cli" }, + { [("COPILOT_ALLOW_ALL", "1")], "copilot-cli" }, + { [("COPILOT_GITHUB_TOKEN", "token")], "copilot-cli" }, + { [("AI_AGENT", "github_copilot_vscode_agent")], "copilot-vscode" }, + { [("COPILOT_AGENT", "1")], "copilot-vscode" }, + { [("CODEX_CLI", "1")], "codex" }, + { [("CODEX_SANDBOX", "1")], "codex" }, + { [("CODEX_CI", "1")], "codex" }, + { [("CODEX_THREAD_ID", "thread1")], "codex" }, + { [("OR_APP_NAME", "Aider")], "aider" }, + { [("OR_APP_NAME", "aider")], "aider" }, + { [("OR_APP_NAME", "plandex")], "plandex" }, + { [("OR_APP_NAME", "Plandex")], "plandex" }, + { [("AMP_HOME", "/path/to/amp")], "amp" }, + { [("QWEN_CODE", "1")], "qwen" }, + { [("DROID_CLI", "true")], "droid" }, + { [("OPENCODE_AI", "1")], "opencode" }, + { [("ZED_ENVIRONMENT", "1")], "zed" }, + { [("ZED_TERM", "1")], "zed" }, + { [("KIMI_CLI", "true")], "kimi" }, + { [("OR_APP_NAME", "OpenHands")], "openhands" }, + { [("OR_APP_NAME", "openhands")], "openhands" }, + { [("GOOSE_TERMINAL", "1")], "goose" }, + { [("GOOSE_PROVIDER", "openai")], "goose" }, + { [("CLINE_TASK_ID", "task123")], "cline" }, + { [("ROO_CODE_TASK_ID", "task456")], "roo" }, + { [("WINDSURF_SESSION", "session789")], "windsurf" }, + { [("REPL_ID", "repl1")], "replit" }, + { [("AUGMENT_AGENT", "1")], "augment" }, + { [("ANTIGRAVITY_AGENT", "1")], "antigravity" }, + { [("AGENT_CLI", "true")], "generic_agent" }, + { [("CLAUDECODE", "1"), ("CURSOR_EDITOR", "1") ], "claude, cursor" }, + { [("GEMINI_CLI", "true"), ("GITHUB_COPILOT_CLI_MODE", "true") ], "gemini, copilot-cli" }, + { [("CLAUDECODE", "1"), ("GEMINI_CLI", "true"), ("AGENT_CLI", "true") ], "claude, gemini, generic_agent" }, + { [("CLAUDECODE", "1"), ("CURSOR_EDITOR", "1"), ("GEMINI_CLI", "true"), ("GITHUB_COPILOT_CLI_MODE", "true"), ("AGENT_CLI", "true") ], "claude, cursor, gemini, copilot-cli, generic_agent" }, + { [("OR_APP_NAME", "Aider"), ("CLINE_TASK_ID", "task123") ], "aider, cline" }, + { [("CODEX_CLI", "1"), ("WINDSURF_SESSION", "session789") ], "codex, windsurf" }, + { [("GOOSE_TERMINAL", "1"), ("ROO_CODE_TASK_ID", "task456") ], "goose, roo" }, + { [("GEMINI_CLI", "false")], "gemini" }, + { [("GITHUB_COPILOT_CLI_MODE", "false")], "copilot-cli" }, + { [("AGENT_CLI", "false")], "generic_agent" }, + { [("DROID_CLI", "false")], "droid" }, + { [("KIMI_CLI", "false")], "kimi" }, + { [("CLAUDE_CODE_IS_COWORK", "1"), ("CLAUDE_CODE", "1")], "cowork, claude" }, + { [("OR_APP_NAME", "SomeOtherApp")], null }, + { [("", "")], null } + }; } diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryFixture.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryFixture.cs index ffa66ce038e..7b5dcfab823 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/TelemetryFixture.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/TelemetryFixture.cs @@ -21,11 +21,13 @@ internal sealed class TelemetryFixture : IDisposable /// /// Optional machine information provider. Uses a default test provider if not specified. /// Optional CI environment detector. Uses a default test detector if not specified. + /// Optional coding agent detector. Uses a default test detector if not specified. /// Optional logger. Uses if not specified. /// The sampling result for the activity listener. Defaults to . public TelemetryFixture( IMachineInformationProvider? machineInfoProvider = null, ICIEnvironmentDetector? ciEnvironmentDetector = null, + ICodingAgentDetector? codingAgentDetector = null, ILogger? logger = null, ActivitySamplingResult sampleResult = ActivitySamplingResult.AllDataAndRecorded) { @@ -42,9 +44,10 @@ public TelemetryFixture( machineInfoProvider ??= new TestMachineInformationProvider(); ciEnvironmentDetector ??= new TestCIEnvironmentDetector(); + codingAgentDetector ??= new TestCodingAgentDetector(); logger ??= NullLogger.Instance; - Telemetry = new AspireCliTelemetry(logger, machineInfoProvider, ciEnvironmentDetector, ReportedSourceName, DiagnosticsSourceName); + Telemetry = new AspireCliTelemetry(logger, machineInfoProvider, ciEnvironmentDetector, codingAgentDetector, ReportedSourceName, DiagnosticsSourceName); Telemetry.InitializeAsync().GetAwaiter().GetResult(); } @@ -94,4 +97,14 @@ internal sealed class TestCIEnvironmentDetector : ICIEnvironmentDetector public bool IsCIEnvironment() => IsCIEnvironmentResult; } + + /// + /// A test implementation of with configurable result. + /// + internal sealed class TestCodingAgentDetector : ICodingAgentDetector + { + public string? CodingAgent { get; set; } + + public string? GetCodingAgent() => CodingAgent; + } } diff --git a/tests/Aspire.Cli.Tests/Telemetry/TestTelemetryHelper.cs b/tests/Aspire.Cli.Tests/Telemetry/TestTelemetryHelper.cs index 446db70a6e5..e6f5b587f48 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/TestTelemetryHelper.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/TestTelemetryHelper.cs @@ -18,7 +18,8 @@ public static AspireCliTelemetry CreateInitializedTelemetry() { var provider = new TestMachineInformationProvider(); var ciDetector = new TestCIEnvironmentDetector(); - var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector); + var codingAgentDetector = new TestCodingAgentDetector(); + var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector, codingAgentDetector); telemetry.InitializeAsync().GetAwaiter().GetResult(); return telemetry; } @@ -30,7 +31,8 @@ public static AspireCliTelemetry CreateInitializedTelemetry(string reportedSourc { var provider = new TestMachineInformationProvider(); var ciDetector = new TestCIEnvironmentDetector(); - var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector, reportedSourceName, diagnosticsSourceName); + var codingAgentDetector = new TestCodingAgentDetector(); + var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector, codingAgentDetector, reportedSourceName, diagnosticsSourceName); telemetry.InitializeAsync().GetAwaiter().GetResult(); return telemetry; } @@ -45,4 +47,9 @@ private sealed class TestCIEnvironmentDetector : ICIEnvironmentDetector { public bool IsCIEnvironment() => false; } + + private sealed class TestCodingAgentDetector : ICodingAgentDetector + { + public string? GetCodingAgent() => null; + } }