blog post image
Andrew Lock avatar

Andrew Lock

~6 min read

Under the hood of the Middleware Analysis package

In my last post I showed how you could use the Microsoft.AspNetCore.MiddlewareAnalysis package to analyse your middleware pipeline. In this post I take a look at the source code behind the package, to see how it's implemented.

What can you use the package for?

After you have added the MiddlewareAnalysis package to your ASP.NET Core application, you can use a DiagnosticSource to log arbitrary details about each middleware component when it starts and stops, or when an exception occurs. You can use this to create some very powerfull logs, where you can inspect the raw HttpContext and exception and log any pertinent details. At the simplest level though, you get a log when each middleware component starts or stops.

MiddlewareStarting: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware; /  
MiddlewareStarting: HelloWorld; /  
MiddlewareFinished: HelloWorld; 200  
MiddlewareFinished: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware; 200

Check out my previous post for details on how to add the middleware to your project, as well as how to create a diagnostic source.

How does it work?

In my last post I mention that the analysis package uses the IStartupFilter interface. I discussed this interface in a previous post, but in essence, it allows you to insert middleware into the pipeline without using the normal Startup.Configure approach. By using this filter, the MiddlewareAnalysis package can customise the pipeline by adding services to the DI container. It uses this to insert additional middleware that it uses to log to a DiagnosticSource.

In the rest of this post I'll take a look at the various internal classes that make up the Microsoft.AspNetCore.MiddlewareAnalysis package (don't worry, there's only 4 of them!).

The service collection extensions

First, the easy bit.

As is the case with most packages, there is an extension method to allow you to easily add the necessary services to your application:

public void ConfigureServices(IServiceCollection services)  
{
    services.AddMiddlewareAnalysis();
}

This extension method registers a single instance of an IStartupFilter, the AnalysisStartupFilter. This startup filter will be invoked before your Startup.Configure method is run. In fact, when invoked, the AnalysisStartupFilter will receive your Startup.Configure method as an argument.

The AnalysisStartupFilter

The AnalysisStartupFilter that is added by the previous call to AddMiddlewareAnalysis() acts as a "wrapper" around the Startup.Configure method. It both takes and returns an Action<IApplicationBuilder>, letting you customise the way the middleware pipeline is constructed:

public class AnalysisStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return builder =>
        {
            var wrappedBuilder = new AnalysisBuilder(builder);
            next(wrappedBuilder);

            // There's a couple of other bits here I'll gloss over for now
        };
    }
}

This is an interesting class. Rather than simply adding some fancy middleware to the pipeline, the startup filter creates a new instance of an AnalysisBuilder, passes in the ApplicationBuilder, and then invokes the next method, passing the wrappedBuilder.

This means that when the filter is run (on app startup), it creates a custom IApplicationBuilder, the AnalysisBuilder and passes that to all subsequent filters and the Startup.Configure method. Consequently, all the calls you make in a typical Configure method, are made on the AnalysisBuilder, instead of the original IApplicationBuilder instance:

