Structured logging is gaining more and more popularity in the developers' community. So it makes no surprise that Microsoft has added support for it to the Microsoft.Extensions.Logging package being the part of .Net Core/.Net 5/.Net 6. In this article I'd like to demonstrate how we can use structured logging with this package and show the idea how we can extend it using the new features of C# 10.
Initial Setup
Microsoft.Extensions.Logging is known to be a façade supporting different pluggable underlying logging providers. In this article I will use the Serilog provider and will set it up to output logs to the console in JSON format.
I will use simple console application as an example (GitHub Link). Logging configuration looks the following way:
// Serilog configuration
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console(new CompactJsonFormatter())
.CreateLogger();
// Register Serilog while creating Microsoft.Extensions.Logging.LoggerFactory
using var loggerFactory = LoggerFactory.Create(
builder => builder.AddSerilog(dispose: true));
// Create an instance of ILogger using the factory
var logger = loggerFactory.CreateLogger();
Let's also create two records which will be used in the further examples:
public record Point(double X, double Y);
public record Segment(Point Start, Point End)
{
public double GetLength()
{
var dx = Start.X - End.X;
var dy = Start.Y - End.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
Using ILogger
We will use the ILogger
interface for logging. This interface provides several overloads of the Log
method which take the log level as a parameter. It also provides overloads of methods Log<Level>
, e.g. LogInformation
, LogError
, etc. All these method take the message template string and the array of arguments (params object[]
) as their parameters.
For example, let's log the result of a segment length calculation:
var segment = new Segment(new (1, 2), new (4, 6));
var length = segment.GetLength();
logger.LogInformation(
"The length of segment {@segment} is {length}.", segment, length);
The template string contains two arguments enclosed in braces: segment
and length
. Their values are passed to the method as the following parameters. The @
symbol before the segment
argument is a part of the Message Template specification which means that Serilog must expand the argument's structure as an object. Without this symbol Serilog would just log the result of segment.ToString()
.
As the result we will get the following log item:
{
"@t": "2021-11-13T19:57:31.8636016Z",
"@mt": "The length of segment {@segment} is {length}.",
"segment": {
"Start": {
"X": 1,
"Y": 2,
"$type": "Point"
},
"End": {
"X": 4,
"Y": 6,
"$type": "Point"
},
"$type": "Segment"
},
"length": 5,
"SourceContext": "Program"
}
The resulting JSON object has a property @mt
containing the template string, and properties segment
and length
containing the arguments.
While this approach is quite easy and straightforward, it has some significant disadvantages:
It is necessary to parse the template string at runtime, but it's a quite expensive operation. The logging infrastructure uses an internal cache of 1024 string to make this issue less significant.
The memory is allocated for the arguments array even if the specified log level is disabled.
Arguments of value types are boxed when being placed into the object array.
Names of the variables are duplicated in the template string and in the method parameters.
It is easy to forget some argument or use a wrong argument order.
.Net 6 provides a new source code generator as a better alternative without these disadvantages. We can declare a partial method taking the required parameters and mark it with the LoggerMessageAttribute
as follows:
[LoggerMessage(0, LogLevel.Information, "The length of segment {segment} is {length}.")]
public static partial void LogSegmentLength(this ILogger logger, Segment segment, double length);
This approach provides the best logging performance, however it also has its own disadvantages:
It clutters up the source code with the partial method declarations.
It does not allow to use the
@
symbol before the argument names, preventing Serilog from expanding them into JSON objects.
Adding Support for Interpolated Strings to ILogger
So, we would like to find an expressive way to write structured logs without the mentioned disadvantages. String interpolation could be a convenient way to achieve it, but it will require some additional work from us.
If we try to call a usual logging method with an interpolated string, e.g.:
logger.LogInformation($"The length of segment {segment} is {length}.");
we will get the following log record:
{
"@t": "2021-11-13T23:40:23.1323976Z",
"@mt": "The length of segment Segment { Start = Point { X = 1, Y = 2 }, End = Point { X = 4, Y = 6 } } is 5.",
"SourceContext": "Program"
}
As expected, we have lost information about the arguments because C# compiler has converted our interpolated string into a simple call to String.Format
.
Generally speaking, until C# 10 the compiler could transform the interpolated string to either a FormattableString
instance or a simple string. FormattableString
objects look almost like the thing we need to solve our problem, as they contain the format string and the argument array. Unfortunately, they still do not contain the argument names. Moreover, when the compiler chooses the method overload, it prefers one taking a string, that makes it no so convenient to use.
Luckily, C# 10 provides a new mechanism for the interpolated strings which allows us to implement the solution. We can create a special struct that acts as an interpolated string handler and provides the required functionality especially for our use case:
[InterpolatedStringHandler]
public ref struct StructuredLoggingInterpolatedStringHandler
{
// The actual code will be shown below
}
Next, we can use it as a method argument. For example, let's create a Log
extension method:
public static partial class LoggerExtensions
{
public static void Log(this ILogger logger, LogLevel logLevel,
[InterpolatedStringHandlerArgument("logger", "logLevel")] ref StructuredLoggingInterpolatedStringHandler handler)
{
// The actual code will be shown below
}
}
If we try to pass an interpolated string to our Log
method now:
logger.Log(LogLevel.Information, $"The length of segment {segment} is {length}.");
the compiler will transform it to the following code:
var handler = new StructuredLoggingInterpolatedStringHandler(
27, 2, logger, LogLevel.Information, out bool isEnabled);
if (isEnabled)
{
handler.AppendLiteral("The length of segment ");
handler.AppendFormatted(segment);
handler.AppendLiteral(" is ");
handler.AppendFormatted(length);
handler.AppendLiteral(".");
}
logger.Log(LogLevel.Information, ref handler);
We can see that the compiler uses calls to the AppendLiteral
method in order to add literal parts of the string and calls to the AppendFormatted
method(s) for the arguments. Thus, we can write our handler in a way that it creates a template string to be passed into the original ILogger.Log
, as well as the argument array.
The next question is how we can get the names of the arguments. Fortunately, C# 10 provides another feature that can help us, namely CallerArgumentExpressionAttribute
. Like with the other Caller*
-attributes, if we mark an optional string parameter of the AppendFormatted
method with this attribute, the compiler will pass the C# expression used for the other method's parameter as this parameter's value.
So, let's put everything together and write the code for our handler.
First, we'll declare fields for the message template and its arguments, as well as a property indicating whether the required log level is enabled:
[InterpolatedStringHandler]
public ref struct StructuredLoggingInterpolatedStringHandler
{
private readonly StringBuilder _template = null!;
private readonly List<object?> _arguments = null!;
public bool IsEnabled { get; }
// ...
}
Next, we must create a constructor taking the length of the string literal, the number of arguments, the instance of ILogger
, the log level and the output parameter isEnabled
:
public StructuredLoggingInterpolatedStringHandler(
int literalLength,
int formattedCount,
ILogger logger,
LogLevel logLevel,
out bool isEnabled)
{
IsEnabled = isEnabled = logger.IsEnabled(logLevel);
if (isEnabled)
{
_builder = new StringBuilder(literalLength);
_arguments = new List<object?>(formattedCount);
}
}
Note that we create StringBuilder
and List<object?>
only if the log level passed to the method is enabled.
Note
The source code in GitHub repository uses an optimized private collection instead of List<object?>.
You might wonder how the compiler knows where it can get the ILogger
and the log level from. If you look at the declaration of the Log
method above, you can see that the handler parameter is marked with the [InterpolatedStringHandlerArgument("logger", "logLevel")]
attribute with two arguments. The arguments specify the names of the method parameters containing these values.
Next, we will add the AppendLiteral
method:
public void AppendLiteral(string s)
{
if (!IsEnabled)
return;
_template.Append(s.Replace("{", "{{", StringComparison.Ordinal).Replace("}", "}}", StringComparison.Ordinal));
}
I used two calls to String.Replace
in order to escape the braces in this method. For sure, we could implement this escaping in a more optimal way, but let's leave it as it is in this article for simplicity.
Next, we will add the AppendFormatted
method:
public void AppendFormatted<T>(
T value,
[CallerArgumentExpression("value")] string name = "")
{
if (!IsEnabled)
return;
_arguments.Add(value);
_template.Append($"{{@{name}}}");
}
As I mentioned before, it contains the optional name
parameter marked with the [CallerArgumentExpression("value")]
attribute. The compiler will pass the string representation of the C# expression used for the value
parameter as the value of this parameter (a variable name in the simplest case).
Note that I always add the @
symbol before the argument name. As far as I can see, Serilog handles it well even for primitive types. However, if it didn't, we could always create separate overloads for such types.
StringBuilder.Append
By the way, this method uses a new overload StringBuilder.Append(ref System.Text.StringBuilder.AppendInterpolatedStringHandler handler)
. This overload also uses the new string interpolation mechanism.
At last, we will add a method that returns the built message template and the argument array:
public (string, object?[]) GetTemplateAndArguments() => (_template.ToString(), _arguments.ToArray());
Our handler is ready! You can find its full source code under the spoiler:
Handler source code
[InterpolatedStringHandler]
public ref struct StructuredLoggingInterpolatedStringHandler
{
private readonly StringBuilder _template = null!;
private readonly List<object?> _arguments = null!;
public bool IsEnabled { get; }
public StructuredLoggingInterpolatedStringHandler(int literalLength, int formattedCount, ILogger logger, LogLevel logLevel, out bool isEnabled)
{
IsEnabled = isEnabled = logger.IsEnabled(logLevel);
if (isEnabled)
{
_template = new (literalLength);
_arguments = new (formattedCount);
}
}
public void AppendLiteral(string s)
{
if (!IsEnabled)
return;
_template.Append(s.Replace("{", "{{", StringComparison.Ordinal).Replace("}", "}}", StringComparison.Ordinal));
}
public void AppendFormatted<T>(
T value,
[CallerArgumentExpression("value")] string name = "")
{
if (!IsEnabled)
return;
_arguments.Add(value);
_template.Append($"{{@{name}}}");
}
public (string, object?[]) GetTemplateAndArguments() => (_template.ToString(), _arguments.ToArray());
}
Our Log
method can use it in the following way:
public static partial class LoggerExtensions
{
public static void Log(this ILogger logger, LogLevel logLevel,
[InterpolatedStringHandlerArgument("logger", "logLevel")] ref StructuredLoggingInterpolatedStringHandler handler)
{
if (handler.IsEnabled)
{
var (template, arguments) = handler.GetTemplateAndArguments();
logger.Log(logLevel, template, arguments);
}
}
}
Now we can use it to perform logging:
logger.Log(LogLevel.Information, $"The length of segment {segment} is {length}.");
We will get a correct log record as the result:
{
"@t": "2021-11-14T02:13:34.8380946Z",
"@mt": "The length of segment {@segment} is {@length}.",
"segment": {
"Start": {
"X": 1,
"Y": 2,
"$type": "Point"
},
"End": {
"X": 4,
"Y": 6,
"$type": "Point"
},
"$type": "Segment"
},
"length": 5,
"SourceContext": "Program"
}
By the way, note that the method overload taking the handler has a priority over the one taking a string. In my humble opinion, this behavior is much more correct than the one we have had with FormattableString
overloads since C# 6.
So, now we have an extension method letting us write to the logs with the log level specified in one of the parameters. We would also like to have methods specific for each log level like LogError
. We can create them in the same way, however our original handler requires the log level passed as one of the parameters. Thus, we will have to create a separate handler for each log level. Luckily, we can use composition and reuse our StructuredLoggingInterpolatedStringHandler
inside these handlers, e.g.:
Handler source code
[InterpolatedStringHandler]
public ref struct StructuredLoggingErrorInterpolatedStringHandler
{
private readonly StructuredLoggingInterpolatedStringHandler _handler;
public StructuredLoggingErrorInterpolatedStringHandler(int literalLength, int formattedCount, ILogger logger, out bool isEnabled)
{
_handler = new StructuredLoggingInterpolatedStringHandler(literalLength, formattedCount, logger, LogLevel.Error, out isEnabled);
}
public bool IsEnabled => _handler.IsEnabled;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AppendLiteral(string s) => _handler.AppendLiteral(s);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AppendFormatted<T>(T value, [CallerArgumentExpression("value")] string name = "") => _handler.AppendFormatted(value, name);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public (string, object?[]) GetTemplateAndArguments() => _handler.GetTemplateAndArguments();
}
Method source code
public static void LogError(this ILogger logger, [InterpolatedStringHandlerArgument("logger")] ref StructuredLoggingErrorInterpolatedStringHandler handler)
{
if (handler.IsEnabled)
{
var (template, arguments) = handler.GetTemplateAndArguments();
logger.LogError(template, arguments);
}
}
The full source code of the demo project (with some improvements) can be found at GitHub.
Performance
I used BenchmarkDotNet to measure the performance. The result for logging to Serilog with an empty logger are the following:
Method | Is Enabled | Mean | Error | Std Dev | Ratio | Allocated |
---|---|---|---|---|---|---|
Template and Args | False | 94.43 ns | 0.215 ns | 0.191 ns | 1.00 | 64 B |
Interpolated Handler | False | 14.65 ns | 0.059 ns | 0.055 ns | 0.16 | - |
Logger Message Delegate | False | 14.97 ns | 0.086 ns | 0.080 ns | 0.16 | - |
Formattable String | False | 31.72 ns | 0.314 ns | 0.294 ns | 0.34 | 96 B |
Formattable String NP | False | 40.53 ns | 0.277 ns | 0.246 ns | 0.43 | 160 B |
Formattable String Anonymous | False | 34.67 ns | 0.286 ns | 0.268 ns | 0.37 | 120 B |
Template and Args | True | 4,048.28 ns | 11.917 ns | 11.147 ns | 1.00 | 3,024 B |
Interpolated Handler | True | 4,316.35 ns | 16.610 ns | 15.537 ns | 1.07 | 3,400 B |
Logger Message Delegate | True | 3,977.99 ns | 11.557 ns | 10.811 ns | 0.98 | 2,984 B |
Formattable String | True | 7,239.94 ns | 19.100 ns | 17.866 ns | 1.79 | 7,673 B |
Formattable String NP | True | 5,828.89 ns | 23.061 ns | 20.443 ns | 1.44 | 5,672 B |
Formattable String Anonymous | True | 6,402.80 ns | 22.911 ns | 21.431 ns | 1.58 | 5,832 B |
In this table:
Template and Args - a usual call of
LogInformation(string, object[])
.Interpolated Handler - our code with interpolated string handler.
Logger Message Delegate - cached delegate created by
LoggerMessage.Define
. This approach is also used by the source generator.Formattable String * - different approaches from the https://github.com/Drizin/InterpolatedLogging library.
We can see, that if the log level is disabled, our approach is faster and allocates no wasted memory.
If the log level is enabled, our approach is approximately 7-10% slower than using a raw template or a cached delegate, however it still performs much better than https://github.com/Drizin/InterpolatedLogging.
Conclusion
Using C# 10, we were able to add the structured logging support with string interpolation and get rid of the issues with the argument duplication, wrong argument number or order as well as wasting memory when the log level is disabled.
The source code still has some issues like missing escaping of names (as Serilog requires the names to be valid C# identifiers) or suboptimal implementation in some places. You can find a production ready solution in my library called ISLE.