Add health checks and telemetry
This commit is contained in:
parent
fde6a8012c
commit
67bd0bc46d
|
|
@ -1,4 +1,3 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace NejCommon;
|
namespace NejCommon;
|
||||||
|
|
|
||||||
146
Health.cs
Normal file
146
Health.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using NejCommon.Utils;
|
||||||
|
|
||||||
|
namespace NejCommon;
|
||||||
|
|
||||||
|
public static class HealthUtils
|
||||||
|
{
|
||||||
|
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
// ========================================
|
||||||
|
// Kubernetes Probes
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Liveness Probe: "Is the application running?"
|
||||||
|
// If it fails, Kubernetes RESTARTS the pod
|
||||||
|
.AddCheck("self",
|
||||||
|
() => HealthCheckResult.Healthy("Application is running"),
|
||||||
|
tags: ["live"])
|
||||||
|
|
||||||
|
// Readiness Probe: "Is the application ready to receive traffic?"
|
||||||
|
// If it fails, Kubernetes REMOVES the pod from Service
|
||||||
|
.AddCheck("readiness",
|
||||||
|
() => HealthCheckResult.Healthy("Application is ready"),
|
||||||
|
tags: ["ready"])
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Critical Dependencies (Database)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PostgreSQL - Main database
|
||||||
|
.Add(new HealthCheckRegistration(
|
||||||
|
name: "postgres",
|
||||||
|
factory: sp => new PostgresSqlHealthCheck(
|
||||||
|
sp.GetRequiredService<IConfiguration>(),
|
||||||
|
sp.GetRequiredService<ILogger<PostgresSqlHealthCheck>>(),
|
||||||
|
sp),
|
||||||
|
failureStatus: HealthStatus.Degraded, // Degraded allows app to continue
|
||||||
|
tags: ["dependency", "critical", "database"],
|
||||||
|
timeout: TimeSpan.FromSeconds(15)));
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Health Check Publisher (Periodic Checks)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
builder.Services.Configure<HealthCheckPublisherOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Delay = TimeSpan.FromSeconds(5); // Wait 5s after startup
|
||||||
|
options.Period = TimeSpan.FromSeconds(30); // Execute every 30s
|
||||||
|
});
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
Status = report.Status.ToString(),
|
||||||
|
Timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
|
||||||
|
TotalDuration = $"{report.TotalDuration.TotalMilliseconds}ms",
|
||||||
|
Results = report.Entries.Select(e => new
|
||||||
|
{
|
||||||
|
Name = e.Key,
|
||||||
|
Status = e.Value.Status.ToString(),
|
||||||
|
Duration = $"{e.Value.Duration.TotalMilliseconds}ms",
|
||||||
|
Description = e.Value.Description,
|
||||||
|
Error = e.Value.Exception?.Message,
|
||||||
|
Data = e.Value.Data?.Count > 0 ? e.Value.Data : null,
|
||||||
|
Tags = e.Value.Tags?.ToList()
|
||||||
|
}).ToList(),
|
||||||
|
Summary = new
|
||||||
|
{
|
||||||
|
Total = report.Entries.Count,
|
||||||
|
Healthy = report.Entries.Count(e => e.Value.Status == HealthStatus.Healthy),
|
||||||
|
Degraded = report.Entries.Count(e => e.Value.Status == HealthStatus.Degraded),
|
||||||
|
Unhealthy = report.Entries.Count(e => e.Value.Status == HealthStatus.Unhealthy)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await context.Response.WriteAsync(
|
||||||
|
JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
public static WebApplication MapHealthEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var healthCheckOptions = new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = _ => true, // All dependencies
|
||||||
|
ResponseWriter = WriteHealthCheckResponse,
|
||||||
|
ResultStatusCodes = new Dictionary<HealthStatus, int>
|
||||||
|
{
|
||||||
|
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
||||||
|
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||||
|
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var liveOptions = new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("live"),
|
||||||
|
ResponseWriter = WriteHealthCheckResponse,
|
||||||
|
ResultStatusCodes = new Dictionary<HealthStatus, int>
|
||||||
|
{
|
||||||
|
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
||||||
|
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||||
|
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var readyOptions = new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("ready"),
|
||||||
|
ResponseWriter = WriteHealthCheckResponse,
|
||||||
|
ResultStatusCodes = new Dictionary<HealthStatus, int>
|
||||||
|
{
|
||||||
|
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
||||||
|
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||||
|
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map endpoints to both /health and /healthz (K8s compatibility)
|
||||||
|
MapDetailedHealthEndpoints(app, "/health",
|
||||||
|
healthCheckOptions, liveOptions, readyOptions);
|
||||||
|
MapDetailedHealthEndpoints(app, "/healthz",
|
||||||
|
healthCheckOptions, liveOptions, readyOptions);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MapDetailedHealthEndpoints(WebApplication app, string v, HealthCheckOptions healthCheckOptions, HealthCheckOptions liveOptions, HealthCheckOptions readyOptions)
|
||||||
|
{
|
||||||
|
app.MapHealthChecks(v, healthCheckOptions);
|
||||||
|
app.MapHealthChecks(v + "/live", liveOptions);
|
||||||
|
app.MapHealthChecks(v + "/ready", readyOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
using NejCommon.Utils;
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
@ -81,6 +82,8 @@ namespace NejCommon.Models
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(bindingContext));
|
throw new ArgumentNullException(nameof(bindingContext));
|
||||||
}
|
}
|
||||||
|
using var activity = Telemetry.Start();
|
||||||
|
|
||||||
var cancellationToken = bindingContext.HttpContext.RequestAborted;
|
var cancellationToken = bindingContext.HttpContext.RequestAborted;
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
|
|
||||||
92
Utils/PostgresSqlHealthCheck.cs
Normal file
92
Utils/PostgresSqlHealthCheck.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using NejAccountingAPI.Models;
|
||||||
|
|
||||||
|
namespace NejCommon.Utils;
|
||||||
|
|
||||||
|
public class PostgresSqlHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<PostgresSqlHealthCheck> _logger;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
public PostgresSqlHealthCheck(
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<PostgresSqlHealthCheck> logger,
|
||||||
|
IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Try to get DbContext via DI
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider
|
||||||
|
.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
// Execute simple query to check connectivity
|
||||||
|
var canConnect = await dbContext.Database
|
||||||
|
.CanConnectAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!canConnect)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy(
|
||||||
|
"Cannot connect to PostgreSQL database",
|
||||||
|
data: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["timestamp"] = DateTime.UtcNow,
|
||||||
|
["database"] = "PostgreSQL"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending migrations
|
||||||
|
var pendingMigrations = await dbContext.Database
|
||||||
|
.GetPendingMigrationsAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (pendingMigrations.Any())
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Degraded(
|
||||||
|
"Database has pending migrations",
|
||||||
|
data: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["pendingMigrations"] = pendingMigrations.Count(),
|
||||||
|
["migrations"] = string.Join(", ", pendingMigrations)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test real query to validate permissions
|
||||||
|
var recordCount = await dbContext.ApiKeys
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
return HealthCheckResult.Healthy(
|
||||||
|
"PostgreSQL connection is healthy",
|
||||||
|
data: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["responseTime"] = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"PostgreSQL health check failed: {Message}", ex.Message);
|
||||||
|
|
||||||
|
return HealthCheckResult.Unhealthy(
|
||||||
|
"PostgreSQL health check failed",
|
||||||
|
exception: ex,
|
||||||
|
data: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["error"] = ex.Message,
|
||||||
|
["timestamp"] = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Utils/Telemetry.cs
Normal file
14
Utils/Telemetry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace NejCommon.Utils;
|
||||||
|
|
||||||
|
public static class Telemetry
|
||||||
|
{
|
||||||
|
public const string SourceName = "Api";
|
||||||
|
|
||||||
|
public static readonly ActivitySource Source = new(SourceName);
|
||||||
|
|
||||||
|
public static Activity? Start([CallerMemberName] string operation = "")
|
||||||
|
=> Source.StartActivity(operation);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user