Request ID middleware in ASP.NET Core

February 2nd 2024 Logging .NET Azure

In a previous post, I wrote about the built-in log correlation features for ASP.NET Core when logging to Application Insights. But what if you want to log elsewhere or want more control over the correlation ID? A custom middleware could be all you need.

Essentially, the middleware only needs to create a logging scope around the remaining request pipeline:

public class RequestIdMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(
        HttpContext context,
        ILogger<RequestIdMiddleware> logger
    )
    {
        var requestId = Guid.NewGuid().ToString();
        using (logger.BeginScope(new Dictionary<string, object> {
            ["X-Request-ID"] = requestId
        }))
        {
            await next(context);
        }
    }
}

The specified scope will automatically be included with all log entries emitted from inside the scope.

To make the middleware easier to use, you might consider creating an extension method:

public static class RequestIdMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestId(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestIdMiddleware>();
    }
}

To enable the middleware, you need to call the extension method on your WebApplication:

var app = builder.Build();
app.UseRequestId();

Middleware is being executed in the order of registration, so you'll want to do it as early as possible, since only logging calls that happen afterward will have the scope included.

Unfortunately, you still need to pay attention to how logging scopes are supported by the logging provider you are using.

Application Insights, for example, requires the scope to be defined as a collection of string-object pairs in order for it to be included in custom dimensions. This approach is also used in the code snippet above. The scopes are being logged by default. This allows you to query the logs by the scope value:

traces
| where customDimensions.["X-Request-ID"] == "62cbb46e-3c34-4b5d-8a6e-151000896fb8"

Console logger on the other hand has scopes disabled by default, so you first need to explicitly enable them in the configuration:

{
  "Logging": {
    "Console": {
      "IncludeScopes": true
    }
  }
}

But even then, it will still simply call ToString() on the provided scope, which will make the output for the scope above pretty much useless (notice the System.Collections.Generic.Dictionary`2[System.String,System.Object] part in the output):

info: WebApiRequestIdMiddleware.Controllers.WeatherForecastController[0]
      => SpanId:721994e51973b062, TraceId:9d1cb7f51aba35f4732d04a892e9fc25, ParentId:0000000000000000 => ConnectionId:0HN0KRBKAV91I => RequestPath:/WeatherForecast RequestId:0HN0KRBKAV91I:0000000F => System.Collections.Generic.Dictionary`2[System.String,System.Object] => WebApiRequestIdMiddleware.Controllers.WeatherForecastController.Get (WebApiRequestIdMiddleware)
      Date: 01/15/2024, Temperature: 27

If you care about console output, you can easily fix this issue by creating a custom dictionary type with overloaded the ToString() method that outputs the dictionary content:

public class ConsoleLoggerDictionary<TKey, TValue> : Dictionary<TKey, TValue>
    where TKey : notnull
{
    public override string ToString()
    {
        return string.Join(
            ", ",
            this.AsEnumerable()
                .Select(keyValue => $"{keyValue.Key}: {keyValue.Value}")
        );
    }
}

You can then use this dictionary in your middleware instead of the default one:

using (
    logger.BeginScope(
        new ConsoleLoggerDictionary<string, object> { ["X-Request-ID"] = requestId }
    )
)
{
    await next(context);
}

This will make the output closer to what you would expect (notice the X-Request-ID: 8179f392-9658-40b2-9cdd-2b83454ea1a6 part):

info: WebApiRequestIdMiddleware.Controllers.WeatherForecastController[0]
      => SpanId:46039b860ce512c7, TraceId:f46faf959f829986a22f1a94caf36c1b, ParentId:0000000000000000 => ConnectionId:0HN0KS7P8NVL6 => RequestPath:/WeatherForecast RequestId:0HN0KS7P8NVL6:00000015 => X-Request-ID: 8179f392-9658-40b2-9cdd-2b83454ea1a6 => WebApiRequestIdMiddleware.Controllers.WeatherForecastController.Get (WebApiRequestIdMiddleware)
      Date: 01/15/2024, Temperature: 42

You can easily add more custom functionality to your middleware. For example, you can try reading the request ID value from the request header and only generate a new value if it's not provided:

var requestId =
    context.Request.Headers["X-Request-ID"].FirstOrDefault()
        ?? Guid.NewGuid().ToString();

You might also want to include the request ID value in error responses of your service. To make the value available to your error handler, you can store it in the request header even if you generate it in the middleware:

var requestId = context.Request.Headers["X-Request-ID"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(requestId))
{
    requestId = Guid.NewGuid().ToString();
    context.Request.Headers["X-Request-ID"] = requestId;
}

You can then simply read the value in your exception filter and include it in the response:

public class ExceptionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context) { }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.Exception == null)
        {
            return;
        }
        context.Result = new ObjectResult(
            new
            {
                RequestId = context.HttpContext.Request.Headers["X-Request-ID"]
                    .FirstOrDefault(),
                context.Exception.Message
            }
        )
        {
            StatusCode = StatusCodes.Status500InternalServerError
        };

        context.ExceptionHandled = true;
    }
}

Of course, don't forget to register the filter if you want to use it:

builder.Services.AddControllers(options =>
{
    options.Filters.Add<ExceptionFilter>();
});

I pushed a working sample application with full source code to my GitHub repository. To try it out with Application Insights yourself, you'll have to create your own resource in Azure and add its connection string to the appsettings.json file as described in my previous post.

Log correlation can make troubleshooting issues much easier. If what Application Insights provides by default isn't enough for you, you can always extend and customize the functionality to your needs by creating custom middleware. Just make sure to test well in your production environment, as support for logging scopes differs between logging providers.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License