Skip to content

Commit

Permalink
Add Api Health checks
Browse files Browse the repository at this point in the history
- VotingMonitor health check
- Redis health check
- Windows Azure health check
- Firebase health check

Redis, Windows Azure and Firebase are configured
to be checked only on specific conditions
that depend on their configuration settings

Runtime configuration changes are considered
so if you enable a health check while the
application is running, then this is executed
otherwise the health check is tagged as not run

Health Check endpoint /health is customized to
include specific details for each health check

The health checks are published on Application Insights
  • Loading branch information
adrianiftode committed Apr 28, 2021
1 parent 28633c0 commit d27884a
Show file tree
Hide file tree
Showing 12 changed files with 498 additions and 5 deletions.
7 changes: 7 additions & 0 deletions src/VotingIrregularities.sln
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VoteMonitor.Api.Tests", "test\VoteMonitor.Api.Tests\VoteMonitor.Api.Tests.csproj", "{C19B6C2B-D63C-426A-ADED-00C4F5B78929}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -126,6 +128,10 @@ Global
{674C9094-58B8-4749-9EB1-2FFD054B8D7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{674C9094-58B8-4749-9EB1-2FFD054B8D7D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{674C9094-58B8-4749-9EB1-2FFD054B8D7D}.Release|Any CPU.Build.0 = Release|Any CPU
{C19B6C2B-D63C-426A-ADED-00C4F5B78929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C19B6C2B-D63C-426A-ADED-00C4F5B78929}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C19B6C2B-D63C-426A-ADED-00C4F5B78929}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C19B6C2B-D63C-426A-ADED-00C4F5B78929}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -149,6 +155,7 @@ Global
{DD341123-8FD7-4506-AA0D-4280A5112A6F} = {8A09B442-FB79-4293-BF9B-E34DD3AE70F3}
{C7767496-1BC5-41AD-98D1-439BAA2ACEAE} = {388C55EB-26FF-46AB-9395-549E1A7A99AD}
{674C9094-58B8-4749-9EB1-2FFD054B8D7D} = {8A09B442-FB79-4293-BF9B-E34DD3AE70F3}
{C19B6C2B-D63C-426A-ADED-00C4F5B78929} = {388C55EB-26FF-46AB-9395-549E1A7A99AD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AF1523BC-7F31-4564-8E1B-D2DB4552FFCB}
Expand Down
2 changes: 1 addition & 1 deletion src/api/VoteMonitor.Api.Core/Services/CacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class CacheService : ICacheService
private readonly IDistributedCache _cache;
private readonly ILogger _logger;

