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:
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:
- Logging to
DiagnosticSource
before the next middleware is invoked - Logging to
DiagnosticSource
after the next middleware has finished being invoked - 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
.