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
97 changes: 97 additions & 0 deletions src/Aspire.Cli/Telemetry/CodingAgentDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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"]),
new("copilot", ["GITHUB_COPILOT_CLI_MODE", "GH_COPILOT_WORKING_DIRECTORY", "COPILOT_CLI", "COPILOT_AGENT", "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]),
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
{
private readonly string[] _variableNames;
private readonly string? _expectedValue;

public DetectionRule(string agentName, string[] variableNames, string? expectedValue = null)
{
AgentName = agentName;
_variableNames = variableNames;
_expectedValue = expectedValue;
}

public string AgentName { get; }

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";

Comment thread
DamianEdwards marked this conversation as resolved.
/// <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
107 changes: 105 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,44 @@ 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(Dictionary<string, string?> environmentVariables, string? expectedCodingAgent)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(environmentVariables)
.Build();

var detector = new CodingAgentDetector(configuration);

Assert.Equal(expectedCodingAgent, detector.GetCodingAgent());
}

[Fact]
public void StartReportedActivity_IncludesAllDefaultTags()
{
Expand Down Expand Up @@ -267,7 +306,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 +318,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 +328,66 @@ 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<Dictionary<string, string?>, string?> CodingAgentTelemetryTestCases => new()
Comment thread
DamianEdwards marked this conversation as resolved.
{
{ new Dictionary<string, string?> { { "CLAUDECODE", "1" } }, "claude" },
{ new Dictionary<string, string?> { { "CLAUDE_CODE", "1" } }, "claude" },
{ new Dictionary<string, string?> { { "CLAUDE_CODE_ENTRYPOINT", "some_value" } }, "claude" },
{ new Dictionary<string, string?> { { "CLAUDE_CODE_IS_COWORK", "1" } }, "cowork" },
{ new Dictionary<string, string?> { { "CURSOR_EDITOR", "1" } }, "cursor" },
{ new Dictionary<string, string?> { { "CURSOR_AI", "1" } }, "cursor" },
{ new Dictionary<string, string?> { { "CURSOR_TRACE_ID", "abc" } }, "cursor" },
{ new Dictionary<string, string?> { { "CURSOR_AGENT", "1" } }, "cursor" },
{ new Dictionary<string, string?> { { "GEMINI_CLI", "true" } }, "gemini" },
{ new Dictionary<string, string?> { { "GEMINI_CLI", "0" } }, "gemini" },
{ new Dictionary<string, string?> { { "GITHUB_COPILOT_CLI_MODE", "true" } }, "copilot" },
{ new Dictionary<string, string?> { { "GH_COPILOT_WORKING_DIRECTORY", "/repo" } }, "copilot" },
{ new Dictionary<string, string?> { { "COPILOT_CLI", "1" } }, "copilot" },
{ new Dictionary<string, string?> { { "COPILOT_AGENT", "1" } }, "copilot" },
{ new Dictionary<string, string?> { { "COPILOT_MODEL", "gpt" } }, "copilot" },
{ new Dictionary<string, string?> { { "COPILOT_ALLOW_ALL", "1" } }, "copilot" },
{ new Dictionary<string, string?> { { "COPILOT_GITHUB_TOKEN", "token" } }, "copilot" },
{ new Dictionary<string, string?> { { "CODEX_CLI", "1" } }, "codex" },
{ new Dictionary<string, string?> { { "CODEX_SANDBOX", "1" } }, "codex" },
{ new Dictionary<string, string?> { { "CODEX_CI", "1" } }, "codex" },
{ new Dictionary<string, string?> { { "CODEX_THREAD_ID", "thread1" } }, "codex" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "Aider" } }, "aider" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "aider" } }, "aider" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "plandex" } }, "plandex" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "Plandex" } }, "plandex" },
{ new Dictionary<string, string?> { { "AMP_HOME", "/path/to/amp" } }, "amp" },
{ new Dictionary<string, string?> { { "QWEN_CODE", "1" } }, "qwen" },
{ new Dictionary<string, string?> { { "DROID_CLI", "true" } }, "droid" },
{ new Dictionary<string, string?> { { "OPENCODE_AI", "1" } }, "opencode" },
{ new Dictionary<string, string?> { { "ZED_ENVIRONMENT", "1" } }, "zed" },
{ new Dictionary<string, string?> { { "ZED_TERM", "1" } }, "zed" },
{ new Dictionary<string, string?> { { "KIMI_CLI", "true" } }, "kimi" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "OpenHands" } }, "openhands" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "openhands" } }, "openhands" },
{ new Dictionary<string, string?> { { "GOOSE_TERMINAL", "1" } }, "goose" },
{ new Dictionary<string, string?> { { "GOOSE_PROVIDER", "openai" } }, "goose" },
{ new Dictionary<string, string?> { { "CLINE_TASK_ID", "task123" } }, "cline" },
{ new Dictionary<string, string?> { { "ROO_CODE_TASK_ID", "task456" } }, "roo" },
{ new Dictionary<string, string?> { { "WINDSURF_SESSION", "session789" } }, "windsurf" },
{ new Dictionary<string, string?> { { "REPL_ID", "repl1" } }, "replit" },
{ new Dictionary<string, string?> { { "AUGMENT_AGENT", "1" } }, "augment" },
{ new Dictionary<string, string?> { { "ANTIGRAVITY_AGENT", "1" } }, "antigravity" },
{ new Dictionary<string, string?> { { "AGENT_CLI", "true" } }, "generic_agent" },
{ new Dictionary<string, string?> { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" },
{ new Dictionary<string, string?> { { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" } }, "gemini, copilot" },
{ new Dictionary<string, string?> { { "CLAUDECODE", "1" }, { "GEMINI_CLI", "true" }, { "AGENT_CLI", "true" } }, "claude, gemini, generic_agent" },
{ new Dictionary<string, string?> { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" }, { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" }, { "AGENT_CLI", "true" } }, "claude, cursor, gemini, copilot, generic_agent" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "Aider" }, { "CLINE_TASK_ID", "task123" } }, "aider, cline" },
{ new Dictionary<string, string?> { { "CODEX_CLI", "1" }, { "WINDSURF_SESSION", "session789" } }, "codex, windsurf" },
{ new Dictionary<string, string?> { { "GOOSE_TERMINAL", "1" }, { "ROO_CODE_TASK_ID", "task456" } }, "goose, roo" },
{ new Dictionary<string, string?> { { "GEMINI_CLI", "false" } }, "gemini" },
{ new Dictionary<string, string?> { { "GITHUB_COPILOT_CLI_MODE", "false" } }, "copilot" },
{ new Dictionary<string, string?> { { "AGENT_CLI", "false" } }, "generic_agent" },
{ new Dictionary<string, string?> { { "DROID_CLI", "false" } }, "droid" },
{ new Dictionary<string, string?> { { "KIMI_CLI", "false" } }, "kimi" },
{ new Dictionary<string, string?> { { "CLAUDE_CODE_IS_COWORK", "1" }, { "CLAUDE_CODE", "1" } }, "cowork, claude" },
{ new Dictionary<string, string?> { { "OR_APP_NAME", "SomeOtherApp" } }, null },
{ new Dictionary<string, string?>(), null },
};
}
Loading
Loading