in ASP.NET Core Logging ~ 6 min read.

Excluding health check endpoints from Serilog request logging
Using Serilog.AspNetCore in ASP.NET Core 3.0 - Part 4

In previous posts in this series I have described how to configure Serilog's RequestLogging middleware to add additional properties to Serilog's request log summary such as the request hostname or the selected endpoint name. I have also showed how to add MVC or RazorPage-specific properties to the summary log using Filters.

In this post I show how to skip adding the summary log message completely for specific requests. This can be useful when you have an endpoint that is hit a lot, where logging every request is of little value.

Health checks are called a lot

The motivation for this post comes from behaviour we've seen when running applications in Kubernetes. Kubernetes uses two types of "health checks" (or "probes") for checking applications are running correctly - liveness probes and readiness probes. You can configure a probe to make an HTTP request to your application as an indicator that your application is running correctly.

As of Kubernetes version 1.16 there is a third type of probe, startup probes.

The Health Check endpoints available in ASP.NET Core 2.2+ are ideally suited for these probes. You can set up a simple, dumb, health check that returns a 200 OK response to every request, to let Kubernetes know your app hasn't crashed.

In ASP.NET Core 3.x, you can configure your health checks using endpoint routing. You must add the required services in Startup.cs by calling AddHealthChecks(), and add a health check endpoint using MapHealthChecks():

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ..other service configuration

        services.AddHealthChecks(); // Add health check services
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            // .. other middleware
            app.UseRouting();
            app.UseAuthorization();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapHealthChecks("/healthz"); //Add health check endpoint
                endpoints.MapControllers();
            });
        }
}

In the example above, sending a request to /healthz will invoke the health check endpoint. As I didn't configure any health checks to run, the endpoint will always return a 200 response, as long as the app is running:

Health check response

The only problem with this is that Kubernetes will call this endpoint a lot. The exact frequency is up to you, but every 10s is common. You want a relatively high frequency so Kubernetes can restart faulty pods quickly.

That's not a problem in of itself; Kestrel can handle millions of requests a second, so it's not a performance concern. More irritating is the number of logs generated by each request. It's not as many as I showed for a request to the MVC infrastructure, but even 1 log per request (as we get with Serilog.AspNetCore) can get irritating.

The main problem is that the logs for successful health check requests don't actually tell us anything useful. They're not related to any business activities, they're purely infrastructural. It would be nice to be able to skip the Serilog request summary logs for these. In the next section I introduce the approach I came up with that relies on the changes from the previous posts in this series.

Customising the log level used for Serilog request logs

In a previous post I showed how to include the selected endpoint in your Serilog request logs. My approach was to provide a custom function to the RequestLoggingOptions.EnrichDiagnosticContext property when registering the Serilog middleware:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ... Other middleware

    app.UseSerilogRequestLogging(opts
        // EnrichFromRequest helper function is shown in the previous post
        => opts.EnrichDiagnosticContext = LogHelper.EnrichFromRequest); 

    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHealthChecks("/healthz"); //Add health check endpoint
        endpoints.MapControllers();
    });
}

RequestLoggingOptions has another property, GetLevel, which is a Func<> used to decide the logging level that should be used for a given request log. By default, it's set to the following function:

public static class SerilogApplicationBuilderExtensions
{
    static LogEventLevel DefaultGetLevel(HttpContext ctx, double _, Exception ex) =>
        ex != null
            ? LogEventLevel.Error 
            : ctx.Response.StatusCode > 499 
                ? LogEventLevel.Error 
                : LogEventLevel.Information;
}

This function checks if an exception was thrown for the request, or if the response code is a 5xx error. If so, it create an Error level summary log, otherwise it creates an Information level log.

Lets say you wanted the summary logs to be logged as Debug instead of Information. First, you would create a helper function like the following which has the required logic:

public static class LogHelper
{
    public static LogEventLevel CustomGetLevel(HttpContext ctx, double _, Exception ex) =>
        ex != null
            ? LogEventLevel.Error 
            : ctx.Response.StatusCode > 499 
                ? LogEventLevel.Error 
                : LogEventLevel.Debug; //Debug instead of Information
}

Then you can set the level function when you call UseSerilogRequestLogging():

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ... Other middleware

    app.UseSerilogRequestLogging(opts => {
        opts.EnrichDiagnosticContext = LogHelper.EnrichFromRequest;
        opts.GetLevel = LogHelper.CustomGetLevel; // Use custom level function
    });

    //... other middleware
}

Now your request summary logs will all be logged as Debug, except when an error occurs (screenshot from Seq):

Seq showing debug request logs

But how does this help our verbosity problem?

