diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/LoggingAspect.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/LoggingAspect.cs index 37cdc1c94..e11434477 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/Internal/LoggingAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Internal/LoggingAspect.cs @@ -133,9 +133,32 @@ private void CaptureCorrelationId(object eventArg, string correlationIdPath) // TODO: For casing parsing to be removed from Logging v2 when we get rid of outputcase without this CorrelationIdPaths.ApiGatewayRest would not work // TODO: This will be removed and replaced by JMesPath - var pathWithOutputCase = correlationIdPaths[i].ToCase(_currentConfig.LoggerOutputCase); - if (!element.TryGetProperty(pathWithOutputCase, out var childElement)) - break; + var pathSegment = correlationIdPaths[i]; + JsonElement childElement; + + // Try original path first (case-sensitive) + if (!element.TryGetProperty(pathSegment, out childElement)) + { + // Try with output case transformation + var pathWithOutputCase = pathSegment.ToCase(_currentConfig.LoggerOutputCase); + if (!element.TryGetProperty(pathWithOutputCase, out childElement)) + { + // Try case-insensitive match as last resort + var found = false; + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, pathSegment, StringComparison.OrdinalIgnoreCase)) + { + childElement = property.Value; + found = true; + break; + } + } + + if (!found) + break; + } + } element = childElement; if (i == correlationIdPaths.Length - 1) diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Scope.cs b/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Scope.cs index 771f3ebb4..889ac9478 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Scope.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/Logger.Scope.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AWS.Lambda.Powertools.Logging.Internal; using AWS.Lambda.Powertools.Logging.Internal.Helpers; namespace AWS.Lambda.Powertools.Logging; @@ -13,6 +14,22 @@ public static partial class Logger /// The scope. private static IDictionary Scope { get; } = new Dictionary(StringComparer.Ordinal); + /// + /// Gets the correlation identifier from the log context. + /// + /// The correlation identifier, or null if not set. + public static string CorrelationId + { + get + { + if (Scope.TryGetValue(LoggingConstants.KeyCorrelationId, out var value)) + { + return value?.ToString(); + } + return null; + } + } + /// /// Appending additional key to the log context. /// diff --git a/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs b/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs index 9a6cda819..99abbd46f 100644 --- a/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs +++ b/libraries/src/AWS.Lambda.Powertools.Logging/PowertoolsLoggerExtensions.cs @@ -170,6 +170,16 @@ public static void Log(this ILogger logger, LogLevel logLevel, Exception excepti #endregion + /// + /// Gets the correlation identifier from the log context. + /// + /// The logger instance. + /// The correlation identifier, or null if not set. + public static string GetCorrelationId(this ILogger logger) + { + return Logger.CorrelationId; + } + /// /// Appending additional key to the log context. /// diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Attributes/LoggingAttributeTest.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Attributes/LoggingAttributeTest.cs index 5fc3e7179..9b0542f76 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Attributes/LoggingAttributeTest.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Attributes/LoggingAttributeTest.cs @@ -4,6 +4,7 @@ using System.Linq; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.ApplicationLoadBalancerEvents; +using Amazon.Lambda.CloudWatchEvents; using Amazon.Lambda.CloudWatchEvents.S3Events; using Amazon.Lambda.TestUtilities; using AWS.Lambda.Powertools.Common; @@ -172,6 +173,7 @@ public void OnExit_WhenHandler_ClearState_Enabled_ClearKeys() [InlineData(CorrelationIdPaths.ApplicationLoadBalancer)] [InlineData(CorrelationIdPaths.EventBridge)] [InlineData("/headers/my_request_id_header")] + [InlineData("/detail/correlationId")] public void OnEntry_WhenEventArgExists_CapturesCorrelationId(string correlationIdPath) { // Arrange @@ -213,6 +215,15 @@ public void OnEntry_WhenEventArgExists_CapturesCorrelationId(string correlationI } }); break; + case "/detail/correlationId": + _testHandlers.CorrelationCloudWatchEventCustomPath(new CloudWatchEvent + { + Detail = new CwEvent + { + CorrelationId = correlationId + } + }); + break; } // Assert @@ -346,6 +357,106 @@ public void When_Setting_SamplingRate_Should_Add_Key() )); } + [Fact] + public void CorrelationId_Property_Should_Return_CorrelationId() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + // Act + _testHandlers.CorrelationCloudWatchEventCustomPath(new CloudWatchEvent + { + Detail = new CwEvent + { + CorrelationId = correlationId + } + }); + + // Assert - Static Logger property + Assert.Equal(correlationId, Logger.CorrelationId); + } + + [Fact] + public void CorrelationId_Extension_Should_Return_CorrelationId_Via_ILogger() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + // Act + _testHandlers.CorrelationIdExtensionTest(new CloudWatchEvent + { + Detail = new CwEvent + { + CorrelationId = correlationId + } + }); + + // Assert - The test handler will verify the extension method works + Assert.Equal(correlationId, Logger.CorrelationId); + } + + [Fact] + public void CorrelationId_Should_Return_Null_When_Not_Set() + { + // Arrange - no correlation ID set + + // Act & Assert + Assert.Null(Logger.CorrelationId); + } + + [Fact] + public void OnEntry_WhenPropertyCasingDoesNotMatch_UsesCaseInsensitiveFallback() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + // This test uses a path "/detail/CORRELATIONID" (all caps) + // but the actual property is "correlationId" (camelCase) + // This should trigger the case-insensitive fallback + + // Act + _testHandlers.CorrelationIdCaseInsensitiveFallback(new CloudWatchEvent + { + Detail = new CwEvent + { + CorrelationId = correlationId + } + }); + + // Assert + var allKeys = Logger.GetAllKeys() + .ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value); + + Assert.True(allKeys.ContainsKey(LoggingConstants.KeyCorrelationId)); + Assert.Equal((string)allKeys[LoggingConstants.KeyCorrelationId], correlationId); + } + + [Fact] + public void OnEntry_WhenNestedPropertyCasingDoesNotMatch_UsesCaseInsensitiveFallback() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + // This test uses a path with mismatched casing at multiple levels + // Path: "/DETAIL/CORRELATIONID" but actual properties are "detail" and "correlationId" + + // Act + _testHandlers.CorrelationIdNestedCaseInsensitive(new CloudWatchEvent + { + Detail = new CwEvent + { + CorrelationId = correlationId + } + }); + + // Assert + var allKeys = Logger.GetAllKeys() + .ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value); + + Assert.True(allKeys.ContainsKey(LoggingConstants.KeyCorrelationId)); + Assert.Equal((string)allKeys[LoggingConstants.KeyCorrelationId], correlationId); + } + [Fact] public void When_Setting_Service_Should_Update_Key() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs index dd332ea4c..31cd5bd65 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Logging.Tests/Handlers/TestHandlers.cs @@ -66,6 +66,41 @@ public void CorrelationCloudWatchEvent(CloudWatchEvent cwEvent) { } + [Logging(CorrelationIdPath = "/detail/correlationId")] + public void CorrelationCloudWatchEventCustomPath(CloudWatchEvent cwEvent) + { + } + + [Logging(CorrelationIdPath = "/detail/correlationId")] + public void CorrelationIdExtensionTest(CloudWatchEvent cwEvent) + { + // Test that the ILogger extension method works + var logger = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => { }).CreateLogger(nameof(TestHandlers)); + var correlationIdFromExtension = logger.GetCorrelationId(); + + // Verify it matches the static property + if (correlationIdFromExtension != Logger.CorrelationId) + { + throw new Exception("Extension method returned different value than static property"); + } + } + + [Logging(CorrelationIdPath = "/detail/CORRELATIONID")] + public void CorrelationIdCaseInsensitiveFallback(CloudWatchEvent cwEvent) + { + // This handler uses all caps "CORRELATIONID" in the path + // but the actual JSON property is "correlationId" (camelCase) + // This tests the case-insensitive fallback logic + } + + [Logging(CorrelationIdPath = "/DETAIL/CORRELATIONID")] + public void CorrelationIdNestedCaseInsensitive(CloudWatchEvent cwEvent) + { + // This handler uses all caps for both path segments + // but the actual JSON properties are "detail" and "correlationId" + // This tests the case-insensitive fallback at multiple levels + } + [Logging(CorrelationIdPath = "/headers/my_request_id_header")] public void CorrelationIdFromString(TestObject testObject) { @@ -172,6 +207,21 @@ public enum Pet } } +public class CwEvent +{ + [JsonPropertyName("rideId")] + public string RideId { get; set; } = string.Empty; + + [JsonPropertyName("riderId")] + public string RiderId { get; set; } = string.Empty; + + [JsonPropertyName("riderName")] + public string RiderName { get; set; } = string.Empty; + + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; set; } +} + public class TestServiceHandler { public void LogWithEnv()