feat: add database and LDAP health checks with JSON response formatting

Implement health checks for SQL Server, Oracle databases (JDE, CMS, GIW), and LDAP servers to enable comprehensive system monitoring via the /health endpoint.
This commit is contained in:
Joseph Doherty
2026-01-30 07:24:33 -05:00
parent ee044d03e0
commit 993126273a
7 changed files with 394 additions and 1 deletions
+28 -1
View File
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.OpenApi.Models;
@@ -117,9 +118,35 @@ public static class ApiDependencyInjection
// Health check endpoint - no authentication required
app.MapHealthChecks("/health", new HealthCheckOptions
{
AllowCachingResponses = false
AllowCachingResponses = false,
ResponseWriter = WriteHealthCheckResponse
}).AllowAnonymous();
return app;
}
/// <summary>
/// Writes detailed JSON response for health check results.
/// </summary>
private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
data = e.Value.Data,
exception = e.Value.Exception?.Message
})
};
await context.Response.WriteAsJsonAsync(result);
}
}
@@ -1,5 +1,6 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.HealthChecks;
using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.QueryBuilders;
@@ -55,6 +56,10 @@ public static class DataAccessDependencyInjection
// Register manual sync request service (scoped - per request lifetime)
services.AddScoped<IManualSyncRequestService, ManualSyncRequestService>();
// Register health check
services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database", tags: ["db", "ready"]);
return services;
}
@@ -0,0 +1,224 @@
using System.Diagnostics;
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace JdeScoping.DataAccess.HealthChecks;
/// <summary>
/// Health check for database connectivity to all data sources.
/// </summary>
/// <remarks>
/// Checks connectivity to:
/// - LotFinder (SQL Server) - Critical, causes Unhealthy if unavailable
/// - JDE, CMS, GIW (Oracle) - Important but not critical, causes Degraded if unavailable
/// </remarks>
public class DatabaseHealthCheck : IHealthCheck
{
private readonly IDbConnectionFactory _connectionFactory;
private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5);
private static readonly long DegradedThresholdMs = 2000;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseHealthCheck"/> class.
/// </summary>
/// <param name="connectionFactory">The database connection factory.</param>
public DatabaseHealthCheck(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
}
/// <inheritdoc/>
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
var unhealthyDbs = new List<string>();
var degradedDbs = new List<string>();
// Check LotFinder (SQL Server) - Critical
await CheckLotFinderAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
// Check Oracle databases - Important but not critical
await CheckJdeAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
await CheckCmsAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
await CheckGiwAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
// Determine overall status
if (unhealthyDbs.Count > 0)
{
return HealthCheckResult.Unhealthy(
$"Database(s) unavailable: {string.Join(", ", unhealthyDbs)}",
data: data);
}
if (degradedDbs.Count > 0)
{
return HealthCheckResult.Degraded(
$"Database(s) slow/degraded: {string.Join(", ", degradedDbs)}",
data: data);
}
return HealthCheckResult.Healthy("All databases connected", data: data);
}
private async Task CheckLotFinderAsync(
Dictionary<string, object> data,
List<string> unhealthyDbs,
List<string> degradedDbs,
CancellationToken cancellationToken)
{
const string name = "LotFinder";
var sw = Stopwatch.StartNew();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(ConnectionTimeout);
await using var conn = await _connectionFactory.CreateLotFinderConnectionAsync(cts.Token);
sw.Stop();
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
sw.Stop();
RecordTimeout(name, sw.ElapsedMilliseconds, data, unhealthyDbs);
}
catch (Exception ex)
{
sw.Stop();
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, unhealthyDbs);
}
}
private async Task CheckJdeAsync(
Dictionary<string, object> data,
List<string> unhealthyDbs,
List<string> degradedDbs,
CancellationToken cancellationToken)
{
const string name = "JDE";
var sw = Stopwatch.StartNew();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(ConnectionTimeout);
await using var conn = await _connectionFactory.CreateJdeConnectionAsync(cts.Token);
sw.Stop();
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
sw.Stop();
RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (Exception ex)
{
sw.Stop();
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
}
}
private async Task CheckCmsAsync(
Dictionary<string, object> data,
List<string> unhealthyDbs,
List<string> degradedDbs,
CancellationToken cancellationToken)
{
const string name = "CMS";
var sw = Stopwatch.StartNew();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(ConnectionTimeout);
await using var conn = await _connectionFactory.CreateCmsConnectionAsync(cts.Token);
sw.Stop();
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
sw.Stop();
RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (Exception ex)
{
sw.Stop();
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
}
}
private async Task CheckGiwAsync(
Dictionary<string, object> data,
List<string> unhealthyDbs,
List<string> degradedDbs,
CancellationToken cancellationToken)
{
const string name = "GIW";
var sw = Stopwatch.StartNew();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(ConnectionTimeout);
await using var conn = await _connectionFactory.CreateGiwConnectionAsync(cts.Token);
sw.Stop();
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
sw.Stop();
RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
}
catch (Exception ex)
{
sw.Stop();
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
}
}
private static void RecordSuccess(
string name,
long elapsedMs,
Dictionary<string, object> data,
List<string> degradedDbs)
{
data[$"{name}_Status"] = "Connected";
data[$"{name}_ResponseMs"] = elapsedMs;
if (elapsedMs > DegradedThresholdMs)
{
degradedDbs.Add(name);
}
}
private static void RecordTimeout(
string name,
long elapsedMs,
Dictionary<string, object> data,
List<string> problemDbs)
{
data[$"{name}_Status"] = "Timeout";
data[$"{name}_ResponseMs"] = elapsedMs;
data[$"{name}_Error"] = $"Connection timeout after {ConnectionTimeout.TotalSeconds}s";
problemDbs.Add(name);
}
private static void RecordFailure(
string name,
long elapsedMs,
string errorMessage,
Dictionary<string, object> data,
List<string> problemDbs)
{
data[$"{name}_Status"] = "Failed";
data[$"{name}_ResponseMs"] = elapsedMs;
data[$"{name}_Error"] = errorMessage;
problemDbs.Add(name);
}
}
@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
<PackageReference Include="SqlKata" Version="3.2.3" />
<PackageReference Include="SqlKata.Execution" Version="3.2.3" />
</ItemGroup>
@@ -2,6 +2,7 @@ using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Options;
using JdeScoping.Core.Validation;
using JdeScoping.Infrastructure.Auth;
using JdeScoping.Infrastructure.HealthChecks;
using JdeScoping.Infrastructure.Options;
using JdeScoping.Infrastructure.Security;
using JdeScoping.Infrastructure.Validation;
@@ -62,6 +63,10 @@ public static class InfrastructureDependencyInjection
// Register configuration validators
services.AddInfrastructureValidators(configuration);
// Register health check
services.AddHealthChecks()
.AddCheck<LdapHealthCheck>("ldap", tags: ["auth", "ready"]);
return services;
}
@@ -0,0 +1,130 @@
using System.DirectoryServices.Protocols;
using System.Net;
using JdeScoping.Infrastructure.Options;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.HealthChecks;
/// <summary>
/// Health check for LDAP server connectivity.
/// </summary>
/// <remarks>
/// When <see cref="LdapOptions.UseFakeAuth"/> is true (development mode),
/// always returns Healthy. Otherwise, checks connectivity to configured LDAP servers.
/// </remarks>
public class LdapHealthCheck : IHealthCheck
{
private readonly LdapOptions _options;
private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Initializes a new instance of the <see cref="LdapHealthCheck"/> class.
/// </summary>
/// <param name="options">The LDAP options.</param>
public LdapHealthCheck(IOptions<LdapOptions> options)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc/>
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
// Skip check if using fake auth (development mode)
if (_options.UseFakeAuth)
{
return HealthCheckResult.Healthy("Using fake auth (development mode)");
}
var servers = _options.ServerUrls;
if (servers.Length == 0)
{
return HealthCheckResult.Unhealthy("No LDAP servers configured");
}
var data = new Dictionary<string, object>();
// Check connectivity to LDAP servers on a background thread
// to avoid blocking the main thread with synchronous LDAP operations
return await Task.Run(() => CheckLdapServers(servers, data), cancellationToken);
}
private static HealthCheckResult CheckLdapServers(string[] servers, Dictionary<string, object> data)
{
var reachableCount = 0;
foreach (var serverUrl in servers)
{
var serverKey = SanitizeServerKey(serverUrl);
try
{
// Parse server URL to extract host and port
var identifier = CreateLdapIdentifier(serverUrl);
using var connection = new LdapConnection(identifier);
connection.Timeout = ConnectionTimeout;
connection.AuthType = AuthType.Anonymous;
// Attempt anonymous bind to test connectivity
connection.Bind();
data[$"{serverKey}_Status"] = "Reachable";
reachableCount++;
}
catch (LdapException ex)
{
data[$"{serverKey}_Status"] = "Unreachable";
data[$"{serverKey}_Error"] = $"LDAP error: {ex.Message}";
}
catch (Exception ex)
{
data[$"{serverKey}_Status"] = "Unreachable";
data[$"{serverKey}_Error"] = ex.Message;
}
}
data["ReachableServers"] = reachableCount;
data["TotalServers"] = servers.Length;
if (reachableCount == 0)
{
return HealthCheckResult.Unhealthy("No LDAP servers reachable", data: data);
}
if (reachableCount < servers.Length)
{
return HealthCheckResult.Degraded(
$"{reachableCount}/{servers.Length} LDAP servers reachable",
data: data);
}
return HealthCheckResult.Healthy("All LDAP servers reachable", data: data);
}
private static LdapDirectoryIdentifier CreateLdapIdentifier(string serverUrl)
{
// Handle both plain hostnames and URLs with scheme
if (serverUrl.StartsWith("ldap://", StringComparison.OrdinalIgnoreCase) ||
serverUrl.StartsWith("ldaps://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(serverUrl);
var port = uri.Port > 0 ? uri.Port : (uri.Scheme.Equals("ldaps", StringComparison.OrdinalIgnoreCase) ? 636 : 389);
return new LdapDirectoryIdentifier(uri.Host, port);
}
// Plain hostname, use default LDAP port
return new LdapDirectoryIdentifier(serverUrl);
}
private static string SanitizeServerKey(string serverUrl)
{
// Create a safe key for the data dictionary
return serverUrl
.Replace("://", "_")
.Replace(":", "_")
.Replace("/", "_");
}
}
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />