Azure Functions – Middleware (dotnet-isolated)

image

Friends, let’s talk about a scenario I’m sure we’ve all encountered. You have a dozen, maybe a hundred, Azure Functions. Each one needs logging. Each one needs to have its execution time measured. Many need a custom authentication check. What do you do?

If you’re like I was in the early days of Functions, you probably started by writing helper methods. You’d call StartLogging() at the beginning of your function and StopLogging() in a finally block. It worked, but it was messy. Your core business logic was cluttered with these repetitive, operational concerns. Every new function was a new opportunity to forget a crucial step.

This, my friends, is a classic software engineering problem known as cross-cutting concerns. These are the tasks that “cut across” multiple layers and modules of your application—things like logging, telemetry, security, and exception handling. And for years, handling them elegantly in Azure Functions was a bit of a challenge.

Then came the .NET Isolated Worker Process. And with it, a beautiful, familiar pattern for us backend veterans: a first-class middleware pipeline.

Today, I want to show you how this pipeline, using the IFunctionsWorkerMiddleware interface, can completely transform how you write your functions, making them cleaner, more reusable, and infinitely more professional.

First, Why Middleware? And Where Can I Use It?

Before we dive into code, let’s set the stage. A middleware pipeline is like an assembly line for your function’s execution. Before your actual function code is run, the request (or trigger) passes through a series of components. Each component can inspect the request, perform an action, and then either pass it along to the next component or short-circuit the pipeline entirely.

A crucial point to understand is that this capability is exclusive to the .NET Isolated Worker Process model.

  • In-Process Model (The Classic Way): Your function code runs in the same process as the Functions host. It’s tightly coupled. While powerful, it doesn’t have a native, injectable middleware pipeline like the one we’re discussing.
  • Isolated Worker Model (The Modern Way): Your function code runs in a separate process. This decoupling is key. It gives you control over the .NET version, dependencies, and, most importantly for our topic, it exposes the execution pipeline, allowing us to inject our own logic.

Choosing the Isolated model is a prerequisite for everything that follows. The benefits—like this middleware pipeline—make it my default choice for all new Azure Functions projects.

Our Mission: Building a Custom Telemetry Middleware

Let’s get our hands dirty. We’re going to build a piece of middleware that accomplishes a common and vital task: capturing custom telemetry for every function execution. We want to know three things for every run:

  1. The name of the function that ran.
  2. The final HTTP status code (if it’s an HTTP trigger).
  3. The total execution duration in milliseconds.

Manually adding this logic to every single function is a recipe for disaster. Instead, we’ll build one piece of middleware to do it for all of them.

Here’s our TelemetryMiddleware:

C#
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.ApplicationInsights;
using System.Diagnostics;

public class TelemetryMiddleware(TelemetryClient telemetryClient) : IFunctionsWorkerMiddleware
{
    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        // 1. DO SOMETHING BEFORE THE FUNCTION EXECUTES
        // Start timing the function execution
        var stopwatch = Stopwatch.StartNew();

        // 2. CALL THE NEXT PIECE OF THE PIPELINE (OR THE FUNCTION ITSELF)
        // This is the most important line. Execution pauses here and moves to the next
        // item in the chain, which will eventually be your function.
        await next(context);

        // 3. DO SOMETHING *AFTER* THE FUNCTION HAS EXECUTED
        // Now that the 'next' delegate has completed, we know the function is done.
        stopwatch.Stop();
        var durationMs = stopwatch.ElapsedMilliseconds;

        // Extract the function name from the context
        var functionName = context.FunctionDefinition.Name;

        // Attempt to get the HTTP response data (if available)
        // This is a great example of defensive coding. Not all functions are HTTP functions!
        var response = context.GetHttpResponseData();

        // Prepare telemetry properties for Application Insights
        var properties = new Dictionary<string, string>
        {
            { "FunctionName", functionName },
            { "StatusCode", response?.StatusCode.ToString() ?? "N/A" },
            { "InvocationId", context.InvocationId } // The InvocationId is a great unique identifier to include!
        };

        // Let's create a custom metric for the duration, which is better for aggregation
        telemetryClient.TrackMetric($"{functionName} ExecutionTime", durationMs, properties);
    }
}

Dissecting the Code – The Anatomy of a Middleware

Let’s break down what’s happening here, because it’s a beautiful and simple pattern.

  1. IFunctionsWorkerMiddleware: This is the contract. Any class that implements this interface can be slotted into the pipeline. It has just one method: Invoke.
  2. Constructor Injection: Notice TelemetryMiddleware(TelemetryClient telemetryClient). Just like in ASP.NET Core, we can use dependency injection to get the services we need. Here, we’re injecting the Application Insights TelemetryClient.
  3. FunctionContext context: This parameter is your treasure chest. It contains everything about the current execution: the function definition, trigger metadata, the invocation ID, and methods to get bindings like the HTTP request and response.
  4. FunctionExecutionDelegate next: This is a delegate that points to the next piece of middleware in the chain. When you call await next(context), you are handing off control. Your middleware’s code execution pauses until the actual function (and any middleware after yours) has finished running.
  5. The “Before and After” Pattern: This is the core magic. Any code you write before await next(context) runs before your function. Any code you write after it runs after your function has completed. This is how we can start a stopwatch before and stop it after.
  6. Extracting Data: We dig into the context to pull out the function name and, importantly, we safely try to get the HttpResponseData. The null-conditional operator (?.) is your best friend here, as this middleware could run for a queue-triggered or timer-triggered function that has no HTTP response.
  7. Tracking Telemetry: Finally, we use the injected telemetryClient to send our custom metric to Application Insights. Using TrackMetric for the duration is often better than TrackEvent because App Insights is optimized for aggregating and charting numerical metric data.

Wiring It All Up in Program.cs

Creating the middleware class is only half the story. We need to tell our Function App to actually use it. This is done in your Program.cs file, where you configure the host.

C#
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Azure.Functions.Worker;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(worker =>
    {
        // This is the magic line!
        // We register our middleware here. The order of registration matters!
        worker.UseMiddleware<TelemetryMiddleware>();
    })
    .ConfigureServices(services =>
    {
        // Ensure Application Insights is registered
        services.AddApplicationInsightsTelemetryWorkerService();

        // If you had other services, you'd register them here.
        // For example:
        // services.AddSingleton<IMyService, MyService>();
    })
    .Build();

host.Run();

The key is the .ConfigureFunctionsWorkerDefaults() block. Inside it, the worker.UseMiddleware<T>() extension method registers our middleware. If you have multiple middleware components, the order in which you register them here is the order in which they will execute. For example, an authentication middleware should almost always come before a telemetry middleware.

The Advantage We’ve Gained

Look at what we’ve achieved. We now have robust, centralized telemetry collection for every single function in our app, and we haven’t added a single line of code to the functions themselves.

  • Our functions are clean: They contain only business logic.
  • Our logic is reusable: This TelemetryMiddleware can be put into a shared library and used across multiple Function App projects.
  • It’s maintainable: If we want to change how we log telemetry (e.g., add more properties), we change it in one place.
  • It’s testable: We can unit test the middleware in isolation, verifying its logic without needing to run a full function.

This pattern is the mark of a mature, well-architected serverless application. So, the next time you find yourself copying and pasting logic across your functions, take a step back and ask: “Can this be middleware?” With the .NET Isolated Worker, the answer is a resounding “Yes!”

Scroll to Top