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
16 changes: 13 additions & 3 deletions src/Aspire.Cli/Telemetry/AspireCliTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AspireCliTelemetry> _logger;
private readonly List<KeyValuePair<string, object?>> _tagsList = [];

Expand All @@ -57,8 +58,9 @@ internal sealed class AspireCliTelemetry : IHostedService
/// <param name="logger">The logger instance for recording errors.</param>
/// <param name="machineInformationProvider">The machine information provider.</param>
/// <param name="ciEnvironmentDetector">The CI environment detector.</param>
public AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector)
: this(logger, machineInformationProvider, ciEnvironmentDetector, ReportedActivitySourceName, DiagnosticsActivitySourceName)
/// <param name="codingAgentDetector">The coding agent detector.</param>
public AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, ICodingAgentDetector codingAgentDetector)
: this(logger, machineInformationProvider, ciEnvironmentDetector, codingAgentDetector, ReportedActivitySourceName, DiagnosticsActivitySourceName)
{
}

Expand All @@ -69,13 +71,15 @@ public AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformatio
/// <param name="logger">The logger instance for recording errors.</param>
/// <param name="machineInformationProvider">The machine information provider.</param>
/// <param name="ciEnvironmentDetector">The CI environment detector.</param>
/// <param name="codingAgentDetector">The coding agent detector.</param>
/// <param name="reportedSourceName">The name for the reported activity source.</param>
/// <param name="diagnosticsSourceName">The name for the diagnostics activity source.</param>
internal AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, string reportedSourceName, string diagnosticsSourceName)
internal AspireCliTelemetry(ILogger<AspireCliTelemetry> 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);
}
Expand Down Expand Up @@ -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()));
Expand Down
95 changes: 95 additions & 0 deletions src/Aspire.Cli/Telemetry/CodingAgentDetector.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Detects coding agents from known environment variables.
/// </summary>
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;

/// <inheritdoc />
public string? GetCodingAgent()
{
List<string>? 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;
}
}
}
16 changes: 16 additions & 0 deletions src/Aspire.Cli/Telemetry/ICodingAgentDetector.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Detects whether the CLI is running under a known coding agent.
/// </summary>
internal interface ICodingAgentDetector
{
/// <summary>
/// Gets the detected coding agent name, or names, for the current environment.
/// </summary>
/// <returns>The detected coding agent names, or <see langword="null"/> when none are detected.</returns>
string? GetCodingAgent();
}
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Telemetry/TelemetryConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ internal static class Tags
/// </summary>
public const string CliBuildId = "aspire.cli.build_id";

/// <summary>
/// Tag for the detected coding agent that invoked the CLI process.
/// </summary>
public const string CodingAgent = "process.coding_agent";

/// <summary>
/// Tag for the deployment environment name ("ci" or "local").
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static IServiceCollection AddTelemetryServices(this IServiceCollection se
}

services.AddSingleton<ICIEnvironmentDetector, CIEnvironmentDetector>();
services.AddSingleton<ICodingAgentDetector, CodingAgentDetector>();
services.AddSingleton<AspireCliTelemetry>();
services.AddSingleton<ProfilingTelemetry>();
services.AddHostedService(sp => sp.GetRequiredService<AspireCliTelemetry>());
Expand Down
117 changes: 115 additions & 2 deletions tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string?>();
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()
{
Expand Down Expand Up @@ -267,7 +315,8 @@ public void StartReportedActivity_ThrowsIfNotInitialized()
{
var provider = new TelemetryFixture.TestMachineInformationProvider();
var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector();
var telemetry = new AspireCliTelemetry(NullLogger<AspireCliTelemetry>.Instance, provider, ciDetector);
var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector();
var telemetry = new AspireCliTelemetry(NullLogger<AspireCliTelemetry>.Instance, provider, ciDetector, codingAgentDetector);

var exception = Assert.Throws<InvalidOperationException>(() => telemetry.StartReportedActivity("test"));
Assert.Contains("not been initialized", exception.Message);
Expand All @@ -278,7 +327,8 @@ public async Task InitializeAsync_IsIdempotent()
{
var provider = new TelemetryFixture.TestMachineInformationProvider();
var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector();
var telemetry = new AspireCliTelemetry(NullLogger<AspireCliTelemetry>.Instance, provider, ciDetector);
var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector();
var telemetry = new AspireCliTelemetry(NullLogger<AspireCliTelemetry>.Instance, provider, ciDetector, codingAgentDetector);

await telemetry.InitializeAsync().DefaultTimeout();
var tagsAfterFirstInit = telemetry.GetDefaultTags().Count;
Expand All @@ -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 }
};
}
15 changes: 14 additions & 1 deletion tests/Aspire.Cli.Tests/Telemetry/TelemetryFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ internal sealed class TelemetryFixture : IDisposable
/// </summary>
/// <param name="machineInfoProvider">Optional machine information provider. Uses a default test provider if not specified.</param>
/// <param name="ciEnvironmentDetector">Optional CI environment detector. Uses a default test detector if not specified.</param>
/// <param name="codingAgentDetector">Optional coding agent detector. Uses a default test detector if not specified.</param>
/// <param name="logger">Optional logger. Uses <see cref="NullLogger"/> if not specified.</param>
/// <param name="sampleResult">The sampling result for the activity listener. Defaults to <see cref="ActivitySamplingResult.AllDataAndRecorded"/>.</param>
public TelemetryFixture(
IMachineInformationProvider? machineInfoProvider = null,
ICIEnvironmentDetector? ciEnvironmentDetector = null,
ICodingAgentDetector? codingAgentDetector = null,
ILogger<AspireCliTelemetry>? logger = null,
ActivitySamplingResult sampleResult = ActivitySamplingResult.AllDataAndRecorded)
{
Expand All @@ -42,9 +44,10 @@ public TelemetryFixture(

machineInfoProvider ??= new TestMachineInformationProvider();
ciEnvironmentDetector ??= new TestCIEnvironmentDetector();
codingAgentDetector ??= new TestCodingAgentDetector();
logger ??= NullLogger<AspireCliTelemetry>.Instance;

Telemetry = new AspireCliTelemetry(logger, machineInfoProvider, ciEnvironmentDetector, ReportedSourceName, DiagnosticsSourceName);
Telemetry = new AspireCliTelemetry(logger, machineInfoProvider, ciEnvironmentDetector, codingAgentDetector, ReportedSourceName, DiagnosticsSourceName);
Telemetry.InitializeAsync().GetAwaiter().GetResult();
}

Expand Down Expand Up @@ -94,4 +97,14 @@ internal sealed class TestCIEnvironmentDetector : ICIEnvironmentDetector

public bool IsCIEnvironment() => IsCIEnvironmentResult;
}

/// <summary>
/// A test implementation of <see cref="ICodingAgentDetector"/> with configurable result.
/// </summary>
internal sealed class TestCodingAgentDetector : ICodingAgentDetector
{
public string? CodingAgent { get; set; }

public string? GetCodingAgent() => CodingAgent;
}
}
Loading
Loading