Understanding your middleware pipeline in .NET 6 with the Middleware Analysis package

Every ASP.NET Core request travels through a middleware pipeline, and most of us only think about the pieces we explicitly add with app.Use calls. .NET 6 quietly wires up several extra pieces of middleware behind the scenes the moment you call WebApplication.CreateBuilder, and that hidden wiring can make debugging pipeline ordering issues frustrating. This article walks through the Microsoft.AspNetCore.MiddlewareAnalysis package, a lesser known diagnostic tool that logs every middleware a request passes through, along with its start, finish, and exception events.

Why you would reach for this package

The MiddlewareAnalysis package combines two ASP.NET Core building blocks: IStartupFilter, which lets you insert code into the pipeline construction process, and DiagnosticSource, a logging mechanism that runs parallel to the usual ILogger infrastructure. Together they give you an AnalysisStartupFilter and an AnalysisMiddleware that sit around every other piece of middleware in your app and emit an event each time a middleware starts, finishes, or throws.

This is not something you would leave switched on in production. The logging is verbose, since every middleware in the pipeline now emits two extra events per request, so on a busy pipeline you would generate a fair amount of noise. Treat it as a debugging tool you switch on temporarily when you need to understand exactly what is happening in your pipeline, then remove once you have your answer.

The end result looks something like this, once wired up. It is not pretty, but it tells you precisely which middleware ran for a given request, in what order, and what status code came out at the end:

MiddlewareStarting: 'Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware'; Request Path: '/'
MiddlewareStarting: 'Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware'; Request Path: '/'
MiddlewareStarting: 'Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware'; Request Path: '/'
MiddlewareStarting: 'Microsoft.AspNetCore.Routing.EndpointMiddleware'; Request Path: '/'
 
MiddlewareFinished: 'Microsoft.AspNetCore.Routing.EndpointMiddleware'; Status: '200'
MiddlewareFinished: 'Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware'; Status: '200'
MiddlewareFinished: 'Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware'; Status: '200'
MiddlewareFinished: 'Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware'; Status: '200'

Each MiddlewareStarting line fires as a request enters a piece of middleware, and each MiddlewareFinished line fires as it leaves. Read the finish events in reverse order and you get the classic nested pipeline shape: the last middleware to start is the first to finish, because control unwinds back out through every layer it passed through on the way in.

A deceptively simple starting point

To see why this package earns its place, start with about as small an ASP.NET Core app as you can write, a single minimal API endpoint using .NET 6’s WebApplication hosting model:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
 
app.MapGet("/", () => "Hello World!");
 
app.Run();

Run this and hit the root endpoint, and you get “Hello World!” back, exactly as expected. Looking at the code, it is tempting to assume there is barely any middleware involved, maybe just routing and the endpoint itself. That assumption is wrong. WebApplicationBuilder registers several pieces of middleware implicitly, including host filtering and the developer exception page, and none of it shows up anywhere in your Program.cs.

Adding the MiddlewareAnalysis package

Two NuGet packages are needed here. The first is Microsoft.AspNetCore.MiddlewareAnalysis itself, which contains AnalysisStartupFilter and AnalysisMiddleware, the two types doing the actual work:

dotnet add package Microsoft.AspNetCore.MiddlewareAnalysis

or by editing the .csproj file directly:

<PackageReference Include="Microsoft.AspNetCore.MiddlewareAnalysis" Version="6.0.5" />

The second package is Microsoft.Extensions.DiagnosticAdapter. The MiddlewareAnalysis package emits its DiagnosticSource events using anonymous types, and reading anonymous types normally means falling back on reflection. The DiagnosticAdapter package gives you a declarative, attribute based way to subscribe to named events instead, which is a lot less painful to work with:

dotnet add package Microsoft.Extensions.DiagnosticAdapter

or in the .csproj:

<PackageReference Include="Microsoft.Extensions.DiagnosticAdapter" Version="3.1.25" />

Note that Microsoft.Extensions.DiagnosticAdapter has not had a stable release beyond the 3.1.x line, even though the rest of the project targets .NET 6. That is fine in practice. The package still works correctly against later runtimes, it is simply no longer under active development.

Writing a diagnostic adapter

