Skip to content

Commit 28da2f4

Browse files
authored
Health check endpoints (#21)
/healthz/live answers as long as the process runs; /healthz/ready also checks the database. Both are left out of the traces.
1 parent 4d48550 commit 28da2f4

7 files changed

Lines changed: 94 additions & 4 deletions

File tree

‎src/WorkoutTracker/Program.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
12
using Microsoft.EntityFrameworkCore;
23
using Microsoft.EntityFrameworkCore.Infrastructure;
34
using WorkoutTracker;
@@ -17,6 +18,7 @@
1718
new PooledDbContextFactory<AppDbContext>(new DbContextOptionsBuilder<AppDbContext>()
1819
.UseAppDatabase(services.GetRequiredService<IConfiguration>(), retryOnFailure: false).Options));
1920

21+
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
2022
builder.Services.AddAppTelemetry(builder.Configuration);
2123
builder.Services.AddSingleton<WorkoutMetrics>();
2224

@@ -50,6 +52,10 @@
5052

5153
app.UseAntiforgery();
5254

55+
// Liveness without the database check: a database outage must not make Kubernetes restart the pod.
56+
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
57+
app.MapHealthChecks("/healthz/ready");
58+
5359
app.MapStaticAssets();
5460
app.MapRazorComponents<App>()
5561
.AddInteractiveServerRenderMode();

‎src/WorkoutTracker/Telemetry.cs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ public static IServiceCollection AddAppTelemetry(this IServiceCollection service
4848
return services;
4949
}
5050

51-
// Static files and Blazor's connection requests (/_blazor/negotiate, the WebSocket) are plumbing. Nothing that is
52-
// traced runs as their child: the Blazor event and SQL spans start their own traces.
53-
public static bool IsTraced(PathString path) => !Path.HasExtension(path.Value) && !path.StartsWithSegments("/_blazor");
51+
// Static files, Blazor's connection requests (/_blazor/negotiate, the WebSocket) and health probes are plumbing.
52+
// Nothing that is traced runs as their child: the Blazor event and SQL spans start their own traces. (The health
53+
// check's own SQL span is dropped with its request, which is what we want.)
54+
public static bool IsTraced(PathString path) =>
55+
!Path.HasExtension(path.Value) && !path.StartsWithSegments("/_blazor") && !path.StartsWithSegments("/healthz");
5456
}

‎src/WorkoutTracker/WorkoutTracker.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<PrivateAssets>all</PrivateAssets>
1818
</PackageReference>
1919
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.13" />
20+
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.13" />
2021
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
2122
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
2223
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />

‎src/WorkoutTracker/packages.lock.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@
3535
"Microsoft.EntityFrameworkCore.Relational": "10.0.13"
3636
}
3737
},
38+
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": {
39+
"type": "Direct",
40+
"requested": "[10.0.13, )",
41+
"resolved": "10.0.13",
42+
"contentHash": "si/fOXt1NXt0pyhwVBNDuN2RYF1SVOf8WEtbOp2BUabs2yjdxsgx6FOJ9bXYSUztIbBdDNa5h5Ocf/R7FuGHGg==",
43+
"dependencies": {
44+
"Microsoft.EntityFrameworkCore.Relational": "10.0.13"
45+
}
46+
},
3847
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
3948
"type": "Direct",
4049
"requested": "[1.19.1, )",

‎tests/WorkoutTracker.Tests/StartupTests.cs‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using Microsoft.AspNetCore.Hosting;
33
using Microsoft.AspNetCore.Mvc.Testing;
4+
using Microsoft.Data.SqlClient;
45
using Microsoft.EntityFrameworkCore;
56
using Microsoft.Extensions.DependencyInjection;
67
using Microsoft.Extensions.Logging;
@@ -17,9 +18,13 @@ public sealed class StartupTests : IDisposable
1718
{
1819
readonly WebApplicationFactory<Program> factory;
1920

21+
readonly SqlServerFixture sqlServer;
22+
readonly string databaseName = SqlServerFixture.NewDatabaseName();
23+
2024
public StartupTests(SqlServerFixture sqlServer)
2125
{
22-
var connectionString = sqlServer.ConnectionString(SqlServerFixture.NewDatabaseName());
26+
this.sqlServer = sqlServer;
27+
var connectionString = sqlServer.ConnectionString(databaseName);
2328
factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b => b
2429
.UseEnvironment("Production")
2530
.UseSetting("ConnectionStrings:Default", connectionString));
@@ -62,5 +67,43 @@ public async Task Pages_retry_database_outages_but_the_migration_does_not()
6267
Assert.False(migrationDb.Database.CreateExecutionStrategy().RetriesOnFailure);
6368
}
6469

