diff --git a/complete/Api/Api.csproj b/complete/Api/Api.csproj index 913c577..d221a93 100644 --- a/complete/Api/Api.csproj +++ b/complete/Api/Api.csproj @@ -5,10 +5,14 @@ enable - - + + + - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/complete/Api/Program.cs b/complete/Api/Program.cs index 0822c19..434cb03 100644 --- a/complete/Api/Program.cs +++ b/complete/Api/Program.cs @@ -1,7 +1,4 @@ -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; -using Api.Data; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; var builder = WebApplication.CreateBuilder(args); @@ -19,8 +16,16 @@ builder.Services.AddNwsManager(); builder.Services.AddOpenTelemetry() - .WithMetrics(m => m.AddMeter("NwsManagerMetrics")) - .WithTracing(m => m.AddSource("NwsManager")); + .WithMetrics(m => m.AddMeter("NwsManagerMetrics")) + .WithTracing(m => m.AddSource("NwsManager")); + +builder.Services.AddHealthChecks() + .AddUrlGroup(new Uri("https://api.weather.gov/"), "NWS Weather API", HealthStatus.Unhealthy, + configureClient: (services, client) => + { + client.DefaultRequestHeaders.Add("User-Agent", "Microsoft - .NET Aspire Demo"); + }); + var app = builder.Build(); @@ -28,10 +33,12 @@ if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); + app.MapOpenApi(); } -app.UseHttpsRedirection(); +// force the SSL redirect +app.UseWhen(context => !context.Request.Path.StartsWithSegments("/health"), + builder => builder.UseHttpsRedirection()); // Map the endpoints for the API app.MapApiEndpoints(); diff --git a/complete/AppHost/HealthChecksUIResource.cs b/complete/AppHost/HealthChecksUIResource.cs new file mode 100644 index 0000000..c2ae175 --- /dev/null +++ b/complete/AppHost/HealthChecksUIResource.cs @@ -0,0 +1,255 @@ +using Aspire.Hosting.Lifecycle; +using System.Diagnostics; + +namespace Aspire.Hosting; + +/// +/// A container-based resource for the HealthChecksUI container. +/// See https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks?tab=readme-ov-file#HealthCheckUI +/// +/// The resource name. +public class HealthChecksUIResource(string name) : ContainerResource(name), IResourceWithServiceDiscovery +{ + /// + /// The projects to be monitored by the HealthChecksUI container. + /// + public IList MonitoredProjects { get; } = []; + + /// + /// Known environment variables for the HealthChecksUI container that can be used to configure the container. + /// Taken from https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/blob/master/doc/ui-docker.md#environment-variables-table + /// + public static class KnownEnvVars + { + public const string UiPath = "ui_path"; + // These keys are taken from https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks?tab=readme-ov-file#sample-2-configuration-using-appsettingsjson + public const string HealthChecksConfigSection = "HealthChecksUI__HealthChecks"; + public const string HealthCheckName = "Name"; + public const string HealthCheckUri = "Uri"; + + internal static string GetHealthCheckNameKey(int index) => $"{HealthChecksConfigSection}__{index}__{HealthCheckName}"; + + internal static string GetHealthCheckUriKey(int index) => $"{HealthChecksConfigSection}__{index}__{HealthCheckUri}"; + } +} + +/// +/// Represents a project to be monitored by a . +/// +public class MonitoredProject(IResourceBuilder project, string endpointName, string probePath) +{ + private string? _name; + + /// + /// The project to be monitored. + /// + public IResourceBuilder Project { get; } = project ?? throw new ArgumentNullException(nameof(project)); + + /// + /// The name of the endpoint the project serves health check details from. If it doesn't exist it will be added. + /// + public string EndpointName { get; } = endpointName ?? throw new ArgumentNullException(nameof(endpointName)); + + /// + /// The name of the project to be displayed in the HealthChecksUI dashboard. Defaults to the project resource's name. + /// + public string Name + { + get => _name ?? Project.Resource.Name; + set { _name = value; } + } + + /// + /// The request path the project serves health check details for the HealthChecksUI dashboard from. + /// + public string ProbePath { get; set; } = probePath ?? throw new ArgumentNullException(nameof(probePath)); +} + +internal class HealthChecksUILifecycleHook(DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook +{ + private const string HEALTHCHECKSUI_URLS = "HEALTHCHECKSUI_URLS"; + + public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + // Configure each project referenced by a Health Checks UI resource + var healthChecksUIResources = appModel.Resources.OfType(); + + foreach (var healthChecksUIResource in healthChecksUIResources) + { + foreach (var monitoredProject in healthChecksUIResource.MonitoredProjects) + { + var project = monitoredProject.Project; + + // Add the health check endpoint if it doesn't exist + var healthChecksEndpoint = project.GetEndpoint(monitoredProject.EndpointName); + if (!healthChecksEndpoint.Exists) + { + project.WithHttpEndpoint(name: monitoredProject.EndpointName); + Debug.Assert(healthChecksEndpoint.Exists, "The health check endpoint should exist after adding it."); + } + + // Set environment variable to configure the URLs the health check endpoint is accessible from + project.WithEnvironment(context => + { + var probePath = monitoredProject.ProbePath.TrimStart('/'); + var healthChecksEndpointsExpression = ReferenceExpression.Create($"{healthChecksEndpoint}/{probePath}"); + + if (context.ExecutionContext.IsRunMode) + { + // Running during dev inner-loop + var containerHost = healthChecksUIResource.GetEndpoint("http").ContainerHost; + var fromContainerUriBuilder = new UriBuilder(healthChecksEndpoint.Url) + { + Host = containerHost, + Path = monitoredProject.ProbePath + }; + + healthChecksEndpointsExpression = ReferenceExpression.Create($"{healthChecksEndpointsExpression};{fromContainerUriBuilder.ToString()}"); + } + + context.EnvironmentVariables.Add(HEALTHCHECKSUI_URLS, healthChecksEndpointsExpression); + }); + } + } + + if (executionContext.IsPublishMode) + { + ConfigureHealthChecksUIContainers(appModel.Resources, isPublishing: true); + } + + return Task.CompletedTask; + } + + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + ConfigureHealthChecksUIContainers(appModel.Resources, isPublishing: false); + + return Task.CompletedTask; + } + + private static void ConfigureHealthChecksUIContainers(IResourceCollection resources, bool isPublishing) + { + var healhChecksUIResources = resources.OfType(); + + foreach (var healthChecksUIResource in healhChecksUIResources) + { + var monitoredProjects = healthChecksUIResource.MonitoredProjects; + + // Add environment variables to configure the HealthChecksUI container with the health checks endpoints of each referenced project + // See example configuration at https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks?tab=readme-ov-file#sample-2-configuration-using-appsettingsjson + for (var i = 0; i < monitoredProjects.Count; i++) + { + var monitoredProject = monitoredProjects[i]; + var healthChecksEndpoint = monitoredProject.Project.GetEndpoint(monitoredProject.EndpointName); + + // Set health check name + var nameEnvVarName = HealthChecksUIResource.KnownEnvVars.GetHealthCheckNameKey(i); + healthChecksUIResource.Annotations.Add( + new EnvironmentCallbackAnnotation( + nameEnvVarName, + () => monitoredProject.Name)); + + // Set health check URL + var probePath = monitoredProject.ProbePath.TrimStart('/'); + var urlEnvVarName = HealthChecksUIResource.KnownEnvVars.GetHealthCheckUriKey(i); + + healthChecksUIResource.Annotations.Add( + new EnvironmentCallbackAnnotation( + context => context[urlEnvVarName] = isPublishing + ? ReferenceExpression.Create($"{healthChecksEndpoint}/{probePath}") + : new HostUrl($"{healthChecksEndpoint.Url}/{probePath}"))); + } + } + } +} + + + +public static class HealthChecksUIExtensions +{ + /// + /// Adds a HealthChecksUI container to the application model. + /// + /// The builder. + /// The resource name. + /// The host port to expose the container on. + /// The tag to use for the container image. Defaults to "5.0.0". + /// The resource builder. + public static IResourceBuilder AddHealthChecksUI( + this IDistributedApplicationBuilder builder, + string name, + int? port = null) + { + builder.Services.TryAddLifecycleHook(); + + var resource = new HealthChecksUIResource(name); + + return builder + .AddResource(resource) + .WithImage(HealthChecksUIDefaults.ContainerImageName, HealthChecksUIDefaults.ContainerImageTag) + .WithImageRegistry(HealthChecksUIDefaults.ContainerRegistry) + .WithEnvironment(HealthChecksUIResource.KnownEnvVars.UiPath, "/") + .WithHttpEndpoint(port: port, targetPort: HealthChecksUIDefaults.ContainerPort); + } + + /// + /// Adds a reference to a project that will be monitored by the HealthChecksUI container. + /// + /// The builder. + /// The project. + /// + /// The name of the HTTP endpoint the serves health check details from. + /// The endpoint will be added if it is not already defined on the . + /// + /// The request path the project serves health check details from. + /// The resource builder. + public static IResourceBuilder WithReference( + this IResourceBuilder builder, + IResourceBuilder project, + string endpointName = HealthChecksUIDefaults.EndpointName, + string probePath = HealthChecksUIDefaults.ProbePath) + { + var monitoredProject = new MonitoredProject(project, endpointName: endpointName, probePath: probePath); + builder.Resource.MonitoredProjects.Add(monitoredProject); + + return builder; + } + + +} + +/// +/// Default values used by . +/// +public static class HealthChecksUIDefaults +{ + /// + /// The default container registry to pull the HealthChecksUI container image from. + /// + public const string ContainerRegistry = "docker.io"; + + /// + /// The default container image name to use for the HealthChecksUI container. + /// + public const string ContainerImageName = "xabarilcoding/healthchecksui"; + + /// + /// The default container image tag to use for the HealthChecksUI container. + /// + public const string ContainerImageTag = "5.0.0"; + + /// + /// The target port the HealthChecksUI container listens on. + /// + public const int ContainerPort = 80; + + /// + /// The default request path projects serve health check details from. + /// + public const string ProbePath = "/health"; + + /// + /// The default name of the HTTP endpoint projects serve health check details from. + /// + public const string EndpointName = "healthchecks"; +} diff --git a/complete/AppHost/Program.cs b/complete/AppHost/Program.cs index 84b7386..3e15640 100644 --- a/complete/AppHost/Program.cs +++ b/complete/AppHost/Program.cs @@ -1,20 +1,28 @@ var builder = DistributedApplication.CreateBuilder(args); var cache = builder.AddRedis("cache") - .WithRedisCommander(); + .WithRedisCommander(); var api = builder.AddProject("api") - .WithReference(cache); + .WithReference(cache); var postgres = builder.AddPostgres("postgres") - .WithDataVolume(isReadOnly: false); + .WithDataVolume(isReadOnly: false); var weatherDb = postgres.AddDatabase("weatherdb"); var web = builder.AddProject("myweatherhub") - .WithReference(api) - .WithReference(weatherDb) - .WaitFor(postgres) - .WithExternalHttpEndpoints(); + .WithReference(api) + .WithReference(weatherDb) + .WaitFor(postgres) + .WithExternalHttpEndpoints(); + + +builder.AddHealthChecksUI("healthchecks") +.WaitFor(web) +.WithReference(web) +.WaitFor(api) +.WithReference(api); + builder.Build().Run(); diff --git a/complete/MyWeatherHub/MyWeatherHub.csproj b/complete/MyWeatherHub/MyWeatherHub.csproj index d92b403..4aa86be 100644 --- a/complete/MyWeatherHub/MyWeatherHub.csproj +++ b/complete/MyWeatherHub/MyWeatherHub.csproj @@ -6,6 +6,7 @@ + all diff --git a/complete/MyWeatherHub/Program.cs b/complete/MyWeatherHub/Program.cs index a9993e2..cb7f5b4 100644 --- a/complete/MyWeatherHub/Program.cs +++ b/complete/MyWeatherHub/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; using MyWeatherHub; using MyWeatherHub.Components; @@ -6,17 +7,22 @@ builder.AddServiceDefaults(); builder.Services.AddHttpClient(client => { - client.BaseAddress = new("https+http://api"); + client.BaseAddress = new("https+http://api"); }); // Add services to the container. builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents(); builder.Services.AddMemoryCache(); builder.AddNpgsqlDbContext(connectionName: "weatherdb"); +builder.Services.AddHealthChecks() + .AddUrlGroup(new Uri(builder.Configuration["services:api:http:0"] + "/openapi/v1.json"), + "Weather Microservice", HealthStatus.Unhealthy); + + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -24,25 +30,25 @@ // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); } -else +else { - using (var scope = app.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.EnsureCreatedAsync(); - } + using var scope = app.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); } -app.UseHttpsRedirection(); +// force the SSL redirect +app.UseWhen(context => !context.Request.Path.StartsWithSegments("/health"), + builder => builder.UseHttpsRedirection()); app.UseStaticFiles(); app.UseAntiforgery(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode(); app.Run(); \ No newline at end of file diff --git a/complete/ServiceDefaults/Extensions.cs b/complete/ServiceDefaults/Extensions.cs index b28c969..2446270 100644 --- a/complete/ServiceDefaults/Extensions.cs +++ b/complete/ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -15,116 +14,120 @@ namespace Microsoft.Extensions.Hosting; // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - // Add browser telemetry support using OpenTelemetry Protocol (OTLP) over HTTP and CORS - builder.Services.AddCors(options => - { - options.AddPolicy("AllowAll", builder => - { - builder.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader(); - }); - }); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + // Add browser telemetry support using OpenTelemetry Protocol (OTLP) over HTTP and CORS + builder.Services.AddCors(options => + { + options.AddPolicy("AllowAll", builder => + { + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + + //return ServiceDefaults.HealthChecks.MapDefaultEndpoints(app); + + + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } } diff --git a/complete/ServiceDefaults/HealthChecks.cs b/complete/ServiceDefaults/HealthChecks.cs new file mode 100644 index 0000000..4c407fe --- /dev/null +++ b/complete/ServiceDefaults/HealthChecks.cs @@ -0,0 +1,68 @@ +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace ServiceDefaults; + +internal static class HealthChecks +{ + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + + var healthChecks = app.MapGroup(""); + + // All health checks must pass for app to be considered ready to accept traffic after starting + healthChecks.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + healthChecks.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + // Add the health checks endpoint for the HealthChecksUI + var healthChecksUrls = app.Configuration["HEALTHCHECKSUI_URLS"]; + if (!string.IsNullOrWhiteSpace(healthChecksUrls)) + { + var pathToHostsMap = GetPathToHostsMap(healthChecksUrls); + + foreach (var path in pathToHostsMap.Keys) + { + // Ensure that the HealthChecksUI endpoint is only accessible from configured hosts, e.g. localhost:12345, hub.docker.internal, etc. + // as it contains more detailed information about the health of the app including the types of dependencies it has. + + healthChecks.MapHealthChecks(path, new() { ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }) + // This ensures that the HealthChecksUI endpoint is only accessible from the configured health checks URLs. + // See this documentation to learn more about restricting access to health checks endpoints via routing: + // https://learn.microsoft.com/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-8.0#use-health-checks-routing + .RequireHost(pathToHostsMap[path]); + } + } + + } + + return app; + } + + private static Dictionary GetPathToHostsMap(string healthChecksUrls) + { + // Given a value like "localhost:12345/healthz;hub.docker.internal:12345/healthz" return a dictionary like: + // { { "healthz", [ "localhost:12345", "hub.docker.internal:12345" ] } } + + var uris = healthChecksUrls.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(url => new Uri(url, UriKind.Absolute)) + .GroupBy(uri => uri.AbsolutePath, uri => uri.Authority) + .ToDictionary(g => g.Key, g => g.ToArray()); + + return uris; + + } + + +} diff --git a/complete/ServiceDefaults/ServiceDefaults.csproj b/complete/ServiceDefaults/ServiceDefaults.csproj index 3a033f3..3cb40ea 100644 --- a/complete/ServiceDefaults/ServiceDefaults.csproj +++ b/complete/ServiceDefaults/ServiceDefaults.csproj @@ -7,6 +7,7 @@ + diff --git a/workshop/12-healthchecks.md b/workshop/12-healthchecks.md new file mode 100644 index 0000000..137f34f --- /dev/null +++ b/workshop/12-healthchecks.md @@ -0,0 +1,231 @@ +# Health Checks + +## Introduction + +In this optional module, we will add health checks to our application. Health checks are used to determine the health of an application and its dependencies. They can be used to monitor the health of the application and its dependencies, and to determine if the application is ready to accept traffic. + +## Adding Health Checks + +### Step 1: Add Health Check Packages + +First, we need to add the necessary packages to our projects. .NET Aspire clients managed by the .NET Aspire team automatically add support for Health Checks, and we would like to also add a health check to both the Api and MyWeatherHub projects to ensure they can reach the National Weather Service and the Api microservice respectfully. For the API project, open the `complete/Api/Api.csproj` file and add the following package reference: + +```xml + + + +``` + +For the MyWeatherHub project, open the `complete/MyWeatherHub/MyWeatherHub.csproj` file and add the following package reference: + +```xml + + + +``` + +### Step 2: Add Health Check Services + +Next, we need to add the health check services to our applications. + +For the API project, open the `complete/Api/Program.cs` file and add the following code: + +```csharp +// Add health check services for the National Weather Service external service, carrying our User-Agent header +builder.Services.AddHealthChecks() + .AddUrlGroup(new Uri("https://api.weather.gov/"), "NWS Weather API", HealthStatus.Unhealthy, + configureClient: (services, client) => + { + client.DefaultRequestHeaders.Add("User-Agent", "Microsoft - .NET Aspire Demo"); + }); +``` + +For the MyWeatherHub project, open the `complete/MyWeatherHub/Program.cs` file and add the following code: + +```csharp +// Add health check services for the API service +builder.Services.AddHealthChecks() + .AddUrlGroup(new Uri(builder.Configuration["services:api:http:0"] + "/openapi/v1.json"), + "Weather Microservice", HealthStatus.Unhealthy); +``` + +### Step 3: Map Health Check Endpoints + +Now, we need to add the health check endpoints to our applications. + +The ServiceDefaults project already maps default health check endpoints using the `MapDefaultEndpoints()` extension method. This method is provided as part of the .NET Aspire service defaults and maps the standard `/health` and `/alive` endpoints. + +To use these endpoints, simply call the method in your application's `Program.cs` file: + +```csharp +app.MapDefaultEndpoints(); +``` + +If you need to add additional health check endpoints, you can add them like this in the Api's `Program.cs` file: + +```csharp +// Add health check endpoints for /health and /alive +app.MapHealthChecks("/health"); +app.MapHealthChecks("/alive", new HealthCheckOptions +{ + Predicate = r => r.Tags.Contains("live") +}); +``` + +### Step 4: Understand the Default Health Check Implementation + +The default implementation in ServiceDefaults/Extensions.cs already includes smart behavior for handling health checks in different environments: + +```csharp +public static WebApplication MapDefaultEndpoints(this WebApplication app) +{ + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + else + { + // Considerations for non-development environments + app.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => true, + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + var result = JsonSerializer.Serialize(new + { + status = report.Status.ToString(), + checks = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + exception = e.Value.Exception?.Message, + duration = e.Value.Duration.ToString() + }) + }); + await context.Response.WriteAsync(result); + } + }); + + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live"), + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + var result = JsonSerializer.Serialize(new + { + status = report.Status.ToString(), + checks = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + exception = e.Value.Exception?.Message, + duration = e.Value.Duration.ToString() + }) + }); + await context.Response.WriteAsync(result); + } + }); + } + + return app; +} +``` + +The implementation includes different approaches for development and production environments: + +- In development: Simple endpoints for quick diagnostics +- In production: More detailed JSON output with additional security considerations + +## Results + +With these two enhancements in place, you can start the application and navigate from the dashboard into the Api or MyWeatherHub projects and see that they still run. Once browsing either project, you can replace the URL path with the segment `/Health` and see the results of your hard work. + +`Healthy` + +That's it. Your applications are healthy. + +What if... you wanted more details? + +## HealthChecksUI + +There is a great tool available that was built with ASP.NET called [HealthChecksUI that is available from Xabaril](https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks). Fortunately, its also available as a Docker container and that makes it easy to integrate with .NET Aspire. + +### Step 1: Add the HealthCheckUI integration and updated Service Defaults + +There are two files in the [complete folder](../complete) that contain the setup code for use of the HealthChecksUI interface. Grab a copy of the `AppHost/HealthChecksUIResource.cs` file and the `ServiceDefaults/HealthChecks.cs` file and copy them to the same locations in your project. + +These files have extensive comments if you would like to read further about how they connect and make additional health check informatoin available. + +### Step 2: Add new references to ServiceDefaults + +The ServiceDefaults project now needs a reference to the HealthChecks.UI.Client package to assist in formatting health information for the UI. Update `ServiceDefaults.csproj` with this entry: + +```xml + + + +``` + +### Step 3: Use the new health checks DefaultEndpoints + +In `ServiceDefaults/Extensions.cs` we need to use the newly formatted healthchecks endpoint and connection management provided by the `HealthChecks.cs` file. Update the `MapDefaultEndpoints` method to immediately return the new implementation from the `HealthChecks` class: + +```csharp +return ServiceDefaults.HealthChecks.MapDefaultEndpoints(app); +``` + +### Step 4: Configure SSL to allow requests for Health + +The Api and MyWeatherHub projects will now start listening on a new port that is dedicated for HealthChecks interaction. This port is not supported for SSL requests when running locally with Docker or Podman, so we need to add an exception for these requests to allow `http` requests. In the `Program.cs` file of each project, update the line `app.UseHttpsRedirection();` to this instead: + +```csharp +// force the SSL redirect +app.UseWhen(context => !context.Request.Path.StartsWithSegments("/health"), + builder => builder.UseHttpsRedirection()); +``` + +### Step 5: Add the HealthChecksUI resource + +The `HealthChecksUIResource.cs` file that was added to the AppHost project contains all of the information needed to start a HealthChecks container and connect our projects to it. This may be wrapped up into a proper .NET Aspire integration in the future that can be installed with a NuGet package, but for now the logic and configuration is all wrapped up in that file. + +We can instruct .NET Aspire to start the HealthChecksUI and configure it to listen to the MyWeatherHub and Api projects with this code. Add this to the end of `Program.cs` just about the `builder.Build().Run();` statement: + +```csharp +builder.AddHealthChecksUI("healthchecks") + .WaitFor(web) + .WithReference(web) + .WaitFor(api) + .WithReference(api); +``` + +We want to add the HealthChecksUI resource using a name of "healthchecks" and we want it to connect to the `web` and `api` projects after they are running. + +### Step 6: Run the app, enjoy your new HealthChecksUI + +Run your application, and visit the new HealthChecks resource from the dashboard. You could see this more complete review of your application's health monitoring and child object statuses: + +![HealthChecks User Interface](media/healthchecksui.png) + +## References + +For more information on health checks, see the following documentation: + +- [Health Checks in .NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/health-checks) +- [Health Checks in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks) + +A more complete [HealthChecks UI sample](https://github.com/dotnet/aspire-samples/tree/main/samples/HealthChecksUI) is available on GitHub + +### HealthChecks and Security + +We strongly recommend adding caching, timeouts, and security to all of your healthchecks endpoints and user interfaces before publishing them to the public internet. The sample as demonstrated here is not recommended for production use. Consult the links above for more information about securing health check endpoints \ No newline at end of file diff --git a/workshop/media/healthchecksui.png b/workshop/media/healthchecksui.png new file mode 100644 index 0000000..1af5f63 Binary files /dev/null and b/workshop/media/healthchecksui.png differ