Skip to content

Commit 9b17227

Browse files
authored
Merge pull request #375 from amirkaws/custom-log-formatter
feat: Add bring your own log formatter to logger
2 parents 8ff0dd3 + 292757f commit 9b17227

File tree

9 files changed

+734
-31
lines changed

9 files changed

+734
-31
lines changed

docs/core/logging.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,3 +515,95 @@ Below are some output examples for different casing.
515515
"function_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72"
516516
}
517517
```
518+
519+
## Custom Log formatter (Bring Your Own Formatter)
520+
521+
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.
522+
523+
=== "Function.cs"
524+
525+
```c# hl_lines="11"
526+
/**
527+
* Handler for requests to Lambda function.
528+
*/
529+
public class Function
530+
{
531+
/// <summary>
532+
/// Function constructor
533+
/// </summary>
534+
public Function()
535+
{
536+
Logger.UseFormatter(new CustomLogFormatter());
537+
}
538+
539+
[Logging(CorrelationIdPath = "/headers/my_request_id_header", SamplingRate = 0.7)]
540+
public async Task<APIGatewayProxyResponse> FunctionHandler
541+
(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context)
542+
{
543+
...
544+
}
545+
}
546+
```
547+
=== "CustomLogFormatter.cs"
548+
549+
```c#
550+
public class CustomLogFormatter : ILogFormatter
551+
{
552+
public object FormatLogEntry(LogEntry logEntry)
553+
{
554+
return new
555+
{
556+
Message = logEntry.Message,
557+
Service = logEntry.Service,
558+
CorrelationIds = new
559+
{
560+
AwsRequestId = logEntry.LambdaContext?.AwsRequestId,
561+
XRayTraceId = logEntry.XRayTraceId,
562+
CorrelationId = logEntry.CorrelationId
563+
},
564+
LambdaFunction = new
565+
{
566+
Name = logEntry.LambdaContext?.FunctionName,
567+
Arn = logEntry.LambdaContext?.InvokedFunctionArn,
568+
MemoryLimitInMB = logEntry.LambdaContext?.MemoryLimitInMB,
569+
Version = logEntry.LambdaContext?.FunctionVersion,
570+
ColdStart = logEntry.ColdStart,
571+
},
572+
Level = logEntry.Level.ToString(),
573+
Timestamp = logEntry.Timestamp.ToString("o"),
574+
Logger = new
575+
{
576+
Name = logEntry.Name,
577+
SampleRate = logEntry.SamplingRate
578+
},
579+
};
580+
}
581+
}
582+
```
583+
584+
=== "Example CloudWatch Logs excerpt"
585+
586+
```json
587+
{
588+
"Message": "Test Message",
589+
"Service": "lambda-example",
590+
"CorrelationIds": {
591+
"AwsRequestId": "52fdfc07-2182-154f-163f-5f0f9a621d72",
592+
"XRayTraceId": "1-61b7add4-66532bb81441e1b060389429",
593+
"CorrelationId": "correlation_id_value"
594+
},
595+
"LambdaFunction": {
596+
"Name": "test",
597+
"Arn": "arn:aws:lambda:eu-west-1:12345678910:function:test",
598+
"MemorySize": 128,
599+
"Version": "$LATEST",
600+
"ColdStart": true
601+
},
602+
"Level": "Information",
603+
"Timestamp": "2021-12-13T20:32:22.5774262Z",
604+
"Logger": {
605+
"Name": "AWS.Lambda.Powertools.Logging.Logger",
606+
"SampleRate": 0.7
607+
}
608+
}
609+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
namespace AWS.Lambda.Powertools.Logging;
17+
18+
/// <summary>
19+
/// Represents a type used to format Powertools log entries.
20+
/// </summary>
21+
public interface ILogFormatter
22+
{
23+
/// <summary>
24+
/// Formats a log entry
25+
/// </summary>
26+
/// <param name="logEntry">The log entry.</param>
27+
/// <returns>Formatted log entry as object.</returns>
28+
object FormatLogEntry(LogEntry logEntry);
29+
}

libraries/src/AWS.Lambda.Powertools.Logging/Internal/PowertoolsLogger.cs

Lines changed: 127 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -206,20 +206,43 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
206206
if (!IsEnabled(logLevel))
207207
return;
208208

