Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion documentation/project-docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Every telemetry event automatically includes these common properties:
| **Telemetry Profile** | Custom telemetry profile (if set via env var) | Custom value or null |
| **Docker Container** | Whether running in Docker container | `True` or `False` |
| **CI** | Whether running in CI environment | `True` or `False` |
| **LLM** | Detected LLM/assistant environment identifiers (comma-separated) | `claude` or `cursor` |
| **LLM** | Detected LLM/assistant environment identifiers (comma-separated) | `claude`, `cursor`, `gemini`, `copilot`, `generic_agent` |
| **Current Path Hash** | SHA256 hash of current directory path | Hashed value |
| **Machine ID** | SHA256 hash of machine MAC address (or GUID if unavailable) | Hashed value |
| **Machine ID Old** | Legacy machine ID for compatibility | Hashed value |
Expand Down
8 changes: 8 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ public static class Constants
public const string Identity = nameof(Identity);
public const string FullPath = nameof(FullPath);

// MSBuild CLI flags

/// <summary>
/// Disables the live-updating node display in the terminal logger, which is useful for LLM/agentic environments.
/// </summary>
public const string TerminalLogger_DisableNodeDisplay = "-tlp:DISABLENODEDISPLAY";


public static readonly string ProjectArgumentName = "<PROJECT>";
public static readonly string SolutionArgumentName = "<SLN_FILE>";
public static readonly string ToolPackageArgumentName = "<PACKAGE_ID>";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ public MSBuildForwardingAppWithoutLogging(MSBuildArgs msbuildArgs, string? msbui
msbuildArgs.OtherMSBuildArgs.Add("-nologo");
}
string? tlpDefault = TerminalLoggerDefault;
// new for .NET 9 - default TL to auto (aka enable in non-CI scenarios)
if (string.IsNullOrWhiteSpace(tlpDefault))
{
tlpDefault = "auto";
Expand Down
10 changes: 9 additions & 1 deletion src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics;
using System.Reflection;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;

Expand All @@ -14,6 +15,11 @@ public class MSBuildForwardingApp : CommandBase

private readonly MSBuildForwardingAppWithoutLogging _forwardingAppWithoutLogging;

/// <summary>
/// Adds the CLI's telemetry logger to the MSBuild arguments if telemetry is enabled.
/// </summary>
/// <param name="msbuildArgs"></param>
/// <returns></returns>
private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs)
{
if (Telemetry.Telemetry.CurrentSessionId != null)
Expand All @@ -34,6 +40,7 @@ private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs)
return msbuildArgs;
}


