Getting Hands On With Asp.net Core Health Checks

I am late to the party. I am just now getting my hands on ASP.net Core’s HealthCheck middleware. The HealthCheck middleware is used to expose endpoints on your site that run a sequence of pre-written tests and provide you with quick diagnostic information about the health of your application. Some examples of how these may be used:

  • Check the status of an external dependency whose status is out of your control (like 3rd party web services and rest services)
  • Check the state of data dependencies like sql server databases and caching servers
  • Check the timing and performance of endpoints on your own site.
  • Check the values for certain settings that may be hard to determine in a multi-environment setup.
  • Check for certain diagnostics info such as disc and memory usage.

So, let’s dive in. In the web project in the github repository the first thing we want to check out is a quick helper class that I wrote that will do most of the heavy lifting for this tutorial. In the file HealthChecks/Checks.cs is a class “HealthCheckHelpers”.

  public static async Task<HealthCheckResult> GenerateHealthCheckResultFromPIngRequest(string hostName)
        {
            using (var thePing = new Ping())
            {
                var pingResult = await thePing.SendPingAsync(hostName);
                var description = $"A ping of the {hostName} website";
                var healthCheckData = new Dictionary<string, object>();
                healthCheckData.Add("RoundTripMS", pingResult.RoundtripTime);
                healthCheckData.Add("ActualIPAddress", pingResult.Address.ToString());
                if (pingResult.Status == IPStatus.Success)
                {
                    return HealthCheckResult.Healthy(description, healthCheckData);
                }

                return HealthCheckResult.Unhealthy(description, null, healthCheckData);
            }
        }

The first thing we want to look at is the function “GenerateHealthCheckResultFromPingRequest”. This function takes a given host and will conduct a ping against it. If it is able to communicate with the host, it will return a healthy report back and give the roundtrip time in milliseconds. If it cannot it will inform the user by issuing an unhealthy report. HealthChecks like these are useful for determining if a deployed site can communicate with external resources. This is very useful in tight security situations where a server or hosting environment may have tightly maintained allowed hosts and port communication.

 public static async Task<HealthCheckResult> RouteTimingHealthCheck(string routePath)
        {
            using (var client = new System.Net.WebClient())
            {
                var watch = new Stopwatch();
                watch.Start();
                var url = $"{BaseUrl}{routePath}";
                var result = await client.DownloadStringTaskAsync(url);
                watch.Stop();
                var milliseconds = watch.ElapsedMilliseconds;
                var healthCheckData = new Dictionary<string, object>();
                healthCheckData.Add("TimeInMS", milliseconds);
                if (milliseconds <= 1000)
                    return HealthCheckResult.Healthy($"call to  the route {routePath}", healthCheckData);
                else if (milliseconds >= 1001 && milliseconds <= 2000)
                    return HealthCheckResult.Degraded($"call to  the route {routePath}", null, healthCheckData);
                else
                    return HealthCheckResult.Unhealthy($"call to  the route {routePath}", null, healthCheckData);
            }
        }

The next block is the function “RouteTimingHealthCheck”. This function will take a route from the API and will perform a call to it. This will check the call’s response and then assign a state of health based on the timing.

 [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }

        [HttpGet("slow")]
        public async Task<string> Slow()
        {
            await Task.Delay(3000);
            return "slow";
        }

In the file Controllers/WeatherForecastController.cs we will use the controller action for the default GET method which should bring back a pretty quick healthy test result and we have made a slow function which force an unhealthy test scenario when called. Finally in the helper class we have:

  public static async Task WriteResponses(HttpContext context, HealthReport result)
        {
            var json = new JObject(
                            new JProperty("status", result.Status.ToString()),
                            new JProperty("results", new JObject(result.Entries.Select(pair =>
                            new JProperty(pair.Key, new JObject(
                                new JProperty("status", pair.Value.Status.ToString()),
                                new JProperty("description", pair.Value.Description),
                                new JProperty("data", new JObject(pair.Value.Data.Select(
                                    p => new JProperty(p.Key, p.Value))))))))));

            context.Response.ContentType = MediaTypeNames.Application.Json;
            await context.Response.WriteAsync(json.ToString(Formatting.Indented));
        }

This function is copied straight from the MSDN for the HealthCheck middleware. It will simply override the default response writer for the health check and format in a clean JSON response.

