< Summary

Information
Class: ProjectTemplate.Web.HealthChecks.ApplicationAuditIntegrityHealthCheck
Assembly: ProjectTemplate.Web
File(s): /home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Web/HealthChecks/ApplicationAuditIntegrityHealthCheck.cs
Line coverage
88%
Covered lines: 69
Uncovered lines: 9
Coverable lines: 78
Total lines: 162
Line coverage: 88.4%
Branch coverage
72%
Covered branches: 32
Total branches: 44
Branch coverage: 72.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%88100%
CheckHealthAsync()71.42%312883.63%
GetStaleRunThreshold(...)100%22100%
EvaluateRunFreshness(...)100%66100%

File(s)

/home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Web/HealthChecks/ApplicationAuditIntegrityHealthCheck.cs

#LineLine coverage
 1using Microsoft.Extensions.Diagnostics.HealthChecks;
 2using Microsoft.Extensions.Options;
 3using ProjectTemplate.Infrastructure.Data.Auditing;
 4
 5namespace ProjectTemplate.Web.HealthChecks;
 6
 7/// <summary>
 8/// Reports audit reconciliation findings, audit delivery state, and whether reconciliation is still running.
 9/// </summary>
 10/// <remarks>
 11/// The check is registered with the <see cref="ApplicationHealthCheckTags.Audit"/> tag and exposed through
 12/// <c>/health/audit-integrity</c>. It is intentionally excluded from readiness: an integrity finding requires operator 
 13/// and a readiness failure would remove every replica from load balancing at the same moment.
 14/// </remarks>
 715public sealed class ApplicationAuditIntegrityHealthCheck(
 716    IServiceScopeFactory scopeFactory,
 717    IOptions<ApplicationAuditReconciliationOptions> options,
 718    TimeProvider timeProvider)
 19    : IHealthCheck
 20{
 21    internal const string NeverRunDescription =
 22        "Audit reconciliation has not completed a run in this process.";
 23
 24    internal const string StaleRunDescription =
 25        "The last successful audit reconciliation run is older than the configured stale-run threshold.";
 26
 27    private const int _defaultStaleRunIntervalMultiplier = 3;
 28
 729    private readonly IServiceScopeFactory _scopeFactory =
 730        scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
 731    private readonly ApplicationAuditReconciliationOptions _options =
 732        options?.Value ?? throw new ArgumentNullException(nameof(options));
 733    private readonly TimeProvider _timeProvider =
 734        timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
 35
 36    /// <summary>
 37    /// Evaluates audit reconciliation findings, delivery health, and reconciliation freshness.
 38    /// </summary>
 39    /// <param name="context">The health check context.</param>
 40    /// <param name="cancellationToken">A token that cancels the check.</param>
 41    /// <returns>The audit integrity health result.</returns>
 42    public async Task<HealthCheckResult> CheckHealthAsync(
 43        HealthCheckContext context,
 44        CancellationToken cancellationToken = default)
 45    {
 746        cancellationToken.ThrowIfCancellationRequested();
 747        if (!_options.Enabled)
 48        {
 149            return HealthCheckResult.Healthy("Audit reconciliation is disabled.");
 50        }
 51
 652        using IServiceScope scope = _scopeFactory.CreateScope();
 653        IApplicationAuditReconciler reconciler = scope.ServiceProvider
 654            .GetRequiredService<IApplicationAuditReconciler>();
 655        ApplicationAuditReconciliationSummary summary = await reconciler
 656            .GetSummaryAsync(cancellationToken)
 657            .ConfigureAwait(false);
 58
 659        ApplicationAuditCompletionOutboxHealth? deliveryHealth = null;
 660        IApplicationAuditCompletionOutboxQuery? outboxQuery = scope.ServiceProvider
 661            .GetService<IApplicationAuditCompletionOutboxQuery>();
 662        if (outboxQuery is not null)
 63        {
 064            deliveryHealth = await outboxQuery.GetHealthAsync(cancellationToken).ConfigureAwait(false);
 065            scope.ServiceProvider
 066                .GetRequiredService<ApplicationAuditReconciliationMetrics>()
 067                .UpdateDelivery(deliveryHealth);
 68        }
 69
 670        var data = new Dictionary<string, object>
 671        {
 672            ["openFindings"] = summary.OpenFindingCount,
 673            ["errorFindings"] = summary.ErrorFindingCount,
 674            ["criticalFindings"] = summary.CriticalFindingCount,
 675            ["manifestVerificationFailures"] = summary.ManifestVerificationFailureCount,
 676            ["missingCompletions"] = summary.MissingCompletionCount,
 677            ["staleDeliveryFindings"] = summary.StaleDeliveryCount,
 678            ["deadLetterFindings"] = summary.DeadLetterCount
 679        };
 80
 681        if (summary.LastRunUtc.HasValue)
 82        {
 483            data["lastReconciliationUtc"] = summary.LastRunUtc.Value;
 84        }
 85
 686        if (deliveryHealth is not null)
 87        {
 088            data["outboxBacklog"] = deliveryHealth.BacklogCount;
 089            data["outboxRetryCount"] = deliveryHealth.TotalRetryCount;
 090            data["outboxDeadLetters"] = deliveryHealth.DeadLetterCount;
 091            if (deliveryHealth.OldestPendingAge.HasValue)
 92            {
 093                data["oldestPendingAgeSeconds"] = deliveryHealth.OldestPendingAge.Value.TotalSeconds;
 94            }
 95        }
 96
 697        string? freshnessProblem = EvaluateRunFreshness(summary.LastRunUtc, data);
 98
 699        bool unhealthy = summary.CriticalFindingCount > 0 ||
 6100            summary.ManifestVerificationFailureCount > 0 ||
 6101            summary.OpenFindingCount >= _options.HealthUnhealthyFindingCount;
 6102        if (unhealthy)
 103        {
 1104            return HealthCheckResult.Unhealthy(
 1105                "Critical audit-integrity findings require operator review.",
 1106                data: data);
 107        }
 108
 109        // Zero findings only means something when reconciliation is actually running. A worker that never completed a
 110        // run, stopped, or keeps failing would otherwise read as healthy indefinitely.
 5111        if (freshnessProblem is not null)
 112        {
 2113            return HealthCheckResult.Degraded(freshnessProblem, data: data);
 114        }
 115
 3116        bool degraded = summary.OpenFindingCount >= _options.HealthWarningFindingCount ||
 3117            summary.StaleDeliveryCount > 0 ||
 3118            summary.DeadLetterCount > 0 ||
 3119            deliveryHealth?.DeadLetterCount > 0;
 3120        return degraded
 3121            ? HealthCheckResult.Degraded(
 3122                "Audit reconciliation or delivery findings require attention.",
 3123                data: data)
 3124            : HealthCheckResult.Healthy("Audit integrity and delivery state are within configured thresholds.", data);
 7125    }
 126
 127    /// <summary>
 128    /// Returns the age after which a successful reconciliation run is considered stale.
 129    /// </summary>
 130    /// <param name="options">The reconciliation options.</param>
 131    /// <returns>The configured threshold, or three reconciliation intervals when none is configured.</returns>
 132    internal static TimeSpan GetStaleRunThreshold(ApplicationAuditReconciliationOptions options)
 133    {
 6134        ArgumentNullException.ThrowIfNull(options);
 135
 6136        return options.HealthStaleRunThreshold ?? (options.Interval * _defaultStaleRunIntervalMultiplier);
 137    }
 138
 139    private string? EvaluateRunFreshness(DateTime? lastRunUtc, Dictionary<string, object> data)
 140    {
 6141        if (!_options.RunWorker)
 142        {
 143            // The scheduled loop runs in another process, so this process cannot observe when reconciliation last ran.
 1144            data["reconciliationFreshnessTracked"] = false;
 1145            return null;
 146        }
 147
 5148        TimeSpan staleRunThreshold = GetStaleRunThreshold(_options);
 5149        data["reconciliationFreshnessTracked"] = true;
 5150        data["reconciliationStaleAfterSeconds"] = staleRunThreshold.TotalSeconds;
 151
 5152        if (!lastRunUtc.HasValue)
 153        {
 1154            return NeverRunDescription;
 155        }
 156
 4157        TimeSpan sinceLastRun = _timeProvider.GetUtcNow().UtcDateTime - lastRunUtc.Value;
 4158        data["secondsSinceLastReconciliation"] = Math.Max(sinceLastRun.TotalSeconds, 0);
 159
 4160        return sinceLastRun > staleRunThreshold ? StaleRunDescription : null;
 161    }
 162}