/// <summary>
/// Mostly intended for quick/one-shot usage - most 'core' SDK commands should do more hands-on parsing.
/// </summary>
Expand All @@ -45,8 +52,9 @@ public MSBuildForwardingApp(IEnumerable<string> rawMSBuildArgs, string? msbuildP

public MSBuildForwardingApp(MSBuildArgs msBuildArgs, string? msbuildPath = null, bool includeLogo = false)
{
var modifiedMSBuildArgs = CommonRunHelpers.AdjustMSBuildForLLMs(ConcatTelemetryLogger(msBuildArgs));
_forwardingAppWithoutLogging = new MSBuildForwardingAppWithoutLogging(
ConcatTelemetryLogger(msBuildArgs),
modifiedMSBuildArgs,
msbuildPath: msbuildPath,
includeLogo: includeLogo);

Expand Down
25 changes: 25 additions & 0 deletions src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,29 @@ public static string GetPropertiesLaunchSettingsPath(string directoryPath, strin

public static string GetFlatLaunchSettingsPath(string directoryPath, string projectNameWithoutExtension)
=> Path.Join(directoryPath, $"{projectNameWithoutExtension}.run.json");


/// <summary>
/// Applies adjustments to MSBuild arguments to better suit LLM/agentic environments, if such an environment is detected.
/// </summary>
public static MSBuildArgs AdjustMSBuildForLLMs(MSBuildArgs msbuildArgs)
{
if (new Telemetry.LLMEnvironmentDetectorForTelemetry().IsLLMEnvironment())
{
// disable the live-update display of the TerminalLogger, which wastes tokens
return msbuildArgs.CloneWithAdditionalArgs(Constants.TerminalLogger_DisableNodeDisplay);
}
else
{
return msbuildArgs;
}
}

/// <summary>
/// Creates a TerminalLogger or ConsoleLogger based on the provided MSBuild arguments.
/// If the environment is detected to be an LLM environment, the logger is adjusted to
/// better suit that environment.
/// </summary>
public static Microsoft.Build.Framework.ILogger GetConsoleLogger(MSBuildArgs args) =>
Microsoft.Build.Logging.TerminalLogger.CreateTerminalOrConsoleLogger([.. AdjustMSBuildForLLMs(args).OtherMSBuildArgs]);
}
4 changes: 3 additions & 1 deletion src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,9 @@ static ICommand CreateCommandForCscBuiltProgram(string entryPointFileFullPath, s
static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, FacadeLogger? binaryLogger, MSBuildArgs buildArgs)
{
List<ILogger> loggersForBuild = [
TerminalLogger.CreateTerminalOrConsoleLogger([$"--verbosity:{LoggerVerbosity.Quiet.ToString().ToLowerInvariant()}", ..buildArgs.OtherMSBuildArgs])
CommonRunHelpers.GetConsoleLogger(
buildArgs.CloneWithExplicitArgs([$"--verbosity:{LoggerVerbosity.Quiet.ToString().ToLowerInvariant()}", ..buildArgs.OtherMSBuildArgs])
)
];
if (binaryLogger is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ public override int Execute()
var verbosity = MSBuildArgs.Verbosity ?? MSBuildForwardingAppWithoutLogging.DefaultVerbosity;
var consoleLogger = minimizeStdOut
? new SimpleErrorLogger()
: TerminalLogger.CreateTerminalOrConsoleLogger([$"--verbosity:{verbosity}", .. MSBuildArgs.OtherMSBuildArgs]);
: CommonRunHelpers.GetConsoleLogger(MSBuildArgs.CloneWithExplicitArgs([$"--verbosity:{verbosity}", .. MSBuildArgs.OtherMSBuildArgs]));
var binaryLogger = GetBinaryLogger(MSBuildArgs.OtherMSBuildArgs);

CacheInfo? cache = null;
Expand Down
16 changes: 8 additions & 8 deletions src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli.Telemetry;

Expand Down Expand Up @@ -33,8 +34,7 @@ public BooleanEnvironmentRule(params string[] variables)

public override bool IsMatch()
{
return _variables.Any(variable =>
bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value);
return _variables.Any(variable => Env.GetEnvironmentVariableAsBool(variable));
}
}

Expand Down Expand Up @@ -81,12 +81,12 @@ public override bool IsMatch()
/// <typeparam name="T">The type of the result value.</typeparam>
internal class EnvironmentDetectionRuleWithResult<T> where T : class
{
private readonly string[] _variables;
private readonly EnvironmentDetectionRule _rule;
private readonly T _result;

public EnvironmentDetectionRuleWithResult(T result, params string[] variables)
public EnvironmentDetectionRuleWithResult(T result, EnvironmentDetectionRule rule)
{
_variables = variables ?? throw new ArgumentNullException(nameof(variables));
_rule = rule ?? throw new ArgumentNullException(nameof(rule));
_result = result ?? throw new ArgumentNullException(nameof(result));
}

Expand All @@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables)
/// <returns>The result value if the rule matches; otherwise, null.</returns>
public T? GetResult()
{
return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable)))
? _result
return _rule.IsMatch()
? _result
: null;
}
}
}
10 changes: 9 additions & 1 deletion src/Cli/dotnet/Telemetry/ILLMEnvironmentDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,13 @@ namespace Microsoft.DotNet.Cli.Telemetry;