209-
var message = new Dictionary<string, object>(StringComparer.Ordinal);
209+
var timestamp = DateTime.UtcNow;
210+
var message = CustomFormatter(state, exception, out var customMessage) && customMessage is not null
211+
? customMessage
212+
: formatter(state, exception);
213+
214+
var logFormatter = Logger.GetFormatter();
215+
var logEntry = logFormatter is null?
216+
GetLogEntry(logLevel, timestamp, message, exception) :
217+
GetFormattedLogEntry(logLevel, timestamp, message, exception, logFormatter);
218+
219+
_systemWrapper.LogLine(JsonSerializer.Serialize(logEntry, JsonSerializerOptions));
220+
}
221+
222+
/// <summary>
223+
/// Gets a log entry.
224+
/// </summary>
225+
/// <param name="logLevel">Entry will be written on this level.</param>
226+
/// <param name="timestamp">Entry timestamp.</param>
227+
/// <param name="message">The message to be written. Can be also an object.</param>
228+
/// <param name="exception">The exception related to this entry.</param>
229+
private Dictionary<string, object> GetLogEntry(LogLevel logLevel, DateTime timestamp, object message,
230+
Exception exception)
231+
{
232+
var logEntry = new Dictionary<string, object>(StringComparer.Ordinal);
210233

211234
// Add Custom Keys
212235
foreach (var (key, value) in Logger.GetAllKeys())
213-
message.TryAdd(key, value);
236+
logEntry.TryAdd(key, value);
214237

215238
// Add Lambda Context Keys
216239
if (PowertoolsLambdaContext.Instance is not null)
217240
{
218-
message.TryAdd(LoggingConstants.KeyFunctionName, PowertoolsLambdaContext.Instance.FunctionName);
219-
message.TryAdd(LoggingConstants.KeyFunctionVersion, PowertoolsLambdaContext.Instance.FunctionVersion);
220-
message.TryAdd(LoggingConstants.KeyFunctionMemorySize, PowertoolsLambdaContext.Instance.MemoryLimitInMB);
221-
message.TryAdd(LoggingConstants.KeyFunctionArn, PowertoolsLambdaContext.Instance.InvokedFunctionArn);
222-
message.TryAdd(LoggingConstants.KeyFunctionRequestId, PowertoolsLambdaContext.Instance.AwsRequestId);
241+
logEntry.TryAdd(LoggingConstants.KeyFunctionName, PowertoolsLambdaContext.Instance.FunctionName);
242+
logEntry.TryAdd(LoggingConstants.KeyFunctionVersion, PowertoolsLambdaContext.Instance.FunctionVersion);
243+
logEntry.TryAdd(LoggingConstants.KeyFunctionMemorySize, PowertoolsLambdaContext.Instance.MemoryLimitInMB);
244+
logEntry.TryAdd(LoggingConstants.KeyFunctionArn, PowertoolsLambdaContext.Instance.InvokedFunctionArn);
245+
logEntry.TryAdd(LoggingConstants.KeyFunctionRequestId, PowertoolsLambdaContext.Instance.AwsRequestId);
223246
}
224247

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

235-
message.TryAdd(LoggingConstants.KeyTimestamp, DateTime.UtcNow.ToString("o"));
236-
message.TryAdd(LoggingConstants.KeyLogLevel, logLevel.ToString());
237-
message.TryAdd(LoggingConstants.KeyService, Service);
238-
message.TryAdd(LoggingConstants.KeyLoggerName, _name);
239-
message.TryAdd(LoggingConstants.KeyMessage,
240-
CustomFormatter(state, exception, out var customMessage) && customMessage is not null
241-
? customMessage
242-
: formatter(state, exception));
258+
logEntry.TryAdd(LoggingConstants.KeyTimestamp, timestamp.ToString("o"));
259+
logEntry.TryAdd(LoggingConstants.KeyLogLevel, logLevel.ToString());
260+
logEntry.TryAdd(LoggingConstants.KeyService, Service);
261+
logEntry.TryAdd(LoggingConstants.KeyLoggerName, _name);
262+
logEntry.TryAdd(LoggingConstants.KeyMessage, message);
263+
243264
if (CurrentConfig.SamplingRate.HasValue)
244-
message.TryAdd(LoggingConstants.KeySamplingRate, CurrentConfig.SamplingRate.Value);
265+
logEntry.TryAdd(LoggingConstants.KeySamplingRate, CurrentConfig.SamplingRate.Value);
245266
if (exception != null)
246-
message.TryAdd(LoggingConstants.KeyException, exception);
267+
logEntry.TryAdd(LoggingConstants.KeyException, exception);
247268

248-
_systemWrapper.LogLine(JsonSerializer.Serialize(message, JsonSerializerOptions));
269+
return logEntry;
270+
}
271+
272+
/// <summary>
273+
/// Gets a formatted log entry.
274+
/// </summary>
275+
/// <param name="logLevel">Entry will be written on this level.</param>
276+
/// <param name="timestamp">Entry timestamp.</param>
277+
/// <param name="message">The message to be written. Can be also an object.</param>
278+
/// <param name="exception">The exception related to this entry.</param>
279+
/// <param name="logFormatter">The custom log entry formatter.</param>
280+
private object GetFormattedLogEntry(LogLevel logLevel, DateTime timestamp, object message,
281+
Exception exception, ILogFormatter logFormatter)
282+
{
283+
if (logFormatter is null)
284+
return null;
285+
286+
var logEntry = new LogEntry
287+
{
288+
Timestamp = timestamp,
289+
Level = logLevel,
290+
Service = Service,
291+
Name = _name,
292+
Message = message,
293+
Exception = exception,
294+
SamplingRate = CurrentConfig.SamplingRate,
295+
};
296+
297+
var extraKeys = new Dictionary<string, object>();
298+
299+
// Add Custom Keys
300+
foreach (var (key, value) in Logger.GetAllKeys())
301+
{
302+
switch (key)
303+
{
304+
case LoggingConstants.KeyColdStart:
305+
logEntry.ColdStart = (bool)value;
306+
break;
307+
case LoggingConstants.KeyXRayTraceId:
308+
logEntry.XRayTraceId = value as string;
309+
break;
310+
case LoggingConstants.KeyCorrelationId:
311+
logEntry.CorrelationId = value as string;
312+
break;
313+
default:
314+
extraKeys.TryAdd(key, value);
315+
break;
316+
}
317+
}
318+
319+
// Add Extra Fields
320+
if (CurrentScope?.ExtraKeys is not null)
321+
{
322+
foreach (var (key, value) in CurrentScope.ExtraKeys)
323+
{
324+
if (!string.IsNullOrWhiteSpace(key))
325+
extraKeys.TryAdd(key, value);
326+
}
327+
}
328+
329+
if (extraKeys.Any())
330+
logEntry.ExtraKeys = extraKeys;
331+
332+
// Add Lambda Context Keys
333+
if (PowertoolsLambdaContext.Instance is not null)
334+
{
335+
logEntry.LambdaContext = new LogEntryLambdaContext
336+
{
337+
FunctionName = PowertoolsLambdaContext.Instance.FunctionName,
338+
FunctionVersion = PowertoolsLambdaContext.Instance.FunctionVersion,
339+
MemoryLimitInMB = PowertoolsLambdaContext.Instance.MemoryLimitInMB,
340+
InvokedFunctionArn = PowertoolsLambdaContext.Instance.InvokedFunctionArn,
341+
AwsRequestId = PowertoolsLambdaContext.Instance.AwsRequestId,
342+
};
343+
}
344+
345+
try
346+
{
347+
var logObject = logFormatter.FormatLogEntry(logEntry);
348+
if (logObject is null)
349+
throw new LogFormatException($"{logFormatter.GetType().FullName} returned Null value.");
350+
return logObject;
351+
}
352+
catch (Exception e)
353+
{
354+
throw new LogFormatException(
355+
$"{logFormatter.GetType().FullName} raised an exception: {e.Message}.", e);
356+
}
249357
}
250358

