From 90f5ef74fe40e4d981b858f3e945ca1af8be5dbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 09:03:41 +0000 Subject: [PATCH 1/5] Initial plan From 4247864c1976927f46ee0689197a68d87fc4852c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 09:11:44 +0000 Subject: [PATCH 2/5] fix environment detection precedence and CI metadata Agent-Logs-Url: https://github.com/xping-dev/sdk-dotnet/sessions/3f753eb5-b070-4f8a-a450-4423ad940fb6 Co-authored-by: xping-admin <180879096+xping-admin@users.noreply.github.com> --- docs/configuration/configuration-reference.md | 35 ++++- docs/getting-started/ci-cd-setup.md | 14 +- .../Configuration/XpingConfiguration.cs | 22 ++- .../XpingConfigurationBuilder.cs | 11 ++ .../XpingServiceCollectionExtensions.cs | 4 + .../Environment/IEnvironmentDetector.cs | 3 +- .../Internals/EnvironmentDetector.cs | 58 +++++++- .../XpingConfigurationBuilderTests.cs | 2 + .../Configuration/XpingConfigurationTests.cs | 1 + .../XpingServiceCollectionExtensionsTests.cs | 15 ++ .../Environment/EnvironmentDetectorTests.cs | 139 ++++++++++++++++++ 11 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs diff --git a/docs/configuration/configuration-reference.md b/docs/configuration/configuration-reference.md index 9ec2f38..78c6703 100644 --- a/docs/configuration/configuration-reference.md +++ b/docs/configuration/configuration-reference.md @@ -26,6 +26,7 @@ Xping SDK supports multiple configuration methods with the following priority or | `FlushInterval` | TimeSpan | `30s` | `XPING_FLUSHINTERVAL` | Auto-flush interval | | `Environment` | string | `Local` | `XPING_ENVIRONMENT` | Environment name | | `AutoDetectCIEnvironment` | bool | `true` | `XPING_AUTODETECTCIENVIRONMENT` | Auto-detect CI/CD | +| `CiEnvironmentName` | string | `CI` | `XPING_CIENVIRONMENTNAME` | Label used for auto-detected CI executions | | `Enabled` | bool | `true` | `XPING_ENABLED` | SDK enabled/disabled | | `CaptureStackTraces` | bool | `true` | `XPING_CAPTURESTACKTRACES` | Include stack traces | | `EnableCompression` | bool | `true` | `XPING_ENABLECOMPRESSION` | Compress uploads | @@ -414,11 +415,12 @@ export XPING_ENVIRONMENT="Staging" The SDK determines the environment name using the following priority (highest to lowest): 1. **`XPING_ENVIRONMENT` environment variable** - Explicit Xping-specific setting (highest priority) -2. **Auto-detected CI** - Returns `"CI"` when `AutoDetectCIEnvironment=true` and running in a detected CI/CD platform +2. **Auto-detected CI** - Returns `CiEnvironmentName` (default `"CI"`) when `AutoDetectCIEnvironment=true` and running in a detected CI/CD platform 3. **`Environment` configuration property** - Value set programmatically or in configuration files -4. **Default** - Returns `"Local"` when none of the above are set +4. **Framework environment variables** - `ASPNETCORE_ENVIRONMENT`, then `DOTNET_ENVIRONMENT` +5. **Default** - Returns `"Local"` when none of the above are set -**Example:** If you won't specify `Environment` and `AutoDetectCIEnvironment=false`, Xping will use `"Local"` as the environment name. However, setting `XPING_ENVIRONMENT=Staging` will override this and use `"Staging"` instead. +**Example:** If you don't specify `Environment` and `AutoDetectCIEnvironment=false`, Xping will use `DOTNET_ENVIRONMENT`/`ASPNETCORE_ENVIRONMENT` when available, and otherwise fall back to `"Local"`. Setting `XPING_ENVIRONMENT=Staging` still overrides everything and uses `"Staging"` instead. --- @@ -428,7 +430,7 @@ The SDK determines the environment name using the following priority (highest to **Default:** `true` **Environment Variable:** `XPING_AUTODETECTCIENVIRONMENT` -Automatically detect when running in CI/CD environments and set `Environment` to `"CI"`. Also captures CI-specific metadata (build numbers, commit SHAs, etc.). +Automatically detect when running in CI/CD environments and set `Environment` to `CiEnvironmentName` (default `"CI"`). Also captures CI-specific metadata (build numbers, commit SHAs, branch names, etc.). **Supported CI/CD platforms:** - GitHub Actions @@ -462,6 +464,31 @@ export XPING_AUTODETECTCIENVIRONMENT="false" --- +### CiEnvironmentName + +**Type:** `string` +**Default:** `"CI"` +**Environment Variable:** `XPING_CIENVIRONMENTNAME` + +Overrides the label used when CI/CD is auto-detected. This is useful when you want CI executions grouped under a more specific environment name such as `"BuildPipeline"` or `"PullRequestValidation"` without disabling auto-detection. + +**Example:** + +```json +{ + "Xping": { + "AutoDetectCIEnvironment": true, + "CiEnvironmentName": "BuildPipeline" + } +} +``` + +```bash +export XPING_CIENVIRONMENTNAME="BuildPipeline" +``` + +--- + ## Feature Flags ### Enabled diff --git a/docs/getting-started/ci-cd-setup.md b/docs/getting-started/ci-cd-setup.md index 34e760d..1cde630 100644 --- a/docs/getting-started/ci-cd-setup.md +++ b/docs/getting-started/ci-cd-setup.md @@ -6,7 +6,7 @@ Learn how to integrate Xping SDK into your CI/CD pipelines for continuous test r ## Overview -Xping SDK automatically detects CI/CD environments and captures relevant metadata like build numbers, commit SHAs, and branch names. This enables you to: +Xping SDK automatically detects CI/CD environments and captures relevant metadata like build numbers, commit SHAs, and branch names. It also marks non-CI executions as local developer-machine runs. This enables you to: - **Track test reliability across builds** - **Detect flaky tests in your pipeline** @@ -87,6 +87,7 @@ Xping automatically captures: - `GITHUB_RUN_NUMBER` - Sequential run number - `GITHUB_SHA` - Commit SHA - `GITHUB_REF` - Branch or tag ref +- `GITHUB_HEAD_REF` / `GITHUB_REF_NAME` - Normalized into `CI.Branch` - `GITHUB_REPOSITORY` - Repository name - `GITHUB_ACTOR` - User who triggered the workflow @@ -216,7 +217,7 @@ Xping automatically captures: - `CI_PIPELINE_ID` - Unique pipeline ID - `CI_JOB_ID` - Job ID - `CI_COMMIT_SHA` - Commit SHA -- `CI_COMMIT_REF_NAME` - Branch or tag name +- `CI_COMMIT_BRANCH` / `CI_COMMIT_REF_NAME` - Normalized into `CI.Branch` - `CI_PROJECT_PATH` - Repository path - `GITLAB_USER_LOGIN` - User who triggered the pipeline @@ -547,6 +548,15 @@ Track different branches in different Xping projects: run: dotnet test ``` +### Custom CI Environment Label + +If you want auto-detected CI runs grouped under a label other than the default `CI`, set `XPING_CIENVIRONMENTNAME`: + +```yaml +env: + XPING__CIENVIRONMENTNAME: "BuildPipeline" +``` + --- ## Verification Checklist diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs index a82ed81..4a5aba6 100644 --- a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs +++ b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs @@ -17,6 +17,13 @@ public sealed class XpingConfiguration /// public const string DefaultEnvironment = "Local"; + /// + /// Represents the default environment name used when CI/CD is auto-detected. + /// + public const string DefaultCiEnvironment = "CI"; + + private string? _environment; + /// /// Gets or sets the Xping API endpoint URL. /// @@ -57,14 +64,25 @@ public sealed class XpingConfiguration /// /// Gets or sets the environment name (e.g., "Local", "CI", "Staging", "Production"). + /// When left unset, the SDK falls back to unless a CI or framework-specific + /// environment variable takes precedence during environment detection. /// - public string Environment { get; set; } = DefaultEnvironment; + public string Environment + { + get => string.IsNullOrWhiteSpace(_environment) ? DefaultEnvironment : _environment!; + set => _environment = value; + } /// /// Gets or sets a value indicating whether to automatically detect CI/CD environments. /// public bool AutoDetectCIEnvironment { get; set; } = true; + /// + /// Gets or sets the environment name to use when CI/CD is auto-detected. + /// + public string CiEnvironmentName { get; set; } = DefaultCiEnvironment; + /// /// Gets or sets a value indicating whether the SDK is enabled. /// @@ -213,4 +231,6 @@ public bool IsValid() { return Validate().Count == 0; } + + internal bool HasExplicitEnvironment => !string.IsNullOrWhiteSpace(_environment); } diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs b/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs index b4cff7c..28ae120 100644 --- a/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs +++ b/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs @@ -89,6 +89,17 @@ public XpingConfigurationBuilder WithAutoDetectCIEnvironment(bool autoDetect) return this; } + /// + /// Sets the environment name to use when CI/CD is auto-detected. + /// + /// The CI/CD environment name. + /// The builder instance for method chaining. + public XpingConfigurationBuilder WithCiEnvironmentName(string ciEnvironmentName) + { + _configuration.CiEnvironmentName = ciEnvironmentName; + return this; + } + /// /// Sets whether the SDK is enabled. /// diff --git a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs index 9734131..bd254cc 100644 --- a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs +++ b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs @@ -492,6 +492,9 @@ private static void BindEnvironmentVariablesWithPrefix(XpingConfiguration config && bool.TryParse(autoDetect, out var ad)) config.AutoDetectCIEnvironment = ad; + if (GetEnv("CIENVIRONMENTNAME") is { } ciEnvironmentName) + config.CiEnvironmentName = ciEnvironmentName; + // Feature Flags if (GetEnv("ENABLED") is { } enabled && bool.TryParse(enabled, out var e)) config.Enabled = e; @@ -557,6 +560,7 @@ private static void CopyConfiguration(XpingConfiguration source, XpingConfigurat target.FlushInterval = source.FlushInterval; target.Environment = source.Environment; target.AutoDetectCIEnvironment = source.AutoDetectCIEnvironment; + target.CiEnvironmentName = source.CiEnvironmentName; target.Enabled = source.Enabled; target.CaptureStackTraces = source.CaptureStackTraces; target.EnableCompression = source.EnableCompression; diff --git a/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs index 8cadf9d..6bfaf5e 100644 --- a/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs +++ b/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs @@ -52,7 +52,8 @@ public interface IEnvironmentDetector /// /// Gets the environment name based on configuration and auto-detection rules. /// Priority: - /// XPING_ENVIRONMENT > AutoDetectCI > Options.Environment > ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT > "Local" + /// XPING_ENVIRONMENT > AutoDetectCI > explicitly configured Options.Environment > + /// ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT > "Local" /// /// /// The environment name is used in confidence calculations, which are performed both globally across all executions diff --git a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs index f699874..870b972 100644 --- a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs +++ b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs @@ -347,11 +347,13 @@ private string DetectEnvironmentName(XpingConfiguration configuration) // Priority 2: Auto-detect CI if enabled if (configuration.AutoDetectCIEnvironment && _ciPlatform.Value.HasValue) { - return "CI"; + return string.IsNullOrWhiteSpace(configuration.CiEnvironmentName) + ? XpingConfiguration.DefaultCiEnvironment + : configuration.CiEnvironmentName; } - // Priority 3: Use configuration property - if (!string.IsNullOrWhiteSpace(configuration.Environment)) + // Priority 3: Use explicitly configured environment property + if (configuration.HasExplicitEnvironment) { return configuration.Environment; } @@ -369,8 +371,8 @@ private string DetectEnvironmentName(XpingConfiguration configuration) return dotnetEnv!; } - // Priority 5: Default to "Local" - return XpingConfiguration.DefaultEnvironment; + // Priority 5: Default to configured/default local environment + return configuration.Environment; } private bool DetectIsContainer() @@ -415,6 +417,12 @@ private Dictionary CollectCustomProperties(string operatingSyste { Dictionary properties = new Dictionary(); + properties["ExecutionContext"] = ciPlatform.HasValue ? "CI" : "Local"; + if (!ciPlatform.HasValue && !isContainer) + { + properties["IsDeveloperMachine"] = "true"; + } + // Add container information if (isContainer) { @@ -468,6 +476,12 @@ private Dictionary CollectCustomProperties(string operatingSyste AddIfNotNull(properties, "CI.RunId", GetEnvironmentVariable("GITHUB_RUN_ID")); AddIfNotNull(properties, "CI.RunNumber", GetEnvironmentVariable("GITHUB_RUN_NUMBER")); AddIfNotNull(properties, "CI.Ref", GetEnvironmentVariable("GITHUB_REF")); + AddIfNotNull(properties, "CI.Branch", GetFirstNonEmptyValue( + GetEnvironmentVariable("GITHUB_HEAD_REF"), + GetEnvironmentVariable("GITHUB_REF_NAME"), + ExtractBranchName(GetEnvironmentVariable("GITHUB_REF")))); + AddIfNotNull(properties, "CI.HeadBranch", GetEnvironmentVariable("GITHUB_HEAD_REF")); + AddIfNotNull(properties, "CI.BaseBranch", GetEnvironmentVariable("GITHUB_BASE_REF")); AddIfNotNull(properties, "CI.SHA", GetEnvironmentVariable("GITHUB_SHA")); AddIfNotNull(properties, "CI.Actor", GetEnvironmentVariable("GITHUB_ACTOR")); AddIfNotNull(properties, "CI.Workflow", GetEnvironmentVariable("GITHUB_WORKFLOW")); @@ -478,6 +492,9 @@ private Dictionary CollectCustomProperties(string operatingSyste AddIfNotNull(properties, "CI.BuildNumber", GetEnvironmentVariable("BUILD_BUILDNUMBER")); AddIfNotNull(properties, "CI.Repository", GetEnvironmentVariable("BUILD_REPOSITORY_NAME")); AddIfNotNull(properties, "CI.SourceBranch", GetEnvironmentVariable("BUILD_SOURCEBRANCH")); + AddIfNotNull(properties, "CI.Branch", GetFirstNonEmptyValue( + GetEnvironmentVariable("BUILD_SOURCEBRANCHNAME"), + ExtractBranchName(GetEnvironmentVariable("BUILD_SOURCEBRANCH")))); AddIfNotNull(properties, "CI.SourceVersion", GetEnvironmentVariable("BUILD_SOURCEVERSION")); AddIfNotNull(properties, "CI.RequestedFor", GetEnvironmentVariable("BUILD_REQUESTEDFOR")); break; @@ -488,6 +505,7 @@ private Dictionary CollectCustomProperties(string operatingSyste AddIfNotNull(properties, "CI.BuildUrl", GetEnvironmentVariable("BUILD_URL")); AddIfNotNull(properties, "CI.GitCommit", GetEnvironmentVariable("GIT_COMMIT")); AddIfNotNull(properties, "CI.GitBranch", GetEnvironmentVariable("GIT_BRANCH")); + AddIfNotNull(properties, "CI.Branch", GetEnvironmentVariable("GIT_BRANCH")); break; case CIPlatform.GitLabCI: @@ -496,7 +514,11 @@ private Dictionary CollectCustomProperties(string operatingSyste AddIfNotNull(properties, "CI.ProjectPath", GetEnvironmentVariable("CI_PROJECT_PATH")); AddIfNotNull(properties, "CI.CommitSHA", GetEnvironmentVariable("CI_COMMIT_SHA")); AddIfNotNull(properties, "CI.CommitBranch", GetEnvironmentVariable("CI_COMMIT_BRANCH")); + AddIfNotNull(properties, "CI.Branch", GetFirstNonEmptyValue( + GetEnvironmentVariable("CI_COMMIT_BRANCH"), + GetEnvironmentVariable("CI_COMMIT_REF_NAME"))); AddIfNotNull(properties, "CI.CommitAuthor", GetEnvironmentVariable("CI_COMMIT_AUTHOR")); + AddIfNotNull(properties, "CI.Actor", GetEnvironmentVariable("GITLAB_USER_LOGIN")); break; case CIPlatform.CircleCI: @@ -556,6 +578,32 @@ private static void AddIfNotNull(Dictionary dictionary, string k } } + private static string? GetFirstNonEmptyValue(params string?[] values) + { + foreach (string? value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static string? ExtractBranchName(string? gitRef) + { + if (string.IsNullOrWhiteSpace(gitRef)) + { + return null; + } + + const string headsPrefix = "refs/heads/"; + return gitRef!.StartsWith(headsPrefix, StringComparison.OrdinalIgnoreCase) + ? gitRef.Substring(headsPrefix.Length) + : gitRef; + } + private static string? GetEnvironmentVariable(string variable) { try diff --git a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs index 48107bc..7b3047f 100644 --- a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs +++ b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs @@ -39,6 +39,7 @@ public void BuilderShouldAllowSettingAllProperties() .WithFlushInterval(TimeSpan.FromMinutes(1)) .WithEnvironment("Production") .WithAutoDetectCIEnvironment(false) + .WithCiEnvironmentName("Pipeline") .WithEnabled(false) .WithCaptureStackTraces(false) .WithEnableCompression(false) @@ -58,6 +59,7 @@ public void BuilderShouldAllowSettingAllProperties() Assert.Equal(TimeSpan.FromMinutes(1), config.FlushInterval); Assert.Equal("Production", config.Environment); Assert.False(config.AutoDetectCIEnvironment); + Assert.Equal("Pipeline", config.CiEnvironmentName); Assert.False(config.Enabled); Assert.False(config.CaptureStackTraces); Assert.False(config.EnableCompression); diff --git a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs index 1025752..88b8882 100644 --- a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs +++ b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs @@ -23,6 +23,7 @@ public void DefaultConfigurationShouldHaveCorrectValues() Assert.Equal(TimeSpan.FromSeconds(30), config.FlushInterval); Assert.Equal("Local", config.Environment); Assert.True(config.AutoDetectCIEnvironment); + Assert.Equal("CI", config.CiEnvironmentName); Assert.True(config.Enabled); Assert.True(config.CaptureStackTraces); Assert.True(config.EnableCompression); diff --git a/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs b/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs index 689aae2..300a4d3 100644 --- a/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs +++ b/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs @@ -646,4 +646,19 @@ public void BindEnvVars_AUTODETECTCIENVIRONMENT_ShouldParseBool() Assert.False(bound.AutoDetectCIEnvironment); } + + [Fact] + public void BindEnvVars_CIENVIRONMENTNAME_ShouldOverrideConfiguredValue() + { + using var _key = WithEnv("XPING_APIKEY", "k"); + using var _proj = WithEnv("XPING_PROJECTID", "p"); + using var _ = WithEnv("XPING_CIENVIRONMENTNAME", "BuildPipeline"); + + var services = new ServiceCollection(); + services.AddXpingConfigurationFromConfiguration(InMemoryXpingConfig()); + var bound = services.BuildServiceProvider() + .GetRequiredService>().Value; + + Assert.Equal("BuildPipeline", bound.CiEnvironmentName); + } } diff --git a/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs new file mode 100644 index 0000000..af148b1 --- /dev/null +++ b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs @@ -0,0 +1,139 @@ +/* + * © 2026 Xping.io. All Rights Reserved. + * License: [MIT] + */ + +using Microsoft.Extensions.Options; +using Moq; +using Xping.Sdk.Core.Configuration; +using Xping.Sdk.Core.Models.Environments; +using Xping.Sdk.Core.Services.Environment; +using Xping.Sdk.Core.Services.Environment.Internals; +using Xping.Sdk.Core.Services.Network; + +namespace Xping.Sdk.Core.Tests.Services.Environment; + +[Collection("Sequential")] +public sealed class EnvironmentDetectorTests +{ + private static readonly string[] _ciVariables = + [ + "CI", + "GITHUB_ACTIONS", + "TF_BUILD", + "JENKINS_URL", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "TEAMCITY_VERSION", + "BITBUCKET_PIPELINE_UUID", + "APPVEYOR", + ]; + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithDotnetEnvironmentAndDefaultConfiguration_UsesDotnetEnvironment() + { + using var clearedCiVariables = ClearEnvironmentVariables(_ciVariables); + using var dotnetEnvironment = new EnvRestorer("DOTNET_ENVIRONMENT", "Development"); + + IEnvironmentDetector detector = CreateDetector(); + + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("Development", info.EnvironmentName); + Assert.False(info.IsCIEnvironment); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithLocalExecution_MarksDeveloperMachine() + { + using var clearedCiVariables = ClearEnvironmentVariables(_ciVariables); + + IEnvironmentDetector detector = CreateDetector(); + + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.False(info.IsCIEnvironment); + Assert.Equal("Local", info.EnvironmentName); + Assert.Equal("Local", info.CustomProperties["ExecutionContext"]); + Assert.Equal("true", info.CustomProperties["IsDeveloperMachine"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithGitHubActions_CapturesNormalizedBranchAndConfiguredCiName() + { + using var githubActions = new EnvRestorer("GITHUB_ACTIONS", "true"); + using var githubHeadRef = new EnvRestorer("GITHUB_HEAD_REF", "feature/refactor-environment"); + using var githubRefName = new EnvRestorer("GITHUB_REF_NAME", "17/merge"); + using var githubRef = new EnvRestorer("GITHUB_REF", "refs/pull/17/merge"); + using var githubRepository = new EnvRestorer("GITHUB_REPOSITORY", "xping-dev/sdk-dotnet"); + using var githubRunId = new EnvRestorer("GITHUB_RUN_ID", "42"); + using var githubSha = new EnvRestorer("GITHUB_SHA", "abc123"); + using var githubActor = new EnvRestorer("GITHUB_ACTOR", "octocat"); + + IEnvironmentDetector detector = CreateDetector(new XpingConfiguration + { + CiEnvironmentName = "BuildPipeline", + }); + + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.True(info.IsCIEnvironment); + Assert.Equal("BuildPipeline", info.EnvironmentName); + Assert.Equal("CI", info.CustomProperties["ExecutionContext"]); + Assert.Equal("GitHubActions", info.CustomProperties["CIPlatform"]); + Assert.Equal("feature/refactor-environment", info.CustomProperties["CI.Branch"]); + Assert.Equal("refs/pull/17/merge", info.CustomProperties["CI.Ref"]); + Assert.DoesNotContain("IsDeveloperMachine", info.CustomProperties.Keys); + } + + private static EnvironmentDetector CreateDetector(XpingConfiguration? configuration = null) + { + XpingConfiguration resolvedConfiguration = configuration ?? new XpingConfiguration(); + resolvedConfiguration.CollectNetworkMetrics = false; + + return new EnvironmentDetector( + Options.Create(resolvedConfiguration), + Mock.Of()); + } + + private static CompositeDisposable ClearEnvironmentVariables(IEnumerable variableNames) + { + List restorers = []; + foreach (string variableName in variableNames) + { + restorers.Add(new EnvRestorer(variableName, null)); + } + + return new CompositeDisposable(restorers); + } + + private sealed class EnvRestorer : IDisposable + { + private readonly string _name; + private readonly string? _originalValue; + + public EnvRestorer(string name, string? value) + { + _name = name; + _originalValue = System.Environment.GetEnvironmentVariable(name); + System.Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + System.Environment.SetEnvironmentVariable(_name, _originalValue); + } + } + + private sealed class CompositeDisposable(IEnumerable disposables) : IDisposable + { + public void Dispose() + { + foreach (IDisposable disposable in disposables.Reverse()) + { + disposable.Dispose(); + } + } + } +} From ccfe72361700ae5a189c8e5a60d91817a080ab49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 09:14:15 +0000 Subject: [PATCH 3/5] chore: address environment detection review feedback Agent-Logs-Url: https://github.com/xping-dev/sdk-dotnet/sessions/3f753eb5-b070-4f8a-a450-4423ad940fb6 Co-authored-by: xping-admin <180879096+xping-admin@users.noreply.github.com> --- src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs | 3 ++- .../Services/Environment/Internals/EnvironmentDetector.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs index 4a5aba6..0e527f8 100644 --- a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs +++ b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs @@ -18,7 +18,8 @@ public sealed class XpingConfiguration public const string DefaultEnvironment = "Local"; /// - /// Represents the default environment name used when CI/CD is auto-detected. + /// Represents the default value for when CI/CD is auto-detected + /// and no explicit override is configured. /// public const string DefaultCiEnvironment = "CI"; diff --git a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs index 870b972..8724909 100644 --- a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs +++ b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs @@ -417,7 +417,9 @@ private Dictionary CollectCustomProperties(string operatingSyste { Dictionary properties = new Dictionary(); - properties["ExecutionContext"] = ciPlatform.HasValue ? "CI" : "Local"; + properties["ExecutionContext"] = ciPlatform.HasValue + ? XpingConfiguration.DefaultCiEnvironment + : XpingConfiguration.DefaultEnvironment; if (!ciPlatform.HasValue && !isContainer) { properties["IsDeveloperMachine"] = "true"; From d557b1edf17e70637ecd2f12790358e42010f78a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 09:16:35 +0000 Subject: [PATCH 4/5] chore: finalize environment detection polish Agent-Logs-Url: https://github.com/xping-dev/sdk-dotnet/sessions/3f753eb5-b070-4f8a-a450-4423ad940fb6 Co-authored-by: xping-admin <180879096+xping-admin@users.noreply.github.com> --- src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs | 3 +-- .../Services/Environment/Internals/EnvironmentDetector.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs index 0e527f8..a164355 100644 --- a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs +++ b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs @@ -18,8 +18,7 @@ public sealed class XpingConfiguration public const string DefaultEnvironment = "Local"; /// - /// Represents the default value for when CI/CD is auto-detected - /// and no explicit override is configured. + /// Represents the default value for when CI/CD is auto-detected and no explicit override is configured. /// public const string DefaultCiEnvironment = "CI"; diff --git a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs index 8724909..7fb974a 100644 --- a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs +++ b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs @@ -602,7 +602,7 @@ private static void AddIfNotNull(Dictionary dictionary, string k const string headsPrefix = "refs/heads/"; return gitRef!.StartsWith(headsPrefix, StringComparison.OrdinalIgnoreCase) - ? gitRef.Substring(headsPrefix.Length) + ? gitRef.Substring(headsPrefix.Length, gitRef.Length - headsPrefix.Length) : gitRef; } From 0bbc3d2aa23ae5fcfc5b1c7c0a05c5f74fd35cd0 Mon Sep 17 00:00:00 2001 From: xping-admin Date: Sun, 10 May 2026 17:04:59 +0200 Subject: [PATCH 5/5] Fix PR review comments: CopyConfiguration guard, test env cleanup, doc consistency - Guard target.Environment assignment in CopyConfiguration with HasExplicitEnvironment check to prevent overwriting ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT precedence - Rename _ciVariables to _environmentVariables and add XPING_ENVIRONMENT, ASPNETCORE_ENVIRONMENT, DOTNET_ENVIRONMENT to cleanup list to prevent flaky tests - Fix YAML example in ci-cd-setup.md to use single underscore (XPING_CIENVIRONMENTNAME) instead of double underscore which is not the SDK custom binding format --- docs/getting-started/ci-cd-setup.md | 2 +- .../Extensions/XpingServiceCollectionExtensions.cs | 6 +++++- .../Services/Environment/EnvironmentDetectorTests.cs | 9 ++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/getting-started/ci-cd-setup.md b/docs/getting-started/ci-cd-setup.md index 1cde630..1453251 100644 --- a/docs/getting-started/ci-cd-setup.md +++ b/docs/getting-started/ci-cd-setup.md @@ -554,7 +554,7 @@ If you want auto-detected CI runs grouped under a label other than the default ` ```yaml env: - XPING__CIENVIRONMENTNAME: "BuildPipeline" + XPING_CIENVIRONMENTNAME: "BuildPipeline" ``` --- diff --git a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs index bd254cc..30d81c8 100644 --- a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs +++ b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs @@ -558,7 +558,11 @@ private static void CopyConfiguration(XpingConfiguration source, XpingConfigurat target.ProjectId = source.ProjectId; target.BatchSize = source.BatchSize; target.FlushInterval = source.FlushInterval; - target.Environment = source.Environment; + if (source.HasExplicitEnvironment) + { + target.Environment = source.Environment; + } + target.AutoDetectCIEnvironment = source.AutoDetectCIEnvironment; target.CiEnvironmentName = source.CiEnvironmentName; target.Enabled = source.Enabled; diff --git a/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs index af148b1..5fbd672 100644 --- a/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs +++ b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs @@ -16,7 +16,7 @@ namespace Xping.Sdk.Core.Tests.Services.Environment; [Collection("Sequential")] public sealed class EnvironmentDetectorTests { - private static readonly string[] _ciVariables = + private static readonly string[] _environmentVariables = [ "CI", "GITHUB_ACTIONS", @@ -28,12 +28,15 @@ public sealed class EnvironmentDetectorTests "TEAMCITY_VERSION", "BITBUCKET_PIPELINE_UUID", "APPVEYOR", + "XPING_ENVIRONMENT", + "ASPNETCORE_ENVIRONMENT", + "DOTNET_ENVIRONMENT", ]; [Fact] public async Task BuildEnvironmentInfoAsync_WithDotnetEnvironmentAndDefaultConfiguration_UsesDotnetEnvironment() { - using var clearedCiVariables = ClearEnvironmentVariables(_ciVariables); + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); using var dotnetEnvironment = new EnvRestorer("DOTNET_ENVIRONMENT", "Development"); IEnvironmentDetector detector = CreateDetector(); @@ -47,7 +50,7 @@ public async Task BuildEnvironmentInfoAsync_WithDotnetEnvironmentAndDefaultConfi [Fact] public async Task BuildEnvironmentInfoAsync_WithLocalExecution_MarksDeveloperMachine() { - using var clearedCiVariables = ClearEnvironmentVariables(_ciVariables); + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); IEnvironmentDetector detector = CreateDetector();