Understanding log scopes
How did they do that?
Published in Articles on by Michiel van Oosterhout ~ 6 min read
For some time now Microsoft has offered an extension library for structured logging for .NET, and it has a feature known as 'log scopes'. According to the documentation:
A scope groups a set of logical operations. This grouping can be used to attach the same data to each log that's created as part of a set. For example, every log created as part of processing a transaction can include the transaction ID.
The 'magic' of a log scope is that it can be created in one method, and it then automagically applies to log events created in methods called within the scope, and any methods called from those methods, and so on, until the scope is disposed.
In the example below, any log event created in the handler
object's Invoke
method will have the value of transactionId
'attached' to it. This manifests as an item in an array of objects, the array being the value of a property added to the log event called Scope
.
using (_logger.BeginScope(transactionId))
{
await handler.Invoke(request);
}
However, the scope itself cannot be accessed. The handler
object's Invoke
method cannot discover what is in the current scope.
Understanding current scope
There are three aspects to log scopes that we should understand:
- the proper mental model;
- how they are applied to log events;
- and how they are stored.
Let's start with the first.
Current scope as a trail of breadcrumbs
The ILogger
interface declares the IDisposable BeginScope<TState>(TState state)
method. You need to create a using
block to ensure the scope gets properly disposed of. When this method is called again inside the using
block, the current scope is extended. You can think of this as a trail of breadcrumbs. Perhaps you first add the customer number, then the order number, and finally the product ID. In this example, log events can have 1, 2 or 3 values applied to them, depending on the level of nesting at which they were created.
using (_logger.BeginScope(customerNumber))
{
using (_logger.BeginScope(orderNumber))
{
// etc.
}
}
However, this is not 100% true. Depending on the type of state
, the semantics may differ. This becomes important when applying the current scope to a log event.
Applying the current scope to a log event
When a log event is created, the logger can inspect the current scope, and apply it to the log event in any way it sees fit.
The ILogger
interface declares the BeginScope<TState>(TState state)
method. Since it is generic, any type of state can be added to the current scope. In his article, The semantics of ILogger.BeginScope()
, Nicholas Blumhardt, author of Serilog, describes how state is interpreted depending on its type.
- If
TState
isIEnumerable<KeyValuePair<string, object>>
, then each item is attached to the log event as a property, using the key as its name and the value as its value. - Otherwise, the object (for example, the transaction ID, or the customer number) is simply added to the
Scope
property (the array of objects mentioned earlier). - There is also an extension method on
ILogger<T>
,BeginScope(string messageFormat, params object?[] args)
, which parsesmessageFormat
, finds placeholders for eacharg
inargs
, and builds anIEnumerable<KeyValuePair<string, object>>
using that information.messageFormat
itself is added to that using the key{OriginalFormat}
(beware, that is an implementation detail, and could change without notice). The items are attached to the log event (as in 1), and the formatted message is added to theScope
property (as in 2).
Storing current scope
For storing the current scope, the logging library provides a default implementation of IExternalScopeProvider
. When your logger provider implements ISupportExternalScope
, then this default external scope provider will be assigned to it. All the logger need to do is 'forward' state
to the provider, and it will handle the rest.
When it is time to create a log event, you can ask the external scope provider for the scope using its ForEachScope
method, which accepts a callback.
The default external scope provider is LoggerExternalScopeProvider
, and its implementation is where we can find the 'magic' that makes log scopes work.
The default external scope provider has a Scope
class that acts as a singly linked list, every state object that it is given results in a new Scope
object, whose Parent
property points to the previous Scope
object. When this provider's ForEachScope
method is called, it simply uses the current Scope
object, then its parent, then its parent's parent and so on, until it reaches the first Scope
object, which has no parent.
The Scope
object is what implements IDisposable
, and when it gets disposed, it simply changes the provider's current scope to point to the scope's parent, thereby removing itself from the linked list.
The default external scope provider uses a private readonly AsyncLocal<Scope?>
field to maintain
the linked list of scopes. This ensures the current scope is maintained after any continuations of an async
method.
Testing BeginScope
If you want to test if your code adds to the current scope correctly, you should use Microsoft's FakeLoggerProvider
(in Microsoft.Extensions.Diagnostics.Testing), and set its external scope provider to the default LoggerExternalScopeProvider
:
var fakeLoggerProvider = new FakeLoggerProvider();
fakeLoggerProvider.SetScopeProvider(new LoggerExternalScopeProvider());
hostBuilder.ConfigureLogging(loggingBuilder =>
{
loggingBuilder.AddProvider(loggerProvider);
})
Now any ILogger<T>
will be a FakeLogger<T>
, and they will share the same LoggerExternalScopeProvider
. You should have a consistent scope shared between multiple loggers that are active within the boundary of your test.
Summary
Log scope is stored by default as a linked list of Scope
objects in an AsyncLocal<T>
field. Logger providers can simply accept an external scope provider and delegate the storage of scope objects to the provider. Applying scope as a trail of breadcrumbs and/or a list of additional properties attached to each log event, is up to the logger implementation, but that is how Microsoft did it, so it's likely 3rd-party logger providers do the same.