251359
/// <summary>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
using System;
17+
using System.Collections.Generic;
18+
using Microsoft.Extensions.Logging;
19+
20+
namespace AWS.Lambda.Powertools.Logging;
21+
22+
/// <summary>
23+
/// Powertools Log Entry
24+
/// </summary>
25+
public class LogEntry
26+
{
27+
/// <summary>
28+
/// Indicates the cold start.
29+
/// </summary>
30+
/// <value>The cold start value.</value>
31+
public bool ColdStart { get; internal set; }
32+
33+
/// <summary>
34+
/// Gets the X-Ray trace identifier.
35+
/// </summary>
36+
/// <value>The X-Ray trace identifier.</value>
37+
public string XRayTraceId { get; internal set; }
38+
39+
/// <summary>
40+
/// Gets the correlation identifier.
41+
/// </summary>
42+
/// <value>The correlation identifier.</value>
43+
public string CorrelationId { get; internal set; }
44+
45+
/// <summary>
46+
/// Log entry timestamp in UTC.
47+
/// </summary>
48+
public DateTime Timestamp { get; internal set; }
49+
50+
/// <summary>
51+
/// Log entry Level is used for logging.
52+
/// </summary>
53+
public LogLevel Level { get; internal set; }
54+
55+
/// <summary>
56+
/// Service name is used for logging.
57+
/// </summary>
58+
public string Service { get; internal set; }
59+
60+
/// <summary>
61+
/// Logger name is used for logging.
62+
/// </summary>
63+
public string Name { get; internal set; }
64+
65+
/// <summary>
66+
/// Log entry Level is used for logging.
67+
/// </summary>
68+
public object Message { get; internal set; }
69+
70+
/// <summary>
71+
/// Dynamically set a percentage of logs to DEBUG level.
72+
/// This can be also set using the environment variable <c>POWERTOOLS_LOGGER_SAMPLE_RATE</c>.
73+
/// </summary>
74+
/// <value>The sampling rate.</value>
75+
public double? SamplingRate { get; internal set; }
76+
77+
/// <summary>
78+
/// Gets the appended additional keys to a log entry.
79+
/// <value>The extra keys.</value>
80+
/// </summary>
81+
public Dictionary<string, object> ExtraKeys { get; internal set; }
82+
83+
/// <summary>
84+
/// The exception related to this entry.
85+
/// </summary>
86+
public Exception Exception { get; internal set; }
87+
88+
/// <summary>
89+
/// The Lambda Context related to this entry.
90+
/// </summary>
91+
public LogEntryLambdaContext LambdaContext { get; internal set; }
92+
}

0 commit comments

Comments
 (0)