Skip to content

Commit

Permalink
Refactor healthchecks service mapping to support filtering on check (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK authored Jun 14, 2023
1 parent ff1a07b commit 697f349
Show file tree
Hide file tree
Showing 15 changed files with 449 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand Down
59 changes: 54 additions & 5 deletions src/Grpc.AspNetCore.HealthChecks/GrpcHealthChecksPublisher.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand All @@ -16,32 +16,81 @@

#endregion

using System.Linq;
using Grpc.Health.V1;
using Grpc.HealthCheck;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Grpc.AspNetCore.HealthChecks;

internal sealed class GrpcHealthChecksPublisher : IHealthCheckPublisher
{
private readonly HealthServiceImpl _healthService;
private readonly ILogger _logger;
private readonly GrpcHealthChecksOptions _options;

public GrpcHealthChecksPublisher(HealthServiceImpl healthService, IOptions<GrpcHealthChecksOptions> options)
public GrpcHealthChecksPublisher(HealthServiceImpl healthService, IOptions<GrpcHealthChecksOptions> options, ILoggerFactory loggerFactory)
{
_healthService = healthService;
_options = options.Value;
_logger = loggerFactory.CreateLogger<GrpcHealthChecksPublisher>();
}

public Task PublishAsync(HealthReport report, CancellationToken cancellationToken)
{
foreach (var registration in _options.Services)
Log.EvaluatingPublishedHealthReport(_logger, report.Entries.Count, _options.Services.Count);

foreach (var serviceMapping in _options.Services)
{
var resolvedStatus = HealthChecksStatusHelpers.GetStatus(report, registration.Predicate);
IEnumerable<KeyValuePair<string, HealthReportEntry>> serviceEntries = report.Entries;

if (serviceMapping.HealthCheckPredicate != null)
{
serviceEntries = serviceEntries.Where(entry =>
{
var context = new HealthCheckMapContext(entry.Key, entry.Value.Tags);
return serviceMapping.HealthCheckPredicate(context);
});
}

#pragma warning disable CS0618 // Type or member is obsolete
if (serviceMapping.Predicate != null)
{
serviceEntries = serviceEntries.Where(entry =>
{
var result = new HealthResult(entry.Key, entry.Value.Tags, entry.Value.Status, entry.Value.Description, entry.Value.Duration, entry.Value.Exception, entry.Value.Data);
return serviceMapping.Predicate(result);
});
}
#pragma warning restore CS0618 // Type or member is obsolete

var (resolvedStatus, resultCount) = HealthChecksStatusHelpers.GetStatus(serviceEntries);

_healthService.SetStatus(registration.Name, resolvedStatus);
Log.ServiceMappingStatusUpdated(_logger, serviceMapping.Name, resolvedStatus, resultCount);
_healthService.SetStatus(serviceMapping.Name, resolvedStatus);
}

return Task.CompletedTask;
}

private static class Log
{
private static readonly Action<ILogger, int, int, Exception?> _evaluatingPublishedHealthReport =
LoggerMessage.Define<int, int>(LogLevel.Trace, new EventId(1, "EvaluatingPublishedHealthReport"), "Evaluating {HealthReportEntryCount} published health report entries against {ServiceMappingCount} service mappings.");

private static readonly Action<ILogger, string, HealthCheckResponse.Types.ServingStatus, int, Exception?> _serviceMappingStatusUpdated =
LoggerMessage.Define<string, HealthCheckResponse.Types.ServingStatus, int>(LogLevel.Debug, new EventId(2, "ServiceMappingStatusUpdated"), "Service '{ServiceName}' status updated to {Status}. {EntriesCount} health report entries evaluated.");

public static void EvaluatingPublishedHealthReport(ILogger logger, int healthReportEntryCount, int serviceMappingCount)
{
_evaluatingPublishedHealthReport(logger, healthReportEntryCount, serviceMappingCount, null);
}

public static void ServiceMappingStatusUpdated(ILogger logger, string serviceName, HealthCheckResponse.Types.ServingStatus status, int entriesCount)
{
_serviceMappingStatusUpdated(logger, serviceName, status, entriesCount, null);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand Down Expand Up @@ -79,7 +79,7 @@ private static IHealthChecksBuilder AddGrpcHealthChecksCore(IServiceCollection s
services.Configure<GrpcHealthChecksOptions>(options =>
{
// Add default registration that uses all results for default service: ""
options.Services.MapService(string.Empty, r => true);
options.Services.Map(string.Empty, r => true);
});

return services.AddHealthChecks();
Expand Down
46 changes: 46 additions & 0 deletions src/Grpc.AspNetCore.HealthChecks/HealthCheckMapContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#endregion

namespace Grpc.AspNetCore.HealthChecks;

/// <summary>
/// Context used to map health check registrations to a service.
/// </summary>
public sealed class HealthCheckMapContext
{
/// <summary>
/// Creates a new instance of <see cref="HealthCheckMapContext"/>.
/// </summary>
/// <param name="name">The health check name.</param>
/// <param name="tags">Tags associated with the health check.</param>
public HealthCheckMapContext(string name, IEnumerable<string> tags)
{
Name = name;
Tags = tags;
}

/// <summary>
/// Gets the health check name.
/// </summary>
public string Name { get; }

/// <summary>
/// Gets the tags associated with the health check.
/// </summary>
public IEnumerable<string> Tags { get; }
}
3 changes: 2 additions & 1 deletion src/Grpc.AspNetCore.HealthChecks/HealthResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand All @@ -23,6 +23,7 @@ namespace Grpc.AspNetCore.HealthChecks;
/// <summary>
/// Represents the result of a single <see cref="IHealthCheck"/>.
/// </summary>
[Obsolete($"HealthResult is obsolete and will be removed in a future release. Use {nameof(HealthCheckMapContext)} instead.")]
public sealed class HealthResult
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand All @@ -16,32 +16,35 @@

#endregion

using Grpc.AspNetCore.HealthChecks;
using Grpc.Health.V1;
using Microsoft.Extensions.Diagnostics.HealthChecks;

internal static class HealthChecksStatusHelpers
{
public static HealthCheckResponse.Types.ServingStatus GetStatus(HealthReport report, Func<HealthResult, bool> predicate)
public static (HealthCheckResponse.Types.ServingStatus status, int resultCount) GetStatus(IEnumerable<KeyValuePair<string, HealthReportEntry>> results)
{
var filteredResults = report.Entries
.Select(entry => new HealthResult(entry.Key, entry.Value.Tags, entry.Value.Status, entry.Value.Description, entry.Value.Duration, entry.Value.Exception, entry.Value.Data))
.Where(predicate);

var resultCount = 0;
var resolvedStatus = HealthCheckResponse.Types.ServingStatus.Unknown;
foreach (var result in filteredResults)
foreach (var result in results)
{
if (result.Status == HealthStatus.Unhealthy)
{
resolvedStatus = HealthCheckResponse.Types.ServingStatus.NotServing;
resultCount++;

// No point continuing to check statuses.
break;
// NotServing is a final status but keep iterating to discover how many results are being evaluated.
if (resolvedStatus == HealthCheckResponse.Types.ServingStatus.NotServing)
{
continue;
}

resolvedStatus = HealthCheckResponse.Types.ServingStatus.Serving;
if (result.Value.Status == HealthStatus.Unhealthy)
{
resolvedStatus = HealthCheckResponse.Types.ServingStatus.NotServing;
}
else
{
resolvedStatus = HealthCheckResponse.Types.ServingStatus.Serving;
}
}

return resolvedStatus;
return (resolvedStatus, resultCount);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand Down Expand Up @@ -74,8 +74,35 @@ private async Task<HealthCheckResponse> GetHealthCheckResponseAsync(string servi
HealthCheckResponse.Types.ServingStatus status;
if (_grpcHealthCheckOptions.Services.TryGetServiceMapping(service, out var serviceMapping))
{
var result = await _healthCheckService.CheckHealthAsync(_healthCheckOptions.Predicate, cancellationToken);
status = HealthChecksStatusHelpers.GetStatus(result, serviceMapping.Predicate);
var result = await _healthCheckService.CheckHealthAsync((HealthCheckRegistration registration) =>
{
if (_healthCheckOptions.Predicate != null && !_healthCheckOptions.Predicate(registration))
{
return false;
}

if (serviceMapping.HealthCheckPredicate != null && !serviceMapping.HealthCheckPredicate(new HealthCheckMapContext(registration.Name, registration.Tags)))
{
return false;
}

return true;
}, cancellationToken);

IEnumerable<KeyValuePair<string, HealthReportEntry>> serviceEntries = result.Entries;

#pragma warning disable CS0618 // Type or member is obsolete
if (serviceMapping.Predicate != null)
{
serviceEntries = serviceEntries.Where(entry =>
{
var result = new HealthResult(entry.Key, entry.Value.Tags, entry.Value.Status, entry.Value.Description, entry.Value.Duration, entry.Value.Exception, entry.Value.Data);
return serviceMapping.Predicate(result);
});
}
#pragma warning restore CS0618 // Type or member is obsolete

(status, _) = HealthChecksStatusHelpers.GetStatus(serviceEntries);
}
else
{
Expand Down Expand Up @@ -128,7 +155,7 @@ public async Task WriteAsync(HealthCheckResponse message)
_receivedFirstWrite = true;
message = await _service.GetHealthCheckResponseAsync(_request.Service, throwOnNotFound: false, _cancellationToken);
}

await _innerResponseStream.WriteAsync(message);
}
}
Expand Down
44 changes: 42 additions & 2 deletions src/Grpc.AspNetCore.HealthChecks/ServiceMapping.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#region Copyright notice and license
#region Copyright notice and license

// Copyright 2019 The gRPC Authors
//
Expand Down Expand Up @@ -28,19 +28,59 @@ public sealed class ServiceMapping
/// </summary>
/// <param name="name">The service name.</param>
/// <param name="predicate">The predicate used to filter <see cref="HealthResult"/> instances. These results determine service health.</param>
[Obsolete("This constructor is obsolete and will be removed in the future. Use ServiceMapping(string name, Func<HealthCheckRegistration, bool> predicate) to map service names to .NET health checks.")]
public ServiceMapping(string name, Func<HealthResult, bool> predicate)
{
Name = name;
Predicate = predicate;
}

/// <summary>
/// Creates a new instance of <see cref="ServiceMapping"/>.
/// </summary>
/// <param name="name">The service name.</param>
/// <param name="predicate">
/// The predicate used to filter health checks when the <c>Health</c> service <c>Check</c> and <c>Watch</c> methods are called.
/// <para>
/// The <c>Health</c> service methods have different behavior:
/// </para>
/// <list type="bullet">
/// <item><description><c>Check</c> uses the predicate to determine which health checks are run for a service.</description></item>
/// <item><description><c>Watch</c> periodically runs all health checks. The predicate filters the health results for a service.</description></item>
/// </list>
/// <para>
/// The health result for the service is based on the health check results.
/// </para>
/// </param>
public ServiceMapping(string name, Func<HealthCheckMapContext, bool> predicate)
{
Name = name;
HealthCheckPredicate = predicate;
}

/// <summary>
/// Gets the service name.
/// </summary>
public string Name { get; }

/// <summary>
/// Gets the predicate used to filter health checks when the <c>Health</c> service <c>Check</c> and <c>Watch</c> methods are called.
/// <para>
/// The <c>Health</c> service methods have different behavior:
/// </para>
/// <list type="bullet">
/// <item><description><c>Check</c> uses the predicate to determine which health checks are run for a service.</description></item>
/// <item><description><c>Watch</c> periodically runs all health checks. The predicate filters the health results for a service.</description></item>
/// </list>
/// <para>
/// The health result for the service is based on the health check results.
/// </para>
/// </summary>
public Func<HealthCheckMapContext, bool>? HealthCheckPredicate { get; }

/// <summary>
/// Gets the predicate used to filter <see cref="HealthResult"/> instances. These results determine service health.
/// </summary>
public Func<HealthResult, bool> Predicate { get; }
[Obsolete($"This member is obsolete and will be removed in the future. Use {nameof(HealthCheckPredicate)} to map service names to .NET health checks.")]
public Func<HealthResult, bool>? Predicate { get; }
}
Loading

0 comments on commit 697f349

Please sign in to comment.