From a30befae0f9066773181cf67e06e525614ef6d2e Mon Sep 17 00:00:00 2001 From: Andrew Stanton-Nurse Date: Wed, 18 Oct 2017 13:52:06 -0700 Subject: [PATCH] Add abstractions for Health Checks and a simple middleware and service to run them (#408) --- .gitignore | 3 + DiagnosticsPages.sln | 95 ++++++- .../HealthChecksSample.csproj | 17 ++ samples/HealthChecksSample/Program.cs | 23 ++ samples/HealthChecksSample/Startup.cs | 28 ++ .../HealthCheckAppBuilderExtensions.cs | 32 +++ .../HealthCheckMiddleware.cs | 79 ++++++ .../HealthCheckOptions.cs | 18 ++ ...AspNetCore.Diagnostics.HealthChecks.csproj | 28 ++ .../HealthCheckResult.cs | 188 +++++++++++++ .../HealthCheckStatus.cs | 41 +++ .../IHealthCheck.cs | 27 ++ ...agnostics.HealthChecks.Abstractions.csproj | 16 ++ .../CompositeHealthCheckResult.cs | 66 +++++ .../HealthCheck.cs | 41 +++ .../HealthCheckLogScope.cs | 48 ++++ .../HealthCheckService.cs | 132 +++++++++ .../HealthCheckServiceCollectionExtensions.cs | 31 +++ .../HealthChecksBuilder.cs | 17 ++ .../HealthChecksBuilderAddCheckExtensions.cs | 39 +++ .../IHealthCheckService.cs | 46 ++++ .../IHealthChecksBuilder.cs | 22 ++ ...Extensions.Diagnostics.HealthChecks.csproj | 21 ++ .../HealthCheckMiddlewareTests.cs | 243 +++++++++++++++++ ...Core.Diagnostics.HealthChecks.Tests.csproj | 21 ++ .../CompositeHealthCheckResultTests.cs | 31 +++ .../HealthCheckServiceTests.cs | 256 ++++++++++++++++++ .../HealthChecksBuilderTests.cs | 30 ++ ...ions.Diagnostics.HealthChecks.Tests.csproj | 20 ++ .../ServiceCollectionExtensionsTests.cs | 33 +++ 30 files changed, 1691 insertions(+), 1 deletion(-) create mode 100644 samples/HealthChecksSample/HealthChecksSample.csproj create mode 100644 samples/HealthChecksSample/Program.cs create mode 100644 samples/HealthChecksSample/Startup.cs create mode 100644 src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckAppBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs create mode 100644 src/Microsoft.AspNetCore.Diagnostics.HealthChecks/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckResult.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckStatus.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheck.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/CompositeHealthCheckResult.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheck.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckLogScope.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckServiceCollectionExtensions.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilder.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthChecksBuilder.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj create mode 100644 test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs create mode 100644 test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj create mode 100644 test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/CompositeHealthCheckResultTests.cs create mode 100644 test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs create mode 100644 test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs create mode 100644 test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj create mode 100644 test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/ServiceCollectionExtensionsTests.cs diff --git a/.gitignore b/.gitignore index 57e03b8437..7e9c96ceac 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ launchSettings.json .testPublish/ global.json korebuild-lock.txt + +# Rider and friends +.idea/ diff --git a/DiagnosticsPages.sln b/DiagnosticsPages.sln index c5bafc7b5b..655c4fcb5a 100644 --- a/DiagnosticsPages.sln +++ b/DiagnosticsPages.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26228.9 +VisualStudioVersion = 15.0.26923.0 MinimumVisualStudioVersion = 15.0.26730.03 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{509A6F36-AD80-4A18-B5B1-717D38DFF29D}" EndProject @@ -44,6 +44,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Diagnostics.EFCore.Function EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Diagnostics.FunctionalTests", "test\Diagnostics.FunctionalTests\Diagnostics.FunctionalTests.csproj", "{C142A666-D932-4E0D-8D18-5B08944C1077}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions", "src\Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions\Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj", "{0D103C24-B9E8-468A-B113-509FCFEF7B45}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Diagnostics.HealthChecks", "src\Microsoft.Extensions.Diagnostics.HealthChecks\Microsoft.Extensions.Diagnostics.HealthChecks.csproj", "{F285F000-9342-4A01-9706-BAB2B97B4F97}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Diagnostics.HealthChecks", "src\Microsoft.AspNetCore.Diagnostics.HealthChecks\Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj", "{1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealthChecksSample", "samples\HealthChecksSample\HealthChecksSample.csproj", "{3B4E60F6-E42D-496E-B96F-71A11DABAEE7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests", "test\Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests\Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj", "{E718CE19-23CC-427F-B06E-B866E9A35924}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Diagnostics.HealthChecks.Tests", "test\Microsoft.Extensions.Diagnostics.HealthChecks.Tests\Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj", "{3783E8E4-2E96-4987-A83A-0CCCAF4891C1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -260,6 +272,78 @@ Global {C142A666-D932-4E0D-8D18-5B08944C1077}.Release|Mixed Platforms.Build.0 = Release|Any CPU {C142A666-D932-4E0D-8D18-5B08944C1077}.Release|x86.ActiveCfg = Release|Any CPU {C142A666-D932-4E0D-8D18-5B08944C1077}.Release|x86.Build.0 = Release|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Debug|x86.Build.0 = Debug|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Release|Any CPU.Build.0 = Release|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Release|x86.ActiveCfg = Release|Any CPU + {0D103C24-B9E8-468A-B113-509FCFEF7B45}.Release|x86.Build.0 = Release|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Debug|x86.ActiveCfg = Debug|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Debug|x86.Build.0 = Debug|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Release|Any CPU.Build.0 = Release|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Release|x86.ActiveCfg = Release|Any CPU + {F285F000-9342-4A01-9706-BAB2B97B4F97}.Release|x86.Build.0 = Release|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Debug|x86.Build.0 = Debug|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Release|Any CPU.Build.0 = Release|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Release|x86.ActiveCfg = Release|Any CPU + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C}.Release|x86.Build.0 = Release|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Debug|x86.Build.0 = Debug|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Release|Any CPU.Build.0 = Release|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Release|x86.ActiveCfg = Release|Any CPU + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7}.Release|x86.Build.0 = Release|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Debug|x86.ActiveCfg = Debug|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Debug|x86.Build.0 = Debug|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Release|Any CPU.Build.0 = Release|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Release|x86.ActiveCfg = Release|Any CPU + {E718CE19-23CC-427F-B06E-B866E9A35924}.Release|x86.Build.0 = Release|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Debug|x86.Build.0 = Debug|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Release|Any CPU.Build.0 = Release|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Release|x86.ActiveCfg = Release|Any CPU + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -283,5 +367,14 @@ Global {AA3661A1-CE8D-4597-ADFD-A5A30834E5D0} = {2AF90579-B118-4583-AE88-672EFACB5BC4} {043C5272-D7F7-4DB1-830F-5DC93CC0878E} = {2AF90579-B118-4583-AE88-672EFACB5BC4} {C142A666-D932-4E0D-8D18-5B08944C1077} = {2AF90579-B118-4583-AE88-672EFACB5BC4} + {0D103C24-B9E8-468A-B113-509FCFEF7B45} = {509A6F36-AD80-4A18-B5B1-717D38DFF29D} + {F285F000-9342-4A01-9706-BAB2B97B4F97} = {509A6F36-AD80-4A18-B5B1-717D38DFF29D} + {1B2B1EF4-9066-4F38-ADCF-D05C6423E21C} = {509A6F36-AD80-4A18-B5B1-717D38DFF29D} + {3B4E60F6-E42D-496E-B96F-71A11DABAEE7} = {ACAA0157-A8C4-4152-93DE-90CCDF304087} + {E718CE19-23CC-427F-B06E-B866E9A35924} = {2AF90579-B118-4583-AE88-672EFACB5BC4} + {3783E8E4-2E96-4987-A83A-0CCCAF4891C1} = {2AF90579-B118-4583-AE88-672EFACB5BC4} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D915AA7B-4ADE-4BAC-AF65-1E800D3F3580} EndGlobalSection EndGlobal diff --git a/samples/HealthChecksSample/HealthChecksSample.csproj b/samples/HealthChecksSample/HealthChecksSample.csproj new file mode 100644 index 0000000000..1eade53383 --- /dev/null +++ b/samples/HealthChecksSample/HealthChecksSample.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp2.0 + + + + + + + + + + + + + diff --git a/samples/HealthChecksSample/Program.cs b/samples/HealthChecksSample/Program.cs new file mode 100644 index 0000000000..be3f8bcdf1 --- /dev/null +++ b/samples/HealthChecksSample/Program.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace HealthChecksSample +{ + public class Program + { + public static void Main(string[] args) + { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) => + new WebHostBuilder() + .ConfigureLogging(builder => + { + builder.AddConsole(); + }) + .UseKestrel() + .UseStartup() + .Build(); + } +} diff --git a/samples/HealthChecksSample/Startup.cs b/samples/HealthChecksSample/Startup.cs new file mode 100644 index 0000000000..e3bb04150c --- /dev/null +++ b/samples/HealthChecksSample/Startup.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthChecksSample +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.AddHealthChecks(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + app.UseHealthChecks("/health"); + + app.Run(async (context) => + { + await context.Response.WriteAsync("Hello World!"); + }); + } + } +} diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckAppBuilderExtensions.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckAppBuilderExtensions.cs new file mode 100644 index 0000000000..916ca46df4 --- /dev/null +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckAppBuilderExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// extension methods for the . + /// + public static class HealthCheckAppBuilderExtensions + { + /// + /// Adds a middleware that provides a REST API for requesting health check status. + /// + /// The . + /// The path on which to provide the API. + /// A reference to the after the operation has completed. + public static IApplicationBuilder UseHealthChecks(this IApplicationBuilder app, PathString path) + { + app = app ?? throw new ArgumentNullException(nameof(app)); + + return app.UseMiddleware(Options.Create(new HealthCheckOptions() + { + Path = path + })); + } + } +} diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs new file mode 100644 index 0000000000..18c10da287 --- /dev/null +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + public class HealthCheckMiddleware + { + private readonly RequestDelegate _next; + private readonly HealthCheckOptions _healthCheckOptions; + private readonly IHealthCheckService _healthCheckService; + + public HealthCheckMiddleware(RequestDelegate next, IOptions healthCheckOptions, IHealthCheckService healthCheckService) + { + _next = next; + _healthCheckOptions = healthCheckOptions.Value; + _healthCheckService = healthCheckService; + } + + /// + /// Process an individual request. + /// + /// + /// + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path == _healthCheckOptions.Path) + { + // Get results + var result = await _healthCheckService.CheckHealthAsync(context.RequestAborted); + + // Map status to response code + switch (result.Status) + { + case HealthCheckStatus.Failed: + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + break; + case HealthCheckStatus.Unhealthy: + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + break; + case HealthCheckStatus.Degraded: + // Degraded doesn't mean unhealthy so we return 200, but the content will contain more details + context.Response.StatusCode = StatusCodes.Status200OK; + break; + case HealthCheckStatus.Healthy: + context.Response.StatusCode = StatusCodes.Status200OK; + break; + default: + // This will only happen when we change HealthCheckStatus and we don't update this. + Debug.Fail($"Unrecognized HealthCheckStatus value: {result.Status}"); + throw new InvalidOperationException($"Unrecognized HealthCheckStatus value: {result.Status}"); + } + + // Render results to JSON + var json = new JObject( + new JProperty("status", result.Status.ToString()), + new JProperty("results", new JObject(result.Results.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)))))))))); + await context.Response.WriteAsync(json.ToString(Formatting.None)); + } + else + { + await _next(context); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs new file mode 100644 index 0000000000..8416e6843d --- /dev/null +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + /// + /// Contains options for the . + /// + public class HealthCheckOptions + { + /// + /// Gets or sets the path at which the Health Check results will be available. + /// + public PathString Path { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj new file mode 100644 index 0000000000..18befec631 --- /dev/null +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/Microsoft.AspNetCore.Diagnostics.HealthChecks.csproj @@ -0,0 +1,28 @@ + + + + ASP.NET Core middleware for returning the results of Health Checks in the application + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + ASP.NET Core middleware for returning the results of Health Checks in the application + + + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + + + + + + + + + + + + + diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckResult.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckResult.cs new file mode 100644 index 0000000000..e672c88db5 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckResult.cs @@ -0,0 +1,188 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the result of a health check. + /// + public struct HealthCheckResult + { + private static readonly IReadOnlyDictionary _emptyReadOnlyDictionary = new Dictionary(); + + private string _description; + private IReadOnlyDictionary _data; + + /// + /// Gets a value indicating the status of the component that was checked. + /// + public HealthCheckStatus Status { get; } + + /// + /// Gets an representing the exception that was thrown when checking for status (if any). + /// + /// + /// This value is expected to be 'null' if is . + /// + public Exception Exception { get; } + + /// + /// Gets a human-readable description of the status of the component that was checked. + /// + public string Description => _description ?? string.Empty; + + /// + /// Gets additional key-value pairs describing the health of the component. + /// + public IReadOnlyDictionary Data => _data ?? _emptyReadOnlyDictionary; + + /// + /// Creates a new with the specified , , + /// , and . + /// + /// A value indicating the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + /// A human-readable description of the status of the component that was checked. + /// Additional key-value pairs describing the health of the component. + public HealthCheckResult(HealthCheckStatus status, Exception exception, string description, IReadOnlyDictionary data) + { + if (status == HealthCheckStatus.Unknown) + { + throw new ArgumentException($"'{nameof(HealthCheckStatus.Unknown)}' is not a valid value for the 'status' parameter.", nameof(status)); + } + + Status = status; + Exception = exception; + _description = description; + _data = data; + } + + /// + /// Creates a representing an unhealthy component. + /// + /// A representing an unhealthy component. + public static HealthCheckResult Unhealthy() + => new HealthCheckResult(HealthCheckStatus.Unhealthy, exception: null, description: string.Empty, data: null); + + /// + /// Creates a representing an unhealthy component. + /// + /// A representing an unhealthy component. + /// A human-readable description of the status of the component that was checked. + public static HealthCheckResult Unhealthy(string description) + => new HealthCheckResult(HealthCheckStatus.Unhealthy, exception: null, description: description, data: null); + + /// + /// Creates a representing an unhealthy component. + /// + /// A representing an unhealthy component. + /// A human-readable description of the status of the component that was checked. + /// Additional key-value pairs describing the health of the component. + public static HealthCheckResult Unhealthy(string description, IReadOnlyDictionary data) + => new HealthCheckResult(HealthCheckStatus.Unhealthy, exception: null, description: description, data: data); + + /// + /// Creates a representing an unhealthy component. + /// + /// A representing an unhealthy component. + /// An representing the exception that was thrown when checking for status (if any). + public static HealthCheckResult Unhealthy(Exception exception) + => new HealthCheckResult(HealthCheckStatus.Unhealthy, exception, description: string.Empty, data: null); + + /// + /// Creates a representing an unhealthy component. + /// + /// A representing an unhealthy component. + /// A human-readable description of the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + public static HealthCheckResult Unhealthy(string description, Exception exception) + => new HealthCheckResult(HealthCheckStatus.Unhealthy, exception, description, data: null); + + /// + /// Creates a representing an unhealthy component. + /// + /// A representing an unhealthy component. + /// A human-readable description of the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + public static HealthCheckResult Unhealthy(string description, Exception exception, IReadOnlyDictionary data) + => new HealthCheckResult(HealthCheckStatus.Unhealthy, exception, description, data); + + /// + /// Creates a representing a healthy component. + /// + /// A representing a healthy component. + public static HealthCheckResult Healthy() + => new HealthCheckResult(HealthCheckStatus.Healthy, exception: null, description: string.Empty, data: null); + + /// + /// Creates a representing a healthy component. + /// + /// A representing a healthy component. + /// A human-readable description of the status of the component that was checked. + public static HealthCheckResult Healthy(string description) + => new HealthCheckResult(HealthCheckStatus.Healthy, exception: null, description: description, data: null); + + /// + /// Creates a representing a healthy component. + /// + /// A representing a healthy component. + /// A human-readable description of the status of the component that was checked. + /// Additional key-value pairs describing the health of the component. + public static HealthCheckResult Healthy(string description, IReadOnlyDictionary data) + => new HealthCheckResult(HealthCheckStatus.Healthy, exception: null, description: description, data: data); + + /// + /// Creates a representing a component in a degraded state. + /// + /// A representing a component in a degraded state. + public static HealthCheckResult Degraded() + => new HealthCheckResult(HealthCheckStatus.Degraded, exception: null, description: string.Empty, data: null); + + /// + /// Creates a representing a component in a degraded state. + /// + /// A representing a component in a degraded state. + /// A human-readable description of the status of the component that was checked. + public static HealthCheckResult Degraded(string description) + => new HealthCheckResult(HealthCheckStatus.Degraded, exception: null, description: description, data: null); + + /// + /// Creates a representing a component in a degraded state. + /// + /// A representing a component in a degraded state. + /// A human-readable description of the status of the component that was checked. + /// Additional key-value pairs describing the health of the component. + public static HealthCheckResult Degraded(string description, IReadOnlyDictionary data) + => new HealthCheckResult(HealthCheckStatus.Degraded, exception: null, description: description, data: data); + + /// + /// Creates a representing a component in a degraded state. + /// + /// A representing a component in a degraded state. + public static HealthCheckResult Degraded(Exception exception) + => new HealthCheckResult(HealthCheckStatus.Degraded, exception: null, description: string.Empty, data: null); + + /// + /// Creates a representing a component in a degraded state. + /// + /// A representing a component in a degraded state. + /// A human-readable description of the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + public static HealthCheckResult Degraded(string description, Exception exception) + => new HealthCheckResult(HealthCheckStatus.Degraded, exception, description, data: null); + + /// + /// Creates a representing a component in a degraded state. + /// + /// A representing a component in a degraded state. + /// A human-readable description of the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + public static HealthCheckResult Degraded(string description, Exception exception, IReadOnlyDictionary data) + => new HealthCheckResult(HealthCheckStatus.Degraded, exception, description, data); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckStatus.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckStatus.cs new file mode 100644 index 0000000000..c16739eadc --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthCheckStatus.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the status of a health check result. + /// + /// + /// The values of this enum or ordered from least healthy to most healthy. So is + /// greater than but less than . + /// + public enum HealthCheckStatus + { + /// + /// This value should not be returned by a health check. It is used to represent an uninitialized value. + /// + Unknown = 0, + + /// + /// This value should not be returned by a health check. It is used to indicate that an unexpected exception was + /// thrown when running the health check. + /// + Failed = 1, + + /// + /// Indicates that the health check determined that the component was unhealthy. + /// + Unhealthy = 2, + + /// + /// Indicates that the health check determined that the component was in a degraded state. + /// + Degraded = 3, + + /// + /// Indicates that the health check determined that the component was healthy. + /// + Healthy = 4, + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheck.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheck.cs new file mode 100644 index 0000000000..2bf751180d --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheck.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents a health check, which can be used to check the status of a component in the application, such as a backend service, database or some internal + /// state. + /// + public interface IHealthCheck + { + /// + /// Gets the name of the health check, which should indicate the component being checked. + /// + string Name { get; } + + /// + /// Runs the health check, returning the status of the component being checked. + /// + /// A that can be used to cancel the health check. + /// A that completes when the health check has finished, yielding the status of the component being checked. + Task CheckHealthAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj new file mode 100644 index 0000000000..bcbc46c8ca --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj @@ -0,0 +1,16 @@ + + + + Abstractions for defining health checks in .NET applications + +Commonly Used Types +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck + + Microsoft.Extensions.Diagnostics.HealthChecks + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + + + diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/CompositeHealthCheckResult.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/CompositeHealthCheckResult.cs new file mode 100644 index 0000000000..900b15bd71 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/CompositeHealthCheckResult.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the results of multiple health checks. + /// + public class CompositeHealthCheckResult + { + /// + /// A containing the results from each health check. + /// + /// + /// The keys in this dictionary map to the name of the health check, the values are the + /// returned when was called for that health check. + /// + public IReadOnlyDictionary Results { get; } + + /// + /// Gets a representing the aggregate status of all the health checks. + /// + /// + /// This value is determined by taking the "worst" result of all the results. So if any result is , + /// this value is . If no result is but any result is + /// , this value is , etc. + /// + public HealthCheckStatus Status { get; } + + /// + /// Create a new from the specified results. + /// + /// A containing the results from each health check. + public CompositeHealthCheckResult(IReadOnlyDictionary results) + { + Results = results; + Status = CalculateAggregateStatus(results.Values); + } + + private HealthCheckStatus CalculateAggregateStatus(IEnumerable results) + { + // This is basically a Min() check, but we know the possible range, so we don't need to walk the whole list + var currentValue = HealthCheckStatus.Healthy; + foreach (var result in results) + { + if (currentValue > result.Status) + { + currentValue = result.Status; + } + + if (currentValue == HealthCheckStatus.Failed) + { + // Game over, man! Game over! + // (We hit the worst possible status, so there's no need to keep iterating) + return currentValue; + } + } + + return currentValue; + } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheck.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheck.cs new file mode 100644 index 0000000000..e72910df33 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheck.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A simple implementation of which uses a provided delegate to + /// implement the check. + /// + public class HealthCheck : IHealthCheck + { + private readonly Func> _check; + + /// + /// Gets the name of the health check, which should indicate the component being checked. + /// + public string Name { get; } + + /// + /// Create an instance of from the specified and . + /// + /// The name of the health check, which should indicate the component being checked. + /// A delegate which provides the code to execute when the health check is run. + public HealthCheck(string name, Func> check) + { + Name = name; + _check = check; + } + + /// + /// Runs the health check, returning the status of the component being checked. + /// + /// A that can be used to cancel the health check. + /// A that completes when the health check has finished, yielding the status of the component being checked. + public Task CheckHealthAsync(CancellationToken cancellationToken = default) => _check(cancellationToken); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckLogScope.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckLogScope.cs new file mode 100644 index 0000000000..c7ef3ff5bd --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckLogScope.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal class HealthCheckLogScope : IReadOnlyList> + { + public string HealthCheckName { get; } + + int IReadOnlyCollection>.Count { get; } = 1; + + KeyValuePair IReadOnlyList>.this[int index] + { + get + { + if (index == 0) + { + return new KeyValuePair(nameof(HealthCheckName), HealthCheckName); + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + /// + /// Creates a new instance of with the provided name. + /// + /// The name of the health check being executed. + public HealthCheckLogScope(string healthCheckName) + { + HealthCheckName = healthCheckName; + } + + IEnumerator> IEnumerable>.GetEnumerator() + { + yield return new KeyValuePair(nameof(HealthCheckName), HealthCheckName); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable>)this).GetEnumerator(); + } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs new file mode 100644 index 0000000000..78d0a91de3 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs @@ -0,0 +1,132 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Default implementation of . + /// + public class HealthCheckService : IHealthCheckService + { + private readonly ILogger _logger; + + /// + /// A containing all the health checks registered in the application. + /// + /// + /// The key maps to the property of the health check, and the value is the + /// instance itself. + /// + public IReadOnlyDictionary Checks { get; } + + /// + /// Constructs a from the provided collection of instances. + /// + /// The instances that have been registered in the application. + public HealthCheckService(IEnumerable healthChecks) : this(healthChecks, NullLogger.Instance) { } + + /// + /// Constructs a from the provided collection of instances, and the provided logger. + /// + /// The instances that have been registered in the application. + /// A that can be used to log events that occur during health check operations. + public HealthCheckService(IEnumerable healthChecks, ILogger logger) + { + healthChecks = healthChecks ?? throw new ArgumentNullException(nameof(logger)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Scan the list for duplicate names to provide a better error if there are duplicates. + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var duplicates = new List(); + foreach (var check in healthChecks) + { + if (!names.Add(check.Name)) + { + duplicates.Add(check.Name); + } + } + + if (duplicates.Count > 0) + { + throw new ArgumentException($"Duplicate health checks were registered with the name(s): {string.Join(", ", duplicates)}", nameof(healthChecks)); + } + + Checks = healthChecks.ToDictionary(c => c.Name, StringComparer.OrdinalIgnoreCase); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + foreach (var check in Checks) + { + _logger.LogDebug("Health check '{healthCheckName}' has been registered", check.Key); + } + } + } + + /// + /// Runs all the health checks in the application and returns the aggregated status. + /// + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + public Task CheckHealthAsync(CancellationToken cancellationToken = default) => + CheckHealthAsync(Checks.Values, cancellationToken); + + /// + /// Runs the provided health checks and returns the aggregated status + /// + /// The instances to be run. + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + public async Task CheckHealthAsync(IEnumerable checks, CancellationToken cancellationToken = default) + { + var results = new Dictionary(Checks.Count, StringComparer.OrdinalIgnoreCase); + foreach (var check in checks) + { + cancellationToken.ThrowIfCancellationRequested(); + // If the health check does things like make Database queries using EF or backend HTTP calls, + // it may be valuable to know that logs it generates are part of a health check. So we start a scope. + using (_logger.BeginScope(new HealthCheckLogScope(check.Name))) + { + HealthCheckResult result; + try + { + _logger.LogTrace("Running health check: {healthCheckName}", check.Name); + result = await check.CheckHealthAsync(cancellationToken); + _logger.LogTrace("Health check '{healthCheckName}' completed with status '{healthCheckStatus}'", check.Name, result.Status); + } + catch (Exception ex) + { + // We don't log this as an error because a health check failing shouldn't bring down the active task. + _logger.LogError(ex, "Health check '{healthCheckName}' threw an unexpected exception", check.Name); + result = new HealthCheckResult(HealthCheckStatus.Failed, ex, ex.Message, data: null); + } + + // This can only happen if the result is default(HealthCheckResult) + if (result.Status == HealthCheckStatus.Unknown) + { + // This is different from the case above. We throw here because a health check is doing something specifically incorrect. + var exception = new InvalidOperationException($"Health check '{check.Name}' returned a result with a status of Unknown"); + _logger.LogError(exception, "Health check '{healthCheckName}' returned a result with a status of Unknown", check.Name); + throw exception; + } + + results[check.Name] = result; + } + } + return new CompositeHealthCheckResult(results); + } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckServiceCollectionExtensions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f0caa8db1f --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods for registering in an . + /// + public static class HealthCheckServiceCollectionExtensions + { + /// + /// Adds the to the container, using the provided delegate to register + /// health checks. + /// + /// + /// This operation is idempotent - multiple invocations will still only result in a single + /// instance in the . It can be invoked + /// multiple times in order to get access to the in multiple places. + /// + /// The to add the to. + /// An instance of from which health checks can be registered. + public static IHealthChecksBuilder AddHealthChecks(this IServiceCollection services) + { + services.TryAdd(ServiceDescriptor.Singleton()); + return new HealthChecksBuilder(services); + } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilder.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilder.cs new file mode 100644 index 0000000000..4e1d851eff --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal class HealthChecksBuilder : IHealthChecksBuilder + { + public IServiceCollection Services { get; } + + public HealthChecksBuilder(IServiceCollection services) + { + Services = services; + } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs new file mode 100644 index 0000000000..3b3ee1eb54 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods for registering instances in an . + /// + public static class HealthChecksBuilderAddCheckExtensions + { + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The to add the check to. + /// The name of the health check, which should indicate the component being checked. + /// A delegate which provides the code to execute when the health check is run. + /// The . + public static IHealthChecksBuilder AddCheck(this IHealthChecksBuilder builder, string name, Func> check) + { + builder.Services.AddSingleton(services => new HealthCheck(name, check)); + return builder; + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The to add the check to. + /// The name of the health check, which should indicate the component being checked. + /// A delegate which provides the code to execute when the health check is run. + /// The . + public static IHealthChecksBuilder AddCheck(this IHealthChecksBuilder builder, string name, Func> check) => + builder.AddCheck(name, _ => check()); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs new file mode 100644 index 0000000000..9c1bfbafc4 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A service which can be used to check the status of instances registered in the application. + /// + public interface IHealthCheckService + { + /// + /// A containing all the health checks registered in the application. + /// + /// + /// The key maps to the property of the health check, and the value is the + /// instance itself. + /// + IReadOnlyDictionary Checks { get; } + + /// + /// Runs all the health checks in the application and returns the aggregated status. + /// + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + Task CheckHealthAsync(CancellationToken cancellationToken = default); + + /// + /// Runs the provided health checks and returns the aggregated status + /// + /// The instances to be run. + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + Task CheckHealthAsync(IEnumerable checks, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthChecksBuilder.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthChecksBuilder.cs new file mode 100644 index 0000000000..22cd691aa2 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthChecksBuilder.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A builder used to collect instances of and register them on an . + /// + /// + /// This type wraps an and provides a place for health check components to attach extension + /// methods for registering themselves in the . + /// + public interface IHealthChecksBuilder + { + /// + /// Gets the into which instances should be registered. + /// + IServiceCollection Services { get; } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj new file mode 100644 index 0000000000..bc1ecc7101 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj @@ -0,0 +1,21 @@ + + + Components for performing health checks in .NET applications + +Commonly Used Types: +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheckService +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthChecksBuilder + + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + + + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs new file mode 100644 index 0000000000..c6aaee52ff --- /dev/null +++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs @@ -0,0 +1,243 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Diagnostics.HealthChecks +{ + public class HealthCheckMiddlewareTests + { + [Theory] + [InlineData("/frob")] + [InlineData("/health/")] // Match is exact, for now at least + public async Task IgnoresRequestThatDoesNotMatchPath(string requestPath) + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync(requestPath); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("/health")] + [InlineData("/Health")] + [InlineData("/HEALTH")] + public async Task ReturnsEmptyHealthyRequestIfNoHealthChecksRegistered(string requestPath) + { + var expectedJson = JsonConvert.SerializeObject(new + { + status = "Healthy", + results = new { } + }, Formatting.None); + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync(requestPath); + + var result = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedJson, result); + } + + [Fact] + public async Task ReturnsResultsFromHealthChecks() + { + var expectedJson = JsonConvert.SerializeObject(new + { + status = "Unhealthy", + results = new + { + Foo = new + { + status = "Healthy", + description = "Good to go!", + data = new { } + }, + Bar = new + { + status = "Degraded", + description = "Feeling a bit off.", + data = new { someUsefulAttribute = 42 } + }, + Baz = new + { + status = "Unhealthy", + description = "Not feeling good at all", + data = new { } + }, + Boz = new + { + status = "Unhealthy", + description = string.Empty, + data = new { } + }, + }, + }, Formatting.None); + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("Good to go!"))) + .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Degraded("Feeling a bit off.", new Dictionary() + { + { "someUsefulAttribute", 42 } + }))) + .AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Unhealthy("Not feeling good at all", new Exception("Bad times")))) + .AddCheck("Boz", () => Task.FromResult(HealthCheckResult.Unhealthy(new Exception("Very bad times")))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + var result = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedJson, result); + } + + [Fact] + public async Task StatusCodeIs200IfNoChecks() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks(); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task StatusCodeIs200IfAllChecksHealthy() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task StatusCodeIs200IfCheckIsDegraded() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Degraded("Not so great."))) + .AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task StatusCodeIs503IfCheckIsUnhealthy() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("Pretty bad."))) + .AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task StatusCodeIs500IfCheckIsFailed() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHealthChecks("/health"); + }) + .ConfigureServices(services => + { + services.AddHealthChecks() + .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) + .AddCheck("Bar", () => Task.FromResult(new HealthCheckResult(HealthCheckStatus.Failed, null, null, null))) + .AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + } +} diff --git a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj new file mode 100644 index 0000000000..e575aea3c6 --- /dev/null +++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.0;net461 + netcoreapp2.0 + Microsoft.AspNetCore.Diagnostics.HealthChecks + + + + + + + + + + + + + + + diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/CompositeHealthCheckResultTests.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/CompositeHealthCheckResultTests.cs new file mode 100644 index 0000000000..3852969cda --- /dev/null +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/CompositeHealthCheckResultTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks.Tests +{ + public class CompositeHealthCheckResultTests + { + [Theory] + [InlineData(HealthCheckStatus.Healthy)] + [InlineData(HealthCheckStatus.Degraded)] + [InlineData(HealthCheckStatus.Unhealthy)] + [InlineData(HealthCheckStatus.Failed)] + public void Status_MatchesWorstStatusInResults(HealthCheckStatus statusValue) + { + var result = new CompositeHealthCheckResult(new Dictionary() + { + {"Foo", HealthCheckResult.Healthy() }, + {"Bar", HealthCheckResult.Healthy() }, + {"Baz", new HealthCheckResult(statusValue, exception: null, description: null, data: null) }, + {"Quick", HealthCheckResult.Healthy() }, + {"Quack", HealthCheckResult.Healthy() }, + {"Quock", HealthCheckResult.Healthy() }, + }); + + Assert.Equal(statusValue, result.Status); + } + } +} diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs new file mode 100644 index 0000000000..2a7fa8395f --- /dev/null +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs @@ -0,0 +1,256 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthCheckServiceTests + { + [Fact] + public void Constructor_BuildsDictionaryOfChecks() + { + // Arrange + var fooCheck = new HealthCheck("Foo", _ => Task.FromResult(HealthCheckResult.Healthy())); + var barCheck = new HealthCheck("Bar", _ => Task.FromResult(HealthCheckResult.Healthy())); + var bazCheck = new HealthCheck("Baz", _ => Task.FromResult(HealthCheckResult.Healthy())); + var checks = new[] { fooCheck, barCheck, bazCheck }; + + // Act + var service = new HealthCheckService(checks); + + // Assert + Assert.Same(fooCheck, service.Checks["Foo"]); + Assert.Same(barCheck, service.Checks["Bar"]); + Assert.Same(bazCheck, service.Checks["Baz"]); + Assert.Equal(3, service.Checks.Count); + } + + [Fact] + public void Constructor_ThrowsUsefulExceptionForDuplicateNames() + { + // Arrange + var checks = new[] + { + new HealthCheck("Foo", _ => Task.FromResult(HealthCheckResult.Healthy())), + new HealthCheck("Foo", _ => Task.FromResult(HealthCheckResult.Healthy())), + new HealthCheck("Bar", _ => Task.FromResult(HealthCheckResult.Healthy())), + new HealthCheck("Baz", _ => Task.FromResult(HealthCheckResult.Healthy())), + new HealthCheck("Baz", _ => Task.FromResult(HealthCheckResult.Healthy())), + }; + + // Act + var exception = Assert.Throws(() => new HealthCheckService(checks)); + + // Assert + Assert.Equal($"Duplicate health checks were registered with the name(s): Foo, Baz{Environment.NewLine}Parameter name: healthChecks", exception.Message); + } + + [Fact] + public async Task CheckAsync_RunsAllChecksAndAggregatesResultsAsync() + { + const string DataKey = "Foo"; + const string DataValue = "Bar"; + const string DegradedMessage = "I'm not feeling so good"; + const string UnhealthyMessage = "Halp!"; + const string HealthyMessage = "Everything is A-OK"; + var exception = new Exception("Things are pretty bad!"); + + // Arrange + var data = new Dictionary() + { + { DataKey, DataValue } + }; + + var healthyCheck = new HealthCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + var degradedCheck = new HealthCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + var unhealthyCheck = new HealthCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); + + var service = new HealthCheckService(new[] + { + healthyCheck, + degradedCheck, + unhealthyCheck, + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection(results.Results, + actual => + { + Assert.Equal(healthyCheck.Name, actual.Key); + Assert.Equal(HealthyMessage, actual.Value.Description); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Collection(actual.Value.Data, item => + { + Assert.Equal(DataKey, item.Key); + Assert.Equal(DataValue, item.Value); + }); + }, + actual => + { + Assert.Equal(degradedCheck.Name, actual.Key); + Assert.Equal(DegradedMessage, actual.Value.Description); + Assert.Equal(HealthCheckStatus.Degraded, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Empty(actual.Value.Data); + }, + actual => + { + Assert.Equal(unhealthyCheck.Name, actual.Key); + Assert.Equal(UnhealthyMessage, actual.Value.Description); + Assert.Equal(HealthCheckStatus.Unhealthy, actual.Value.Status); + Assert.Same(exception, actual.Value.Exception); + Assert.Empty(actual.Value.Data); + }); + } + + [Fact] + public async Task CheckAsync_RunsProvidedChecksAndAggregatesResultsAsync() + { + const string DataKey = "Foo"; + const string DataValue = "Bar"; + const string DegradedMessage = "I'm not feeling so good"; + const string UnhealthyMessage = "Halp!"; + const string HealthyMessage = "Everything is A-OK"; + var exception = new Exception("Things are pretty bad!"); + + // Arrange + var data = new Dictionary + { + { DataKey, DataValue } + }; + + var healthyCheck = new HealthCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + var degradedCheck = new HealthCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + var unhealthyCheck = new HealthCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); + + var service = new HealthCheckService(new[] + { + healthyCheck, + degradedCheck, + unhealthyCheck, + }); + + // Act + var results = await service.CheckHealthAsync(new[] + { + service.Checks["HealthyCheck"] + }); + + // Assert + Assert.Collection(results.Results, + actual => + { + Assert.Equal(healthyCheck.Name, actual.Key); + Assert.Equal(HealthyMessage, actual.Value.Description); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Collection(actual.Value.Data, item => + { + Assert.Equal(DataKey, item.Key); + Assert.Equal(DataValue, item.Value); + }); + }); + } + + [Fact] + public async Task CheckHealthAsync_ConvertsExceptionInHealthCheckerToFailedResultAsync() + { + // Arrange + var thrownException = new InvalidOperationException("Whoops!"); + var faultedException = new InvalidOperationException("Ohnoes!"); + var service = new HealthCheckService(new[] + { + new HealthCheck("Throws", ct => throw thrownException), + new HealthCheck("Faults", ct => Task.FromException(faultedException)), + new HealthCheck("Succeeds", ct => Task.FromResult(HealthCheckResult.Healthy())), + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection(results.Results, + actual => + { + Assert.Equal("Throws", actual.Key); + Assert.Equal(thrownException.Message, actual.Value.Description); + Assert.Equal(HealthCheckStatus.Failed, actual.Value.Status); + Assert.Same(thrownException, actual.Value.Exception); + }, + actual => + { + Assert.Equal("Faults", actual.Key); + Assert.Equal(faultedException.Message, actual.Value.Description); + Assert.Equal(HealthCheckStatus.Failed, actual.Value.Status); + Assert.Same(faultedException, actual.Value.Exception); + }, + actual => + { + Assert.Equal("Succeeds", actual.Key); + Assert.Empty(actual.Value.Description); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + }); + } + + [Fact] + public async Task CheckHealthAsync_SetsUpALoggerScopeForEachCheck() + { + // Arrange + var sink = new TestSink(); + var check = new HealthCheck("TestScope", cancellationToken => + { + Assert.Collection(sink.Scopes, + actual => + { + Assert.Equal(actual.LoggerName, typeof(HealthCheckService).FullName); + Assert.Collection((IEnumerable>)actual.Scope, + item => + { + Assert.Equal("HealthCheckName", item.Key); + Assert.Equal("TestScope", item.Value); + }); + }); + return Task.FromResult(HealthCheckResult.Healthy()); + }); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var service = new HealthCheckService(new[] { check }, loggerFactory.CreateLogger()); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection(results.Results, actual => + { + Assert.Equal("TestScope", actual.Key); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_ThrowsIfCheckReturnsUnknownStatusResult() + { + // Arrange + var service = new HealthCheckService(new[] + { + new HealthCheck("Kaboom", ct => Task.FromResult(default(HealthCheckResult))), + }); + + // Act + var ex = await Assert.ThrowsAsync(() => service.CheckHealthAsync()); + + // Assert + Assert.Equal("Health check 'Kaboom' returned a result with a status of Unknown", ex.Message); + } + } +} diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs new file mode 100644 index 0000000000..a24a4a5b4e --- /dev/null +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthChecksBuilderTests + { + [Fact] + public void ChecksCanBeRegisteredInMultipleCallsToAddHealthChecks() + { + var services = new ServiceCollection(); + services.AddHealthChecks() + .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy())); + services.AddHealthChecks() + .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Healthy())); + + // Act + var healthCheckService = services.BuildServiceProvider().GetRequiredService(); + + // Assert + Assert.Collection(healthCheckService.Checks, + actual => Assert.Equal("Foo", actual.Key), + actual => Assert.Equal("Bar", actual.Key)); + } + } +} diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj new file mode 100644 index 0000000000..d6a1b4e79e --- /dev/null +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp2.0;net461 + netcoreapp2.0 + Microsoft.Extensions.Diagnostics.HealthChecks + + + + + + + + + + + + + + diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/ServiceCollectionExtensionsTests.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..b33bd36eb6 --- /dev/null +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddHealthChecks(); + services.AddHealthChecks(); + + // Assert + Assert.Collection(services, + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IHealthCheckService), actual.ServiceType); + Assert.Equal(typeof(HealthCheckService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }); + } + } +}