diff --git a/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs b/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs index 4281480cedaa..b4d6a384a45c 100644 --- a/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs +++ b/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs @@ -1,73 +1,50 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable +using System; +using System.Linq; namespace Microsoft.DotNet.Cli.Telemetry; internal class CIEnvironmentDetectorForTelemetry : ICIEnvironmentDetector { - // Systems that provide boolean values only, so we can simply parse and check for true - private static readonly string[] _booleanVariables = [ - // Azure Pipelines - https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables#system-variables-devops-services - "TF_BUILD", - // GitHub Actions - https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables - "GITHUB_ACTIONS", - // AppVeyor - https://www.appveyor.com/docs/environment-variables/ - "APPVEYOR", - // A general-use flag - Many of the major players support this: AzDo, GitHub, GitLab, AppVeyor, Travis CI, CircleCI. - // Given this, we could potentially remove all of these other options? - "CI", - // Travis CI - https://docs.travis-ci.com/user/environment-variables/#default-environment-variables - "TRAVIS", - // CircleCI - https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables - "CIRCLECI", - ]; - - // Systems where every variable must be present and not-null before returning true - private static readonly string[][] _allNotNullVariables = [ + private static readonly EnvironmentDetectionRule[] _detectionRules = [ + // Systems that provide boolean values only, so we can simply parse and check for true + new BooleanEnvironmentRule( + // Azure Pipelines - https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables#system-variables-devops-services + "TF_BUILD", + // GitHub Actions - https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables + "GITHUB_ACTIONS", + // AppVeyor - https://www.appveyor.com/docs/environment-variables/ + "APPVEYOR", + // A general-use flag - Many of the major players support this: AzDo, GitHub, GitLab, AppVeyor, Travis CI, CircleCI. + // Given this, we could potentially remove all of these other options? + "CI", + // Travis CI - https://docs.travis-ci.com/user/environment-variables/#default-environment-variables + "TRAVIS", + // CircleCI - https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables + "CIRCLECI" + ), + + // Systems where every variable must be present and not-null before returning true // AWS CodeBuild - https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html - ["CODEBUILD_BUILD_ID", "AWS_REGION"], + new AllPresentEnvironmentRule("CODEBUILD_BUILD_ID", "AWS_REGION"), // Jenkins - https://github.com/jenkinsci/jenkins/blob/master/core/src/main/resources/jenkins/model/CoreEnvironmentContributor/buildEnv.groovy - ["BUILD_ID", "BUILD_URL"], + new AllPresentEnvironmentRule("BUILD_ID", "BUILD_URL"), // Google Cloud Build - https://cloud.google.com/build/docs/configuring-builds/substitute-variable-values#using_default_substitutions - ["BUILD_ID", "PROJECT_ID"] - ]; - - // Systems where the variable must be present and not-null - private static readonly string[] _ifNonNullVariables = [ - // TeamCity - https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters - "TEAMCITY_VERSION", - // JetBrains Space - https://www.jetbrains.com/help/space/automation-environment-variables.html#general - "JB_SPACE_API_URL" + new AllPresentEnvironmentRule("BUILD_ID", "PROJECT_ID"), + + // Systems where the variable must be present and not-null + new AnyPresentEnvironmentRule( + // TeamCity - https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters + "TEAMCITY_VERSION", + // JetBrains Space - https://www.jetbrains.com/help/space/automation-environment-variables.html#general + "JB_SPACE_API_URL" + ) ]; public bool IsCIEnvironment() { - foreach (var booleanVariable in _booleanVariables) - { - if (bool.TryParse(Environment.GetEnvironmentVariable(booleanVariable), out bool envVar) && envVar) - { - return true; - } - } - - foreach (var variables in _allNotNullVariables) - { - if (variables.All((variable) => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable)))) - { - return true; - } - } - - foreach (var variable in _ifNonNullVariables) - { - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) - { - return true; - } - } - - return false; + return _detectionRules.Any(rule => rule.IsMatch()); } } diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs new file mode 100644 index 000000000000..5cd73f53abb8 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs @@ -0,0 +1,103 @@ +// 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.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Cli.Telemetry; + +/// +/// Base class for environment detection rules that can be evaluated against environment variables. +/// +internal abstract class EnvironmentDetectionRule +{ + /// + /// Evaluates the rule against the current environment. + /// + /// True if the rule matches the current environment; otherwise, false. + public abstract bool IsMatch(); +} + +/// +/// Rule that matches when any of the specified environment variables is set to "true". +/// +internal class BooleanEnvironmentRule : EnvironmentDetectionRule +{ + private readonly string[] _variables; + + public BooleanEnvironmentRule(params string[] variables) + { + _variables = variables ?? throw new ArgumentNullException(nameof(variables)); + } + + public override bool IsMatch() + { + return _variables.Any(variable => + bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value); + } +} + +/// +/// Rule that matches when all specified environment variables are present and not null/empty. +/// +internal class AllPresentEnvironmentRule : EnvironmentDetectionRule +{ + private readonly string[] _variables; + + public AllPresentEnvironmentRule(params string[] variables) + { + _variables = variables ?? throw new ArgumentNullException(nameof(variables)); + } + + public override bool IsMatch() + { + return _variables.All(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))); + } +} + +/// +/// Rule that matches when any of the specified environment variables is present and not null/empty. +/// +internal class AnyPresentEnvironmentRule : EnvironmentDetectionRule +{ + private readonly string[] _variables; + + public AnyPresentEnvironmentRule(params string[] variables) + { + _variables = variables ?? throw new ArgumentNullException(nameof(variables)); + } + + public override bool IsMatch() + { + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))); + } +} + +/// +/// Rule that matches when any of the specified environment variables is present and not null/empty, +/// and returns the associated result value. +/// +/// The type of the result value. +internal class EnvironmentDetectionRuleWithResult where T : class +{ + private readonly string[] _variables; + private readonly T _result; + + public EnvironmentDetectionRuleWithResult(T result, params string[] variables) + { + _variables = variables ?? throw new ArgumentNullException(nameof(variables)); + _result = result ?? throw new ArgumentNullException(nameof(result)); + } + + /// + /// Evaluates the rule and returns the result if matched. + /// + /// The result value if the rule matches; otherwise, null. + public T? GetResult() + { + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) + ? _result + : null; + } +} \ No newline at end of file diff --git a/src/Cli/dotnet/Telemetry/ICIEnvironmentDetector.cs b/src/Cli/dotnet/Telemetry/ICIEnvironmentDetector.cs index d65c4c87feeb..4c36e8faea01 100644 --- a/src/Cli/dotnet/Telemetry/ICIEnvironmentDetector.cs +++ b/src/Cli/dotnet/Telemetry/ICIEnvironmentDetector.cs @@ -1,8 +1,6 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - namespace Microsoft.DotNet.Cli.Telemetry; internal interface ICIEnvironmentDetector diff --git a/src/Cli/dotnet/Telemetry/ILLMEnvironmentDetector.cs b/src/Cli/dotnet/Telemetry/ILLMEnvironmentDetector.cs new file mode 100644 index 000000000000..fe599569aa6c --- /dev/null +++ b/src/Cli/dotnet/Telemetry/ILLMEnvironmentDetector.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Cli.Telemetry; + +internal interface ILLMEnvironmentDetector +{ + string? GetLLMEnvironment(); +} \ No newline at end of file diff --git a/src/Cli/dotnet/Telemetry/LLMEnvironmentDetectorForTelemetry.cs b/src/Cli/dotnet/Telemetry/LLMEnvironmentDetectorForTelemetry.cs new file mode 100644 index 000000000000..16d13a6879e7 --- /dev/null +++ b/src/Cli/dotnet/Telemetry/LLMEnvironmentDetectorForTelemetry.cs @@ -0,0 +1,23 @@ +// 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[] _detectionRules = [ + // Claude Code + new EnvironmentDetectionRuleWithResult("claude", "CLAUDECODE"), + // Cursor AI + new EnvironmentDetectionRuleWithResult("cursor", "CURSOR_EDITOR") + ]; + + public string? GetLLMEnvironment() + { + var results = _detectionRules.Select(r => r.GetResult()).Where(r => r != null).ToArray(); + return results.Length > 0 ? string.Join(", ", results) : null; + } +} \ No newline at end of file diff --git a/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs b/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs index 13aaf12883a4..625e343d7589 100644 --- a/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs +++ b/src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs @@ -18,10 +18,12 @@ internal class TelemetryCommonProperties( Func getDeviceId = null, IDockerContainerDetector dockerContainerDetector = null, IUserLevelCacheWriter userLevelCacheWriter = null, - ICIEnvironmentDetector ciEnvironmentDetector = null) + ICIEnvironmentDetector ciEnvironmentDetector = null, + ILLMEnvironmentDetector llmEnvironmentDetector = null) { private readonly IDockerContainerDetector _dockerContainerDetector = dockerContainerDetector ?? new DockerContainerDetectorForTelemetry(); private readonly ICIEnvironmentDetector _ciEnvironmentDetector = ciEnvironmentDetector ?? new CIEnvironmentDetectorForTelemetry(); + private readonly ILLMEnvironmentDetector _llmEnvironmentDetector = llmEnvironmentDetector ?? new LLMEnvironmentDetectorForTelemetry(); private readonly Func _getCurrentDirectory = getCurrentDirectory ?? Directory.GetCurrentDirectory; private readonly Func _hasher = hasher ?? Sha256Hasher.Hash; private readonly Func _getMACAddress = getMACAddress ?? MacAddressGetter.GetMacAddress; @@ -47,6 +49,7 @@ internal class TelemetryCommonProperties( private const string SessionId = "SessionId"; private const string CI = "Continuous Integration"; + private const string LLM = "llm"; private const string TelemetryProfileEnvironmentVariable = "DOTNET_CLI_TELEMETRY_PROFILE"; private const string CannotFindMacAddress = "Unknown"; @@ -67,6 +70,7 @@ public FrozenDictionary GetTelemetryCommonProperties(string curr {TelemetryProfile, Environment.GetEnvironmentVariable(TelemetryProfileEnvironmentVariable)}, {DockerContainer, _userLevelCacheWriter.RunWithCache(IsDockerContainerCacheKey, () => _dockerContainerDetector.IsDockerContainer().ToString("G") )}, {CI, _ciEnvironmentDetector.IsCIEnvironment().ToString() }, + {LLM, _llmEnvironmentDetector.GetLLMEnvironment() }, {CurrentPathHash, _hasher(_getCurrentDirectory())}, {MachineIdOld, _userLevelCacheWriter.RunWithCache(MachineIdCacheKey, GetMachineId)}, // we don't want to recalcuate a new id for every new SDK version. Reuse the same path across versions. diff --git a/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs b/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs index f7c75d576118..a77f0bf81765 100644 --- a/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs +++ b/test/dotnet.Tests/TelemetryCommonPropertiesTests.cs @@ -163,6 +163,13 @@ public void TelemetryCommonPropertiesShouldContainLibcReleaseAndVersion() } } + [Fact] + public void TelemetryCommonPropertiesShouldReturnIsLLMDetection() + { + var unitUnderTest = new TelemetryCommonProperties(getMACAddress: () => null, userLevelCacheWriter: new NothingCache()); + unitUnderTest.GetTelemetryCommonProperties("dummySessionId")["llm"].Should().BeOneOf("claude", null); + } + [Theory] [MemberData(nameof(CITelemetryTestCases))] public void CanDetectCIStatusForEnvVars(Dictionary envVars, bool expected) @@ -184,6 +191,27 @@ public void CanDetectCIStatusForEnvVars(Dictionary envVars, bool } } + [Theory] + [MemberData(nameof(LLMTelemetryTestCases))] + public void CanDetectLLMStatusForEnvVars(Dictionary envVars, string expected) + { + try + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, value); + } + new LLMEnvironmentDetectorForTelemetry().GetLLMEnvironment().Should().Be(expected); + } + finally + { + foreach (var (key, value) in envVars) + { + Environment.SetEnvironmentVariable(key, null); + } + } + } + [Theory] [InlineData("dummySessionId")] [InlineData(null)] @@ -196,6 +224,14 @@ public void TelemetryCommonPropertiesShouldContainSessionId(string sessionId) commonProperties["SessionId"].Should().Be(sessionId); } + + public static IEnumerable LLMTelemetryTestCases => new List{ + new object[] { new Dictionary { { "CLAUDECODE", "1" } }, "claude" }, + new object[] { new Dictionary { { "CURSOR_EDITOR", "1" } }, "cursor" }, + new object[] { new Dictionary { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" }, + new object[] { new Dictionary(), null }, + }; + public static IEnumerable CITelemetryTestCases => new List{ new object[] { new Dictionary { { "TF_BUILD", "true" } }, true }, new object[] { new Dictionary { { "GITHUB_ACTIONS", "true" } }, true },