Skip to content

Commit

Permalink
feat: Expand health check (#126)
Browse files Browse the repository at this point in the history
* feat: Add database health check

* test: Clear health check options when the application under test is constructed

* feat(Health check): Make overall status property name configurable

* docs: Describe expanded health check endpoint
  • Loading branch information
Regenhardt authored Mar 21, 2024
1 parent 440f930 commit 872afce
Show file tree
Hide file tree
Showing 8 changed files with 125 additions and 26 deletions.
40 changes: 21 additions & 19 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,44 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Azure.Search.Documents" Version="11.4.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.18.0" />
<PackageVersion Include="McMaster.Extensions.CommandLineUtils" Version="4.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.2" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.36.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.2" />
<PackageVersion Include="Azure.Search.Documents" Version="11.5.1" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.19.1" />
<PackageVersion Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.3" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.38.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageVersion Include="NuGet.Frameworks" Version="6.9.1" />
<PackageVersion Include="NuGet.Protocol" Version="6.9.1" />
<PackageVersion Include="NuGet.Versioning" Version="6.9.1" />
<PackageVersion Include="System.Reflection.Metadata" Version="8.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.2" />
<PackageVersion Include="System.Text.Json" Version="8.0.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="xunit" Version="2.7.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="Humanizer" Version="2.14.1" />
<PackageVersion Include="Markdig" Version="0.35.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.2" />
<PackageVersion Include="Markdig" Version="0.36.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.3" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.2" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.1" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageVersion Include="Aliyun.OSS.SDK.NetCore" Version="2.13.0" />
<PackageVersion Include="AWSSDK.S3" Version="3.7.305.29" />
<PackageVersion Include="AWSSDK.SecurityToken" Version="3.7.300.54" />
<PackageVersion Include="AWSSDK.S3" Version="3.7.307" />
<PackageVersion Include="AWSSDK.SecurityToken" Version="3.7.300.60" />
<PackageVersion Include="Microsoft.Azure.Cosmos.Table" Version="1.0.8" />
<PackageVersion Include="Microsoft.Azure.Search" Version="10.1.0" />
<PackageVersion Include="Microsoft.Azure.Storage.Blob" Version="11.2.3" />
<PackageVersion Include="Google.Cloud.Storage.V1" Version="4.8.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.2" />
<PackageVersion Include="Google.Cloud.Storage.V1" Version="4.9.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
</ItemGroup>
</Project>
17 changes: 14 additions & 3 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,27 @@ If not specified, the `MaxRequestBodySize` in BaGetter defaults to 250MB (262144

## Health Endpoint

When running within a containerized environment like Kubernetes, a basic health endpoint is exposed at `/health` that returns 200 OK and the text "Healthy" when running.
A health endpoint is exposed at `/health` that returns 200 OK or 503 Service Unavailable and always includes a json object listing the current status of the application:

This path is configurable if needed:
```json
{
"Status": "Healthy",
"Sqlite": "Healthy",
...
}
```

The services can be omitted by setting the `Statistics:ListConfiguredServices` to false, in which case only the `Status` property is returned in the json object.

This path and the name of the "Status" property are configurable if needed:

```json
{
...

"HealthCheck": {
"Path": "/healthz"
"Path": "/healthz",
"StatusPropertyName": "Status"
},

...
Expand Down
2 changes: 2 additions & 0 deletions src/BaGetter.Core/BaGetter.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="NuGet.Protocol" />
<PackageReference Include="System.Reflection.Metadata" />
Expand Down
7 changes: 6 additions & 1 deletion src/BaGetter.Core/Configuration/HealthCheckOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace BaGetter.Core;
Expand All @@ -8,6 +8,11 @@ public class HealthCheckOptions : IValidatableObject
[Required]
public string Path { get; set; }

/// <summary>
/// What the overall status property is called in the health check response. Default is "Status".
/// </summary>
public string StatusPropertyName { get; set; } = "Status";

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (! Path.StartsWith('/'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ public static IServiceCollection AddBaGetDbContextProvider<TContext>(
return provider.GetRequiredService<DatabaseSearchService>();
});

services.AddHealthChecks()
.AddDbContextCheck<TContext>(databaseType, tags: [databaseType]);

return services;
}

Expand Down
61 changes: 61 additions & 0 deletions src/BaGetter.Core/Extensions/HealthCheckExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace BaGetter.Core.Extensions;

public static class HealthCheckExtensions
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};

/// <summary>
/// Formats the <see cref="HealthReport"/> as JSON and writes it to the specified <see cref="Stream"/>.
/// </summary>
/// <param name="report">The report to format.</param>
/// <param name="stream">A writable stream to write the report to. Will not be closed.</param>
/// <param name="detailedReport">Whether to include detailed information about each health check.</param>
/// <param name="statusPropertyName">The name of the property that will contain the overall status.</param>
/// <param name="cancellationToken"></param>
/// <returns>A <see cref="Task"/> completing when the report is completely written to the stream.</returns>
public static async Task FormatAsJson(this HealthReport report, Stream stream, bool detailedReport, string statusPropertyName = "Status",
CancellationToken cancellationToken = default)
{
// Always include the overall status.
IEnumerable<(string Key, HealthStatus Value)> entries = [(statusPropertyName, report.Status)];

// Include details if requested.
if (detailedReport)
{
entries = entries.Concat(report.Entries.Select(entry => (entry.Key, entry.Value.Status)));
}

await JsonSerializer.SerializeAsync(
stream,
entries.ToDictionary(entry => entry.Key, entry => entry.Value),
SerializerOptions,
cancellationToken);
}

/// <summary>
/// Determine whether a health check is configured for BaGetter.
/// </summary>
/// <param name="check">The <see cref="HealthCheckRegistration"/>.</param>
/// <param name="options">The current BaGetter configuration. Will be checked for configured services.</param>
/// <returns>A boolean representing whether the given check is configured in this BaGetter instance.</returns>
public static bool IsConfigured(this HealthCheckRegistration check, BaGetterOptions options)
{
return check.Tags.Count == 0 || // General checks
check.Tags.Contains(options.Database.Type) || // Database check
check.Tags.Contains(options.Storage.Type) || // Storage check
check.Tags.Contains(options.Search.Type); // Search check
}
}
18 changes: 15 additions & 3 deletions src/BaGetter/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using BaGetter.Core;
using BaGetter.Core.Extensions;
using BaGetter.Web;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
Expand All @@ -8,18 +9,19 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using HealthCheckOptions = Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions;

namespace BaGetter;

public class Startup
{
private IConfiguration Configuration { get; }

public Startup(IConfiguration configuration)
{
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}

private IConfiguration Configuration { get; }

public void ConfigureServices(IServiceCollection services)
{
services.ConfigureOptions<ValidateBaGetterOptions>();
Expand Down Expand Up @@ -94,6 +96,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
baget.MapEndpoints(endpoints);
});

app.UseHealthChecks(options.HealthCheck.Path);
app.UseHealthChecks(options.HealthCheck.Path,
new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
await report.FormatAsJson(context.Response.Body, options.Statistics.ListConfiguredServices, options.HealthCheck.StatusPropertyName,
context.RequestAborted);
},
Predicate = check => check.IsConfigured(options)
}
);
}
}
3 changes: 3 additions & 0 deletions tests/BaGetter.Tests/Support/BaGetApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit.Abstractions;
Expand Down Expand Up @@ -82,6 +83,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
services.AddSingleton(_upstreamClient);
}
services.Configure<HealthCheckServiceOptions>(opts => opts.Registrations.Clear());
// Setup the integration test database.
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
Expand Down

0 comments on commit 872afce

Please sign in to comment.