From 6f4f13670bfd0d1b18d5004234eecc591eafcb08 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:30:47 -0500 Subject: [PATCH 01/11] Add deployment environments to events --- README.md | 9 +++++++++ .../Configuration/ExceptionlessConfiguration.cs | 8 ++++++++ .../Extensions/EventBuilderExtensions.cs | 8 +++++++- .../ExceptionlessConfigurationExtensions.cs | 15 ++++++++++++++- src/Exceptionless/Models/Client/Event.cs | 16 ++++++++++++++-- .../Default/015_ConfigurationDefaultsPlugin.cs | 6 +++++- .../Utility/DeploymentEnvironment.cs | 11 +++++++++++ .../ExceptionlessExtensions.cs | 7 ++++++- .../Serializer/StorageSerializerTestBase.cs | 1 + .../Platforms/HostingExtensionsTests.cs | 13 +++++++++++++ .../015_ConfigurationDefaultsPluginTests.cs | 16 ++++++++++++++++ .../Plugins/910_DuplicateCheckerPluginTests.cs | 15 +++++++++++++++ .../Serializer/Models/EventSerializerTests.cs | 14 ++++++++++++++ 13 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 src/Exceptionless/Utility/DeploymentEnvironment.cs diff --git a/README.md b/README.md index 63d5f844..2410b010 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,15 @@ The definition of the word exceptionless is: to be without exception. [Exception ## Using Exceptionless +Set the deployment environment once at startup, with an optional override on each event: + +```csharp +client.Configuration.SetEnvironment("production"); +client.CreateLog("Deployment complete").SetEnvironment("staging").Submit(); +``` + +The hosting integration falls back to `IHostEnvironment.EnvironmentName`. Explicit configuration wins; `Exceptionless:Environment` and `Exceptionless__Environment` are also supported. Names are trimmed, lowercased, and limited to 64 characters. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. + Refer to the Exceptionless documentation here: [Exceptionless Docs](https://exceptionless.com/docs/). ## Getting Started (Development) diff --git a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs index 348ddae2..722d3333 100644 --- a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs +++ b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs @@ -13,6 +13,14 @@ namespace Exceptionless { public class ExceptionlessConfiguration { + private string _environment; + + /// The default deployment environment for every event. + public string Environment { + get => _environment; + set => _environment = Utility.DeploymentEnvironment.Normalize(value); + } + private const string DEFAULT_SERVER_URL = "https://collector.exceptionless.io"; private const string DEFAULT_CONFIG_SERVER_URL = "https://config.exceptionless.io"; private const string DEFAULT_HEARTBEAT_SERVER_URL = "https://heartbeat.exceptionless.io"; diff --git a/src/Exceptionless/Extensions/EventBuilderExtensions.cs b/src/Exceptionless/Extensions/EventBuilderExtensions.cs index 58323a38..559ba4c4 100644 --- a/src/Exceptionless/Extensions/EventBuilderExtensions.cs +++ b/src/Exceptionless/Extensions/EventBuilderExtensions.cs @@ -4,6 +4,12 @@ namespace Exceptionless { public static class EventBuilderExtensions { + /// Overrides the deployment environment for this event. + public static EventBuilder SetEnvironment(this EventBuilder builder, string environment) { + builder.Target.Environment = environment; + return builder; + } + /// /// Sets the user's identity (ie. email address, username, user id) that the event happened to. /// @@ -109,4 +115,4 @@ public static EventBuilder AddRecentTraceLogEntries(this EventBuilder builder, D return builder; } } -} \ No newline at end of file +} diff --git a/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs b/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs index 6050d569..64d9f825 100644 --- a/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs +++ b/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs @@ -27,6 +27,11 @@ namespace Exceptionless { public static class ExceptionlessConfigurationExtensions { + /// Sets the default deployment environment for every event. + public static void SetEnvironment(this ExceptionlessConfiguration config, string environment) { + config.Environment = environment; + } + private const string INSTALL_ID_KEY = "ExceptionlessInstallId"; /// @@ -412,6 +417,9 @@ public static void ReadFromConfiguration(this ExceptionlessConfiguration config, throw new ArgumentNullException(nameof(settings)); var section = settings.GetSection("Exceptionless"); + if (section["Environment"] != null) { + config.Environment = section["Environment"]; + } if (Boolean.TryParse(section["Enabled"], out bool enabled) && !enabled) config.Enabled = false; @@ -483,6 +491,11 @@ public static void ReadFromConfiguration(this ExceptionlessConfiguration config, /// /// The configuration object you want to apply the attribute settings to. public static void ReadFromEnvironmentalVariables(this ExceptionlessConfiguration config) { + string environment = GetEnvironmentalVariable("Exceptionless:Environment") ?? GetEnvironmentalVariable("Exceptionless__Environment"); + if (environment != null) { + config.Environment = environment; + } + string apiKey = GetEnvironmentalVariable("Exceptionless:ApiKey") ?? GetEnvironmentalVariable("Exceptionless__ApiKey"); if (IsValidApiKey(apiKey)) config.ApiKey = apiKey; @@ -576,4 +589,4 @@ private static bool IsValidApiKey(string apiKey) { return !String.IsNullOrEmpty(apiKey) && apiKey != "API_KEY_HERE"; } } -} \ No newline at end of file +} diff --git a/src/Exceptionless/Models/Client/Event.cs b/src/Exceptionless/Models/Client/Event.cs index 31d233d9..555685c0 100644 --- a/src/Exceptionless/Models/Client/Event.cs +++ b/src/Exceptionless/Models/Client/Event.cs @@ -4,6 +4,15 @@ namespace Exceptionless.Models { [Json.JsonObject(NamingStrategyType = typeof(Json.Serialization.SnakeCaseNamingStrategy))] public class Event : IData { + private string _environment; + + /// The deployment environment, such as production or staging. + [Json.JsonProperty(NullValueHandling = Json.NullValueHandling.Ignore)] + public string Environment { + get => _environment; + set => _environment = Utility.DeploymentEnvironment.Normalize(value); + } + public Event() { Tags = new TagSet(); Data = new DataDictionary(); @@ -60,7 +69,7 @@ public Event() { public string ReferenceId { get; set; } protected bool Equals(Event other) { - return string.Equals(Type, other.Type) && string.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && string.Equals(Message, other.Message) && string.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data); + return string.Equals(Environment, other.Environment) && string.Equals(Type, other.Type) && string.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && string.Equals(Message, other.Message) && string.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data); } public override bool Equals(object obj) { @@ -84,6 +93,9 @@ public override int GetHashCode() { hashCode = (hashCode * 397) ^ (Geo == null ? 0 : Geo.GetHashCode()); hashCode = (hashCode * 397) ^ Value.GetHashCode(); hashCode = (hashCode * 397) ^ (Data == null ? 0 : Data.GetCollectionHashCode(_exclusions)); + if (Environment != null) { + hashCode = (hashCode * 397) ^ Environment.GetHashCode(); + } return hashCode; } } @@ -115,4 +127,4 @@ public static class KnownDataKeys { public const string ManualStackingInfo = "@stack"; } } -} \ No newline at end of file +} diff --git a/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs b/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs index e2257bdc..dfc151f7 100644 --- a/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs +++ b/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs @@ -2,6 +2,10 @@ [Priority(15)] public class ConfigurationDefaultsPlugin : IEventPlugin { public void Run(EventPluginContext context) { + if (context.Event.Environment == null) { + context.Event.Environment = context.Client.Configuration.Environment; + } + foreach (string tag in context.Client.Configuration.DefaultTags) context.Event.Tags.Add(tag); @@ -11,4 +15,4 @@ public void Run(EventPluginContext context) { } } } -} \ No newline at end of file +} diff --git a/src/Exceptionless/Utility/DeploymentEnvironment.cs b/src/Exceptionless/Utility/DeploymentEnvironment.cs new file mode 100644 index 00000000..a0d7f2cc --- /dev/null +++ b/src/Exceptionless/Utility/DeploymentEnvironment.cs @@ -0,0 +1,11 @@ +using System; +using System.Linq; + +namespace Exceptionless.Utility { + internal static class DeploymentEnvironment { + public static string Normalize(string value) { + string name = value?.Trim(); + return String.IsNullOrEmpty(name) || name.Length > 64 || name.Any(Char.IsControl) ? null : name.ToLowerInvariant(); + } + } +} diff --git a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs index b224111f..cfeaf036 100644 --- a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs +++ b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs @@ -60,7 +60,10 @@ public static IHostApplicationBuilder AddExceptionless(this IHostApplicationBuil /// The pre-configured instance /// public static IServiceCollection AddExceptionless(this IServiceCollection services, ExceptionlessClient client) { - return services.AddSingleton(client); + return services.AddSingleton(sp => { + client.Configuration.Environment ??= sp.GetService()?.EnvironmentName; + return client; + }); } /// @@ -90,6 +93,7 @@ public static IServiceCollection AddExceptionless(this IServiceCollection servic client.Configuration.ReadFromEnvironmentalVariables(); configure?.Invoke(client.Configuration); + client.Configuration.Environment ??= sp.GetService()?.EnvironmentName; return client; }); @@ -110,6 +114,7 @@ public static IServiceCollection AddExceptionless(this IServiceCollection servic client.Configuration.ReadFromConfiguration(configuration); configure?.Invoke(client.Configuration); + client.Configuration.Environment ??= sp.GetService()?.EnvironmentName; return client; }); diff --git a/test/Exceptionless.TestHarness/Serializer/StorageSerializerTestBase.cs b/test/Exceptionless.TestHarness/Serializer/StorageSerializerTestBase.cs index 39bb0db0..9044adc1 100644 --- a/test/Exceptionless.TestHarness/Serializer/StorageSerializerTestBase.cs +++ b/test/Exceptionless.TestHarness/Serializer/StorageSerializerTestBase.cs @@ -27,6 +27,7 @@ private Event CreateSimpleEvent() { var ev= new Event { Date = DateTime.Now, Message = "Testing", + Environment = "production", Type = Event.KnownTypes.Log, Source = "StorageSerializer" }; diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index 6a783446..a8b15454 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -7,6 +7,19 @@ namespace Exceptionless.Tests.Platforms { public class HostingExtensionsTests { + [Theory] + [InlineData(null, "staging")] + [InlineData("production", "production")] + public void AddExceptionless_DeploymentEnvironment_UsesHostAsFallback(string? configuredEnvironment, string expectedEnvironment) { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Staging" }); + var client = new ExceptionlessClient(); + client.Configuration.Environment = configuredEnvironment; + builder.AddExceptionless(client); + + using var services = builder.Services.BuildServiceProvider(); + Assert.Equal(expectedEnvironment, services.GetRequiredService().Configuration.Environment); + } + [Fact] public void AddExceptionless_WhenCalled_RegistersClientAndLifetimeService() { // Arrange diff --git a/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs b/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs index 5d6da120..9b87ab6b 100644 --- a/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs @@ -8,6 +8,22 @@ namespace Exceptionless.Tests.Plugins { public class ConfigurationDefaultsPluginTests : PluginTestBase { + [Fact] + public void Run_DeploymentEnvironment_UsesDefaultAndPreservesEventOverride() { + var client = CreateClient(); + client.Configuration.SetEnvironment(" Production "); + var plugin = new ConfigurationDefaultsPlugin(); + var context = new EventPluginContext(client, new Event()); + plugin.Run(context); + Assert.Equal("production", context.Event.Environment); + + var builder = client.CreateLog("test", "message").SetEnvironment(" Staging "); + var overridden = new EventPluginContext(client, builder.Target); + plugin.Run(overridden); + Assert.Equal("staging", overridden.Event.Environment); + Assert.Empty(overridden.Event.Data); + } + public ConfigurationDefaultsPluginTests(ITestOutputHelper output) : base(output) { } [Fact] diff --git a/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs b/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs index ef479fc0..fe471345 100644 --- a/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs @@ -14,6 +14,21 @@ namespace Exceptionless.Tests.Plugins { public class DuplicateCheckerPluginTests : PluginTestBase { public DuplicateCheckerPluginTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public void Run_SameEventAcrossEnvironments_OnlyMergesWithinEnvironment() { + var client = CreateClient(); + using (var plugin = new DuplicateCheckerPlugin(TimeSpan.FromMinutes(1))) { + foreach (bool duplicate in new[] { false, true }) { + foreach (string environment in new[] { "production", "staging", null }) { + var builder = client.CreateLog("Environment test").SetEnvironment(environment); + var context = new EventPluginContext(client, builder.Target, builder.PluginContextData); + plugin.Run(context); + Assert.Equal(duplicate, context.Cancel); + } + } + } + } [Fact] public void CanRemoveDuplicateExceptions() { diff --git a/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs b/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs index 3d20f279..719545cd 100644 --- a/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs +++ b/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs @@ -6,6 +6,20 @@ namespace Exceptionless.Tests.Serializer.Models { public class EventSerializerTests : SerializerTestBase { + [Fact] + public void Serialize_DeploymentEnvironment_RoundTripsSeparatelyFromRuntimeMetadata() { + var model = new Event { Environment = " Production " }; + model.Data[Event.KnownDataKeys.EnvironmentInfo] = new EnvironmentInfo { MachineName = "worker-1" }; + string json = Serialize(model); + Assert.Contains("\"environment\":\"production\"", json); + var result = Deserialize(json); + Assert.Equal("production", result.Environment); + Assert.Equal("worker-1", result.GetEnvironmentInfo().MachineName); + Assert.Null(new Event { Environment = new string('x', 65) }.Environment); + Assert.Null(new Event { Environment = " " }.Environment); + Assert.NotEqual(new Event { Environment = "production" }, new Event { Environment = "staging" }); + } + /* lang=json */ private const string MinimalJson = """{"type":"log","source":"app","date":"0001-01-01T00:00:00+00:00","tags":[],"message":null,"geo":null,"value":null,"count":null,"data":{},"reference_id":null}"""; /* lang=json */ From 5713934220378f505017c5cb6a6f0bb03ee00d22 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:35:48 -0500 Subject: [PATCH 02/11] Update SourceLink patch to unblock SDK validation --- build/common.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/common.props b/build/common.props index 904a13ee..49ec682b 100644 --- a/build/common.props +++ b/build/common.props @@ -41,7 +41,7 @@ - + From 2ac945eb94542d99d17a99594ef27c659dba27de Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:45:39 -0500 Subject: [PATCH 03/11] Preserve client ownership and environment override semantics --- README.md | 2 +- .../Configuration/ExceptionlessSection.cs | 3 ++ .../ExceptionlessConfigurationExtensions.cs | 23 ++++++++-- src/Exceptionless/Models/Client/Event.cs | 7 +++- .../015_ConfigurationDefaultsPlugin.cs | 2 +- .../Utility/DeploymentEnvironment.cs | 11 ++++- .../ExceptionlessExtensions.cs | 6 +-- ...DeploymentEnvironmentConfigurationTests.cs | 42 +++++++++++++++++++ .../Exceptionless.Tests.csproj | 1 + .../Platforms/HostingExtensionsTests.cs | 17 ++++++++ .../015_ConfigurationDefaultsPluginTests.cs | 14 +++++++ 11 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs diff --git a/README.md b/README.md index 2410b010..32bb4277 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ client.Configuration.SetEnvironment("production"); client.CreateLog("Deployment complete").SetEnvironment("staging").Submit(); ``` -The hosting integration falls back to `IHostEnvironment.EnvironmentName`. Explicit configuration wins; `Exceptionless:Environment` and `Exceptionless__Environment` are also supported. Names are trimmed, lowercased, and limited to 64 characters. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. +The hosting integration falls back to `IHostEnvironment.EnvironmentName`. Explicit configuration wins; `Exceptionless:Environment` and `Exceptionless__Environment` are also supported. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed, lowercased, and limited to 64 characters. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. Refer to the Exceptionless documentation here: [Exceptionless Docs](https://exceptionless.com/docs/). diff --git a/src/Exceptionless/Configuration/ExceptionlessSection.cs b/src/Exceptionless/Configuration/ExceptionlessSection.cs index 9209a1ff..86e0b74f 100644 --- a/src/Exceptionless/Configuration/ExceptionlessSection.cs +++ b/src/Exceptionless/Configuration/ExceptionlessSection.cs @@ -11,6 +11,9 @@ internal class ExceptionlessSection : ConfigurationSection { [ConfigurationProperty("apiKey", IsRequired = true)] public string ApiKey { get { return base["apiKey"] as string; } set { base["apiKey"] = value; } } + [ConfigurationProperty("environment")] + public string Environment { get { return base["environment"] as string; } set { base["environment"] = value; } } + [ConfigurationProperty("serverUrl")] public string ServerUrl { get { return base["serverUrl"] as string; } set { base["serverUrl"] = value; } } diff --git a/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs b/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs index 64d9f825..46f76699 100644 --- a/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs +++ b/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs @@ -20,6 +20,7 @@ #endif #if NET45 +using System.Collections.Specialized; using System.Configuration; using Exceptionless.Extensions; using Exceptionless.Utility; @@ -293,9 +294,16 @@ public static void ReadFromConfigSection(this ExceptionlessConfiguration config) config.Resolver.GetLog().Error(typeof(ExceptionlessConfigurationExtensions), ex, String.Concat("Error retrieving configuration section: ", ex.Message)); } + config.ReadFromConfigSection(section); + } + + internal static void ReadFromConfigSection(this ExceptionlessConfiguration config, ExceptionlessSection section) { if (section == null) return; + if (section.ElementInformation.Properties["environment"].ValueOrigin != PropertyValueOrigin.Default) + config.Environment = section.Environment; + if (!section.Enabled) config.Enabled = false; @@ -385,18 +393,25 @@ public static void ReadFromConfigSection(this ExceptionlessConfiguration config) /// /// The configuration object you want to apply the attribute settings to. public static void ReadFromAppSettings(this ExceptionlessConfiguration config) { - string apiKey = ConfigurationManager.AppSettings["Exceptionless:ApiKey"]; + config.ReadFromAppSettings(ConfigurationManager.AppSettings); + } + + internal static void ReadFromAppSettings(this ExceptionlessConfiguration config, NameValueCollection settings) { + if (settings["Exceptionless:Environment"] != null) + config.Environment = settings["Exceptionless:Environment"]; + + string apiKey = settings["Exceptionless:ApiKey"]; if (IsValidApiKey(apiKey)) config.ApiKey = apiKey; - if (Boolean.TryParse(ConfigurationManager.AppSettings["Exceptionless:Enabled"], out bool enabled) && !enabled) + if (Boolean.TryParse(settings["Exceptionless:Enabled"], out bool enabled) && !enabled) config.Enabled = false; - string serverUrl = ConfigurationManager.AppSettings["Exceptionless:ServerUrl"]; + string serverUrl = settings["Exceptionless:ServerUrl"]; if (!String.IsNullOrEmpty(serverUrl)) config.ServerUrl = serverUrl; - string defaultTags = ConfigurationManager.AppSettings["Exceptionless:DefaultTags"]; + string defaultTags = settings["Exceptionless:DefaultTags"]; if (!String.IsNullOrEmpty(defaultTags)) foreach (var tag in defaultTags.SplitAndTrim(',').Where(tag => !String.IsNullOrEmpty(tag))) config.DefaultTags.Add(tag); diff --git a/src/Exceptionless/Models/Client/Event.cs b/src/Exceptionless/Models/Client/Event.cs index 555685c0..66785849 100644 --- a/src/Exceptionless/Models/Client/Event.cs +++ b/src/Exceptionless/Models/Client/Event.cs @@ -10,9 +10,14 @@ public class Event : IData { [Json.JsonProperty(NullValueHandling = Json.NullValueHandling.Ignore)] public string Environment { get => _environment; - set => _environment = Utility.DeploymentEnvironment.Normalize(value); + set { + _environment = Utility.DeploymentEnvironment.Normalize(value); + HasEnvironmentOverride = value != null; + } } + internal bool HasEnvironmentOverride { get; private set; } + public Event() { Tags = new TagSet(); Data = new DataDictionary(); diff --git a/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs b/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs index dfc151f7..3a9a12bc 100644 --- a/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs +++ b/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs @@ -2,7 +2,7 @@ [Priority(15)] public class ConfigurationDefaultsPlugin : IEventPlugin { public void Run(EventPluginContext context) { - if (context.Event.Environment == null) { + if (!context.Event.HasEnvironmentOverride) { context.Event.Environment = context.Client.Configuration.Environment; } diff --git a/src/Exceptionless/Utility/DeploymentEnvironment.cs b/src/Exceptionless/Utility/DeploymentEnvironment.cs index a0d7f2cc..d1982ec6 100644 --- a/src/Exceptionless/Utility/DeploymentEnvironment.cs +++ b/src/Exceptionless/Utility/DeploymentEnvironment.cs @@ -1,11 +1,18 @@ using System; -using System.Linq; namespace Exceptionless.Utility { internal static class DeploymentEnvironment { public static string Normalize(string value) { string name = value?.Trim(); - return String.IsNullOrEmpty(name) || name.Length > 64 || name.Any(Char.IsControl) ? null : name.ToLowerInvariant(); + if (String.IsNullOrEmpty(name) || name.Length > 64) + return null; + + foreach (char character in name) { + if (Char.IsControl(character)) + return null; + } + + return name.ToLowerInvariant(); } } } diff --git a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs index cfeaf036..2092c433 100644 --- a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs +++ b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs @@ -30,6 +30,7 @@ public static IHostApplicationBuilder UseExceptionless(this IHostApplicationBuil /// Adds the given pre-configured to the host builder and registers lifecycle hooks. /// public static IHostApplicationBuilder AddExceptionless(this IHostApplicationBuilder builder, ExceptionlessClient client) { + client.Configuration.Environment ??= builder.Environment.EnvironmentName; builder.Services.AddExceptionless(client); builder.Services.AddExceptionlessLifetimeService(); return builder; @@ -60,10 +61,7 @@ public static IHostApplicationBuilder AddExceptionless(this IHostApplicationBuil /// The pre-configured instance /// public static IServiceCollection AddExceptionless(this IServiceCollection services, ExceptionlessClient client) { - return services.AddSingleton(sp => { - client.Configuration.Environment ??= sp.GetService()?.EnvironmentName; - return client; - }); + return services.AddSingleton(client); } /// diff --git a/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs b/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs new file mode 100644 index 00000000..2bfad2e9 --- /dev/null +++ b/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs @@ -0,0 +1,42 @@ +#if NET45 +using System.Collections.Specialized; +using System.Configuration; +using System.IO; +using Xunit; + +namespace Exceptionless.Tests.Configuration { + public class DeploymentEnvironmentConfigurationTests { + [Fact] + public void ReadFromConfigSection_LoadsEnvironmentAttribute() { + string path = Path.GetTempFileName(); + try { + File.WriteAllText(path, "
"); + var mapped = ConfigurationManager.OpenMappedExeConfiguration(new ExeConfigurationFileMap { ExeConfigFilename = path }, ConfigurationUserLevel.None); + using var client = new ExceptionlessClient(); + client.Configuration.ReadFromConfigSection((ExceptionlessSection)mapped.GetSection("exceptionless")); + Assert.Equal("staging", client.Configuration.Environment); + } finally { + File.Delete(path); + } + } + + [Fact] + public void ReadFromConfigSection_MissingEnvironment_PreservesConfiguredValue() { + using var client = new ExceptionlessClient(); + client.Configuration.Environment = "staging"; + client.Configuration.ReadFromConfigSection(new ExceptionlessSection()); + Assert.Equal("staging", client.Configuration.Environment); + } + + [Fact] + public void ReadFromAppSettings_LoadsEnvironmentAndPreservesMissingSetting() { + using var client = new ExceptionlessClient(); + client.Configuration.Environment = "staging"; + client.Configuration.ReadFromAppSettings(new NameValueCollection()); + Assert.Equal("staging", client.Configuration.Environment); + client.Configuration.ReadFromAppSettings(new NameValueCollection { ["Exceptionless:Environment"] = " Production " }); + Assert.Equal("production", client.Configuration.Environment); + } + } +} +#endif diff --git a/test/Exceptionless.Tests/Exceptionless.Tests.csproj b/test/Exceptionless.Tests/Exceptionless.Tests.csproj index e1c0134d..97f0ccb9 100644 --- a/test/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/test/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -51,6 +51,7 @@ + diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index a8b15454..11424fee 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -1,8 +1,11 @@ #if NET10_0_OR_GREATER using System.Linq; +using Exceptionless.Configuration; +using Exceptionless.Dependency; using Exceptionless.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Moq; using Xunit; namespace Exceptionless.Tests.Platforms { @@ -20,6 +23,20 @@ public void AddExceptionless_DeploymentEnvironment_UsesHostAsFallback(string? co Assert.Equal(expectedEnvironment, services.GetRequiredService().Configuration.Environment); } + [Fact] + public void AddExceptionless_ProvidedClient_RemainsOwnedByCaller() { + var resolver = new Mock(); + using var client = new ExceptionlessClient(new ExceptionlessConfiguration(resolver.Object)); + var builder = Host.CreateApplicationBuilder(); + builder.AddExceptionless(client); + + using (var services = builder.Services.BuildServiceProvider()) { + Assert.Same(client, services.GetRequiredService()); + } + + resolver.Verify(r => r.Dispose(), Times.Never); + } + [Fact] public void AddExceptionless_WhenCalled_RegistersClientAndLifetimeService() { // Arrange diff --git a/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs b/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs index 9b87ab6b..e82f5dd2 100644 --- a/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs @@ -24,6 +24,20 @@ public void Run_DeploymentEnvironment_UsesDefaultAndPreservesEventOverride() { Assert.Empty(overridden.Event.Data); } + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("prod\ninvalid")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + public void Run_InvalidDeploymentEnvironment_DoesNotUseDefault(string environment) { + var client = CreateClient(); + client.Configuration.SetEnvironment("production"); + var builder = client.CreateLog("test", "message").SetEnvironment(environment); + var context = new EventPluginContext(client, builder.Target); + new ConfigurationDefaultsPlugin().Run(context); + Assert.Null(context.Event.Environment); + } + public ConfigurationDefaultsPluginTests(ITestOutputHelper output) : base(output) { } [Fact] From 5f875dcdd6c5dd2888135f85e13e46f5a4f42987 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:51:50 -0500 Subject: [PATCH 04/11] Keep explicitly invalid host environments unspecified --- .../Configuration/ExceptionlessConfiguration.cs | 8 +++++++- .../ExceptionlessExtensions.cs | 9 ++++++--- .../Platforms/HostingExtensionsTests.cs | 6 +++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs index 722d3333..4a1eddab 100644 --- a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs +++ b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs @@ -18,9 +18,15 @@ public class ExceptionlessConfiguration { /// The default deployment environment for every event. public string Environment { get => _environment; - set => _environment = Utility.DeploymentEnvironment.Normalize(value); + set { + _environment = Utility.DeploymentEnvironment.Normalize(value); + IsEnvironmentConfigured = value != null; + } } + /// Whether an environment was explicitly configured, including an invalid value that remains unspecified. + public bool IsEnvironmentConfigured { get; private set; } + private const string DEFAULT_SERVER_URL = "https://collector.exceptionless.io"; private const string DEFAULT_CONFIG_SERVER_URL = "https://config.exceptionless.io"; private const string DEFAULT_HEARTBEAT_SERVER_URL = "https://heartbeat.exceptionless.io"; diff --git a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs index 2092c433..c140a24d 100644 --- a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs +++ b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs @@ -30,7 +30,8 @@ public static IHostApplicationBuilder UseExceptionless(this IHostApplicationBuil /// Adds the given pre-configured to the host builder and registers lifecycle hooks. ///
public static IHostApplicationBuilder AddExceptionless(this IHostApplicationBuilder builder, ExceptionlessClient client) { - client.Configuration.Environment ??= builder.Environment.EnvironmentName; + if (!client.Configuration.IsEnvironmentConfigured) + client.Configuration.Environment = builder.Environment.EnvironmentName; builder.Services.AddExceptionless(client); builder.Services.AddExceptionlessLifetimeService(); return builder; @@ -91,7 +92,8 @@ public static IServiceCollection AddExceptionless(this IServiceCollection servic client.Configuration.ReadFromEnvironmentalVariables(); configure?.Invoke(client.Configuration); - client.Configuration.Environment ??= sp.GetService()?.EnvironmentName; + if (!client.Configuration.IsEnvironmentConfigured) + client.Configuration.Environment = sp.GetService()?.EnvironmentName; return client; }); @@ -112,7 +114,8 @@ public static IServiceCollection AddExceptionless(this IServiceCollection servic client.Configuration.ReadFromConfiguration(configuration); configure?.Invoke(client.Configuration); - client.Configuration.Environment ??= sp.GetService()?.EnvironmentName; + if (!client.Configuration.IsEnvironmentConfigured) + client.Configuration.Environment = sp.GetService()?.EnvironmentName; return client; }); diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index 11424fee..71223172 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -13,7 +13,11 @@ public class HostingExtensionsTests { [Theory] [InlineData(null, "staging")] [InlineData("production", "production")] - public void AddExceptionless_DeploymentEnvironment_UsesHostAsFallback(string? configuredEnvironment, string expectedEnvironment) { + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("prod\ninvalid", null)] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", null)] + public void AddExceptionless_DeploymentEnvironment_UsesHostAsFallback(string? configuredEnvironment, string? expectedEnvironment) { var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Staging" }); var client = new ExceptionlessClient(); client.Configuration.Environment = configuredEnvironment; From fca9d0e0b34d80553a6bedeba8b3b255601ad1e4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:59:49 -0500 Subject: [PATCH 05/11] Keep host defaults distinct from explicit environment settings --- .../Configuration/ExceptionlessConfiguration.cs | 6 ++++++ .../ExceptionlessExtensions.cs | 9 +++------ .../Platforms/HostingExtensionsTests.cs | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs index 4a1eddab..27e5e0e5 100644 --- a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs +++ b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs @@ -27,6 +27,12 @@ public string Environment { /// Whether an environment was explicitly configured, including an invalid value that remains unspecified. public bool IsEnvironmentConfigured { get; private set; } + /// Applies a deployment environment fallback without replacing an explicitly configured value. + public void SetDefaultEnvironment(string environment) { + if (!IsEnvironmentConfigured) + _environment = Utility.DeploymentEnvironment.Normalize(environment); + } + private const string DEFAULT_SERVER_URL = "https://collector.exceptionless.io"; private const string DEFAULT_CONFIG_SERVER_URL = "https://config.exceptionless.io"; private const string DEFAULT_HEARTBEAT_SERVER_URL = "https://heartbeat.exceptionless.io"; diff --git a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs index c140a24d..5ba0b6b9 100644 --- a/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs +++ b/src/Platforms/Exceptionless.Extensions.Hosting/ExceptionlessExtensions.cs @@ -30,8 +30,7 @@ public static IHostApplicationBuilder UseExceptionless(this IHostApplicationBuil /// Adds the given pre-configured to the host builder and registers lifecycle hooks. /// public static IHostApplicationBuilder AddExceptionless(this IHostApplicationBuilder builder, ExceptionlessClient client) { - if (!client.Configuration.IsEnvironmentConfigured) - client.Configuration.Environment = builder.Environment.EnvironmentName; + client.Configuration.SetDefaultEnvironment(builder.Environment.EnvironmentName); builder.Services.AddExceptionless(client); builder.Services.AddExceptionlessLifetimeService(); return builder; @@ -92,8 +91,7 @@ public static IServiceCollection AddExceptionless(this IServiceCollection servic client.Configuration.ReadFromEnvironmentalVariables(); configure?.Invoke(client.Configuration); - if (!client.Configuration.IsEnvironmentConfigured) - client.Configuration.Environment = sp.GetService()?.EnvironmentName; + client.Configuration.SetDefaultEnvironment(sp.GetService()?.EnvironmentName); return client; }); @@ -114,8 +112,7 @@ public static IServiceCollection AddExceptionless(this IServiceCollection servic client.Configuration.ReadFromConfiguration(configuration); configure?.Invoke(client.Configuration); - if (!client.Configuration.IsEnvironmentConfigured) - client.Configuration.Environment = sp.GetService()?.EnvironmentName; + client.Configuration.SetDefaultEnvironment(sp.GetService()?.EnvironmentName); return client; }); diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index 71223172..ab137cee 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -27,6 +27,20 @@ public void AddExceptionless_DeploymentEnvironment_UsesHostAsFallback(string? co Assert.Equal(expectedEnvironment, services.GetRequiredService().Configuration.Environment); } + [Fact] + public void AddExceptionless_HostFallback_DoesNotBecomeExplicitConfiguration() { + using var client = new ExceptionlessClient(); + var staging = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Staging" }); + staging.AddExceptionless(client); + Assert.Equal("staging", client.Configuration.Environment); + Assert.False(client.Configuration.IsEnvironmentConfigured); + + var production = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); + production.AddExceptionless(client); + Assert.Equal("production", client.Configuration.Environment); + Assert.False(client.Configuration.IsEnvironmentConfigured); + } + [Fact] public void AddExceptionless_ProvidedClient_RemainsOwnedByCaller() { var resolver = new Mock(); From cb3f2150eff7b4a4758545f5315fd42cfaddd59f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 23:07:07 -0500 Subject: [PATCH 06/11] Preserve environment overrides when splitting aggregate exceptions --- src/Exceptionless/Models/Client/Event.cs | 2 +- .../005_HandleAggregateExceptionsPlugin.cs | 5 ++++- .../005_HandleAggregateExceptionsPluginTests.cs | 15 +++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless/Models/Client/Event.cs b/src/Exceptionless/Models/Client/Event.cs index 66785849..23c3e77d 100644 --- a/src/Exceptionless/Models/Client/Event.cs +++ b/src/Exceptionless/Models/Client/Event.cs @@ -16,7 +16,7 @@ public string Environment { } } - internal bool HasEnvironmentOverride { get; private set; } + internal bool HasEnvironmentOverride { get; set; } public Event() { Tags = new TagSet(); diff --git a/src/Exceptionless/Plugins/Default/005_HandleAggregateExceptionsPlugin.cs b/src/Exceptionless/Plugins/Default/005_HandleAggregateExceptionsPlugin.cs index 245dc1a3..d3faae5e 100644 --- a/src/Exceptionless/Plugins/Default/005_HandleAggregateExceptionsPlugin.cs +++ b/src/Exceptionless/Plugins/Default/005_HandleAggregateExceptionsPlugin.cs @@ -21,7 +21,10 @@ public void Run(EventPluginContext context) { ctx.SetException(ex); var serializer = context.Resolver.GetJsonSerializer(); - context.Client.SubmitEvent(serializer.Deserialize(serializer.Serialize(context.Event), typeof(Event)) as Event, ctx); + var child = serializer.Deserialize(serializer.Serialize(context.Event), typeof(Event)) as Event; + if (child != null) + child.HasEnvironmentOverride = context.Event.HasEnvironmentOverride; + context.Client.SubmitEvent(child, ctx); } context.Cancel = true; diff --git a/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs b/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs index 0f05689c..e758b510 100644 --- a/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs @@ -37,23 +37,30 @@ public void SingleInnerException() { Assert.True(context.Cancel); } - [Fact] - public async Task MultipleInnerException() { + [Theory] + [InlineData(null, "production")] + [InlineData("Staging", "staging")] + [InlineData("", null)] + [InlineData("prod\ninvalid", null)] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", null)] + public async Task MultipleInnerException(string? environment, string? expectedEnvironment) { var submissionClient = new InMemorySubmissionClient(); - var client = new ExceptionlessClient("LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"); + using var client = new ExceptionlessClient("LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"); client.Configuration.Resolver.Register(submissionClient); + client.Configuration.Environment = "production"; var plugin = new HandleAggregateExceptionsPlugin(); var exceptionOne = new Exception("one"); var exceptionTwo = new Exception("two"); - var context = new EventPluginContext(client, new Event()); + var context = new EventPluginContext(client, new Event { Environment = environment }); context.ContextData.SetException(new AggregateException(exceptionOne, exceptionTwo)); plugin.Run(context); Assert.True(context.Cancel); await client.ProcessQueueAsync(); Assert.Equal(2, submissionClient.Events.Count); + Assert.All(submissionClient.Events, ev => Assert.Equal(expectedEnvironment, ev.Environment)); } } } From 609fe96b8d85ffad10e47e73cc3eff296170e654 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 23:16:50 -0500 Subject: [PATCH 07/11] Apply deployment environments before event exclusions --- .../001_DeploymentEnvironmentPlugin.cs | 9 +++ .../015_ConfigurationDefaultsPlugin.cs | 6 +- .../Plugins/EventPluginManager.cs | 1 + .../001_DeploymentEnvironmentPluginTests.cs | 60 +++++++++++++++++++ .../015_ConfigurationDefaultsPluginTests.cs | 30 ---------- 5 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 src/Exceptionless/Plugins/Default/001_DeploymentEnvironmentPlugin.cs create mode 100644 test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs diff --git a/src/Exceptionless/Plugins/Default/001_DeploymentEnvironmentPlugin.cs b/src/Exceptionless/Plugins/Default/001_DeploymentEnvironmentPlugin.cs new file mode 100644 index 00000000..4c16f622 --- /dev/null +++ b/src/Exceptionless/Plugins/Default/001_DeploymentEnvironmentPlugin.cs @@ -0,0 +1,9 @@ +namespace Exceptionless.Plugins.Default { + [Priority(1)] + public sealed class DeploymentEnvironmentPlugin : IEventPlugin { + public void Run(EventPluginContext context) { + if (!context.Event.HasEnvironmentOverride) + context.Event.Environment = context.Client.Configuration.Environment; + } + } +} diff --git a/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs b/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs index 3a9a12bc..e2257bdc 100644 --- a/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs +++ b/src/Exceptionless/Plugins/Default/015_ConfigurationDefaultsPlugin.cs @@ -2,10 +2,6 @@ [Priority(15)] public class ConfigurationDefaultsPlugin : IEventPlugin { public void Run(EventPluginContext context) { - if (!context.Event.HasEnvironmentOverride) { - context.Event.Environment = context.Client.Configuration.Environment; - } - foreach (string tag in context.Client.Configuration.DefaultTags) context.Event.Tags.Add(tag); @@ -15,4 +11,4 @@ public void Run(EventPluginContext context) { } } } -} +} \ No newline at end of file diff --git a/src/Exceptionless/Plugins/EventPluginManager.cs b/src/Exceptionless/Plugins/EventPluginManager.cs index 3f9dfd1b..b9c36f7d 100644 --- a/src/Exceptionless/Plugins/EventPluginManager.cs +++ b/src/Exceptionless/Plugins/EventPluginManager.cs @@ -24,6 +24,7 @@ public static void Run(EventPluginContext context) { } public static void AddDefaultPlugins(ExceptionlessConfiguration config) { + config.AddPlugin(); config.AddPlugin(); config.AddPlugin(); config.AddPlugin(); diff --git a/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs b/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs new file mode 100644 index 00000000..52b97514 --- /dev/null +++ b/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs @@ -0,0 +1,60 @@ +using Exceptionless.Models; +using Exceptionless.Plugins; +using Exceptionless.Plugins.Default; +using Xunit; + +namespace Exceptionless.Tests.Plugins { + public class DeploymentEnvironmentPluginTests : PluginTestBase { + public DeploymentEnvironmentPluginTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public void Run_DeploymentEnvironment_UsesDefaultAndPreservesEventOverride() { + var client = CreateClient(); + client.Configuration.SetEnvironment(" Production "); + var plugin = new DeploymentEnvironmentPlugin(); + var context = new EventPluginContext(client, new Event()); + plugin.Run(context); + Assert.Equal("production", context.Event.Environment); + + var builder = client.CreateLog("test", "message").SetEnvironment(" Staging "); + var overridden = new EventPluginContext(client, builder.Target); + plugin.Run(overridden); + Assert.Equal("staging", overridden.Event.Environment); + Assert.Empty(overridden.Event.Data); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("prod\ninvalid")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + public void Run_InvalidDeploymentEnvironment_DoesNotUseDefault(string environment) { + var client = CreateClient(); + client.Configuration.SetEnvironment("production"); + var builder = client.CreateLog("test", "message").SetEnvironment(environment); + var context = new EventPluginContext(client, builder.Target); + new DeploymentEnvironmentPlugin().Run(context); + Assert.Null(context.Event.Environment); + } + + [Theory] + [InlineData(null, "development", true)] + [InlineData("production", "production", false)] + [InlineData("", null, false)] + public void Pipeline_ExclusionCallbacks_SeeEffectiveEnvironment(string? environment, string? expectedEnvironment, bool cancelled) { + using var client = CreateClient(); + client.Configuration.Environment = "development"; + string? observedEnvironment = null; + client.Configuration.AddEventExclusion(ev => { + observedEnvironment = ev.Environment; + return ev.Environment != "development"; + }); + var context = new EventPluginContext(client, new Event { Type = Event.KnownTypes.FeatureUsage, Environment = environment }); + + EventPluginManager.Run(context); + + Assert.Equal(expectedEnvironment, observedEnvironment); + Assert.Equal(cancelled, context.Cancel); + } + } +} diff --git a/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs b/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs index e82f5dd2..5d6da120 100644 --- a/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/015_ConfigurationDefaultsPluginTests.cs @@ -8,36 +8,6 @@ namespace Exceptionless.Tests.Plugins { public class ConfigurationDefaultsPluginTests : PluginTestBase { - [Fact] - public void Run_DeploymentEnvironment_UsesDefaultAndPreservesEventOverride() { - var client = CreateClient(); - client.Configuration.SetEnvironment(" Production "); - var plugin = new ConfigurationDefaultsPlugin(); - var context = new EventPluginContext(client, new Event()); - plugin.Run(context); - Assert.Equal("production", context.Event.Environment); - - var builder = client.CreateLog("test", "message").SetEnvironment(" Staging "); - var overridden = new EventPluginContext(client, builder.Target); - plugin.Run(overridden); - Assert.Equal("staging", overridden.Event.Environment); - Assert.Empty(overridden.Event.Data); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("prod\ninvalid")] - [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] - public void Run_InvalidDeploymentEnvironment_DoesNotUseDefault(string environment) { - var client = CreateClient(); - client.Configuration.SetEnvironment("production"); - var builder = client.CreateLog("test", "message").SetEnvironment(environment); - var context = new EventPluginContext(client, builder.Target); - new ConfigurationDefaultsPlugin().Run(context); - Assert.Null(context.Event.Environment); - } - public ConfigurationDefaultsPluginTests(ITestOutputHelper output) : base(output) { } [Fact] From 400e18dbabb5197e45206ac49e2a2218a75e8983 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 10 Sep 2026 00:10:21 -0500 Subject: [PATCH 08/11] Preserve supplied environment casing --- README.md | 2 +- src/Exceptionless/Utility/DeploymentEnvironment.cs | 2 +- .../DeploymentEnvironmentConfigurationTests.cs | 4 ++-- .../Exceptionless.Tests/Platforms/HostingExtensionsTests.cs | 6 +++--- .../Plugins/001_DeploymentEnvironmentPluginTests.cs | 4 ++-- .../Plugins/910_DuplicateCheckerPluginTests.cs | 2 +- .../Serializer/Models/EventSerializerTests.cs | 5 +++-- 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 32bb4277..11361029 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ client.Configuration.SetEnvironment("production"); client.CreateLog("Deployment complete").SetEnvironment("staging").Submit(); ``` -The hosting integration falls back to `IHostEnvironment.EnvironmentName`. Explicit configuration wins; `Exceptionless:Environment` and `Exceptionless__Environment` are also supported. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed, lowercased, and limited to 64 characters. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. +The hosting integration falls back to `IHostEnvironment.EnvironmentName`. Explicit configuration wins; `Exceptionless:Environment` and `Exceptionless__Environment` are also supported. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. Refer to the Exceptionless documentation here: [Exceptionless Docs](https://exceptionless.com/docs/). diff --git a/src/Exceptionless/Utility/DeploymentEnvironment.cs b/src/Exceptionless/Utility/DeploymentEnvironment.cs index d1982ec6..743a1e39 100644 --- a/src/Exceptionless/Utility/DeploymentEnvironment.cs +++ b/src/Exceptionless/Utility/DeploymentEnvironment.cs @@ -12,7 +12,7 @@ public static string Normalize(string value) { return null; } - return name.ToLowerInvariant(); + return name; } } } diff --git a/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs b/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs index 2bfad2e9..5ab8dce5 100644 --- a/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs +++ b/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs @@ -14,7 +14,7 @@ public void ReadFromConfigSection_LoadsEnvironmentAttribute() { var mapped = ConfigurationManager.OpenMappedExeConfiguration(new ExeConfigurationFileMap { ExeConfigFilename = path }, ConfigurationUserLevel.None); using var client = new ExceptionlessClient(); client.Configuration.ReadFromConfigSection((ExceptionlessSection)mapped.GetSection("exceptionless")); - Assert.Equal("staging", client.Configuration.Environment); + Assert.Equal("Staging", client.Configuration.Environment); } finally { File.Delete(path); } @@ -35,7 +35,7 @@ public void ReadFromAppSettings_LoadsEnvironmentAndPreservesMissingSetting() { client.Configuration.ReadFromAppSettings(new NameValueCollection()); Assert.Equal("staging", client.Configuration.Environment); client.Configuration.ReadFromAppSettings(new NameValueCollection { ["Exceptionless:Environment"] = " Production " }); - Assert.Equal("production", client.Configuration.Environment); + Assert.Equal("Production", client.Configuration.Environment); } } } diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index ab137cee..3db62467 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -11,7 +11,7 @@ namespace Exceptionless.Tests.Platforms { public class HostingExtensionsTests { [Theory] - [InlineData(null, "staging")] + [InlineData(null, "Staging")] [InlineData("production", "production")] [InlineData("", null)] [InlineData(" ", null)] @@ -32,12 +32,12 @@ public void AddExceptionless_HostFallback_DoesNotBecomeExplicitConfiguration() { using var client = new ExceptionlessClient(); var staging = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Staging" }); staging.AddExceptionless(client); - Assert.Equal("staging", client.Configuration.Environment); + Assert.Equal("Staging", client.Configuration.Environment); Assert.False(client.Configuration.IsEnvironmentConfigured); var production = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); production.AddExceptionless(client); - Assert.Equal("production", client.Configuration.Environment); + Assert.Equal("Production", client.Configuration.Environment); Assert.False(client.Configuration.IsEnvironmentConfigured); } diff --git a/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs b/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs index 52b97514..491a6ffa 100644 --- a/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/001_DeploymentEnvironmentPluginTests.cs @@ -14,12 +14,12 @@ public void Run_DeploymentEnvironment_UsesDefaultAndPreservesEventOverride() { var plugin = new DeploymentEnvironmentPlugin(); var context = new EventPluginContext(client, new Event()); plugin.Run(context); - Assert.Equal("production", context.Event.Environment); + Assert.Equal("Production", context.Event.Environment); var builder = client.CreateLog("test", "message").SetEnvironment(" Staging "); var overridden = new EventPluginContext(client, builder.Target); plugin.Run(overridden); - Assert.Equal("staging", overridden.Event.Environment); + Assert.Equal("Staging", overridden.Event.Environment); Assert.Empty(overridden.Event.Data); } diff --git a/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs b/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs index fe471345..35a73735 100644 --- a/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/910_DuplicateCheckerPluginTests.cs @@ -20,7 +20,7 @@ public void Run_SameEventAcrossEnvironments_OnlyMergesWithinEnvironment() { var client = CreateClient(); using (var plugin = new DuplicateCheckerPlugin(TimeSpan.FromMinutes(1))) { foreach (bool duplicate in new[] { false, true }) { - foreach (string environment in new[] { "production", "staging", null }) { + foreach (string environment in new[] { "Production", "production", "staging", null }) { var builder = client.CreateLog("Environment test").SetEnvironment(environment); var context = new EventPluginContext(client, builder.Target, builder.PluginContextData); plugin.Run(context); diff --git a/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs b/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs index 719545cd..ed05b026 100644 --- a/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs +++ b/test/Exceptionless.Tests/Serializer/Models/EventSerializerTests.cs @@ -11,13 +11,14 @@ public void Serialize_DeploymentEnvironment_RoundTripsSeparatelyFromRuntimeMetad var model = new Event { Environment = " Production " }; model.Data[Event.KnownDataKeys.EnvironmentInfo] = new EnvironmentInfo { MachineName = "worker-1" }; string json = Serialize(model); - Assert.Contains("\"environment\":\"production\"", json); + Assert.Contains("\"environment\":\"Production\"", json); var result = Deserialize(json); - Assert.Equal("production", result.Environment); + Assert.Equal("Production", result.Environment); Assert.Equal("worker-1", result.GetEnvironmentInfo().MachineName); Assert.Null(new Event { Environment = new string('x', 65) }.Environment); Assert.Null(new Event { Environment = " " }.Environment); Assert.NotEqual(new Event { Environment = "production" }, new Event { Environment = "staging" }); + Assert.NotEqual(new Event { Environment = "Production" }, new Event { Environment = "production" }); } /* lang=json */ From 1e984cd78139b92172c5373608ea075e3103f09a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 10 Sep 2026 00:16:45 -0500 Subject: [PATCH 09/11] Preserve casing in aggregate exception expectations --- .../Plugins/005_HandleAggregateExceptionsPluginTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs b/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs index e758b510..f6b42b24 100644 --- a/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs +++ b/test/Exceptionless.Tests/Plugins/005_HandleAggregateExceptionsPluginTests.cs @@ -39,7 +39,7 @@ public void SingleInnerException() { [Theory] [InlineData(null, "production")] - [InlineData("Staging", "staging")] + [InlineData("Staging", "Staging")] [InlineData("", null)] [InlineData("prod\ninvalid", null)] [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", null)] From a315b7dd38fa3f33f69d3509cc2ef0e2c0684237 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 21 Oct 2026 00:16:37 -0500 Subject: [PATCH 10/11] Test environment configuration paths and document precedence Cover reading Exceptionless:Environment from IConfiguration and both DI factory overloads, and document that a configured empty value stays unspecified and that Startup re-reads environment variables. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../Platforms/HostingExtensionsTests.cs | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 11361029..c2861822 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ client.Configuration.SetEnvironment("production"); client.CreateLog("Deployment complete").SetEnvironment("staging").Submit(); ``` -The hosting integration falls back to `IHostEnvironment.EnvironmentName`. Explicit configuration wins; `Exceptionless:Environment` and `Exceptionless__Environment` are also supported. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. +You can also set `Exceptionless:Environment` in .NET configuration or the `Exceptionless__Environment` environment variable. The hosting integration uses `IHostEnvironment.EnvironmentName` only when no environment is configured; a configured empty or invalid value leaves events unspecified instead. As with the API key, `Startup()` (which `UseExceptionless` calls) reads environment variables again, and on .NET Framework the configuration file, so those values replace one set in code. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. Refer to the Exceptionless documentation here: [Exceptionless Docs](https://exceptionless.com/docs/). diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index 3db62467..72b56d1a 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -1,8 +1,10 @@ #if NET10_0_OR_GREATER +using System.Collections.Generic; using System.Linq; using Exceptionless.Configuration; using Exceptionless.Dependency; using Exceptionless.Extensions.Hosting; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Moq; @@ -41,6 +43,67 @@ public void AddExceptionless_HostFallback_DoesNotBecomeExplicitConfiguration() { Assert.False(client.Configuration.IsEnvironmentConfigured); } + [Fact] + public void ReadFromConfiguration_Environment_LoadsTrimmedValueAndPreservesMissingSetting() { + // Arrange + using var client = new ExceptionlessClient(); + client.Configuration.Environment = "staging"; + var missing = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Exceptionless:ApiKey"] = "test-api-key" }).Build(); + var configured = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Exceptionless:Environment"] = " Production " }).Build(); + + // Act + client.Configuration.ReadFromConfiguration(missing); + string? afterMissing = client.Configuration.Environment; + client.Configuration.ReadFromConfiguration(configured); + + // Assert + Assert.Equal("staging", afterMissing); + Assert.Equal("Production", client.Configuration.Environment); + Assert.True(client.Configuration.IsEnvironmentConfigured); + } + + [Theory] + [InlineData(" Staging ", "Staging")] + [InlineData(null, "Production")] + [InlineData("", null)] + public void AddExceptionless_ConfigurationEnvironment_TakesPrecedenceOverHost(string? configuredEnvironment, string? expectedEnvironment) { + // Arrange + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); + if (configuredEnvironment != null) + builder.Configuration.AddInMemoryCollection(new Dictionary { ["Exceptionless:Environment"] = configuredEnvironment }); + + try { + // Act + builder.Services.AddExceptionless(builder.Configuration); + using var services = builder.Services.BuildServiceProvider(); + var client = services.GetRequiredService(); + + // Assert + Assert.Equal(expectedEnvironment, client.Configuration.Environment); + } finally { + ExceptionlessClient.Default.Configuration.Environment = null; + } + } + + [Fact] + public void AddExceptionless_ConfigureCallback_TakesPrecedenceOverConfigurationAndHost() { + // Arrange + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); + builder.Configuration.AddInMemoryCollection(new Dictionary { ["Exceptionless:Environment"] = "staging" }); + + try { + // Act + builder.Services.AddExceptionless(configuration => configuration.SetEnvironment("qa-west")); + using var services = builder.Services.BuildServiceProvider(); + var client = services.GetRequiredService(); + + // Assert + Assert.Equal("qa-west", client.Configuration.Environment); + } finally { + ExceptionlessClient.Default.Configuration.Environment = null; + } + } + [Fact] public void AddExceptionless_ProvidedClient_RemainsOwnedByCaller() { var resolver = new Mock(); From 3785ce2b8a96f21f923ae59590952a122265a3f0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 21 Oct 2026 07:49:47 -0500 Subject: [PATCH 11/11] Let an environment set in code take precedence over configuration Startup re-reads environment variables (and, on .NET Framework, the configuration file), which replaced an environment set in code. Configuration sources now apply only when code has not set one, and later sources still replace earlier ones. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../ExceptionlessConfiguration.cs | 13 +++++- .../ExceptionlessConfigurationExtensions.cs | 14 ++---- ...DeploymentEnvironmentConfigurationTests.cs | 22 +++++++-- .../Platforms/HostingExtensionsTests.cs | 45 ++++++++++++++++--- 5 files changed, 75 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c2861822..b82f732d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ client.Configuration.SetEnvironment("production"); client.CreateLog("Deployment complete").SetEnvironment("staging").Submit(); ``` -You can also set `Exceptionless:Environment` in .NET configuration or the `Exceptionless__Environment` environment variable. The hosting integration uses `IHostEnvironment.EnvironmentName` only when no environment is configured; a configured empty or invalid value leaves events unspecified instead. As with the API key, `Startup()` (which `UseExceptionless` calls) reads environment variables again, and on .NET Framework the configuration file, so those values replace one set in code. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. +You can also set `Exceptionless:Environment` in .NET configuration or the `Exceptionless__Environment` environment variable. A value set in code takes precedence; configuration and environment variables apply only when code has not set one. The hosting integration uses `IHostEnvironment.EnvironmentName` only when no environment is configured; a configured empty or invalid value leaves events unspecified instead. .NET Framework applications can use the `Exceptionless:Environment` app setting or the `environment` attribute on the `` configuration section. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid values remain unspecified. The top-level event `environment` is separate from machine diagnostics in `data.@environment`. Stacks and fixed versions remain shared across environments. Refer to the Exceptionless documentation here: [Exceptionless Docs](https://exceptionless.com/docs/). diff --git a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs index 27e5e0e5..ff81c1d4 100644 --- a/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs +++ b/src/Exceptionless/Configuration/ExceptionlessConfiguration.cs @@ -14,19 +14,30 @@ namespace Exceptionless { public class ExceptionlessConfiguration { private string _environment; + private bool _isEnvironmentSetInCode; - /// The default deployment environment for every event. + /// The default deployment environment for every event. A value set here takes precedence over configuration files and environment variables. public string Environment { get => _environment; set { _environment = Utility.DeploymentEnvironment.Normalize(value); IsEnvironmentConfigured = value != null; + _isEnvironmentSetInCode = value != null; } } /// Whether an environment was explicitly configured, including an invalid value that remains unspecified. public bool IsEnvironmentConfigured { get; private set; } + /// Applies an environment from a configuration source unless one was set in code. Later sources replace earlier ones. + internal void SetConfiguredEnvironment(string environment) { + if (environment == null || _isEnvironmentSetInCode) + return; + + _environment = Utility.DeploymentEnvironment.Normalize(environment); + IsEnvironmentConfigured = true; + } + /// Applies a deployment environment fallback without replacing an explicitly configured value. public void SetDefaultEnvironment(string environment) { if (!IsEnvironmentConfigured) diff --git a/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs b/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs index 46f76699..98cff990 100644 --- a/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs +++ b/src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs @@ -302,7 +302,7 @@ internal static void ReadFromConfigSection(this ExceptionlessConfiguration confi return; if (section.ElementInformation.Properties["environment"].ValueOrigin != PropertyValueOrigin.Default) - config.Environment = section.Environment; + config.SetConfiguredEnvironment(section.Environment); if (!section.Enabled) config.Enabled = false; @@ -397,8 +397,7 @@ public static void ReadFromAppSettings(this ExceptionlessConfiguration config) { } internal static void ReadFromAppSettings(this ExceptionlessConfiguration config, NameValueCollection settings) { - if (settings["Exceptionless:Environment"] != null) - config.Environment = settings["Exceptionless:Environment"]; + config.SetConfiguredEnvironment(settings["Exceptionless:Environment"]); string apiKey = settings["Exceptionless:ApiKey"]; if (IsValidApiKey(apiKey)) @@ -432,9 +431,7 @@ public static void ReadFromConfiguration(this ExceptionlessConfiguration config, throw new ArgumentNullException(nameof(settings)); var section = settings.GetSection("Exceptionless"); - if (section["Environment"] != null) { - config.Environment = section["Environment"]; - } + config.SetConfiguredEnvironment(section["Environment"]); if (Boolean.TryParse(section["Enabled"], out bool enabled) && !enabled) config.Enabled = false; @@ -506,10 +503,7 @@ public static void ReadFromConfiguration(this ExceptionlessConfiguration config, /// /// The configuration object you want to apply the attribute settings to. public static void ReadFromEnvironmentalVariables(this ExceptionlessConfiguration config) { - string environment = GetEnvironmentalVariable("Exceptionless:Environment") ?? GetEnvironmentalVariable("Exceptionless__Environment"); - if (environment != null) { - config.Environment = environment; - } + config.SetConfiguredEnvironment(GetEnvironmentalVariable("Exceptionless:Environment") ?? GetEnvironmentalVariable("Exceptionless__Environment")); string apiKey = GetEnvironmentalVariable("Exceptionless:ApiKey") ?? GetEnvironmentalVariable("Exceptionless__ApiKey"); if (IsValidApiKey(apiKey)) diff --git a/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs b/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs index 5ab8dce5..3821e507 100644 --- a/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs +++ b/test/Exceptionless.Tests/Configuration/DeploymentEnvironmentConfigurationTests.cs @@ -30,13 +30,29 @@ public void ReadFromConfigSection_MissingEnvironment_PreservesConfiguredValue() [Fact] public void ReadFromAppSettings_LoadsEnvironmentAndPreservesMissingSetting() { + // Arrange using var client = new ExceptionlessClient(); - client.Configuration.Environment = "staging"; - client.Configuration.ReadFromAppSettings(new NameValueCollection()); - Assert.Equal("staging", client.Configuration.Environment); + + // Act client.Configuration.ReadFromAppSettings(new NameValueCollection { ["Exceptionless:Environment"] = " Production " }); + client.Configuration.ReadFromAppSettings(new NameValueCollection()); + + // Assert Assert.Equal("Production", client.Configuration.Environment); } + + [Fact] + public void ReadFromAppSettings_EnvironmentSetInCode_KeepsCodeValue() { + // Arrange + using var client = new ExceptionlessClient(); + client.Configuration.Environment = "staging"; + + // Act + client.Configuration.ReadFromAppSettings(new NameValueCollection { ["Exceptionless:Environment"] = "production" }); + + // Assert + Assert.Equal("staging", client.Configuration.Environment); + } } } #endif diff --git a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs index 72b56d1a..de6abd00 100644 --- a/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs +++ b/test/Exceptionless.Tests/Platforms/HostingExtensionsTests.cs @@ -47,21 +47,50 @@ public void AddExceptionless_HostFallback_DoesNotBecomeExplicitConfiguration() { public void ReadFromConfiguration_Environment_LoadsTrimmedValueAndPreservesMissingSetting() { // Arrange using var client = new ExceptionlessClient(); - client.Configuration.Environment = "staging"; + var staging = CreateConfiguration(" Staging "); + var production = CreateConfiguration("production"); var missing = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Exceptionless:ApiKey"] = "test-api-key" }).Build(); - var configured = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Exceptionless:Environment"] = " Production " }).Build(); // Act + client.Configuration.ReadFromConfiguration(staging); + string? afterStaging = client.Configuration.Environment; + client.Configuration.ReadFromConfiguration(production); client.Configuration.ReadFromConfiguration(missing); - string? afterMissing = client.Configuration.Environment; - client.Configuration.ReadFromConfiguration(configured); // Assert - Assert.Equal("staging", afterMissing); - Assert.Equal("Production", client.Configuration.Environment); + Assert.Equal("Staging", afterStaging); + Assert.Equal("production", client.Configuration.Environment); Assert.True(client.Configuration.IsEnvironmentConfigured); } + [Fact] + public void ReadFromConfiguration_EnvironmentSetInCode_KeepsCodeValue() { + // Arrange + using var client = new ExceptionlessClient(); + client.Configuration.ReadFromConfiguration(CreateConfiguration("staging")); + client.Configuration.SetEnvironment("qa-west"); + + // Act + client.Configuration.ReadFromConfiguration(CreateConfiguration("production")); + + // Assert + Assert.Equal("qa-west", client.Configuration.Environment); + } + + [Fact] + public void ReadFromConfiguration_EnvironmentClearedInCode_UsesConfiguration() { + // Arrange + using var client = new ExceptionlessClient(); + client.Configuration.SetEnvironment("qa-west"); + client.Configuration.Environment = null; + + // Act + client.Configuration.ReadFromConfiguration(CreateConfiguration("production")); + + // Assert + Assert.Equal("production", client.Configuration.Environment); + } + [Theory] [InlineData(" Staging ", "Staging")] [InlineData(null, "Production")] @@ -155,6 +184,10 @@ public void UseExceptionless_WhenCalledTwice_DoesNotRegisterDuplicateLifetimeSer Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(IHostedService)); Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(IHostedLifecycleService)); } + + private static IConfiguration CreateConfiguration(string environment) { + return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Exceptionless:Environment"] = environment }).Build(); + } } } #endif