70+
[Theory]
71+
[InlineData("/healthz/live")]
72+
[InlineData("/healthz/ready")]
73+
public async Task Health_checks_report_healthy(string path)
74+
{
75+
using var client = factory.CreateClient();
76+
77+
var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
78+
79+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
80+
Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
81+
}
82+
83+
[Fact]
84+
public async Task Without_the_database_only_readiness_fails()
85+
{
86+
using var client = factory.CreateClient();
87+
await DropDatabaseAsync();
88+
89+
var live = await client.GetAsync("/healthz/live", TestContext.Current.CancellationToken);
90+
var ready = await client.GetAsync("/healthz/ready", TestContext.Current.CancellationToken);
91+
92+
Assert.Equal(HttpStatusCode.OK, live.StatusCode);
93+
Assert.Equal(HttpStatusCode.ServiceUnavailable, ready.StatusCode);
94+
Assert.Equal("Unhealthy", await ready.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
95+
}
96+
97+
// The app can't start without its database (it migrates at startup), so the database goes away afterwards.
98+
async Task DropDatabaseAsync()
99+
{
100+
SqlConnection.ClearAllPools();
101+
await using var connection = new SqlConnection(sqlServer.ConnectionString("master"));
102+
await connection.OpenAsync(TestContext.Current.CancellationToken);
103+
await using var command = new SqlCommand(
104+
$"ALTER DATABASE [{databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{databaseName}];", connection);
105+
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
106+
}
107+
65108
public void Dispose() => factory.Dispose();
66109
}

‎tests/WorkoutTracker.Tests/TelemetryTests.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ public void SignalR_spans_and_untraced_requests_are_left_out()
6969
[InlineData("/_blazor/negotiate", false)]
7070
[InlineData("/_blazor/initializers/", false)]
7171
[InlineData("/_blazorish", true)]
72+
[InlineData("/healthz/live", false)]
73+
[InlineData("/healthz/ready", false)]
7274
public void Static_files_and_Blazor_connection_requests_are_not_traced(string path, bool traced) =>
7375
Assert.Equal(traced, Telemetry.IsTraced(path));
7476

‎tests/WorkoutTracker.Tests/packages.lock.json‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,32 @@
527527
"Microsoft.Extensions.Options": "10.0.13"
528528
}
529529
},
530+
"Microsoft.Extensions.Diagnostics.HealthChecks": {
531+
"type": "Transitive",
532+
"resolved": "10.0.13",
533+
"contentHash": "jCa1Msld3AzdcxsYo1D3QmjSPOd06NIICbrBE9vZHPcnfQ0Q2sbRwkoHXy1A0cHnGG8s1KTJ75JpBt8mYHsyMg==",
534+
"dependencies": {
535+
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.13",
536+
"Microsoft.Extensions.Hosting.Abstractions": "10.0.13",
537+
"Microsoft.Extensions.Logging.Abstractions": "10.0.13",
538+
"Microsoft.Extensions.Options": "10.0.13"
539+
}
540+
},
541+
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
542+
"type": "Transitive",
543+
"resolved": "10.0.13",
544+
"contentHash": "XJMQCk8ZabnwEEBFccQIFWKT9Rmad5Daj5BSFg9/dByW2KqO2or1XR+SXxOhHRCfng0mupGph3lfwN2iH7ihGA=="
545+
},
546+
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": {
547+
"type": "Transitive",
548+
"resolved": "10.0.13",
549+
"contentHash": "si/fOXt1NXt0pyhwVBNDuN2RYF1SVOf8WEtbOp2BUabs2yjdxsgx6FOJ9bXYSUztIbBdDNa5h5Ocf/R7FuGHGg==",
550+
"dependencies": {
551+
"Microsoft.EntityFrameworkCore.Relational": "10.0.13",
552+
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.13",
553+
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.13"
554+
}
555+
},
530556
"Microsoft.Extensions.FileProviders.Abstractions": {
531557
"type": "Transitive",
532558
"resolved": "10.0.13",
@@ -1111,6 +1137,7 @@
11111137
"type": "Project",
11121138
"dependencies": {
11131139
"Microsoft.EntityFrameworkCore.SqlServer": "[10.0.13, )",
1140+
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[10.0.13, )",
11141141
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.19.1, )",
11151142
"OpenTelemetry.Extensions.Hosting": "[1.19.1, )",
11161143
"OpenTelemetry.Instrumentation.AspNetCore": "[1.19.0, )",

0 commit comments

Comments
 (0)