Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .autover/changes/2d110000-94ab-425d-b995-defdb0c81121.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"Projects": [
{
"Name": "Amazon.Lambda.Logging.AspNetCore",
"Type": "Minor",
"ChangelogMessages": [
"Added structured scope support to Lambda JSON logging with safe handling of invalid, colliding, and duplicate scope keys"
]
}
]
}
189 changes: 184 additions & 5 deletions Libraries/src/Amazon.Lambda.Logging.AspNetCore/LambdaILogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@
using System.Collections.Generic;

namespace Microsoft.Extensions.Logging
{
internal class LambdaILogger : ILogger
{
// Private fields
private readonly string _categoryName;
{
internal class LambdaILogger : ILogger
{
/// <summary>
/// The set of JSON property names written unconditionally by the Lambda RuntimeSupport JSON log formatter
/// (Amazon.Lambda.RuntimeSupport.Helpers.Logging.JsonLogMessageFormatter). Scope values are never allowed to
/// use these names so that a scope entry can never overwrite/corrupt these reserved metadata fields.
/// </summary>
private static readonly HashSet<string> ReservedMessagePropertyNames = new HashSet<string>(StringComparer.Ordinal)
{
"timestamp",
"level",
"requestId",
"tenantId",
"traceId",
"message",
"errorType",
"errorMessage",
"stackTrace",
};

// Private fields
private readonly string _categoryName;
private readonly LambdaLoggerOptions _options;


Expand Down Expand Up @@ -75,6 +93,58 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
messageTemplate = formatter.Invoke(state, exception);
}

// Append structured scope key/value pairs to the template and parameters so they
// are emitted as named JSON properties by the Lambda JSON formatter.
if (_options.IncludeScopes && ScopeProvider != null)
{
// Names already claimed by the message template itself (explicit message properties) always win.
// Scope entries that collide with these names, or with each other, must never be allowed to
// silently corrupt the message or reserved JSON metadata fields written by the RuntimeSupport
// JSON formatter (e.g. "timestamp", "level", "message", ...).
var messagePropertyNames = ExtractTemplatePropertyNames(messageTemplate);

// Preserves the order scope property names were first encountered, while allowing a later
// (i.e. more inner/nested) scope to overwrite the value of an earlier (outer) scope that used
// the same key. IExternalScopeProvider.ForEachScope invokes the callback from the outermost
// scope to the innermost scope, so later invocations here represent inner scopes.
var orderedScopeKeys = new List<string>();
var scopeValuesByKey = new Dictionary<string, object>(StringComparer.Ordinal);

ScopeProvider.ForEachScope((scope, list) =>
{
if (scope is IEnumerable<KeyValuePair<string, object>> scopeKvps)
{
foreach (var kvp in scopeKvps)
{
if (!IsSupportedScopeKey(kvp.Key) || messagePropertyNames.Contains(kvp.Key))
{
continue;
}

if (!scopeValuesByKey.ContainsKey(kvp.Key))
{
list.Add(kvp.Key);
}

// Overwrite with the latest value seen for this key so that, within a single
// scope's enumerable and across nested scopes, the innermost/last value wins.
scopeValuesByKey[kvp.Key] = kvp.Value;
}
}
}, orderedScopeKeys);

if (orderedScopeKeys.Count > 0)
{
var sb = new System.Text.StringBuilder(messageTemplate);
foreach (var key in orderedScopeKeys)
{
sb.Append($" {{{key}}}");
parameters.Add(scopeValuesByKey[key]);
}
messageTemplate = sb.ToString();
}
}

if (_options.IncludeCategory)
{
// Unlike the text format, the JSON format otherwise drops the
Expand Down Expand Up @@ -125,6 +195,115 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
}
}

/// <summary>
/// Determines whether a scope key can be safely appended as a "{key}" message-template placeholder
/// and understood correctly by the Lambda RuntimeSupport JSON log formatter's message template parser.
/// </summary>
/// <remarks>
/// The RuntimeSupport parser (Amazon.Lambda.RuntimeSupport.Helpers.Logging.AbstractLogMessageFormatter /
/// MessageProperty) treats '{' and '}' as structural delimiters, an optional leading '@' as a directive
/// that switches the value to JSON serialization, and the first ':' as the start of a .NET format string
/// applied to the value. None of these characters can appear in the key without changing how the
/// template is parsed or silently truncating/renaming the resulting JSON property. Keys containing
/// whitespace are also rejected since they do not represent a well-formed identifier for a JSON
/// property name. Unsupported keys are skipped entirely rather than sanitized/renamed to avoid
/// introducing new collisions or misleading data.
/// </remarks>
/// <param name="key">The scope dictionary key to validate.</param>
/// <returns>True if the key is safe to use as a message-template property name.</returns>
private static bool IsSupportedScopeKey(string key)
{
if (string.IsNullOrEmpty(key) || key == "{OriginalFormat}")
{
return false;
}

if (ReservedMessagePropertyNames.Contains(key))
{
return false;
}

foreach (var c in key)
{
if (c == '{' || c == '}' || c == ':' || c == '@' || char.IsWhiteSpace(c))
{
return false;
}
}

return true;
}

/// <summary>
/// Parses a message template to determine the set of message-property names it explicitly defines
/// (e.g. "User {Name} logged in" defines the property name "Name"). Used to ensure scope values never
/// override an explicit message property with the same name.
/// </summary>
/// <param name="messageTemplate">The message template to inspect.</param>
/// <returns>The set of property names already used by the message template.</returns>
private static HashSet<string> ExtractTemplatePropertyNames(string messageTemplate)
{
var names = new HashSet<string>(StringComparer.Ordinal);

if (string.IsNullOrEmpty(messageTemplate))
{
return names;
}

var inParameter = false;
var possibleParameterOpen = false;
int paramStartIdx = -1;

for (int i = 0, l = messageTemplate.Length; i < l; i++)
{
var c = messageTemplate[i];
if (c == '{')
{
if (!inParameter && !possibleParameterOpen)
{
possibleParameterOpen = true;
}
else if (possibleParameterOpen)
{
// escaped "{{"
possibleParameterOpen = false;
}
}
else if (c == '}')
{
if (inParameter || possibleParameterOpen)
{
if (paramStartIdx != -1)
{
var token = messageTemplate.Substring(paramStartIdx, i - paramStartIdx);
if (token.Length > 0 && token[0] == '@')
{
token = token.Substring(1);
}
var colonIdx = token.IndexOf(':');
if (colonIdx >= 0)
{
token = token.Substring(0, colonIdx);
}
names.Add(token.Trim());
}

inParameter = false;
possibleParameterOpen = false;
paramStartIdx = -1;
}
}
else if (possibleParameterOpen)
{
paramStartIdx = i;
possibleParameterOpen = false;
inParameter = true;
}
}

return names;
}

private static Amazon.Lambda.Core.LogLevel ConvertLogLevel(LogLevel logLevel)
{
switch (logLevel)
Expand Down
22 changes: 22 additions & 0 deletions Libraries/src/Amazon.Lambda.Logging.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,25 @@ using(defaultLogger.BeginScope(awsRequestId))
}
}
```

## Structured scopes in Lambda JSON mode

When the `AWS_LAMBDA_LOG_FORMAT` environment variable is set to `JSON` and `IncludeScopes` is `true`, scope state objects that implement `IEnumerable<KeyValuePair<string, object>>` (such as `Dictionary<string, object>`) will have their key/value entries included as structured parameters in the emitted JSON log entry.

```csharp
var loggerOptions = new LambdaLoggerOptions { IncludeScopes = true };

var scopeProperties = new Dictionary<string, object>
{
{ "RequestId", "abc-123" },
{ "UserId", 42 }
};

using (logger.BeginScope(scopeProperties))
{
logger.LogInformation("Order {OrderId} placed", orderId);
// Emits JSON with RequestId, UserId, and OrderId as structured properties.
}
```

Nested structured scopes are supported. The scope properties are prepended to the parameter list (outermost scope first), followed by the message-template parameters. Non-structured scopes (e.g. plain strings) are silently ignored in JSON mode.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Amazon.Lambda.Core\Amazon.Lambda.Core.csproj" />
<ProjectReference Include="..\..\src\Amazon.Lambda.Logging.AspNetCore\Amazon.Lambda.Logging.AspNetCore.csproj" />
<!-- Referenced only by tests to drive log messages through the actual Lambda RuntimeSupport JSON log
formatter and assert on the resulting JSON. Production code in Amazon.Lambda.Logging.AspNetCore does
not, and must not, depend on Amazon.Lambda.RuntimeSupport. -->
<ProjectReference Include="..\..\src\Amazon.Lambda.RuntimeSupport\Amazon.Lambda.RuntimeSupport.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading