From db957fc0790418184273ea60c9501b70244aedc8 Mon Sep 17 00:00:00 2001 From: Henrique Graca <999396+hjgraca@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:10:30 +0100 Subject: [PATCH 1/3] fix(common): graceful fallback when LambdaTraceProvider is unavailable (#1173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XRayTraceId now probes for LambdaTraceProvider.CurrentTraceId on first access and caches the result. When the type is not available (customers on Amazon.Lambda.Core < 2.8.0), it falls back to reading the _X_AMZN_TRACE_ID environment variable — matching pre-3.1.0 behaviour — instead of throwing a TypeLoadException at runtime. Closes #1173 --- .../Core/PowertoolsConfigurations.cs | 47 ++++++++++- .../Core/PowertoolsConfigurationsTest.cs | 82 +++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs b/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs index c13710131..745981f6f 100644 --- a/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs +++ b/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs @@ -1,4 +1,6 @@ +using System; using System.Globalization; +using System.Runtime.CompilerServices; using Amazon.Lambda.Core; using AWS.Lambda.Powertools.Common.Core; @@ -28,6 +30,12 @@ public class PowertoolsConfigurations : IPowertoolsConfigurations /// private static IPowertoolsConfigurations _instance; + /// + /// Whether LambdaTraceProvider is available in the loaded Amazon.Lambda.Core assembly. + /// Null means not yet checked, true/false is the cached result. + /// + private static bool? _isTraceProviderAvailable; + /// /// Initializes a new instance of the class. /// @@ -165,10 +173,12 @@ public bool GetEnvironmentVariableOrDefault(string variable, bool defaultValue) /// /// Gets the X-Ray trace identifier. + /// Uses LambdaTraceProvider.CurrentTraceId when available (Amazon.Lambda.Core >= 2.8.0) + /// for correct trace ID isolation in concurrent Lambda executions (LMI). + /// Falls back to the _X_AMZN_TRACE_ID environment variable for older runtimes. /// /// The X-Ray trace identifier. - public string XRayTraceId => - LambdaTraceProvider.CurrentTraceId; + public string XRayTraceId => GetTraceId(); /// /// Gets a value indicating whether this instance is Lambda. @@ -212,4 +222,37 @@ public bool GetEnvironmentVariableOrDefault(string variable, bool defaultValue) /// public string AwsInitializationType => GetEnvironmentVariable(Constants.AWSInitializationTypeEnv); + + private string GetTraceId() + { + if (_isTraceProviderAvailable == true) + return GetTraceIdFromProvider(); + + if (_isTraceProviderAvailable == false) + return GetEnvironmentVariable(Constants.XrayTraceIdEnv); + + // First call — probe whether LambdaTraceProvider exists in the loaded runtime + try + { + var traceId = GetTraceIdFromProvider(); + _isTraceProviderAvailable = true; + return traceId; + } + catch (TypeLoadException) + { + _isTraceProviderAvailable = false; + return GetEnvironmentVariable(Constants.XrayTraceIdEnv); + } + } + + /// + /// Isolated call to LambdaTraceProvider.CurrentTraceId. + /// Must not be inlined so that the TypeLoadException is thrown only + /// when this method is invoked, not when the caller is compiled. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static string GetTraceIdFromProvider() + { + return LambdaTraceProvider.CurrentTraceId; + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs b/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs index 5e4503072..ed5da8e79 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs @@ -534,5 +534,87 @@ public void IsLambdaEnvironment_WhenEnvironmentHasValue_ReturnsTrue() } #endregion + + #region XRayTraceId Tests + + [Fact] + public void XRayTraceId_WhenLambdaTraceProviderAvailable_ReturnsTraceId() + { + // Arrange + var environment = Substitute.For(); + var configurations = new PowertoolsConfigurations(environment); + + // Reset cached state so the probe runs fresh + ResetTraceProviderState(); + + // Act - LambdaTraceProvider is available in test env (Amazon.Lambda.Core 2.8.0) + var result = configurations.XRayTraceId; + + // Assert - should not throw and should not fall back to env var + environment.DidNotReceive() + .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + } + + [Fact] + public void XRayTraceId_WhenLambdaTraceProviderUnavailable_FallsBackToEnvironmentVariable() + { + // Arrange + var traceId = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1"; + var environment = Substitute.For(); + environment.GetEnvironmentVariable(Constants.XrayTraceIdEnv).Returns(traceId); + var configurations = new PowertoolsConfigurations(environment); + + // Simulate LambdaTraceProvider not being available (older Amazon.Lambda.Core) + SetTraceProviderAvailable(false); + + // Act + var result = configurations.XRayTraceId; + + // Assert + environment.Received(1) + .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + Assert.Equal(traceId, result); + } + + [Fact] + public void XRayTraceId_CachesTraceProviderAvailability_OnSubsequentCalls() + { + // Arrange + var environment = Substitute.For(); + var configurations = new PowertoolsConfigurations(environment); + + // Simulate LambdaTraceProvider not being available + SetTraceProviderAvailable(false); + + var traceId1 = "Root=1-aaa;Parent=bbb;Sampled=1"; + var traceId2 = "Root=1-ccc;Parent=ddd;Sampled=1"; + environment.GetEnvironmentVariable(Constants.XrayTraceIdEnv).Returns(traceId1, traceId2); + + // Act + var result1 = configurations.XRayTraceId; + var result2 = configurations.XRayTraceId; + + // Assert - should go straight to env var on second call (cached false) + environment.Received(2) + .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + Assert.Equal(traceId1, result1); + Assert.Equal(traceId2, result2); + } + + private static void ResetTraceProviderState() + { + var field = typeof(PowertoolsConfigurations).GetField("_isTraceProviderAvailable", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + field?.SetValue(null, null); + } + + private static void SetTraceProviderAvailable(bool available) + { + var field = typeof(PowertoolsConfigurations).GetField("_isTraceProviderAvailable", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + field?.SetValue(null, (bool?)available); + } + + #endregion } } \ No newline at end of file From ba1859c7185245cbbf47f2556e56a987f2d64193 Mon Sep 17 00:00:00 2001 From: Henrique Graca <999396+hjgraca@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:15:35 +0100 Subject: [PATCH 2/3] fix: make GetTraceId static to satisfy Sonar S2696 Instance method was setting a static field. Refactored to accept a Func fallback so the method can be fully static. --- .../Core/PowertoolsConfigurations.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs b/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs index 745981f6f..8b22b3e7a 100644 --- a/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs +++ b/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs @@ -178,7 +178,7 @@ public bool GetEnvironmentVariableOrDefault(string variable, bool defaultValue) /// Falls back to the _X_AMZN_TRACE_ID environment variable for older runtimes. /// /// The X-Ray trace identifier. - public string XRayTraceId => GetTraceId(); + public string XRayTraceId => GetTraceId(() => GetEnvironmentVariable(Constants.XrayTraceIdEnv)); /// /// Gets a value indicating whether this instance is Lambda. @@ -223,13 +223,13 @@ public bool GetEnvironmentVariableOrDefault(string variable, bool defaultValue) public string AwsInitializationType => GetEnvironmentVariable(Constants.AWSInitializationTypeEnv); - private string GetTraceId() + private static string GetTraceId(Func fallback) { if (_isTraceProviderAvailable == true) return GetTraceIdFromProvider(); if (_isTraceProviderAvailable == false) - return GetEnvironmentVariable(Constants.XrayTraceIdEnv); + return fallback(); // First call — probe whether LambdaTraceProvider exists in the loaded runtime try @@ -241,7 +241,7 @@ private string GetTraceId() catch (TypeLoadException) { _isTraceProviderAvailable = false; - return GetEnvironmentVariable(Constants.XrayTraceIdEnv); + return fallback(); } } From 1f245d6ecbbe4ee75a75f95cff811afdd305c11f Mon Sep 17 00:00:00 2001 From: Henrique Graca <999396+hjgraca@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:21:13 +0100 Subject: [PATCH 3/3] fix: address Copilot review feedback - Thread safety: replace bool? with int + Volatile.Read/Write for atomic access to _traceProviderState across concurrent invocations - Tests: add try/finally with ResetTraceProviderState to prevent cross-test contamination of static state - Tests: Assert.NotNull on reflection field lookups so tests fail loudly if the field is renamed - Tests: rename caching test to clarify it covers cached-unavailable path, not the initial probe - Tests: remove invalid assertion on return value (LambdaTraceProvider returns null in test env with no active trace) --- .../Core/PowertoolsConfigurations.cs | 16 ++- .../Core/PowertoolsConfigurationsTest.cs | 129 +++++++++++------- 2 files changed, 88 insertions(+), 57 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs b/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs index 8b22b3e7a..d5bb32a3e 100644 --- a/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs +++ b/libraries/src/AWS.Lambda.Powertools.Common/Core/PowertoolsConfigurations.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Runtime.CompilerServices; +using System.Threading; using Amazon.Lambda.Core; using AWS.Lambda.Powertools.Common.Core; @@ -32,9 +33,10 @@ public class PowertoolsConfigurations : IPowertoolsConfigurations /// /// Whether LambdaTraceProvider is available in the loaded Amazon.Lambda.Core assembly. - /// Null means not yet checked, true/false is the cached result. + /// 0 = not yet checked, 1 = available, -1 = unavailable. + /// Stored as int for atomic reads/writes via Volatile. /// - private static bool? _isTraceProviderAvailable; + private static int _traceProviderState; // 0 = unknown, 1 = available, -1 = unavailable /// /// Initializes a new instance of the class. @@ -225,22 +227,24 @@ public bool GetEnvironmentVariableOrDefault(string variable, bool defaultValue) private static string GetTraceId(Func fallback) { - if (_isTraceProviderAvailable == true) + var state = Volatile.Read(ref _traceProviderState); + + if (state == 1) return GetTraceIdFromProvider(); - if (_isTraceProviderAvailable == false) + if (state == -1) return fallback(); // First call — probe whether LambdaTraceProvider exists in the loaded runtime try { var traceId = GetTraceIdFromProvider(); - _isTraceProviderAvailable = true; + Volatile.Write(ref _traceProviderState, 1); return traceId; } catch (TypeLoadException) { - _isTraceProviderAvailable = false; + Volatile.Write(ref _traceProviderState, -1); return fallback(); } } diff --git a/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs b/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs index ed5da8e79..784cda2f3 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Common.Tests/Core/PowertoolsConfigurationsTest.cs @@ -540,79 +540,106 @@ public void IsLambdaEnvironment_WhenEnvironmentHasValue_ReturnsTrue() [Fact] public void XRayTraceId_WhenLambdaTraceProviderAvailable_ReturnsTraceId() { - // Arrange - var environment = Substitute.For(); - var configurations = new PowertoolsConfigurations(environment); - - // Reset cached state so the probe runs fresh ResetTraceProviderState(); - // Act - LambdaTraceProvider is available in test env (Amazon.Lambda.Core 2.8.0) - var result = configurations.XRayTraceId; - - // Assert - should not throw and should not fall back to env var - environment.DidNotReceive() - .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + try + { + // Arrange + var environment = Substitute.For(); + var configurations = new PowertoolsConfigurations(environment); + + // Act - LambdaTraceProvider is available in test env (Amazon.Lambda.Core 2.8.0) + // Returns null/empty in test env (no active Lambda trace), but should not throw + var result = configurations.XRayTraceId; + + // Assert - should not fall back to env var (provider was used instead) + environment.DidNotReceive() + .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + } + finally + { + ResetTraceProviderState(); + } } [Fact] public void XRayTraceId_WhenLambdaTraceProviderUnavailable_FallsBackToEnvironmentVariable() { - // Arrange - var traceId = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1"; - var environment = Substitute.For(); - environment.GetEnvironmentVariable(Constants.XrayTraceIdEnv).Returns(traceId); - var configurations = new PowertoolsConfigurations(environment); - - // Simulate LambdaTraceProvider not being available (older Amazon.Lambda.Core) - SetTraceProviderAvailable(false); - - // Act - var result = configurations.XRayTraceId; + ResetTraceProviderState(); - // Assert - environment.Received(1) - .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); - Assert.Equal(traceId, result); + try + { + // Arrange + var traceId = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1"; + var environment = Substitute.For(); + environment.GetEnvironmentVariable(Constants.XrayTraceIdEnv).Returns(traceId); + var configurations = new PowertoolsConfigurations(environment); + + // Simulate LambdaTraceProvider not being available (older Amazon.Lambda.Core) + SetTraceProviderState(-1); + + // Act + var result = configurations.XRayTraceId; + + // Assert + environment.Received(1) + .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + Assert.Equal(traceId, result); + } + finally + { + ResetTraceProviderState(); + } } [Fact] - public void XRayTraceId_CachesTraceProviderAvailability_OnSubsequentCalls() + public void XRayTraceId_WhenProviderCachedUnavailable_UsesEnvVarOnSubsequentCalls() { - // Arrange - var environment = Substitute.For(); - var configurations = new PowertoolsConfigurations(environment); - - // Simulate LambdaTraceProvider not being available - SetTraceProviderAvailable(false); - - var traceId1 = "Root=1-aaa;Parent=bbb;Sampled=1"; - var traceId2 = "Root=1-ccc;Parent=ddd;Sampled=1"; - environment.GetEnvironmentVariable(Constants.XrayTraceIdEnv).Returns(traceId1, traceId2); + ResetTraceProviderState(); - // Act - var result1 = configurations.XRayTraceId; - var result2 = configurations.XRayTraceId; - - // Assert - should go straight to env var on second call (cached false) - environment.Received(2) - .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); - Assert.Equal(traceId1, result1); - Assert.Equal(traceId2, result2); + try + { + // Arrange + var environment = Substitute.For(); + var configurations = new PowertoolsConfigurations(environment); + + // Simulate LambdaTraceProvider not being available (cached) + SetTraceProviderState(-1); + + var traceId1 = "Root=1-aaa;Parent=bbb;Sampled=1"; + var traceId2 = "Root=1-ccc;Parent=ddd;Sampled=1"; + environment.GetEnvironmentVariable(Constants.XrayTraceIdEnv).Returns(traceId1, traceId2); + + // Act + var result1 = configurations.XRayTraceId; + var result2 = configurations.XRayTraceId; + + // Assert - should go straight to env var on both calls (cached unavailable) + environment.Received(2) + .GetEnvironmentVariable(Arg.Is(i => i == Constants.XrayTraceIdEnv)); + Assert.Equal(traceId1, result1); + Assert.Equal(traceId2, result2); + } + finally + { + ResetTraceProviderState(); + } } private static void ResetTraceProviderState() { - var field = typeof(PowertoolsConfigurations).GetField("_isTraceProviderAvailable", + var field = typeof(PowertoolsConfigurations).GetField("_traceProviderState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - field?.SetValue(null, null); + Assert.NotNull(field); + field.SetValue(null, 0); } - private static void SetTraceProviderAvailable(bool available) + private static void SetTraceProviderState(int state) { - var field = typeof(PowertoolsConfigurations).GetField("_isTraceProviderAvailable", + var field = typeof(PowertoolsConfigurations).GetField("_traceProviderState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - field?.SetValue(null, (bool?)available); + Assert.NotNull(field); + field.SetValue(null, state); } #endregion