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
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
7 changes: 6 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,9 @@ public class MSBuildForwardingApp : CommandBase

private readonly MSBuildForwardingAppWithoutLogging _forwardingAppWithoutLogging;

/// <summary>
/// Adds the CLI's telemetry logger to the MSBuild arguments if telemetry is enabled.
/// </summary>
private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs)
{
if (Telemetry.Telemetry.CurrentSessionId != null)
Expand Down Expand Up @@ -45,8 +49,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());
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Tests;
using BuildCommand = Microsoft.DotNet.Cli.Commands.Build.BuildCommand;

namespace Microsoft.DotNet.Cli.MSBuild.Tests
Expand All @@ -10,8 +12,8 @@ namespace Microsoft.DotNet.Cli.MSBuild.Tests
public class GivenDotnetBuildInvocation : IClassFixture<NullCurrentSessionIdFixture>
{
string[] ExpectedPrefix = ["-maxcpucount", "--verbosity:m", "-tlp:default=auto", "-nologo"];
public static string[] RestoreExpectedPrefixForImplicitRestore = [..RestoringCommand.RestoreOptimizationProperties.Select(kvp => $"--restoreProperty:{kvp.Key}={kvp.Value}")];
public static string[] RestoreExpectedPrefixForSeparateRestore = [..RestoringCommand.RestoreOptimizationProperties.Select(kvp => $"--property:{kvp.Key}={kvp.Value}")];
public static string[] RestoreExpectedPrefixForImplicitRestore = [.. RestoringCommand.RestoreOptimizationProperties.Select(kvp => $"--restoreProperty:{kvp.Key}={kvp.Value}")];
public static string[] RestoreExpectedPrefixForSeparateRestore = [.. RestoringCommand.RestoreOptimizationProperties.Select(kvp => $"--property:{kvp.Key}={kvp.Value}")];

const string NugetInteractiveProperty = "--property:NuGetInteractive=false";

Expand Down Expand Up @@ -118,5 +120,47 @@ public void MsbuildInvocationIsCorrectForSeparateRestore(
.BeEquivalentTo([.. ExpectedPrefix, "-consoleloggerparameters:Summary", NugetInteractiveProperty, .. expectedAdditionalArgs]);
});
}

[Theory]
[MemberData(memberName: nameof(TelemetryCommonPropertiesTests.LLMTelemetryTestCases), MemberType =typeof(TelemetryCommonPropertiesTests))]
public void WhenLLMIsDetectedTLLiveUpdateIsDisabled(Dictionary<string, string>? llmEnvVarsToSet, string? expectedLLMName)
{
CommandDirectoryContext.PerformActionWithBasePath(WorkingDirectory, () =>
{
try
{
// Set environment variables to simulate LLM environment
if (llmEnvVarsToSet is not null)
{
foreach (var (key, value) in llmEnvVarsToSet)
{
Environment.SetEnvironmentVariable(key, value);
}
}

var command = (RestoringCommand)BuildCommand.FromArgs([]);

if (expectedLLMName is not null)
{
command.GetArgumentTokensToMSBuild().Should().Contain(Constants.TerminalLogger_DisableNodeDisplay);
}
else
{
command.GetArgumentTokensToMSBuild().Should().NotContain(Constants.TerminalLogger_DisableNodeDisplay);
}
}
finally
{
// Clear the environment variables after the test
if (llmEnvVarsToSet is not null)
{
foreach (var (key, value) in llmEnvVarsToSet)
{
Environment.SetEnvironmentVariable(key, null);
}
}
}
});
}
}
}
Loading
Loading