A diagnostic adapter is a plain class with one method per event you want to handle, each decorated with a DiagnosticName attribute naming the event it subscribes to. The MiddlewareAnalysis package raises three events: MiddlewareStarting, MiddlewareFinished, and MiddlewareException. Here is an adapter that logs all three, with ILogger injected through the constructor:

public class AnalysisDiagnosticAdapter
{
    private readonly ILogger<AnalysisDiagnosticAdapter> _logger;
    public AnalysisDiagnosticAdapter(ILogger<AnalysisDiagnosticAdapter> logger)
    {
        _logger = logger;
    }
 
    [DiagnosticName("Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareStarting")]
    public void OnMiddlewareStarting(HttpContext httpContext, string name, Guid instance, long timestamp)
    {
        _logger.LogInformation($"MiddlewareStarting: '{name}'; Request Path: '{httpContext.Request.Path}'");
    }
 
    [DiagnosticName("Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareException")]
    public void OnMiddlewareException(Exception exception, HttpContext httpContext, string name, Guid instance, long timestamp, long duration)
    {
        _logger.LogInformation($"MiddlewareException: '{name}'; '{exception.Message}'");
    }
 
    [DiagnosticName("Microsoft.AspNetCore.MiddlewareAnalysis.MiddlewareFinished")]
    public void OnMiddlewareFinished(HttpContext httpContext, string name, Guid instance, long timestamp, long duration)
    {
        _logger.LogInformation($"MiddlewareFinished: '{name}'; Status: '{httpContext.Response.StatusCode}'");
    }
}

The method names in this class do not matter to the framework at all, you could call them anything you like. What matters is the parameter names on each method. The DiagnosticAdapter package matches parameters to the anonymous event object’s properties by name, not by position, so a mistyped parameter name silently ends up passing null, or throws at runtime, rather than failing to compile. This is the single most common mistake when writing one of these adapters, and it is worth checking parameter names against the source whenever a handler is not firing as expected.

Notice that OnMiddlewareStarting receives the full HttpContext. That is one of the genuinely useful things about DiagnosticSource compared to conventional logging: the decision about what to record is made inside the handler, with full access to the request, rather than being fixed at the call site. The sample above only logs the path and status code, but nothing stops you from reading headers, query parameters, or claims off the context and routing them to a different sink entirely.

Wiring the adapter into the pipeline

With the adapter class written, it needs to subscribe to the “Microsoft.AspNetCore” DiagnosticListener that the generic host registers in the DI container by default. Retrieve that listener and subscribe the adapter using the SubscribeWithAdapter extension method that ships with the DiagnosticAdapter package:

using System.Diagnostics;
 
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
 
// Grab the "Microsoft.AspNetCore" DiagnosticListener from DI
var listener = app.Services.GetRequiredService<DiagnosticListener>();
 
// Create an instance of the AnalysisDiagnosticAdapter using the IServiceProvider
// so that the ILogger is injected from DI
var observer = ActivatorUtilities.CreateInstance<AnalysisDiagnosticAdapter>(app.Services);
 
// Subscribe to the listener with the SubscribeWithAdapter() extension method
using var disposable = listener.SubscribeWithAdapter(observer);
 
app.MapGet("/", () => "Hello World!");
 
app.Run();

At this point the adapter is listening, but nothing is producing events yet, because AnalysisMiddleware itself has not been added to the pipeline. Running the app now would show no output from the adapter at all.

Registering AnalysisMiddleware the right way in .NET 6

The documented way to add the analysis middleware is a single call, AddMiddlewareAnalysis(), on the service collection:

builder.Services.AddMiddlewareAnalysis();

On .NET 6 this call alone does not capture the full pipeline, and it is easy to miss why. IStartupFilter instances run in the order they are registered, and to see every middleware, including the ones the framework adds implicitly, AnalysisStartupFilter needs to be the first filter registered, not just one of them. WebApplication.CreateBuilder(args) already registers some startup filters internally before your own code runs, so calling AddMiddlewareAnalysis() afterwards puts your filter behind those, and you lose visibility into that early part of the pipeline.

The fix is to insert AnalysisStartupFilter at position zero in the service collection manually, ahead of anything WebApplication.CreateBuilder already added:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
 
