< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Health.HealthProbeRunner
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/HealthProbeRunner.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
97%
Covered lines: 83
Uncovered lines: 2
Coverable lines: 85
Total lines: 196
Line coverage: 97.6%
Branch coverage
90%
Covered branches: 27
Total branches: 30
Branch coverage: 90%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 97.6% (83/85) Branch coverage: 90% (27/30) Total lines: 196 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 97.6% (83/85) Branch coverage: 90% (27/30) Total lines: 196 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
RunAsync()100%88100%
ExecuteProbeAsync()80%101094.28%
DetermineOverallStatus(...)90%1010100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/HealthProbeRunner.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics;
 3using SerilogLogger = Serilog.ILogger;
 4
 5namespace Kestrun.Health;
 6
 7/// <summary>
 8/// Executes registered <see cref="IProbe"/> instances and aggregates their results into a single report.
 9/// </summary>
 10internal static class HealthProbeRunner
 11{
 12    /// <summary>
 13    /// Executes the provided probes and builds an aggregated <see cref="HealthReport"/>.
 14    /// </summary>
 15    /// <param name="probes">The probes to execute.</param>
 16    /// <param name="tagFilter">Optional tag filter (case-insensitive). When provided, only probes that advertise at lea
 17    /// <param name="perProbeTimeout">Maximum execution time per probe. Specify <see cref="TimeSpan.Zero"/> to disable t
 18    /// <param name="maxDegreeOfParallelism">Maximum number of probes executed concurrently. Values less than one disabl
 19    /// <param name="logger">Logger used for diagnostics.</param>
 20    /// <param name="ct">Cancellation token tied to the HTTP request.</param>
 21    /// <returns>The aggregated health report.</returns>
 22    public static async Task<HealthReport> RunAsync(
 23        IReadOnlyList<IProbe> probes,
 24        IReadOnlyList<string> tagFilter,
 25        TimeSpan perProbeTimeout,
 26        int maxDegreeOfParallelism,
 27        SerilogLogger logger,
 28        CancellationToken ct)
 29    {
 1630        ArgumentNullException.ThrowIfNull(probes);
 1631        ArgumentNullException.ThrowIfNull(tagFilter);
 1632        ArgumentNullException.ThrowIfNull(logger);
 33
 1634        string[] normalizedTags = tagFilter.Count == 0
 1635            ? []
 836            : [.. tagFilter.Select(static t => t.Trim())
 837                           .Where(static t => !string.IsNullOrWhiteSpace(t))
 1638                           .Distinct(StringComparer.OrdinalIgnoreCase)];
 39
 1640        var selected = normalizedTags.Length == 0
 1641            ? probes
 3842            : [.. probes.Where(p => p.Tags.Any(tag => normalizedTags.Contains(tag, StringComparer.OrdinalIgnoreCase)))];
 43
 1644        if (selected.Count == 0)
 45        {
 446            return new HealthReport(
 447                ProbeStatus.Healthy,
 448                ProbeStatusLabels.STATUS_HEALTHY,
 449                DateTimeOffset.UtcNow,
 450                [],
 451                new HealthSummary(0, 0, 0, 0),
 452                normalizedTags);
 53        }
 54
 1255        var entries = new ConcurrentBag<HealthProbeEntry>();
 1256        using var throttle = maxDegreeOfParallelism > 0 ? new SemaphoreSlim(maxDegreeOfParallelism) : null;
 57
 1258        var tasks = selected
 3459            .Select(probe => ExecuteProbeAsync(probe, perProbeTimeout, throttle, logger, entries, ct))
 1260            .ToArray();
 1261        await Task.WhenAll(tasks).ConfigureAwait(false);
 62
 4263        var ordered = entries.OrderBy(static e => e.Name, StringComparer.OrdinalIgnoreCase).ToArray();
 1264        var summary = new HealthSummary(
 1265            ordered.Length,
 3466            ordered.Count(static e => e.Status == ProbeStatus.Healthy),
 3467            ordered.Count(static e => e.Status == ProbeStatus.Degraded),
 4668            ordered.Count(static e => e.Status == ProbeStatus.Unhealthy));
 69
 70        // Determine overall status using explicit precedence: Unhealthy > Degraded > Healthy
 4671        var overall = DetermineOverallStatus(ordered.Select(static e => e.Status));
 1272        return new HealthReport(
 1273            overall,
 1274            overall.ToString().ToLowerInvariant(),
 1275            DateTimeOffset.UtcNow,
 1276            ordered,
 1277            summary,
 1278            normalizedTags);
 1679    }
 80
 81    /// <summary>
 82    /// Executes a single probe with timeout and error handling, adding the result to the provided sink.
 83    /// </summary>
 84    /// <param name="probe">The probe to execute.</param>
 85    /// <param name="perProbeTimeout">Maximum execution time for the probe. Specify <see cref="TimeSpan.Zero"/> to disab
 86    /// <param name="throttle">Optional semaphore used to limit concurrency. May be null to disable throttling.</param>
 87    /// <param name="logger">Logger used for diagnostics.</param>
 88    /// <param name="sink">Concurrent sink to which the result is added.</param>
 89    /// <param name="ct">Cancellation token tied to the HTTP request.</param>
 90    /// <returns>A task representing the asynchronous operation.</returns>
 91    private static async Task ExecuteProbeAsync(
 92        IProbe probe,
 93        TimeSpan perProbeTimeout,
 94        SemaphoreSlim? throttle,
 95        SerilogLogger logger,
 96        ConcurrentBag<HealthProbeEntry> sink,
 97        CancellationToken ct)
 98    {
 3499        if (throttle is not null)
 100        {
 8101            await throttle.WaitAsync(ct).ConfigureAwait(false);
 102        }
 103
 104        try
 105        {
 34106            using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 34107            if (perProbeTimeout > TimeSpan.Zero)
 108            {
 33109                linkedCts.CancelAfter(perProbeTimeout);
 110            }
 111
 34112            var sw = Stopwatch.StartNew();
 34113            ProbeResult? result = null;
 34114            string? error = null;
 115
 116            try
 117            {
 34118                result = await probe.CheckAsync(linkedCts.Token).ConfigureAwait(false)
 34119                      ?? new ProbeResult(ProbeStatus.Unhealthy, "Probe returned null result");
 32120            }
 1121            catch (OperationCanceledException) when (!ct.IsCancellationRequested && linkedCts.IsCancellationRequested)
 122            {
 123                // Timeout Policy:
 124                // A per-probe timeout is considered a transient performance issue rather than a hard failure.
 125                // We classify these as Degraded to signal slowness / partial impairment while allowing
 126                // other Healthy probes to keep the overall status from immediately flipping to Unhealthy.
 127                // Rationale:
 128                //   * Many probes (HTTP, process, IO) may occasionally exceed a strict SLA due to load.
 129                //   * Treating every timeout as Unhealthy causes noisy flapping and obscures true faults.
 130                //   * Aggregation precedence still ensures multiple Degraded probes can surface an overall
 131                //     Degraded status, while a single critical failure (explicit Unhealthy) dominates.
 132                // If future scenarios require elevating timeouts to Unhealthy, this mapping can be made
 133                // configurable (e.g., via HealthProbeOptions). For now we keep policy simple & conservative.
 1134                logger.Warning("Health probe {Probe} timed out after {Timeout}.", probe.Name, perProbeTimeout);
 1135                result = new ProbeResult(ProbeStatus.Degraded, $"Timed out after {perProbeTimeout.TotalSeconds:N1}s");
 1136            }
 0137            catch (OperationCanceledException) when (ct.IsCancellationRequested)
 138            {
 0139                throw;
 140            }
 1141            catch (Exception ex)
 142            {
 1143                logger.Error(ex, "Health probe {Probe} threw an exception.", probe.Name);
 1144                error = ex.Message;
 1145                result = new ProbeResult(ProbeStatus.Unhealthy, $"Exception: {ex.Message}");
 1146            }
 147            finally
 148            {
 34149                sw.Stop();
 150            }
 151
 34152            sink.Add(new HealthProbeEntry(
 34153                probe.Name,
 34154                probe.Tags ?? [],
 34155                result.Status,
 34156                result.Status.ToString().ToLowerInvariant(),
 34157                result.Description,
 34158                result.Data,
 34159                sw.Elapsed,
 34160                error));
 34161        }
 162        finally
 163        {
 34164            _ = throttle?.Release();
 165        }
 34166    }
 167
 168    /// <summary>
 169    /// Determines the overall health status using explicit precedence rules.
 170    /// Unhealthy takes precedence over all others, Degraded over Healthy.
 171    /// Returns Healthy if no statuses are provided.
 172    /// </summary>
 173    /// <param name="statuses">Collection of probe statuses.</param>
 174    /// <returns>The highest precedence status found.</returns>
 175    private static ProbeStatus DetermineOverallStatus(IEnumerable<ProbeStatus> statuses)
 176    {
 12177        var foundAny = false;
 12178        var foundDegraded = false;
 179
 89180        foreach (var status in statuses)
 181        {
 34182            foundAny = true;
 34183            if (status == ProbeStatus.Unhealthy)
 184            {
 3185                return ProbeStatus.Unhealthy;
 186            }
 187
 31188            if (status == ProbeStatus.Degraded)
 189            {
 6190                foundDegraded = true;
 191            }
 192        }
 193
 9194        return !foundAny ? ProbeStatus.Healthy : foundDegraded ? ProbeStatus.Degraded : ProbeStatus.Healthy;
 3195    }
 196}