Implementing Health Checks in ASP.NET Framework Applications

Being able to monitor your ASP.NET web applications and APIs and detect any issues early on is crucial in ensuring your sites are up and running and in a healthy state. With ASP.NET Core 2.2 and later this now comes in the box (as I'll show you in later articles) but for .NET Framework apps you'll have to use to kind of custom solution. To implement simple monitoring in ASP.NET Framework apps (i.e. non-Core applications), I usually add some custom code to my projects to provide health information from an endpoint and then use external tools to monitor that endpoint. In this article I'll show you how you can implement health checks using custom code targeting the .NET Framework (although it should also work on .NET Core). In future articles I'll then show you how you can set up similar checks in an ASP.NET Core app with standard functionality and how to extend the base system using existing third-party health checks like an SMTP server monitor, to make your health checks even more powerful.

Introduction to Health Checks

A health check is typically an HTTP endpoint that provides information about the state of a system. It can be as simple as providing information about a single site being up or down, or it could be more complex and provide state information about a system and its dependencies, such as a database, a mail server, and HTTP endpoints that the site connects to. The latter is especially useful when you're building a microservice architecture as you need to watch a multitude of interconnected service in order to determine a system's overall health.

In my ASP.NET Framework projects I often implement health checks with some custom code. I usually have an IHealthCheckProvider interface that defines a single method to perform some kind of health check and then return the results to the calling code. Concrete classes then implement this interface. Next, to return the combined health status to the client, I create a result container DTO (HealthCheckResult) and a result class for individual health checks (HealthCheckItemResult). The container holds a list of results of the individual checks as well as some overall, machine-wide information. The following figure shows the overall class hierarchy of the solution:

Class diagram of the health monitoring solution

To return the data from an endpoint like /api/health, I have some code that finds all classes in the current app domain that implement the IHealthCheckProvider interface, execute them to get their health status, add their results to the HealthCheckResult container and finally return the result to the client as JSON. Here's an example of the JSON output:

{
  "hasFailures": false,
  "machineName": "XPS15",
  "timeTakenInSeconds": 0.017837199999999998,
  "healthChecks": [
    {
      "resourceName": "DatabaseHealthCheckProvider",
      "friendlyName": "Checks database",
      "description": "Checks whether the main database can be accessed.",
      "state": "Healthy",
      "messages": [
        
      ]
    },
    {
      "resourceName": "DiskSpaceHealthCheckProvider",
      "friendlyName": "Checks disk space",
      "description": "Validates that the available disk space is more than 10 percent.",
      "state": "Healthy",
      "messages": [
        {
          "message": "There is more than 10 percent available disk space."
        }
      ]
    }
  ]
}            

The /api/health endpoint can then be monitored by an uptime / availability checker like Uptime Robot, Azure's Availability checker, a custom solution or whatever other solution you may be using. That checker then watches the endpoint for a success string (like "hasFailures:" false in the example above) and trigger an alert when the end point doesn't deliver that success string. This way, you'll be notified immediately when something happens to your production system. The individual providers return their state with the HealthState enum which contains three members:

  • Healthy - the (sub)system being checked is fully operational and there are no known issues
  • Degraded - the system is operational but there are factors that influence it negatively, such as degraded performance
  • Unhealthy - the system is encountering problems that prevent it from functioning correctly

You'll find the full code for the article as a download at the end of this article, as well as in the Github repository for this article. The remainder of this section shows some highlights of the code so you can see how it all fits together.

As mentioned earlier, each health check provider implements just a single interface with a single method called GetHealthCheckAsync. The interface is used to dynamically find all providers in the current application as you'll see later. Here's the definition of the interface:

public interface IHealthCheckProvider
{
  Task<HealthCheckItemResult> GetHealthCheckAsync();
}

GetHealthCheckAsync returns an instance of HealthCheckItemResult that contains the results of the executed check.

Concrete providers then implement this interface and perform various kinds of checks. The code below shows a simple example that uses an EF context to check connectivity to the database:

public class DatabaseHealthCheckProvider : IHealthCheckProvider
{
  public async Task GetHealthCheckAsync()
  {
    var result = new HealthCheckItemResult(nameof(DatabaseHealthCheckProvider), SortOrder,
             "Checks the database", "Checks whether the database can be accessed.");
    try
    {
      var context = new MyDbContext();  // Change to match your own context; use DI when appropriate
      var item = await context.SomeSet.FirstAsync(); // Update to match one of your own DbSets.
      if (item != null)
      {
        result.HealthState = HealthState.Healthy;
        result.Messages.Add("Successfully retrieved a record from the database.");
      }
      else
      {
        result.HealthState = HealthState.Unhealthy;
        result.Messages.Add("Connected to the database but could not find the requested record.");
      }
    }
    catch
    {
      result.HealthState = HealthState.Unhealthy;
      result.Messages.Add("Error retrieving a record from the database.");
    }
    return result;
  }

  public int SortOrder => 30;
}

And here's a simple example that checks the server's C drive for available disk space:

public class DiskSpaceHealthCheckProvider : IHealthCheckProvider
{
  private const int MinPercentageFree = 10;
  private const int WarningPercentageFree = 20;

  /// <summary>
  /// Returns the health heck info.
  /// </summary>
  public Task<HealthCheckItemResult> GetHealthCheckAsync()
  {
    var result = new HealthCheckItemResult(nameof(DiskSpaceHealthCheckProvider), SortOrder, 
          "Checks disk space", 
          $"Validates that the available disk space is more than {MinPercentageFree} percent.");
    try
    {
      var percentageFree = GetPercentageFree("C");
      result.HealthState = DetermineState(percentageFree);
      result.Messages.Add($"There is {(percentageFree > MinPercentageFree ? 
                      "more" : "LESS")} than {MinPercentageFree} percent available disk space.");
    }
    catch
    {
      result.HealthState = HealthState.Unhealthy;
      result.Messages.Add("Could not validate disk space.");
    }
    return Task.FromResult(result);
  }

  private static double GetPercentageFree(string drive)
  {
    var driveInfo = new DriveInfo(drive);
    var freeSpace = driveInfo.TotalFreeSpace;
    var totalSpace = driveInfo.TotalSize;
    var percentageFree = Convert.ToDouble(freeSpace) / Convert.ToDouble(totalSpace) * 100;
    return percentageFree;
  }

  private static HealthState DetermineState(double percentageFree)
  {
    if (percentageFree < MinPercentageFree)
    {
      return  HealthState.Unhealthy;
    }
    if (percentageFree < WarningPercentageFree)
    {
      return HealthState.Degraded;
    }
    return HealthState.Healthy;
  }

  public int SortOrder => 30;
}

