Panagiotis answer is brilliant and brought me to an elegant solution I'd love to leave for the next developers stumbling over this ...
To achieve periodical updates without implementing a background service or any timers, I registered an IHealthCheckPublisher
. With this, ASP.NET Core will automatically run the registered health checks periodically and publish their results to the corresponding implementation.
In my tests, the health report was published every 30 seconds by default.
// add a publisher to cache the latest health report
services.AddSingleton<IHealthCheckPublisher, HealthReportCachePublisher>();
I registered my implementation HealthReportCachePublisher
which does nothing more than taking a published health report and keeping it in a static property.
I don't really like static properties but to me it seems adequate for this use case.
/// <summary>
/// This publisher takes a health report and keeps it as "Latest".
/// Other health checks or endpoints can reuse the latest health report to provide
/// health check APIs without having the checks executed on each request.
/// </summary>
public class HealthReportCachePublisher : IHealthCheckPublisher
{
/// <summary>
/// The latest health report which got published
/// </summary>
public static HealthReport Latest { get; set; }
/// <summary>
/// Publishes a provided report
/// </summary>
/// <param name="report">The result of executing a set of health checks</param>
/// <param name="cancellationToken">A task which will complete when publishing is complete</param>
/// <returns></returns>
public Task PublishAsync(HealthReport report, CancellationToken cancellationToken)
{
Latest = report;
return Task.CompletedTask;
}
}
Now the real magic happens here
As seen in every Health Checks sample, I mapped the health checks to the route /health
and use the UIResponseWriter.WriteHealthCheckUIResponse
to return a beautiful json response.
But I mapped another route /health/latest
. There, a predicate _ => false
prevents any health checks to be executed at all. But instead of returning the empty results of zero health checks, I return the previously published health report by accessing the static HealthReportCachePublisher.Latest
.
app.UseEndpoints(endpoints =>
{
// live health data: executes health checks for each request
endpoints.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions()
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// latest health report: won't execute health checks but return the cached data from the HealthReportCachePublisher
endpoints.MapHealthChecks("/health/latest", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions()
{
Predicate = _ => false, // do not execute any health checks, we just want to return the latest health report
ResponseWriter = (context, _) => UIResponseWriter.WriteHealthCheckUIResponse(context, HealthReportCachePublisher.Latest)
});
});
This way, calling /health
is returning the live health reports, by executing all the health checks on each request. This might take a while if there are many things to check or network requests to make.
Calling /health/latest
will always return the latest pre-evaluated health report. This is extremely fast and may help a lot if you have a load balancer waiting for the health report to route incoming requests accordingly.
A little addition: The solution above uses the route mapping to cancel the execution of health checks and returning the latest health report. As suggested, I tried to build an further health check first which should return the latest, cached health report but this has two downsides:
- The new health check to return the cached report itself appears in the results as well (or has to be fitered by name or tags).
- There's no easy way to map the cached health report to a
HealthCheckResult
. If you copy over the properties and status codes this might work. But the resulting json is basically a health report containing an inner health report. That's not what you want to have.