// insert the AnalysisStartupFilter as the first IStartupFilter in the container
builder.Services.Insert(0, ServiceDescriptor.Transient<IStartupFilter, AnalysisStartupFilter>());
WebApplication app = builder.Build();
// ... rest of the configuration

This one line change is the difference between seeing four or five middleware entries and seeing the complete picture, including the middleware the framework wires up before your Program.cs even runs.

Running the app and reading the log

With the adapter registered and AnalysisStartupFilter inserted first, run the app and make a request. The response is still the same “Hello World!” you started with, but the console now fills up with MiddlewareStarting and MiddlewareFinished entries for every middleware the request passed through:

Console output from AnalysisDiagnosticAdapter showing every middleware the request passed through
Console output from AnalysisDiagnosticAdapter showing every middleware the request passed through

In a development environment, a request to / produces five MiddlewareStarting events and five matching MiddlewareFinished events. Four of those are middleware WebApplication added automatically, host filtering, the developer exception page, endpoint routing, and the endpoint itself, and the fifth is AnalysisMiddleware logging itself, which is a quirk of how .NET 6 builds the pipeline rather than something you need to fix. The finish events appear in reverse order to the start events, exactly as you would expect from a nested pipeline, and each finish event carries the status code at that point, so you can pinpoint exactly where a 200 turned into something else.

Watching for exceptions

The third event, MiddlewareException, fires when a middleware further down the pipeline throws. To see it in action, add an endpoint that deliberately throws:

app.Map("/error", ThrowError); // Run ThrowError as the RequestDelegate
 
app.Run();
 
static string ThrowError() => throw new NotImplementedException();

Hitting /error triggers the exception, and because DeveloperExceptionPageMiddleware is registered automatically by WebApplication in a development environment, the browser gets a formatted error page rather than a bare 500 with no detail:

The developer exception page shown when the /error endpoint throws NotImplementedException
The developer exception page shown when the /error endpoint throws NotImplementedException

The log tells the more interesting part of the story. MiddlewareException entries appear starting from EndpointMiddleware, where the exception originates, and continue as it bubbles outward through AnalysisMiddleware and EndpointRoutingMiddleware. Once it reaches DeveloperExceptionPageMiddleware, the exception is caught and handled, so the log switches back to ordinary MiddlewareFinished events, now carrying a 500 status instead of 200.

Event parameters at a glance

DiagnosticSource events are not documented particularly well outside the package source itself, so it helps to keep a quick reference to what each one exposes. All three events share name, httpContext, and instanceId, and the two completion events add duration:

  • MiddlewareStarting: name, httpContext, instanceId (a Guid unique to this middleware instance), and timestamp, taken from Stopwatch.GetTimestamp()
  • MiddlewareFinished: the same four parameters plus duration, the elapsed ticks between start and finish
  • MiddlewareException: everything MiddlewareFinished has, plus ex, the exception that was thrown

Event names have to match exactly, including case, but parameter names on your adapter methods are not case sensitive. If an adapter method silently stops firing after a package update, checking the current parameter names in source is usually faster than guessing.

What is actually happening under the hood

AnalysisStartupFilter works by inserting a small piece of AnalysisMiddleware between each real middleware in your pipeline, effectively wrapping every step with logging on both sides. That is also why the middleware count in the log looks higher than what you would count by reading your own Program.cs, since every implicit and explicit middleware gets its own wrapper.

When this earns a place in your toolkit

Reach for this package when a request is taking a path through your pipeline you cannot explain, when you have added or reordered several pieces of middleware and want to confirm the actual execution order, or when you are debugging why a status code changes somewhere between your endpoint and the client. It has limited value on a pipeline you already understand well, and it should not run continuously in production given the extra event traffic on every request. If you only need coarse request timing rather than a full middleware breakdown, ASP.NET Core’s built in request logging or Application Insights dependency tracking will usually get you there with less setup.

Summary

The Microsoft.AspNetCore.MiddlewareAnalysis package, paired with a DiagnosticSource adapter, gives you a precise view of every middleware a request passes through, in order, along with timing and status information. On .NET 6, the one detail worth remembering is registering AnalysisStartupFilter first in the service collection, otherwise you miss the middleware the framework adds implicitly. Wired up correctly, it turns pipeline debugging from guesswork into something you can read straight off the console.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading