Server Timing API Meets Isolated Worker Process Azure Functions - Custom Middleware and Dependency Injection

Server Timing API has piqued my interest back in 2017. I've always been a promoter of a data-driven approach to non-functional requirements. Also, I always warned the teams I worked with that it's very easy to not see the forest for the trees. Server Timing API was bringing a convenient way to communicate backend performance information to developer tools in the browser. It enabled access to back-end and front-end performance data in one place and within the context of actual interaction with the application. I've experimented with the technology together with a couple of teams where a culture of fronted and backend engineers working close together was high. The results were great, which pushed me to create a small library to simplify the onboarding of Server Timing API in ASP.NET Core applications. I've been using that library with multiple teams through the years and judging by the downloads number I wasn't the only one. There were even some contributions to the library.

Some time ago, an issue was raised asking if the library could also support Azure Functions using isolated worker process mode. I couldn't think of a good reason why not, it was a great idea. Of course, I couldn't add the support directly to the existing library. Yes, the isolated worker process mode of Azure Functions shares a lot of concepts with ASP.NET Core, but the technicalities are different. So, I've decided to create a separate library. While doing so, I've also decided to put some notes around those concepts into a blog post in hope that someone might find them useful in the future.

So, first things first, what is isolated worker process mode and why we are talking about it?

Azure Functions Execution Modes

There are two execution modes in Azure Functions: in-process and isolated worker process. The in-process mode means that the function code is running in the same process as the host. This is the approach that has been taken for .NET functions from the beginning (while functions in other languages were running in a separate process since version 2). This enabled Azure Functions to provide unique benefits for .NET functions (like rich bindings and direct access to SDKs) but at a price. The .NET functions could only use the same .NET version as the host. Dependency conflicts were also common. This is why Azure Functions has fully embraced the isolated worker process mode for .NET functions in version 4 and now developers have a choice of which mode they want to use. Sometimes this choice is simple (if you want to use non-LTS versions of .NET, the isolated worker process is your only option), sometimes it is more nuanced (for example isolated worker process functions have slightly longer cold start). You can take a look at the full list of differences here.

When considering simplifying the onboarding of Server Timing API, the isolated worker process mode is the only option as it supports a crucial feature - custom middleware registration.

Custom Middleware

The ability to register a custom middleware is crucial for enabling capabilities like Server Timing API because it allows for injecting logic into the invocation pipeline.

In isolated worker process Azure Functions, the invocation pipeline is represented by FunctionExecutionDelegate. Although it would be possible to work with FunctionExecutionDelegate directly (by wrapping it with parent invocations), Azure Functions provides a convenient extension method UseMiddleware() which enables registering inline or factory-based middleware. What is missing in comparison to ASP.NET Core is the convention-based middleware. This might be surprising at first as the convention-based approach is probably the most popular one in ASP.NET Core. So, for those of you who are not familiar with the factory-based approach, it requires the middleware class to implement a specific interface. In the case of Azure Functions, it's IFunctionsWorkerMiddleware (in ASP.NET Core it's IMiddleware). The factory-based middleware is prepared to be registered with different lifetimes, so the Invoke method takes not only the context but also the delegate representing the next middleware in the pipeline as a parameter. Similarly to ASP.NET Core, we are being given the option to run code before and after functions execute, by wrapping it around the call to the next middleware delegate.

internal class ServerTimingMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        // Pre-function execution

        await next(context);

        // Post-function execution
    }
}

The aforementioned UseMiddleware() extension method should be called inside the ConfigureFunctionsWorkerDefaults method as part of the host preparation steps. This method registers the middleware as a singleton (so it has the same lifetime as convention-based middleware in ASP.NET Core). It can be registered with different lifetimes, but it has to be done manually which includes wrapping invocation of FunctionExecutionDelegate. For the ones interested I recommend checking the UseMiddleware() source code for inspiration.

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(workerApplication =>
    {
        // Register middleware with the worker
        workerApplication.UseMiddleware();
    })
    .Build();

