From 3e4a3d0b9013f8b41ff848d1de5c3a0d45c359c8 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Thu, 30 Aug 2018 10:51:48 -0700 Subject: [PATCH] Allow health checks to use any DI lifetime (#466) * Allow health checks to use any DI lifetime This change allows registered IHealthCheck implementations to use any DI lifetime. This is necessary for scenarios like using EF which requires a scope. The works by having the health check service create a scope for each time it queries health checks. This scope does not overlap or share state with other scopes (the request scope) so there is no crosstalk between processing going on per-request in ASP.NET Core and the health check operation. * PR feedback and some logging cleanup --- build/dependencies.props | 1 + samples/HealthChecksSample/DBHealthStartup.cs | 1 - .../LivenessProbeStartup.cs | 8 +- samples/HealthChecksSample/Program.cs | 4 +- samples/HealthChecksSample/appsettings.json | 8 + .../HealthCheckMiddleware.cs | 5 +- .../HealthCheckOptions.cs | 12 +- .../HealthCheckService.cs | 206 +++++++++-------- .../HealthChecksBuilderAddCheckExtensions.cs | 24 +- .../IHealthCheckService.cs | 40 ++-- ...Extensions.Diagnostics.HealthChecks.csproj | 1 + .../Properties/AssemblyInfo.cs | 3 + .../HealthCheckMiddlewareTests.cs | 35 +-- .../HealthCheckServiceTests.cs | 212 +++++++++++++----- .../HealthChecksBuilderTests.cs | 11 +- 15 files changed, 346 insertions(+), 225 deletions(-) create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/Properties/AssemblyInfo.cs diff --git a/build/dependencies.props b/build/dependencies.props index b8974fdba7..ad2a6ed050 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -31,6 +31,7 @@ 2.2.0-preview1-34967 2.2.0-preview1-34967 2.2.0-preview1-34967 + 2.2.0-preview1-34967 2.0.9 2.1.2 2.2.0-preview1-26618-02 diff --git a/samples/HealthChecksSample/DBHealthStartup.cs b/samples/HealthChecksSample/DBHealthStartup.cs index f82f4c54ef..58b6c8d157 100644 --- a/samples/HealthChecksSample/DBHealthStartup.cs +++ b/samples/HealthChecksSample/DBHealthStartup.cs @@ -21,7 +21,6 @@ namespace HealthChecksSample { // Registers required services for health checks services.AddHealthChecks() - // Add a health check for a SQL database .AddCheck(new SqlConnectionHealthCheck("MyDatabase", Configuration["ConnectionStrings:DefaultConnection"])); } diff --git a/samples/HealthChecksSample/LivenessProbeStartup.cs b/samples/HealthChecksSample/LivenessProbeStartup.cs index f5eabc6696..aa644d231f 100644 --- a/samples/HealthChecksSample/LivenessProbeStartup.cs +++ b/samples/HealthChecksSample/LivenessProbeStartup.cs @@ -17,7 +17,6 @@ namespace HealthChecksSample // Registers required services for health checks services .AddHealthChecks() - .AddCheck("identity", () => Task.FromResult(HealthCheckResult.Healthy())) .AddCheck(new SlowDependencyHealthCheck()); } @@ -53,11 +52,8 @@ namespace HealthChecksSample // The liveness check uses an 'identity' health check that always returns healthy app.UseHealthChecks("/health/live", new HealthCheckOptions() { - // Filters the set of health checks run by this middleware - HealthCheckNames = - { - "identity", - }, + // Exclude all checks, just return a 200. + Predicate = (check) => false, }); app.Run(async (context) => diff --git a/samples/HealthChecksSample/Program.cs b/samples/HealthChecksSample/Program.cs index d24e0b38c1..425b0b3f1a 100644 --- a/samples/HealthChecksSample/Program.cs +++ b/samples/HealthChecksSample/Program.cs @@ -15,7 +15,7 @@ namespace HealthChecksSample { _scenarios = new Dictionary(StringComparer.OrdinalIgnoreCase) { - { "", typeof(BasicStartup) }, + { "", typeof(DBHealthStartup) }, { "basic", typeof(BasicStartup) }, { "writer", typeof(CustomWriterStartup) }, { "liveness", typeof(LivenessProbeStartup) }, @@ -48,6 +48,8 @@ namespace HealthChecksSample .UseConfiguration(config) .ConfigureLogging(builder => { + builder.SetMinimumLevel(LogLevel.Trace); + builder.AddConfiguration(config); builder.AddConsole(); }) .UseKestrel() diff --git a/samples/HealthChecksSample/appsettings.json b/samples/HealthChecksSample/appsettings.json index 77b5f890a4..21b2dcdbfd 100644 --- a/samples/HealthChecksSample/appsettings.json +++ b/samples/HealthChecksSample/appsettings.json @@ -1,5 +1,13 @@ { "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=HealthCheckSample;Trusted_Connection=True;MultipleActiveResultSets=true;ConnectRetryCount=0" + }, + "Logging": { + "LogLevel": { + "Default": "Debug" + }, + "Console": { + "IncludeScopes": "true" + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs index 3a220f2595..20ec5e356e 100644 --- a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs @@ -16,7 +16,6 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks private readonly RequestDelegate _next; private readonly HealthCheckOptions _healthCheckOptions; private readonly IHealthCheckService _healthCheckService; - private readonly IHealthCheck[] _checks; public HealthCheckMiddleware( RequestDelegate next, @@ -41,8 +40,6 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks _next = next; _healthCheckOptions = healthCheckOptions.Value; _healthCheckService = healthCheckService; - - _checks = FilterHealthChecks(_healthCheckService.Checks, healthCheckOptions.Value.HealthCheckNames); } /// @@ -58,7 +55,7 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks } // Get results - var result = await _healthCheckService.CheckHealthAsync(_checks, httpContext.RequestAborted); + var result = await _healthCheckService.CheckHealthAsync(_healthCheckOptions.Predicate, httpContext.RequestAborted); // Map status to response code - this is customizable via options. if (!_healthCheckOptions.ResultStatusCodes.TryGetValue(result.Status, out var statusCode)) diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs index b57374f2ae..7930d25577 100644 --- a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs @@ -15,15 +15,19 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks public class HealthCheckOptions { /// - /// Gets a set of health check names used to filter the set of health checks run. + /// Gets or sets a predicate that is used to filter the set of health checks executed. /// /// - /// If is empty, the will run all + /// If is null, the will run all /// registered health checks - this is the default behavior. To run a subset of health checks, - /// add the names of the desired health checks. + /// provide a function that filters the set of checks. /// - public ISet HealthCheckNames { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public Func Predicate { get; set; } + /// + /// Gets a dictionary mapping the to an HTTP status code applied to the response. + /// This property can be used to configure the status codes returned for each status. + /// public IDictionary ResultStatusCodes { get; } = new Dictionary() { { HealthCheckStatus.Healthy, StatusCodes.Status200OK }, diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs index 35cc0a1c82..111113ea77 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckService.cs @@ -6,127 +6,139 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Extensions.Diagnostics.HealthChecks { - /// - /// Default implementation of . - /// - public class HealthCheckService : IHealthCheckService + internal class HealthCheckService : IHealthCheckService { + private readonly IServiceScopeFactory _scopeFactory; 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) + public HealthCheckService(IServiceScopeFactory scopeFactory, ILogger logger) { - healthChecks = healthChecks ?? throw new ArgumentNullException(nameof(healthChecks)); + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _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) + // We're specifically going out of our way to do this at startup time. We want to make sure you + // get any kind of health-check related error as early as possible. Waiting until someone + // actually tries to **run** health checks would be real baaaaad. + using (var scope = _scopeFactory.CreateScope()) { - 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); - } + var healthChecks = scope.ServiceProvider.GetRequiredService>(); + EnsureNoDuplicates(healthChecks); } } - /// - /// 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) + CheckHealthAsync(predicate: null, cancellationToken); + + public async Task CheckHealthAsync( + Func predicate, + CancellationToken cancellationToken = default) { - var results = new Dictionary(Checks.Count, StringComparer.OrdinalIgnoreCase); - foreach (var check in checks) + using (var scope = _scopeFactory.CreateScope()) { - 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))) + var healthChecks = scope.ServiceProvider.GetRequiredService>(); + + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var healthCheck in healthChecks) { - HealthCheckResult result; - try + if (predicate != null && !predicate(healthCheck)) { - _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); + continue; } - // 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; - } + cancellationToken.ThrowIfCancellationRequested(); - results[check.Name] = result; + // 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(healthCheck.Name))) + { + HealthCheckResult result; + try + { + Log.HealthCheckBegin(_logger, healthCheck); + var stopwatch = ValueStopwatch.StartNew(); + result = await healthCheck.CheckHealthAsync(cancellationToken); + Log.HealthCheckEnd(_logger, healthCheck, result, stopwatch.GetElapsedTime()); + } + catch (Exception ex) + { + Log.HealthCheckError(_logger, healthCheck, ex); + 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. + throw new InvalidOperationException($"Health check '{healthCheck.Name}' returned a result with a status of Unknown"); + } + + results[healthCheck.Name] = result; + } } + + return new CompositeHealthCheckResult(results); + } + } + + private static void EnsureNoDuplicates(IEnumerable healthChecks) + { + // Scan the list for duplicate names to provide a better error if there are duplicates. + var duplicateNames = healthChecks + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateNames.Count > 0) + { + throw new ArgumentException($"Duplicate health checks were registered with the name(s): {string.Join(", ", duplicateNames)}", nameof(healthChecks)); + } + } + + private static class Log + { + public static class EventIds + { + public static readonly EventId HealthCheckBegin = new EventId(100, "HealthCheckBegin"); + public static readonly EventId HealthCheckEnd = new EventId(101, "HealthCheckEnd"); + public static readonly EventId HealthCheckError = new EventId(102, "HealthCheckError"); + } + + private static readonly Action _healthCheckBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckBegin, + "Running health check {HealthCheckName}"); + + private static readonly Action _healthCheckEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckEnd, + "Health check {HealthCheckName} completed after {ElapsedMilliseconds}ms with status {HealthCheckStatus}"); + + private static readonly Action _healthCheckError = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckError, + "Health check {HealthCheckName} threw an unhandled exception"); + + public static void HealthCheckBegin(ILogger logger, IHealthCheck healthCheck) + { + _healthCheckBegin(logger, healthCheck.Name, null); + } + + public static void HealthCheckEnd(ILogger logger, IHealthCheck healthCheck, HealthCheckResult result, TimeSpan duration) + { + _healthCheckEnd(logger, healthCheck.Name, duration.TotalMilliseconds, result.Status, null); + } + + public static void HealthCheckError(ILogger logger, IHealthCheck healthCheck, Exception exception) + { + _healthCheckError(logger, healthCheck.Name, exception); } - return new CompositeHealthCheckResult(results); } } } diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs index b2f931fcd3..d3af5b17c2 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthChecksBuilderAddCheckExtensions.cs @@ -68,7 +68,7 @@ namespace Microsoft.Extensions.DependencyInjection } /// - /// Adds a new health check with the implementation. + /// Adds a new health check with the provided implementation. /// /// The to add the check to. /// An implementation. @@ -88,5 +88,27 @@ namespace Microsoft.Extensions.DependencyInjection builder.Services.AddSingleton(check); return builder; } + + /// + /// Adds a new health check as a transient dependency injected service with the provided type. + /// + /// The health check implementation type. + /// The . + /// The . + /// + /// This method will register a transient service of type with the + /// provided implementation type . Using this method to register a health + /// check allows you to register a health check that depends on transient and scoped services. + /// + public static IHealthChecksBuilder AddCheck(this IHealthChecksBuilder builder) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.Add(ServiceDescriptor.Transient(typeof(IHealthCheck), typeof(T))); + return builder; + } } } diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs index 9c1bfbafc4..f931475a4d 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/IHealthCheckService.cs @@ -1,26 +1,38 @@ // 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; 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. + /// A service which can be used to check the status of instances + /// registered in the application. /// + /// + /// + /// The default implementation of is registered in the dependency + /// injection container as a singleton service by calling + /// . + /// + /// + /// The returned by + /// + /// provides a convenience API for registering health checks. + /// + /// + /// The default implementation of will use all services + /// of type registered in the dependency injection container. + /// implementations may be registered with any service lifetime. The implementation will create a scope + /// for each aggregate health check operation and use the scope to resolve services. The scope + /// created for executing health checks is controlled by the health checks service and does not + /// share scoped services with any other scope 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. /// @@ -34,13 +46,15 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks /// /// Runs the provided health checks and returns the aggregated status /// - /// The instances to be run. + /// + /// A predicate that can be used to include health checks based on user-defined criteria. + /// /// 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, + Task CheckHealthAsync(Func predicate, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj index 56f70f7a10..2b3d0e6261 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj @@ -14,6 +14,7 @@ Microsoft.Extensions.Diagnostics.HealthChecks.IHealthChecksBuilder + diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Properties/AssemblyInfo.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..13e969bfad --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Extensions.Diagnostics.HealthChecks.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ 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 index 692e8b5bfd..1af49d8422 100644 --- a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs +++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs @@ -302,11 +302,7 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks { app.UseHealthChecks("/health", new HealthCheckOptions() { - HealthCheckNames = - { - "Baz", - "FOO", - }, + Predicate = (check) => check.Name == "Foo" || check.Name == "Baz", }); }) .ConfigureServices(services => @@ -327,35 +323,6 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); } - [Fact] - public void CanFilterChecks_ThrowsForMissingCheck() - { - var builder = new WebHostBuilder() - .Configure(app => - { - app.UseHealthChecks("/health", new HealthCheckOptions() - { - HealthCheckNames = - { - "Bazzzzzz", - "FOO", - }, - }); - }) - .ConfigureServices(services => - { - services.AddHealthChecks() - .AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))) - .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("A-ok!"))) - .AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!"))); - }); - - var ex = Assert.Throws(() => new TestServer(builder)); - Assert.Equal( - "The following health checks were not found: 'Bazzzzzz'. Registered health checks: 'Foo, Bar, Baz'.", - ex.Message); - } - [Fact] public async Task CanListenOnPort_AcceptsRequest_OnSpecifiedPort() { diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs index 2a7fa8395f..6e7c28e9ef 100644 --- a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckServiceTests.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Xunit; @@ -12,40 +14,30 @@ 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())), - }; + // + // Doing this the old fashioned way so we can verify that the exception comes + // from the constructor. + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddOptions(); + serviceCollection.AddHealthChecks() + .AddCheck(new HealthCheck("Foo", _ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck(new HealthCheck("Foo", _ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck(new HealthCheck("Bar", _ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck(new HealthCheck("Baz", _ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck(new HealthCheck("Baz", _ => Task.FromResult(HealthCheckResult.Healthy()))); + + var services = serviceCollection.BuildServiceProvider(); + + var scopeFactory = services.GetRequiredService(); + var logger = services.GetRequiredService>(); // Act - var exception = Assert.Throws(() => new HealthCheckService(checks)); + var exception = Assert.Throws(() => new HealthCheckService(scopeFactory, logger)); // Assert Assert.Equal($"Duplicate health checks were registered with the name(s): Foo, Baz{Environment.NewLine}Parameter name: healthChecks", exception.Message); @@ -67,15 +59,11 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks { 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[] + var service = CreateHealthChecksService(b => { - healthyCheck, - degradedCheck, - unhealthyCheck, + b.AddCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + b.AddCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + b.AddCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); }); // Act @@ -85,7 +73,7 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks Assert.Collection(results.Results, actual => { - Assert.Equal(healthyCheck.Name, actual.Key); + Assert.Equal("HealthyCheck", actual.Key); Assert.Equal(HealthyMessage, actual.Value.Description); Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); Assert.Null(actual.Value.Exception); @@ -97,7 +85,7 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks }, actual => { - Assert.Equal(degradedCheck.Name, actual.Key); + Assert.Equal("DegradedCheck", actual.Key); Assert.Equal(DegradedMessage, actual.Value.Description); Assert.Equal(HealthCheckStatus.Degraded, actual.Value.Status); Assert.Null(actual.Value.Exception); @@ -105,7 +93,7 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks }, actual => { - Assert.Equal(unhealthyCheck.Name, actual.Key); + Assert.Equal("UnhealthyCheck", actual.Key); Assert.Equal(UnhealthyMessage, actual.Value.Description); Assert.Equal(HealthCheckStatus.Unhealthy, actual.Value.Status); Assert.Same(exception, actual.Value.Exception); @@ -114,7 +102,7 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks } [Fact] - public async Task CheckAsync_RunsProvidedChecksAndAggregatesResultsAsync() + public async Task CheckAsync_RunsFilteredChecksAndAggregatesResultsAsync() { const string DataKey = "Foo"; const string DataValue = "Bar"; @@ -129,28 +117,21 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks { 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[] + var service = CreateHealthChecksService(b => { - healthyCheck, - degradedCheck, - unhealthyCheck, + b.AddCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + b.AddCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + b.AddCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); }); // Act - var results = await service.CheckHealthAsync(new[] - { - service.Checks["HealthyCheck"] - }); + var results = await service.CheckHealthAsync(c => c.Name == "HealthyCheck"); // Assert Assert.Collection(results.Results, actual => { - Assert.Equal(healthyCheck.Name, actual.Key); + Assert.Equal("HealthyCheck", actual.Key); Assert.Equal(HealthyMessage, actual.Value.Description); Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); Assert.Null(actual.Value.Exception); @@ -168,11 +149,12 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks // Arrange var thrownException = new InvalidOperationException("Whoops!"); var faultedException = new InvalidOperationException("Ohnoes!"); - var service = new HealthCheckService(new[] + + var service = CreateHealthChecksService(b => { - new HealthCheck("Throws", ct => throw thrownException), - new HealthCheck("Faults", ct => Task.FromException(faultedException)), - new HealthCheck("Succeeds", ct => Task.FromResult(HealthCheckResult.Healthy())), + b.AddCheck("Throws", ct => throw thrownException); + b.AddCheck("Faults", ct => Task.FromException(faultedException)); + b.AddCheck("Succeeds", ct => Task.FromResult(HealthCheckResult.Healthy())); }); // Act @@ -223,8 +205,15 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks }); return Task.FromResult(HealthCheckResult.Healthy()); }); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); - var service = new HealthCheckService(new[] { check }, loggerFactory.CreateLogger()); + var service = CreateHealthChecksService(b => + { + // Override the logger factory for testing + b.Services.AddSingleton(loggerFactory); + + b.AddCheck(check); + }); // Act var results = await service.CheckHealthAsync(); @@ -241,9 +230,9 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks public async Task CheckHealthAsync_ThrowsIfCheckReturnsUnknownStatusResult() { // Arrange - var service = new HealthCheckService(new[] + var service = CreateHealthChecksService(b => { - new HealthCheck("Kaboom", ct => Task.FromResult(default(HealthCheckResult))), + b.AddCheck("Kaboom", ct => Task.FromResult(default(HealthCheckResult))); }); // Act @@ -252,5 +241,108 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks // Assert Assert.Equal("Health check 'Kaboom' returned a result with a status of Unknown", ex.Message); } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnTransientService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddTransient(); + + b.AddCheck(); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Results, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnScopedService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddScoped(); + + b.AddCheck(); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Results, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnSingletonService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddSingleton(); + + b.AddCheck(); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Results, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthCheckStatus.Healthy, actual.Value.Status); + }); + } + + private static HealthCheckService CreateHealthChecksService(Action configure) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + + var builder = services.AddHealthChecks(); + if (configure != null) + { + configure(builder); + } + + return (HealthCheckService)services.BuildServiceProvider(validateScopes: true).GetRequiredService(); + } + + private class AnotherService { } + + private class CheckWithServiceDependency : IHealthCheck + { + public CheckWithServiceDependency(AnotherService _) + { + } + + public string Name => "Test"; + + public Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(HealthCheckResult.Healthy()); + } + } } } diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs index a24a4a5b4e..f1bcbbf00b 100644 --- a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthChecksBuilderTests.cs @@ -1,6 +1,8 @@ // 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.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -19,12 +21,13 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks .AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Healthy())); // Act - var healthCheckService = services.BuildServiceProvider().GetRequiredService(); + var checks = services.BuildServiceProvider().GetRequiredService>(); // Assert - Assert.Collection(healthCheckService.Checks, - actual => Assert.Equal("Foo", actual.Key), - actual => Assert.Equal("Bar", actual.Key)); + Assert.Collection( + checks, + actual => Assert.Equal("Foo", actual.Name), + actual => Assert.Equal("Bar", actual.Name)); } } }