The setup to use these functions is quite easy. First the changes to the ConfigureServices method in the Startup class

  public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
            services.AddHealthChecks()
                .AddAsyncCheck("google_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("google.com"))
                .AddAsyncCheck("microsoft_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("microsoft.com"))
                .AddAsyncCheck("yahoo_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("yahoo.com"))
                .AddAsyncCheck("localhost_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("localhost"))
                .AddAsyncCheck("forecast_time_check", () => HealthCheckHelpers.RouteTimingHealthCheck("/weatherforecast"))
                .AddAsyncCheck("forecast_time_check_slow", () => HealthCheckHelpers.RouteTimingHealthCheck("/weatherforecast/slow"))
                .AddDbContextCheck<MyDatabaseContext>("database_check", tags: new[] { "database" });
            services.AddHttpContextAccessor();

            services.AddDbContext<MyDatabaseContext>(options =>
            {
                options.UseSqlServer(@"Connection_string_here");
            });
        }

Looking at the extension method AddAsyncCheck, we see that it allows us to pass in a name for our health check. Next the function takes a Func parameter that returns a Task<HealthCheckResult>. And the final optional parameter is a list of tags that apply to the given test. This allows us to filter and conditionally call certain tests exposed by certain endpoints.

  services.AddHealthChecks()
                .AddAsyncCheck("google_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("google.com"))
                .AddAsyncCheck("microsoft_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("microsoft.com"))
                .AddAsyncCheck("yahoo_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("yahoo.com"))
                .AddAsyncCheck("localhost_ping_check", () => HealthCheckHelpers.GenerateHealthCheckResultFromPIngRequest("localhost"))

In the first four tests we use the ping check which will call some well known sites and attempt to ping them.

.AddAsyncCheck("forecast_time_check", () => HealthCheckHelpers.RouteTimingHealthCheck("/weatherforecast"))
                .AddAsyncCheck("forecast_time_check_slow", () => HealthCheckHelpers.RouteTimingHealthCheck("/weatherforecast/slow"))

Next, we use the route timing test to check some our own API endpoints. We will purposely call the slow endpoint to force an unhealthy response.

 .AddDbContextCheck<MyDatabaseContext>("database_check", tags: new[] { "database" });
...
 services.AddDbContext<MyDatabaseContext>(options =>
            {
                options.UseSqlServer(@"Connection_string_here");
            });

Finally, we have an example of how easy it is to setup database health checks. We have a setup for an empty EF context where we set it up with a bad connection string. This will force an unhealthy response when the health check attempts to communicate with a bad database connection. You can set this up with a legit DB connection string and see a positive health check.

The next section will show our changes to our Configure function:

 public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.Use(async (context, next) =>
            {
                HealthCheckHelpers.BaseUrl = $"{ context.Request.Scheme}://{context.Request.Host}";
                await next();
            });

            app.UseHealthChecks("/health",
               new HealthCheckOptions
               {
                   ResponseWriter = HealthCheckHelpers.WriteResponses
               });

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapHealthChecks("/health/database", new HealthCheckOptions
                {
                    Predicate = (check) => check.Tags.Contains("database"),
                    ResponseWriter = HealthCheckHelpers.WriteResponses
                });

                endpoints.MapHealthChecks("/health/websites", new HealthCheckOptions
                {
                    Predicate = (check) => check.Tags.Contains("websites"),
                    ResponseWriter = HealthCheckHelpers.WriteResponses
                });

                endpoints.MapControllers();
            });
        }
    }

We did very few changes to get our health checks setup.

 app.UseHealthChecks("/health",
               new HealthCheckOptions
               {
                   ResponseWriter = HealthCheckHelpers.WriteResponses
               });

The first noticeable change is adding the UseHealthChecks extension method which add the given endpoint to the middleware collection. When the site is running it will expose an endpoint “/health” in this case which will execute the tests setup in the ConfigureServices method. With no conditions provided this endpoint will perform all given tests. Next we set the ResponseWriter property of the HealthCheckOptions object. We set it to our writer method in our helper class which will change the reponse to JSON.

app.UseEndpoints(endpoints =>
            {
                endpoints.MapHealthChecks("/health/database", new HealthCheckOptions
                {
                    Predicate = (check) => check.Tags.Contains("database"),
                    ResponseWriter = HealthCheckHelpers.WriteResponses
                });

                endpoints.MapHealthChecks("/health/websites", new HealthCheckOptions
                {
                    Predicate = (check) => check.Tags.Contains("websites"),
                    ResponseWriter = HealthCheckHelpers.WriteResponses
                });

                endpoints.MapControllers();
            });

Next, in our implementation of the UseEndpoints extension method we setup endpoints for filtering our health checks. The predicate property takes a function that returns a filter for which health checks to execute. In this example we set up endpoints that will filter the tests according by their assigned tags.

After setup, we can run this project and point our browser to the “/health” endpoint

We should get a json response similar to the response below

You will see the results as an array of HealthCheckResult objects. They will have their assigned name and will have the other custom properties that we assigned as a dictionary object of keys and values and will have a property describing whether they were healthy, degrading, or unhealthy.

Not only can health checks be setup inline as we did, but for more extensive tests they can be set up as classes the implement the IHealthCheck interface.

Conclusion

We only scratched the surface here, but as you can see the possibilities of how this very useful middleware can be endless. Additional information about the HealthCheck middleware can be found below.

MSDN HealthChecks in ASP.net Core