public CacheService(IDistributedCache cache, ILogger logger)
public CacheService(IDistributedCache cache, ILogger<CacheService> logger)
{
_cache = cache;
_logger = logger;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Auth;
using Microsoft.WindowsAzure.Storage.Blob;
using System;
using System.Threading;
using System.Threading.Tasks;
using VoteMonitor.Api.Core.Options;

namespace VoteMonitor.Api.Extensions.HealthChecks
{
public static class AzureBlobStorageHealthChecksExtensions
{
public static IHealthChecksBuilder AddAzureBlobStorage(this IHealthChecksBuilder builder, string name)
=> builder.Add(new HealthCheckRegistration(
name,
sp => new AzureBlobStorageHealthCheck(sp.GetService<IOptionsSnapshot<BlobStorageOptions>>()), null, null, null));
}

public class AzureBlobStorageHealthCheck : IHealthCheck
{
private IOptions<BlobStorageOptions> _storageOptions;

public AzureBlobStorageHealthCheck(IOptions<BlobStorageOptions> storageOptions)
{
_storageOptions = storageOptions;
}

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var credentials = new StorageCredentials(_storageOptions.Value.AccountName, _storageOptions.Value.AccountKey);
var blobClient = new CloudStorageAccount(credentials, _storageOptions.Value.UseHttps).CreateCloudBlobClient();

var serviceProperties = await blobClient.GetServicePropertiesAsync(
new BlobRequestOptions(),
operationContext: null,
cancellationToken: cancellationToken);

var container = blobClient.GetContainerReference(_storageOptions.Value.Container);
await container.FetchAttributesAsync();

return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return new HealthCheckResult(context.Registration.FailureStatus, exception: ex);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace VoteMonitor.Api.Extensions.HealthChecks
{
public static class ConditionalHealthChecksExtensions
{
public static IHealthChecksBuilder CheckOnlyWhen(this IHealthChecksBuilder builder, string name, Func<bool> predicate)
{
builder.Services.Configure<HealthCheckServiceOptions>(options =>
{
var registration = options.Registrations.FirstOrDefault(c => c.Name == name);

if (registration == null)
{
throw new InvalidOperationException($"A health check registration named `{name}` is not found in the health registrations list, so its conditional check cannot be configured. The registration must be added before configuring the conditional predicate.");
}

var factory = registration.Factory;
registration.Factory = sp => new ConditionalHealthCheck(
() => factory(sp),
predicate,
sp.GetService<ILogger<ConditionalHealthCheck>>()
);
});

return builder;
}
}

public class ConditionalHealthCheck : IHealthCheck
{
private const string NotChecked = "NotChecked";
private readonly Func<bool> _predicate;
private readonly ILogger<ConditionalHealthCheck> _logger;

public ConditionalHealthCheck(Func<IHealthCheck> healthCheckFactory,
Func<bool> predicate,
ILogger<ConditionalHealthCheck> logger)
{
HealthCheckFactory = healthCheckFactory;
_predicate = predicate;
_logger = logger;
}

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
context.Registration.Tags.Remove(NotChecked);

if (!_predicate())
{
_logger.LogDebug("Healthcheck `{0}` will not be executed as its checking condition is not met.", context.Registration.Name);

context.Registration.Tags.Add(NotChecked);

return new HealthCheckResult(HealthStatus.Healthy, $"Health check on `{context.Registration.Name}` will not be evaluated " +
$"as its checking condition is not met. This does not mean your dependency is healthy, " +
$"but the health check operation on this dependency is not configured to be executed yet.");
}

return await HealthCheckFactory().CheckHealthAsync(context, cancellationToken);
}

internal Func<IHealthCheck> HealthCheckFactory { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using FirebaseAdmin;
using FirebaseAdmin.Auth;
using Google.Apis.Auth.OAuth2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace VoteMonitor.Api.Extensions.HealthChecks
{
public static class FirebaseHealthChecksExtensions
{
public static IHealthChecksBuilder AddFirebase(this IHealthChecksBuilder builder, string name)
=> builder.Add(new HealthCheckRegistration(
name,
sp => new FirebaseHealthCheck(), null, null, null));
}

public class FirebaseHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
if (FirebaseApp.DefaultInstance == null)
{
FirebaseApp.Create(new AppOptions()
{
Credential = GoogleCredential.GetApplicationDefault(),
});
}

var defaultAuth = FirebaseAuth.DefaultInstance;

return Task.FromResult(HealthCheckResult.Healthy());
}
catch (Exception ex)
{
return Task.FromResult(new HealthCheckResult(context.Registration.FailureStatus, exception: ex));
}
}
}
}
66 changes: 66 additions & 0 deletions src/api/VoteMonitor.Api/Extensions/HealthChecksConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace VoteMonitor.Api.Extensions
{
public static class HealthChecksConfiguration
{
public static async Task WriteResponse(HttpContext context, HealthReport result, IWebHostEnvironment env)
{
context.Response.ContentType = "application/json; charset=utf-8";

var options = new JsonSerializerOptions
{
IgnoreNullValues = true
};

using var stream = new MemoryStream();

var healthResponse = new
{
status = result.Status.ToString(),
totalDuration = result.TotalDuration.ToString(),
entries = result.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
tags = e.Value.Tags,
description = e.Value.Description,
data = env.IsDevelopment() && e.Value.Data?.Count > 0 ? e.Value.Data : null,
exception = env.IsDevelopment() ? ExtractSerializableExceptionData(e.Value.Exception) : null
}).ToList()
};

await JsonSerializer.SerializeAsync(stream, healthResponse, healthResponse.GetType(), options);
var json = Encoding.UTF8.GetString(stream.ToArray());

await context.Response.WriteAsync(json);

static object ExtractSerializableExceptionData(Exception exception)
{
if (exception == null)
{
return exception;
}

return new
{
type = exception.GetType().ToString(),
message = exception.Message,
stackTrace = exception.StackTrace,
source = exception.Source,
data = exception.Data?.Count > 0 ? exception.Data : null,
innerException = exception.InnerException != null ? ExtractSerializableExceptionData(exception.InnerException) : null
};
};
}
}
}
16 changes: 16 additions & 0 deletions src/api/VoteMonitor.Api/Extensions/ServicesExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using VoteMonitor.Api.Core.Extensions;
using VoteMonitor.Api.Core.Options;
using VoteMonitor.Api.Core.Services;
using VoteMonitor.Api.Extensions.HealthChecks;
using VoteMonitor.Entities;

