Skip to content
89 changes: 33 additions & 56 deletions src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs
Original file line number Diff line number Diff line change
@@ -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());
}
}
103 changes: 103 additions & 0 deletions src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Base class for environment detection rules that can be evaluated against environment variables.
/// </summary>
internal abstract class EnvironmentDetectionRule
{
/// <summary>
/// Evaluates the rule against the current environment.
/// </summary>
/// <returns>True if the rule matches the current environment; otherwise, false.</returns>
public abstract bool IsMatch();
}

/// <summary>
/// Rule that matches when any of the specified environment variables is set to "true".
/// </summary>
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);
}
}

/// <summary>
/// Rule that matches when all specified environment variables are present and not null/empty.
/// </summary>
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)));
}
}

/// <summary>
/// Rule that matches when any of the specified environment variables is present and not null/empty.
/// </summary>
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)));
}
}

/// <summary>
/// Rule that matches when any of the specified environment variables is present and not null/empty,
/// and returns the associated result value.
/// </summary>
/// <typeparam name="T">The type of the result value.</typeparam>
internal class EnvironmentDetectionRuleWithResult<T> 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));
}

/// <summary>
/// Evaluates the rule and returns the result if matched.
/// </summary>
/// <returns>The result value if the rule matches; otherwise, null.</returns>
public T? GetResult()
{
return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable)))
? _result
: null;
}
}
4 changes: 1 addition & 3 deletions src/Cli/dotnet/Telemetry/ICIEnvironmentDetector.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/Cli/dotnet/Telemetry/ILLMEnvironmentDetector.cs
Original file line number Diff line number Diff line change
@@ -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();
}
23 changes: 23 additions & 0 deletions src/Cli/dotnet/Telemetry/LLMEnvironmentDetectorForTelemetry.cs
Original file line number Diff line number Diff line change
@@ -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<string>[] _detectionRules = [
// Claude Code
new EnvironmentDetectionRuleWithResult<string>("claude", "CLAUDECODE"),
// Cursor AI
new EnvironmentDetectionRuleWithResult<string>("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;
}
}
6 changes: 5 additions & 1 deletion src/Cli/dotnet/Telemetry/TelemetryCommonProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ internal class TelemetryCommonProperties(
Func<string> 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<string> _getCurrentDirectory = getCurrentDirectory ?? Directory.GetCurrentDirectory;
private readonly Func<string, string> _hasher = hasher ?? Sha256Hasher.Hash;
private readonly Func<string> _getMACAddress = getMACAddress ?? MacAddressGetter.GetMacAddress;
Expand All @@ -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";
Expand All @@ -67,6 +70,7 @@ public FrozenDictionary<string, string> 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.
Expand Down
36 changes: 36 additions & 0 deletions test/dotnet.Tests/TelemetryCommonPropertiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> envVars, bool expected)
Expand All @@ -184,6 +191,27 @@ public void CanDetectCIStatusForEnvVars(Dictionary<string, string> envVars, bool
}
}

[Theory]
[MemberData(nameof(LLMTelemetryTestCases))]
public void CanDetectLLMStatusForEnvVars(Dictionary<string, string> 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)]
Expand All @@ -196,6 +224,14 @@ public void TelemetryCommonPropertiesShouldContainSessionId(string sessionId)
commonProperties["SessionId"].Should().Be(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> { { "CLAUDECODE", "1" }, { "CURSOR_EDITOR", "1" } }, "claude, cursor" },
new object[] { new Dictionary<string, string>(), null },
};

public static IEnumerable<object[]> CITelemetryTestCases => new List<object[]>{
new object[] { new Dictionary<string, string> { { "TF_BUILD", "true" } }, true },
new object[] { new Dictionary<string, string> { { "GITHUB_ACTIONS", "true" } }, true },
Expand Down