Skip to content

feat: Add bring your own log formatter to logger #375

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 21, 2023
Merged
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
92 changes: 92 additions & 0 deletions docs/core/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,95 @@ Below are some output examples for different casing.
"function_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72"
}
```

## Custom Log formatter (Bring Your Own Formatter)

You can customize the structure (keys and values) of your log entries by implementing a custom log formatter and override default log formatter using ``Logger.UseFormatter`` method. You can implement a custom log formatter by inheriting the ``ILogFormatter`` class and implementing the ``object FormatLogEntry(LogEntry logEntry)`` method.

=== "Function.cs"

```c# hl_lines="11"
/**
* Handler for requests to Lambda function.
*/
public class Function
{
/// <summary>
/// Function constructor
/// </summary>
public Function()
{
Logger.UseFormatter(new CustomLogFormatter());
}

[Logging(CorrelationIdPath = "/headers/my_request_id_header", SamplingRate = 0.7)]
public async Task<APIGatewayProxyResponse> FunctionHandler
(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context)
{
...
}
}
```
=== "CustomLogFormatter.cs"

```c#
public class CustomLogFormatter : ILogFormatter
{
public object FormatLogEntry(LogEntry logEntry)
{
return new
{
Message = logEntry.Message,
Service = logEntry.Service,
CorrelationIds = new
{
AwsRequestId = logEntry.LambdaContext?.AwsRequestId,
XRayTraceId = logEntry.XRayTraceId,
CorrelationId = logEntry.CorrelationId
},
LambdaFunction = new
{
Name = logEntry.LambdaContext?.FunctionName,
Arn = logEntry.LambdaContext?.InvokedFunctionArn,
MemoryLimitInMB = logEntry.LambdaContext?.MemoryLimitInMB,
Version = logEntry.LambdaContext?.FunctionVersion,
ColdStart = logEntry.ColdStart,
},
Level = logEntry.Level.ToString(),
Timestamp = logEntry.Timestamp.ToString("o"),
Logger = new
{
Name = logEntry.Name,
SampleRate = logEntry.SamplingRate
},
};
}
}
```

=== "Example CloudWatch Logs excerpt"

```json
{
"Message": "Test Message",
"Service": "lambda-example",
"CorrelationIds": {
"AwsRequestId": "52fdfc07-2182-154f-163f-5f0f9a621d72",
"XRayTraceId": "1-61b7add4-66532bb81441e1b060389429",
"CorrelationId": "correlation_id_value"
},
"LambdaFunction": {
"Name": "test",
"Arn": "arn:aws:lambda:eu-west-1:12345678910:function:test",
"MemorySize": 128,
"Version": "$LATEST",
"ColdStart": true
},
"Level": "Information",
"Timestamp": "2021-12-13T20:32:22.5774262Z",
"Logger": {
"Name": "AWS.Lambda.Powertools.Logging.Logger",
"SampleRate": 0.7
}
}
```
29 changes: 29 additions & 0 deletions libraries/src/AWS.Lambda.Powertools.Logging/ILogFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

namespace AWS.Lambda.Powertools.Logging;

/// <summary>
/// Represents a type used to format Powertools log entries.
/// </summary>
public interface ILogFormatter
{
/// <summary>
/// Formats a log entry
/// </summary>
/// <param name="logEntry">The log entry.</param>
/// <returns>Formatted log entry as object.</returns>
object FormatLogEntry(LogEntry logEntry);
}
146 changes: 127 additions & 19 deletions libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,20 +206,43 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
if (!IsEnabled(logLevel))
return;

var message = new Dictionary<string, object>(StringComparer.Ordinal);
var timestamp = DateTime.UtcNow;
var message = CustomFormatter(state, exception, out var customMessage) && customMessage is not null
? customMessage
: formatter(state, exception);

var logFormatter = Logger.GetFormatter();
var logEntry = logFormatter is null?
GetLogEntry(logLevel, timestamp, message, exception) :
GetFormattedLogEntry(logLevel, timestamp, message, exception, logFormatter);

_systemWrapper.LogLine(JsonSerializer.Serialize(logEntry, JsonSerializerOptions));
}

/// <summary>
/// Gets a log entry.
/// </summary>
/// <param name="logLevel">Entry will be written on this level.</param>
/// <param name="timestamp">Entry timestamp.</param>
/// <param name="message">The message to be written. Can be also an object.</param>
/// <param name="exception">The exception related to this entry.</param>
private Dictionary<string, object> GetLogEntry(LogLevel logLevel, DateTime timestamp, object message,
Exception exception)
{
var logEntry = new Dictionary<string, object>(StringComparer.Ordinal);

// Add Custom Keys
foreach (var (key, value) in Logger.GetAllKeys())
message.TryAdd(key, value);
logEntry.TryAdd(key, value);

// Add Lambda Context Keys
if (PowertoolsLambdaContext.Instance is not null)
{
message.TryAdd(LoggingConstants.KeyFunctionName, PowertoolsLambdaContext.Instance.FunctionName);
message.TryAdd(LoggingConstants.KeyFunctionVersion, PowertoolsLambdaContext.Instance.FunctionVersion);
message.TryAdd(LoggingConstants.KeyFunctionMemorySize, PowertoolsLambdaContext.Instance.MemoryLimitInMB);
message.TryAdd(LoggingConstants.KeyFunctionArn, PowertoolsLambdaContext.Instance.InvokedFunctionArn);
message.TryAdd(LoggingConstants.KeyFunctionRequestId, PowertoolsLambdaContext.Instance.AwsRequestId);
logEntry.TryAdd(LoggingConstants.KeyFunctionName, PowertoolsLambdaContext.Instance.FunctionName);
logEntry.TryAdd(LoggingConstants.KeyFunctionVersion, PowertoolsLambdaContext.Instance.FunctionVersion);
logEntry.TryAdd(LoggingConstants.KeyFunctionMemorySize, PowertoolsLambdaContext.Instance.MemoryLimitInMB);
logEntry.TryAdd(LoggingConstants.KeyFunctionArn, PowertoolsLambdaContext.Instance.InvokedFunctionArn);
logEntry.TryAdd(LoggingConstants.KeyFunctionRequestId, PowertoolsLambdaContext.Instance.AwsRequestId);
}

// Add Extra Fields
Expand All @@ -228,24 +251,109 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
foreach (var (key, value) in CurrentScope.ExtraKeys)
{
if (!string.IsNullOrWhiteSpace(key))
message.TryAdd(key, value);
logEntry.TryAdd(key, value);
}
}

message.TryAdd(LoggingConstants.KeyTimestamp, DateTime.UtcNow.ToString("o"));
message.TryAdd(LoggingConstants.KeyLogLevel, logLevel.ToString());
message.TryAdd(LoggingConstants.KeyService, Service);
message.TryAdd(LoggingConstants.KeyLoggerName, _name);
message.TryAdd(LoggingConstants.KeyMessage,
CustomFormatter(state, exception, out var customMessage) && customMessage is not null
? customMessage
: formatter(state, exception));
logEntry.TryAdd(LoggingConstants.KeyTimestamp, timestamp.ToString("o"));
logEntry.TryAdd(LoggingConstants.KeyLogLevel, logLevel.ToString());
logEntry.TryAdd(LoggingConstants.KeyService, Service);
logEntry.TryAdd(LoggingConstants.KeyLoggerName, _name);
logEntry.TryAdd(LoggingConstants.KeyMessage, message);

if (CurrentConfig.SamplingRate.HasValue)
message.TryAdd(LoggingConstants.KeySamplingRate, CurrentConfig.SamplingRate.Value);
logEntry.TryAdd(LoggingConstants.KeySamplingRate, CurrentConfig.SamplingRate.Value);
if (exception != null)
message.TryAdd(LoggingConstants.KeyException, exception);
logEntry.TryAdd(LoggingConstants.KeyException, exception);

_systemWrapper.LogLine(JsonSerializer.Serialize(message, JsonSerializerOptions));
return logEntry;
}

/// <summary>
/// Gets a formatted log entry.
/// </summary>
/// <param name="logLevel">Entry will be written on this level.</param>
/// <param name="timestamp">Entry timestamp.</param>
/// <param name="message">The message to be written. Can be also an object.</param>
/// <param name="exception">The exception related to this entry.</param>
/// <param name="logFormatter">The custom log entry formatter.</param>
private object GetFormattedLogEntry(LogLevel logLevel, DateTime timestamp, object message,
Exception exception, ILogFormatter logFormatter)
{
if (logFormatter is null)
return null;

var logEntry = new LogEntry
{
Timestamp = timestamp,
Level = logLevel,
Service = Service,
Name = _name,
Message = message,
Exception = exception,
SamplingRate = CurrentConfig.SamplingRate,
};

var extraKeys = new Dictionary<string, object>();

// Add Custom Keys
foreach (var (key, value) in Logger.GetAllKeys())
{
switch (key)
{
case LoggingConstants.KeyColdStart:
logEntry.ColdStart = (bool)value;
break;
case LoggingConstants.KeyXRayTraceId:
logEntry.XRayTraceId = value as string;
break;
case LoggingConstants.KeyCorrelationId:
logEntry.CorrelationId = value as string;
break;
default:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In here we are changing the premise that what is logged should be exactly what is returned from the formatter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't change the object returning from formatter and we log it as is. This is not for the object returned from the formatter, it's for creating an LogEntry to pass it to the formatter.

extraKeys.TryAdd(key, value);
break;
}
}

// Add Extra Fields
if (CurrentScope?.ExtraKeys is not null)
{
foreach (var (key, value) in CurrentScope.ExtraKeys)
{
if (!string.IsNullOrWhiteSpace(key))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't think is possible at runtime to have an empty or null key for dictionary, this could be simplified with logEntry.ExtraKeys = new Dictionary<string, string>(extraKeys);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It cannot, I can remove the null check but is's an append to the existing dictionary, so cannot be replaced.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have replaced it with a new throw new LogFormatException (custom exception)

extraKeys.TryAdd(key, value);
}
}

if (extraKeys.Any())
logEntry.ExtraKeys = extraKeys;

// Add Lambda Context Keys
if (PowertoolsLambdaContext.Instance is not null)
{
logEntry.LambdaContext = new LogEntryLambdaContext
{
FunctionName = PowertoolsLambdaContext.Instance.FunctionName,
FunctionVersion = PowertoolsLambdaContext.Instance.FunctionVersion,
MemoryLimitInMB = PowertoolsLambdaContext.Instance.MemoryLimitInMB,
InvokedFunctionArn = PowertoolsLambdaContext.Instance.InvokedFunctionArn,
AwsRequestId = PowertoolsLambdaContext.Instance.AwsRequestId,
};
}

try
{
var logObject = logFormatter.FormatLogEntry(logEntry);
if (logObject is null)
throw new LogFormatException($"{logFormatter.GetType().FullName} returned Null value.");
return logObject;
}
catch (Exception e)
{
throw new LogFormatException(
$"{logFormatter.GetType().FullName} raised an exception: {e.Message}.", e);
}
}

/// <summary>
Expand Down
92 changes: 92 additions & 0 deletions libraries/src/AWS.Lambda.Powertools.Logging/LogEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;

namespace AWS.Lambda.Powertools.Logging;

/// <summary>
/// Powertools Log Entry
/// </summary>
public class LogEntry
{
/// <summary>
/// Indicates the cold start.
/// </summary>
/// <value>The cold start value.</value>
public bool ColdStart { get; internal set; }

/// <summary>
/// Gets the X-Ray trace identifier.
/// </summary>
/// <value>The X-Ray trace identifier.</value>
public string XRayTraceId { get; internal set; }

/// <summary>
/// Gets the correlation identifier.
/// </summary>
/// <value>The correlation identifier.</value>
public string CorrelationId { get; internal set; }

/// <summary>
/// Log entry timestamp in UTC.
/// </summary>
public DateTime Timestamp { get; internal set; }

/// <summary>
/// Log entry Level is used for logging.
/// </summary>
public LogLevel Level { get; internal set; }

/// <summary>
/// Service name is used for logging.
/// </summary>
public string Service { get; internal set; }

/// <summary>
/// Logger name is used for logging.
/// </summary>
public string Name { get; internal set; }

/// <summary>
/// Log entry Level is used for logging.
/// </summary>
public object Message { get; internal set; }

/// <summary>
/// Dynamically set a percentage of logs to DEBUG level.
/// This can be also set using the environment variable <c>POWERTOOLS_LOGGER_SAMPLE_RATE</c>.
/// </summary>
/// <value>The sampling rate.</value>
public double? SamplingRate { get; internal set; }

/// <summary>
/// Gets the appended additional keys to a log entry.
/// <value>The extra keys.</value>
/// </summary>
public Dictionary<string, object> ExtraKeys { get; internal set; }

/// <summary>
/// The exception related to this entry.
/// </summary>
public Exception Exception { get; internal set; }

/// <summary>
/// The Lambda Context related to this entry.
/// </summary>
public LogEntryLambdaContext LambdaContext { get; internal set; }
}
Loading