feat(health): 0.2.0 — optional per-entry data + Akka cluster-view
Phase 0 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md: give the
canonical health JSON a structured channel so the family overview dashboard can
read each Akka cluster's current leader.
- ZbHealthWriter: optional `"data": {...}` per entry, sourced from
HealthReportEntry.Data, emitted only when non-empty. Per-property JsonIgnore
(NOT a global DefaultIgnoreCondition) so `"description": null` still renders —
payloads from data-less checks stay byte-identical to 0.1.0.
- AkkaClusterHealthCheck: BuildClusterData publishes this node's own view —
leader (omitted while unknown), selfAddress, selfRoles (sorted), memberCount,
unreachableCount — on every result path. The startup-safety paths (no
ActorSystem / cluster inaccessible) stay description-only.
- Tests: writer data emit/omit (raw-JSON assert on the omit case), and a real
single-node self-joined cluster via Akka.TestKit.Xunit2 for the data values.
70 tests green (25/39/6).
- Version 0.1.0 -> 0.2.0; 3 packages published to the Gitea feed and
restore-verified from a scratch consumer, which serves data.leader live.
This commit is contained in:
@@ -44,6 +44,7 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
MemberStatus status;
|
||||
IReadOnlyDictionary<string, object> data;
|
||||
try
|
||||
{
|
||||
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
|
||||
@@ -51,7 +52,9 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
// ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
|
||||
// escaping exception (which the host would record as Unhealthy and pull the node from
|
||||
// rotation).
|
||||
status = Cluster.Get(system).SelfMember.Status;
|
||||
var cluster = Cluster.Get(system);
|
||||
status = cluster.SelfMember.Status;
|
||||
data = BuildClusterData(cluster);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -62,10 +65,47 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
var description = $"Akka cluster member status: {status}";
|
||||
var result = health switch
|
||||
{
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
||||
HealthStatus.Degraded => HealthCheckResult.Degraded(description),
|
||||
_ => HealthCheckResult.Unhealthy(description),
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description, data),
|
||||
HealthStatus.Degraded => HealthCheckResult.Degraded(description, data: data),
|
||||
_ => HealthCheckResult.Unhealthy(description, data: data),
|
||||
};
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects this node's view of the cluster into the check's <c>data</c> dictionary.
|
||||
/// </summary>
|
||||
/// <param name="cluster">The cluster extension for the local <see cref="ActorSystem"/>.</param>
|
||||
/// <returns>
|
||||
/// JSON-friendly values only (string / int / string[]), so the canonical health writer can emit
|
||||
/// them verbatim: <c>leader</c> (omitted while the leader is unknown, e.g. pre-join or during
|
||||
/// convergence), <c>selfAddress</c>, <c>selfRoles</c> (sorted for a stable payload),
|
||||
/// <c>memberCount</c>, <c>unreachableCount</c>.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This is deliberately each node's <em>own</em> view rather than an authoritative fleet answer —
|
||||
/// a consumer that reads it from several members of the same cluster can compare them, and a
|
||||
/// disagreement is a split-brain tell.
|
||||
/// </remarks>
|
||||
internal static IReadOnlyDictionary<string, object> BuildClusterData(Cluster cluster)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
|
||||
var state = cluster.State;
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["selfAddress"] = cluster.SelfAddress.ToString(),
|
||||
["selfRoles"] = cluster.SelfMember.Roles.OrderBy(static r => r, StringComparer.Ordinal).ToArray(),
|
||||
["memberCount"] = state.Members.Count,
|
||||
["unreachableCount"] = state.Unreachable.Count,
|
||||
};
|
||||
|
||||
// Omitted rather than emitted as null: a missing key reads as "this node does not know yet",
|
||||
// which is exactly the pre-join / mid-convergence state.
|
||||
var leader = state.Leader;
|
||||
if (leader is not null)
|
||||
data["leader"] = leader.ToString();
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
@@ -16,15 +17,18 @@ namespace ZB.MOM.WW.Health;
|
||||
/// "status": "Healthy|Degraded|Unhealthy",
|
||||
/// "totalDurationMs": 12.34,
|
||||
/// "entries": {
|
||||
/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23 }
|
||||
/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23, "data": { ... } }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// The <c>description</c> key is always present; when a check supplies no description it is emitted
|
||||
/// as JSON <c>null</c> (not omitted), matching the spec example and the <c>HealthChecks.UI.Client</c>
|
||||
/// shape. The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded
|
||||
/// → 200, Unhealthy → 503); this writer only renders the body and sets
|
||||
/// <c>Content-Type: application/json</c>.
|
||||
/// shape. The <c>data</c> key is the opposite: it is emitted only when the check supplied a non-empty
|
||||
/// <see cref="HealthCheckResult.Data"/> dictionary, so payloads from checks that publish no data stay
|
||||
/// byte-identical to the pre-0.2.0 shape. Its keys are written verbatim (the camelCase naming policy
|
||||
/// applies to the envelope properties, not to dictionary keys). The HTTP status code is left to the
|
||||
/// ASP.NET Core health-checks middleware (Healthy/Degraded → 200, Unhealthy → 503); this writer only
|
||||
/// renders the body and sets <c>Content-Type: application/json</c>.
|
||||
/// </remarks>
|
||||
public static class ZbHealthWriter
|
||||
{
|
||||
@@ -60,6 +64,10 @@ public static class ZbHealthWriter
|
||||
Status = e.Value.Status.ToString(),
|
||||
Description = e.Value.Description,
|
||||
DurationMs = e.Value.Duration.TotalMilliseconds,
|
||||
// HealthReportEntry.Data is never null (empty when the check set none); mapping
|
||||
// empty → null is what drops the key entirely, keeping data-less payloads
|
||||
// byte-identical to the pre-0.2.0 shape.
|
||||
Data = e.Value.Data.Count > 0 ? e.Value.Data : null,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -78,5 +86,11 @@ public static class ZbHealthWriter
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public double DurationMs { get; init; }
|
||||
|
||||
// Per-property ignore, deliberately NOT a global DefaultIgnoreCondition: the writer must keep
|
||||
// emitting "description": null (see SerializerOptions above) while omitting "data" entirely
|
||||
// when a check publishes none.
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, object>? Data { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user