host.Run();

All the valuable information about the invoked function and the invocation itself can be accessed through FunctionContext class. There are also some extension methods available for it, which make it easier to work with that class. One such extension method is GetHttpResponseData() which will return an instance of HttpResponseData if the function has been invoked by an HTTP trigger. This is where the HTTP response can be modified, for example by adding headers related to Server Timing API.

internal class ServerTimingMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        // Pre-function execution

        await next(context);

        // Post-function execution
        HttpResponseData? response = context.GetHttpResponseData();
        if (response is not null)
        {
            response.Headers.Add(
                "Server-Timing",
                "cache;dur=300;desc=\"Cache\",sql;dur=900;desc=\"Sql Server\",fs;dur=600;desc=\"FileSystem\",cpu;dur=1230;desc=\"Total CPU\""
            );
        }
    }
}

To make this functional, the values for the header need to be gathered during the invocation, which means that there needs to be a shared service between the function and the middleware. It's time to bring the dependency injection into the picture.

Dependency Injection

The support for dependency injection in isolated worker process Azure Functions is exactly what you can expect if you have been working with modern .NET. It's based on Microsoft.Extensions.DependencyInjection and supports all lifetimes options. The option which might require clarification is the scoped lifetime. In Azure Functions, it matches a function execution lifetime, which is exactly what is needed for gathering values in the context of a single invocation.

var host = new HostBuilder()
    ...
    .ConfigureServices(s =>
    {
        s.AddScoped();
    })
    .Build();

host.Run();

Functions that are using dependency injection must be implemented as instance methods. When using instance methods, each invocation will create a new instance of the function class. That means that all parameters passed into the constructor of the function class are scoped to that invocation. This makes usage of constructor-based dependency injection safe for services with scoped lifetime.

public class ServerTimingFunctions
{
    private readonly IServerTiming _serverTiming;

    public ServerTimingFunctions(IServerTiming serverTiming)
    {
        _serverTiming = serverTiming;
    }

    [Function("basic")]
    public HttpResponseData Basic([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData request)
    {

        var response = request.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

        _serverTiming.Metrics.Add(new ServerTimingMetric("cache", 300, "Cache"));
        _serverTiming.Metrics.Add(new ServerTimingMetric("sql", 900, "Sql Server"));
        _serverTiming.Metrics.Add(new ServerTimingMetric("fs", 600, "FileSystem"));
        _serverTiming.Metrics.Add(new ServerTimingMetric("cpu", 1230, "Total CPU"));

        response.WriteString("-- Demo.Azure.Functions.Worker.ServerTiming --");

        return response;
    }
}

The above statement is not true for the middleware. As I've already mentioned, the UseMiddleware() method registers the middleware as a singleton. So, even though middleware is being resolved for every invocation separately, it is always the same instance. This means that constructor-based dependency injection is safe only for services with a singleton lifetime. To properly use a service with scoped or transient lifetime we need to use the service locator approach. An invocation-scoped service locator is available for us under FunctionContext.InstanceServices property.

internal class ServerTimingMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        ...

        // Post-function execution
        InvocationResult invocationResult = context.GetInvocationResult();

        HttpResponseData? response = invocationResult.Value as HttpResponseData;
        if (response is not null)
        {
            IServerTiming serverTiming = context.InstanceServices.GetRequiredService();
            response.Headers.Add("Server-Timing", String.Join(",", serverTiming.Metrics));
        }
    }
}

It Works! (And You Can Use It)

This way, by combining support for middleware and dependency injection, I've established the core functionality of my small library. It's out there on NuGet, so if you want to use Server Timing to communicate performance information to your Azure Functions based API consumers you are welcome to use it. If you want to dig a little bit into the code (or maybe you have some suggestions or improvements in mind) it lives in the same repository as the ASP.NET Core one.