public void Configure(IApplicationBuilder app)
{
    // app is now the AnalysisBuilder, so all calls are made
    // on that, instead of the original IApplicationBuilder
    app.UseExceptionHandler("/error");
    app.UseStaticFiles();
    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

Let's take a look at the AnalysisBuilder and figure out what it's playing at.

Intercepting builder calls with the AnalysisBuilder

We now know that the AnalysisBuilder is intercepting all the calls that add middleware to the pipeline. What you may or may not know is that behind the scenes, all the extension method like UseStaticFiles, UseMvc, and even UseMiddleware are ultimately calling a single method on IApplicationBuilder, Use. This makes it relatively easy to implement a wrapper around the default builder:

public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
{
    // You can set a custom name for the middleware by setting 
    // app.Properties["analysis.NextMiddlewareName"] = "Name";

    string middlewareName = string.Empty;
    object middlewareNameObj;
    if (Properties.TryGetValue(NextMiddlewareName, out middlewareNameObj))
    {
        middlewareName = middlewareNameObj?.ToString();
        Properties.Remove(NextMiddlewareName);
    }

    return InnerBuilder.UseMiddleware<AnalysisMiddleware>(middlewareName)
        .Use(middleware);
}

The bulk of this method is taken up attempting to see if you have set a value in the Properties collection, which allows you to specify a custom name for the next middleware in the pipeline (see my previous post).

The interesting bit occurs right at the end of the method. As well as adding the provided middleware to the underlying InnerBuilder, an instance of the AnalysisMiddleware is added to the pipeline. That means for every middleware added, an instance of the analysis middleware is added.

So for the example Configure method I showed earlier, that means our actual pipeline looks something like the following:

Actual middleware pipeline

You may notice that as well as starting with an AnalysisMiddleware instance, the pipeline adds an AnalysisMiddleware after the MVC middleware too. This is thanks to the definition of the Build function in the AnalysisBuilder:

public RequestDelegate Build()
{
    // Add one maker at the end before the default 404 middleware (or any fancy Join middleware).
    return InnerBuilder.UseMiddleware<AnalysisMiddleware>("EndOfPipeline")
        .Build();
}

As the comment says, this ensures a final instance of the AnalysisMiddleware, "EndOfPipeline", is added before the end of the pipeline (and the default 404 middleware is added).

At the end of setup, we have a middleware pipeline configured as I showed in the previous image, where all the middleware we added to the pipeline in Startup.Configure is interspersed with AnalysisMiddleware.

This brings us onto the final piece of the puzzle, the AnalysisMiddleware itself.

Logging to DiagnosticSource with the AnalysisMiddleware

Up to this point, we haven't used DiagnosticSource anywhere in the package. It's all been about injecting the additional middleware into the pipeline. Inside this middleware is where we do the actual logging.

I'll show the code for the AnalysisMiddleware in a second, but essentially it is just doing three things:

  1. Logging to DiagnosticSource before the next middleware is invoked
  2. Logging to DiagnosticSource after the next middleware has finished being invoked
  3. Catching any exceptions, logging them to DiagnosticSource and rethrowing them.

For details on how DiagnosticSource works, check out my previous post. In brief, you can log to a source using Write(), providing a key and an anonymous object. You can see this is exactly what the middleware is doing in the code:

public class AnalysisMiddleware
{
    private readonly Guid _instanceId = Guid.NewGuid();
    private readonly RequestDelegate _next;
    private readonly DiagnosticSource _diagnostics;
    private readonly string _middlewareName;

    public AnalysisMiddleware(RequestDelegate next, DiagnosticSource diagnosticSource, string middlewareName)
    {
        _next = next;
        _diagnostics = diagnosticSource;
        if (string.IsNullOrEmpty(middlewareName))
        {
            middlewareName = next.Target.GetType().FullName;
        }
        _middlewareName = middlewareName;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        var startTimestamp = Stopwatch.GetTimestamp();
        if (_diagnostics.IsEnabled("Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareStarting"))
        {
            _diagnostics.Write(
                "Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareStarting",
                new
                {
                    name = _middlewareName,
                    httpContext = httpContext,
                    instanceId = _instanceId,
                    timestamp = startTimestamp,
                });
        }

        try
        {
            await _next(httpContext);

            if (_diagnostics.IsEnabled("Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareFinished"))
            {
                var currentTimestamp = Stopwatch.GetTimestamp();
                _diagnostics.Write(
                    "Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareFinished", 
                    new
                    {
                        name = _middlewareName,
                        httpContext = httpContext,
                        instanceId = _instanceId,
                        timestamp = currentTimestamp,
                        duration = currentTimestamp - startTimestamp,
                    });
            }
        }
        catch (Exception ex)
        {
            if (_diagnostics.IsEnabled("Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareException"))
            {
                var currentTimestamp = Stopwatch.GetTimestamp();
                _diagnostics.Write(
                    "Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareException", 
                    new
                    {
                        name = _middlewareName,
                        httpContext = httpContext,
                        instanceId = _instanceId,
                        timestamp = currentTimestamp,
                        duration = currentTimestamp - startTimestamp,
                        exception = ex,
                    });
            }
            throw;
        }
    }
}

In the constructor, we are passed a reference to the next middleware in the pipeline, next. If we haven't already been passed an explicit middleware name (see the ApplicationBuilder section) then a name is obtained from the type of next. This will generally return something like "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware".

The Invoke method is then called when the middleware is executed. The code is a little hard to read due to the various parameters passed around: to make it a little easier on your eyes, the overall psuedo-code for the class might look something like:

public class AnalysisMiddleware
{
    private readonly RequestDelegate _next;

    public AnalysisMiddleware(RequestDelegate next, )
    {
        _next = next;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        Diagnostics.Log("MiddlewareStarting")

        try
        {
            await _next(httpContext);
            Diagnostics.Log("MiddlewareFinished")
        }
        catch ()
        {
            Diagnostics.Log("MiddlewareException")
            throw;
        }
    }
}

Hopefully the simplicity of the middleware is more apparent for this latter version. It really is just writing to the the diagnostics source and executing the next middleware in the pipeline.

Summary

And that's all there is to it! Just four classes, providing the ability to log lots of details about your middleware pipeline. The analysis middleware itself uses DiagnosticSource to expose details about the request HttpContext (or exception) currently executing.

The most interesting piece of the package is the way it uses AnalysisBuilder. This shows that you can get complete control over your middleware pipeline by using a simple wrapper class, and by injecting an IStartupFilter. If you haven't, already I really recommend checking out the code on GitHub. It's only four files after all! If you want to see how to actually use the package, check out my previous post on how to use the package in your projects, including setting up a Diagnosticlistener.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?