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
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
Loading