We have a fairly simple netstandard2.0
project for custom middleware that uses Serilog's static LogContext to copy specified HttpContext headers to the log context.
I'm trying to write a unit test where I set up a logger that uses a DelegatingSink
to write to a variable. It then executes the Invoke()
middleware method. I'm then trying to use the event to assert the properties were added. So far the properties being added by the middleware aren't showing up, but the property I'm adding in the test does. I'm assuming it's dealing with different contexts, but I'm not sure how to fix this. I've tried several different things but none have worked.
Since LogContext
is static, I assumed this would be pretty straight forward, but I'm underestimating something. This is where I'm at right now (some code omitted for brevity). I did confirm the LogContext.PushProperty
line of the middleware is being hit when the rest is run.
The test:
...
[Fact]
public async Task Adds_WidgetId_To_LogContext()
{
LogEvent lastEvent = null;
var log = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Sink(new DelegatingSink(e => lastEvent = e))
.CreateLogger();
// tried with and without this, also tried the middleware class name
//.ForContext<HttpContextCorrelationHeadersLoggingMiddlewareTests>();
var context = await GetInvokedContext().ConfigureAwait(false);
LogContext.PushProperty("MyTestProperty", "my-value");
log.Information("test");
// At this point, 'lastEvent' only has the property "MyTestProperty" :(
}
private async Task<DefaultHttpContext> GetInvokedContext(bool withHeaders = true)
{
RequestDelegate next = async (innerContext) =>
await innerContext.Response
.WriteAsync("Test response.")
.ConfigureAwait(false);
var middleware = new MyCustomMiddleware(next, _options);
var context = new DefaultHttpContext();
if (withHeaders)
{
context.Request.Headers.Add(_options.WidgetIdKey, _widgetId);
}
await middleware.Invoke(context).ConfigureAwait(false);
return context;
}
The middleware (test project references this project):
...
public async Task Invoke(HttpContext context)
{
if (context == null || context.Request.Headers.Count == 0) { await _next(context).ConfigureAwait(false); }
var headers = context.Request.Headers;
foreach (var keyName in KeyNames)
{
if (headers.ContainsKey(keyName))
{
LogContext.PushProperty(keyName, headers[keyName]);
}
}
await _next(context).ConfigureAwait(false);
}
...
This is the delegating sink I stole from the Serilog test source:
public class DelegatingSink : ILogEventSink
{
readonly Action<LogEvent> _write;
public DelegatingSink(Action<LogEvent> write)
{
_write = write ?? throw new ArgumentNullException(nameof(write));
}
public void Emit(LogEvent logEvent)
{
_write(logEvent);
}
public static LogEvent GetLogEvent(Action<ILogger> writeAction)
{
LogEvent result = null;
var l = new LoggerConfiguration()
.WriteTo.Sink(new DelegatingSink(le => result = le))
.CreateLogger();
writeAction(l);
return result;
}
}
I also had to unit test the pushed properties of my logged events. Assuming you got are pushing your property as follow :
public async Task<T> FooAsync(/*...*/)
{
/*...*/
using (LogContext.PushProperty("foo", "bar"))
{
Log.Information("foobar");
}
/*...*/
}
You can unit test it like this example with Serilog.Sinks.TestCorrelator as a Serilog sink dedicated to tests (also I'm using NUnit and FluentAssertion too here):
[Test]
public async Task Should_assert_something()
{
///Arrange
// I had issues with unit test seeing log events from other tests running at the same time so I recreate context in each test now
using (TestCorrelator.CreateContext())
using (var logger = new LoggerConfiguration().WriteTo.Sink(new TestCorrelatorSink()).Enrich.FromLogContext().CreateLogger())
{
Log.Logger = logger;
/*...*/
/// Act
var xyz = await FooAsync(/*...*/)
/*...*/
/// Assert
TestCorrelator.GetLogEventsFromCurrentContext().Should().ContainSingle().Which.MessageTemplate.Text.Should().Be("foobar");
}
}
I think your unit test is catching a real bug with the code here.
Serilog's LogContext
applies state to the "logical call context" which follows the ExecutionContext (see a great write-up here).
The counterintuitive result you're seeing here is due to the fact that the "state" applied to the logical call context only applies to context in which the LogContext.PushProperty
call is made. Outer contexts are inherited by inner contexts, but changes within inner contexts do not affect the outer contexts. Your async methods are creating additional contexts (unbeknownst to you), and when you return to your original context the changes made in the inner contexts are lost.
It might be more clear if you look at a simpler example that demonstrates the same problem without having to worry about the async/await task continuation stuff.
void ContextExample()
{
LogContext.PushProperty("MyOuterProperty", "Foo"); // Apply this property to all log events *within this logical call context*
await Task.Run(() =>
{
LogContext.PushProperty("MyInnerProperty", "Bar"); // Apply this property to all log events *within this logical call context*
log.Information("MyFirstLog"); // This log event will contain both MyOuterProperty and MyInnerProperty
}); // We leave the inner call context, destroying the changes we made to it with PushProperty
log.Information("MySecondLog"); // This log event will contain only MyOuterProperty
}
To get what you want, you're going to have to push the property in the same (or an outer) logical call context as the logical call context in which you call log.Information
.
Also, you probably want to call Dispose
on the return values of PushProperty
. It returns an IDisposable
so that you can revert the logical call context back to its original state. You may see some strange behavior if you don't.
P.S. If you want to test log events produced by your code, might I suggest the TestCorrelator sink.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With