How you instantiate and execute these health checks can vary. In some cases, it's enough to just new them up in code directly, execute them and append the results to the wrapper object like so:

var result = new HealthCheckResult();
result.HealthChecks.Add(await new DiskSpaceHealthCheckProvider().GetHealthCheckAsync());
// Other checks are added here
return Ok(result);

In other scenarios, where you use dependency injection, you can configure your DI container to register the various IHealthCheckProvider types so they can be injected in a controller's constructor. Here's an example of how that could look:

[Route("api/health")]
public class HealthController : ApiController
{
  private readonly IHealthCheckProvider[] _healthCheckProviders;

  public HealthController(IHealthCheckProvider[] healthCheckProviders)
  {
    _healthCheckProviders = healthCheckProviders;
  }

  public async Task<IHttpActionResult> GetHealthInfo()
  {
    var result = new HealthCheckResult();
    foreach (var provider in _healthCheckProviders)
    {
      result.HealthChecks.Add(await provider.GetHealthCheckAsync());
    }

    return Ok(result);
  }
}

The array of health check providers is created by the DI container and then injected in the HealthController. You can then simply loop over them and execute them one by one, collecting the results.

In the sample application for this article I took a different approach and use reflection to find all types that implement the IHealthCheck interface, create an instance of them and then execute their GetHealthCheckAsync method. Here's the full code of the HealthController from the sample project for the .NET Framework:

[Route("api/health")]
public class HealthController : ApiController
{
  public async Task<IHttpActionResult> GetHealthInfo()
  {
    var result = new HealthCheckResult
    {
      MachineName = Environment.MachineName
    };

    var stopwatch = new Stopwatch();
    stopwatch.Start();
    var items = new List<HealthCheckItemResult>();
    var instances = new List<IHealthCheckProvider>();
    foreach (var provider in GetAllProviders())
    {
      var instance = (IHealthCheckProvider)Activator.CreateInstance(provider);
      instances.Add(instance);
    }
    await Task.WhenAll(instances.Select(async x => items.Add(await x.GetHealthCheckAsync())));
    stopwatch.Stop();
    result.TimeTakenInSeconds = stopwatch.Elapsed.TotalSeconds;
    result.HealthChecks.AddRange(items.OrderBy(x => x.SortOrder));
    return Ok(result);
  }

