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
@@ -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>