internal interface ILLMEnvironmentDetector
{
/// <summary>
/// Checks the current environment for known indicators of LLM usage and returns a string identifying the LLM environment if detected.
/// </summary>
string? GetLLMEnvironment();
}

/// <summary>
/// Returns true if the current environment is detected to be an LLM/agentic environment, false otherwise.
/// </summary>
bool IsLLMEnvironment();
}
19 changes: 13 additions & 6 deletions src/Cli/dotnet/Telemetry/LLMEnvironmentDetectorForTelemetry.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Linq;

namespace Microsoft.DotNet.Cli.Telemetry;

internal class LLMEnvironmentDetectorForTelemetry : ILLMEnvironmentDetector
{
private static readonly EnvironmentDetectionRuleWithResult<string>[] _detectionRules = [
// Claude Code
new EnvironmentDetectionRuleWithResult<string>("claude", "CLAUDECODE"),
new EnvironmentDetectionRuleWithResult<string>("claude", new AnyPresentEnvironmentRule("CLAUDECODE")),
// Cursor AI
new EnvironmentDetectionRuleWithResult<string>("cursor", "CURSOR_EDITOR")
new EnvironmentDetectionRuleWithResult<string>("cursor", new AnyPresentEnvironmentRule("CURSOR_EDITOR")),
// Gemini
new EnvironmentDetectionRuleWithResult<string>("gemini", new BooleanEnvironmentRule("GEMINI_CLI")),
// GitHub Copilot
new EnvironmentDetectionRuleWithResult<string>("copilot", new BooleanEnvironmentRule("GITHUB_COPILOT_CLI_MODE")),
// (proposed) generic flag for Agentic usage
new EnvironmentDetectionRuleWithResult<string>("generic_agent", new BooleanEnvironmentRule("AGENT_CLI")),
];

/// <inheritdoc/>
public string? GetLLMEnvironment()
{
var results = _detectionRules.Select(r => r.GetResult()).Where(r => r != null).ToArray();
return results.Length > 0 ? string.Join(", ", results) : null;
}
}

/// <inheritdoc/>
public bool IsLLMEnvironment() => !string.IsNullOrEmpty(GetLLMEnvironment());
}
9 changes: 9 additions & 0 deletions test/dotnet.Tests/TelemetryCommonPropertiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,16 @@ public void TelemetryCommonPropertiesShouldContainSessionId(string sessionId)
public static IEnumerable<object[]> LLMTelemetryTestCases => new List<object[]>{
new object[] { new Dictionary<string, string> { { "CLAUDECODE", "1" } }, "claude" },
new object[] { new Dictionary<string, string> { { "CURSOR_EDITOR", "1" } }, "cursor" },
new object[] { new Dictionary<string, string> { { "GEMINI_CLI", "true" } }, "gemini" },
new object[] { new Dictionary<string, string> { { "GITHUB_COPILOT_CLI_MODE", "true" } }, "copilot" },
new object[] { new Dictionary<string, string> { { "AGENT_CLI", "true" } }, "generic_agent" },
new object[] { new Dictionary<string, string> { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" },
new object[] { new Dictionary<string, string> { { "GEMINI_CLI", "true" }, { "GITHUB_COPILOT_CLI_MODE", "true" } }, "gemini, copilot" },
new object[] { new Dictionary<string, string> { { "CLAUDECODE", "1" }, { "GEMINI_CLI", "true" }, { "AGENT_CLI", "true" } }, "claude, gemini, generic_agent" },
new object[] { 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 object[] { new Dictionary<string, string> { { "GEMINI_CLI", "false" } }, null },
new object[] { new Dictionary<string, string> { { "GITHUB_COPILOT_CLI_MODE", "false" } }, null },
new object[] { new Dictionary<string, string> { { "AGENT_CLI", "false" } }, null },
new object[] { new Dictionary<string, string>(), null },
};

Expand Down
Loading