  private List<Type> GetAllProviders()
  {
    return GetTypesDeriving<IHealthCheckProvider>();
  }

  private static List<Type> GetTypesDeriving<T>()
  {
    return (from domainAssembly in AppDomain.CurrentDomain.GetAssemblies()
            from assemblyType in domainAssembly.GetTypes()
            where typeof(T).IsAssignableFrom(assemblyType) && !assemblyType.IsAbstract
            select assemblyType).ToList();
  }
}

Although the code in this controller works, it's mostly just demo code as it could benefit from some error handling and abstraction; however, it should suffice to demonstrate the core concept of the health checks and is very close to code I use in real-world apps.

With this code in place, hitting the api/health endpoint now returns the JSON you saw earlier. The hasFailures property then indicates if the system is in a stable state or whether it has any issues. The items under the healthChecks collection provide additional information about the checked items and why they are considered healthy or unhealthy. An external system can then make calls to this endpoint at a regular interval and notify you when the system is not behaving optimally. This helps tremendously in finding and fixing issues with your sites and servers as soon as possible.

Next steps

This article serves mostly just as a demo to show you how to implement relatively simple health checks in ASP.NET Framework applications. If you want to implement this on your own, you can (and should) improve on this code making it more robust, feature-rich and resilient to errors. It could also benefit from features like the following:

  • In addition to the machine name, add the machine's IP address to the output. In multi-server scenarios like when you host in Azure or AWS, this can help identify which machine the code ran on, and may also help in figuring out why, say, it can't connect to a remote database.
  • Differentiate between Healthy, Degraded and Unhealthy in the hasFailures property (or create multiple properties providing the relevant states.
  • Add an option to selectively call certain health check providers. Sometimes executing a provider may incur costs (for example when it connects to a third-party service) so you may not want to execute those too often. Having a filter for some providers allows you to run the main providers at a high frequency (like every other minute) and execute the paid ones at a slower rate. In a future article I'll discuss the usage of tags in ASP.NET Core Health checks to accomplish the same.
  • Add timeTakenInSeconds to the individual checks as well so you can better understand which ones execute fast and which ones don't.
  • Add more providers; depending on your requirements and system, you could create providers for resources like Azure storage and queues, external database systems, SMTP and FTP connections and more.

In the next article I'll show you how you can implement similar checks in ASP.NET Core using built-in functionality and third-party open-source add-ons. A lot has already been written about health checks in ASP.NET Core, so I'll provide just a quick overview and then dive a little deeper in some helpful prebuilt health checks.

Downloads


Where to Next?

Wonder where to go next? You can read existing comments below or you can post a comment yourself on this article.

Doc ID 609
Full URL https://imar.spaanjaars.com/609/implementing-health-checks-in-aspnet-framework-applications
Short cut https://imar.spaanjaars.com/609/
Written by Imar Spaanjaars
Date Posted 11/01/2020 21:18
Date Last Updated 11/17/2020 13:32

Talk Back! Comment on Imar.Spaanjaars.Com

I am interested in what you have to say about this article. Feel free to post any comments, remarks or questions you may have about this article. The Talk Back feature is not meant for technical questions that are not directly related to this article. So, a post like "Hey, can you tell me how I can upload files to a MySQL database in PHP?" is likely to be removed. Also spam and unrealistic job offers will be deleted immediately.

When you post a comment, you have to provide your name and the comment. Your e-mail address is optional and you only need to provide it if you want me to contact you. It will not be displayed along with your comment. I got sick and tired of the comment spam I was receiving, so I have protected this page with a simple calculation exercise. This means that if you want to leave a comment, you'll need to complete the calculation before you hit the Post Comment button.

If you want to object to a comment made by another visitor, be sure to contact me and I'll look into it ASAP. Don't forget to mention the page link, or the Doc ID of the document.

(Plain text only; no HTML or code that looks like HTML or XML. In other words, don't use < and >. Also no links allowed.