namespace VoteMonitor.Api.Extensions
{
Expand Down Expand Up @@ -89,5 +91,19 @@ public static IServiceCollection AddFirebase(this IServiceCollection services,

return services;
}

public static IServiceCollection AddHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddDbContextCheck<VoteMonitorContext>()
.AddRedis(configuration["RedisCacheOptions:Configuration"], "Redis")
.CheckOnlyWhen("Redis", () => configuration["ApplicationCacheOptions:Implementation"] == "RedisCache")
.AddAzureBlobStorage("AzureBlobStorage")
.CheckOnlyWhen("AzureBlobStorage", () => !(configuration["FileServiceOptions:Type"] == "LocalFileService"))
.AddFirebase("Firebase")
.CheckOnlyWhen("Firebase", () => !string.IsNullOrEmpty(configuration["FirebaseServiceOptions:ServerKey"]))
.AddApplicationInsightsPublisher();
return services;
}
}
}
11 changes: 10 additions & 1 deletion src/api/VoteMonitor.Api/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@
"VoteMonitor.Api": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
},
"VoteMonitor.Api HealthChecks": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "health",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
Expand Down
14 changes: 11 additions & 3 deletions src/api/VoteMonitor.Api/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
using AutoMapper;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using VoteMonitor.Api.Core.Extensions;
using VoteMonitor.Api.Extensions;
using VoteMonitor.Api.Form;
using VoteMonitor.Api.Location.Services;
using VoteMonitor.Entities;
using VoteMonitor.Api.Form;

[assembly: InternalsVisibleTo("VoteMonitor.Api.Tests")]
namespace VoteMonitor.Api
{
public class Startup
Expand Down Expand Up @@ -49,6 +52,7 @@ public void ConfigureServices(IServiceCollection services)
services.ConfigureSwagger();
services.AddApplicationInsightsTelemetry();
services.AddCachingService(Configuration);
services.AddHealthChecks(Configuration);
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
Expand All @@ -69,6 +73,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger<

app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, result) => await HealthChecksConfiguration.WriteResponse(context, result, env)
});
endpoints.MapControllers();
});
}
Expand All @@ -84,7 +92,7 @@ private IEnumerable<Assembly> GetAssemblies()
yield return typeof(Form.Controllers.FormController).GetTypeInfo().Assembly;
yield return typeof(Location.Controllers.PollingStationController).GetTypeInfo().Assembly;
yield return typeof(Note.Controllers.NoteController).GetTypeInfo().Assembly;
yield return typeof(Notification.Controllers.NotificationController).GetTypeInfo().Assembly;
yield return typeof(Notification.Controllers.NotificationController).GetTypeInfo().Assembly;
yield return typeof(Observer.Controllers.ObserverController).GetTypeInfo().Assembly;
yield return typeof(Statistics.Controllers.StatisticsController).GetTypeInfo().Assembly;
yield return typeof(PollingStation.Controllers.PollingStationController).GetTypeInfo().Assembly;
Expand Down
Loading

0 comments on commit d27884a

Please sign in to comment.