When you configure Serilog, you typically define the minimum request level. For example, the following simple configuration sets the default level to Debug(), and writes to a console Sink:

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .WriteTo.Console()
    .CreateLogger();

So the simple way for a log to be filtered is for the log level to be lower than the MinimumLevel specified in the logger configuration. Generally speaking, if you use the lowest level available, Verbose, it will pretty much always be filtered out.

The difficulty is that we don't want to always use Verbose as the log level for our summary logs. If we do that, we won't get any request logs for non-errors, and the Serilog middleware becomes a bit pointless!

Instead, we want to set the log level to Verbose only for requests that hit the health check endpoint. In the next section I'll show how to identify those requests while leaving other requests unaffected.

Using a custom log level for Health Check endpoint requests

The key thing we need is the ability to identify a health-check request at the point the log is written. As shown previously, the GetLevel() method takes the current HttpContext as a parameter, so theoretically there's a few options. The most obvious choices to me are:

  • Compare the HttpContext.Request path to a known list of health-check paths
  • Use the selected Endpoint metadata to identify when a health-check endpoint was called

The first option is the most obvious, but it's not really worth the hassle. Once you get into the weeds of it, you find you have to start duplicating request paths around and handling various edge cases, so I'm going to skip over that one here.

The second option uses a similar approach to the one in my previous post where we obtain the IEndpointFeature that was selected by the EndpointRoutingMiddleware for a given request. This feature (if present) provides details such as the display name and route data for the selected Endpoint.

If we assume that health checks are registered using their default Display Name of "Health checks", then we can identify a "health check" request using the HttpContext as follows:

public static class LogHelper
{
    private static bool IsHealthCheckEndpoint(HttpContext ctx)
    {
        var endpoint = ctx.GetEndpoint();
        if (endpoint is object) // same as !(endpoint is null)
        {
            return string.Equals(
                endpoint.DisplayName, 
                "Health checks",
                StringComparison.Ordinal);
        }
        // No endpoint, so not a health check endpoint
        return false;
    }
}

We can use this function, combined with a custom version of the default GetLevel function to ensure that summary logs for health check requests use a Verbose level, while errors use Error and other requests use Information:

public static class LogHelper
{
    public static LogEventLevel ExcludeHealthChecks(HttpContext ctx, double _, Exception ex) => 
        ex != null
            ? LogEventLevel.Error 
            : ctx.Response.StatusCode > 499 
                ? LogEventLevel.Error 
                : IsHealthCheckEndpoint(ctx) // Not an error, check if it was a health check
                    ? LogEventLevel.Verbose // Was a health check, use Verbose
                    : LogEventLevel.Information;
        }
}

This nested ternary has an extra level to it - for non-errors we check if an endpoint with the display name "Health check" was selected, and if it was, we use the level Verbose, otherwise we use Information.

You could generalise this code further, to allow passing in other display names, or customising the log levels used. I didn't do that here for simplicity, but the associated sample code on GitHub shows how you could do this.

All that remains is to update the Serilog middleware RequestLoggingOptions to use your new function:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ... Other middleware

    app.UseSerilogRequestLogging(opts => {
        opts.EnrichDiagnosticContext = LogHelper.EnrichFromRequest;
        opts.GetLevel = LogHelper.ExcludeHealthChecks; // Use the custom level
    });

    //... other middleware
}

When you check your logs after running your application you'll see your normal request logs for standard requests, but no sign of your health checks (unless an error occurs!). In the following screenshot I configured Serilog to also record Verbose logs so you can see the health check requests - normally they will be filtered out!

Seq screenshot showing Health Check requests using Verbose level

Summary

In this post I showed how to provide a custom function to the Serilog middleware's RequestLoggingOptions that defines what LogEventLevel to use for a given request's log. I showed how this can be used to change the default level to Debug for example. If the level you choose is lower than the minimum level, it will be filtered out completely and not logged.

I showed that you could use this approach to filter out the common (but low-interest) request logs generated by calling health check endpoints. Generally speaking these requests are only interesting if they indicate a problem, but they will typically generate a request log when they succeed as well. As these endpoints are called very frequently they can significantly increase the number of logs written.

The approach in this post is to check the selected IEndpointFeature and check if it has the display name "Health checks". If it does, the request log is written with the Verbose level, which is commonly filtered out. For more flexibility you could customise the logs shown in this post to handle multiple endpoint names, or any other criteria you have.

Loading comments powered by Disqus, please wait…
Andrew Lock | .Net Escapades

Stay up to the date with the latest posts!

Oops! Check your details and try again.
Thanks! Check your email for confirmation.