Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs
T
Joseph Doherty 8da60f57ac feat(health): KPI history + sustained-backlog badge for LocalDb replication (row 33)
LocalDbOplogBacklog was reported and Prometheus-exported with nothing consuming
it: not sampled into KPI history, so a purge-burst spike could not be reviewed
after the fact, and no threshold treatment anywhere. Both halves shipped.

KPI SAMPLING. SiteHealthKpiSampleSource now emits localDbOplogBacklog and
localDbReplicationConnected (0/1), and the backlog is charted on the Health
dashboard's per-site trend panel. Charting is the point: a point-in-time tile
can never answer 'did that spike drain?', which is the only thing separating an
expected purge burst from a stalled peer.

Both are CONDITIONALLY emitted rather than coerced with ?? 0 like the
auditBacklogPending metric beside them. Both health-report fields are nullable
and null means 'no reading' — replication not configured (rig site-b/site-c) or
a failed poll — never zero. A fabricated 0 would render a pair that cannot read
its own oplog, or one deliberately unreplicated, as perfectly healthy once a
minute forever, burying the real series. So a site emits 12 metrics when
unreplicated and 14 when replicated, and a GAP in these two series truthfully
means 'not replicated / not reporting'. Note false and null differ for the
connected flag: a configured link that is DOWN samples as 0; no link samples as
nothing. Both fold as Gauge (summing an oplog depth, or a 0/1 flag, per hour is
meaningless).

THRESHOLD SEMANTICS — sustained, not instantaneous. This is the design, not a
refinement. The site_events retention purge is CDC-captured like any other
write, so the backlog jumps by the deleted batch and drains as the peer acks
it; a bare 'backlog > threshold' badge would fire on every replicated site
every day and be ignored within a week. Healthy is defined by the backlog
RETURNING to ~0, which only a dwell can express. CentralHealthAggregator holds
LocalDbBacklogAboveSince — the instant the backlog first rose above
LocalDbBacklogThreshold (default 1000) — and the badge appears only after
LocalDbBacklogSustainedFor (default 15 min). A report at or below the threshold
clears the anchor; one above leaves the ORIGINAL instant intact, so the window
is sustained rather than sliding.

The null case is deliberately a third branch: a null backlog leaves the anchor
UNCHANGED rather than clearing it. Null is evidence neither of a drain nor of a
backlog, so a node going blind mid-climb must not silently reset its own dwell,
and an unreplicated site (null forever) never accumulates an anchor and can
never be badged.

Validation rejects a non-positive threshold (0 would badge every replicated site
permanently, since an idle backlog already sits at it) and a dwell shorter than
one ReportInterval (satisfiable by a single sample, which collapses the check
back into the bare threshold it replaces).

DOC CORRECTION worth flagging: this row's premise was 'mirror the SiteAuditBacklog
configurable threshold badge'. That badge does not exist — Component-HealthMonitoring.md
described it in two places but no option, no comparison and no markup were ever
implemented. The two stale claims are corrected here rather than left to send the
next reader looking for it; the LocalDb badge is built fresh on the Metrics-stale
badge's pattern, which is a real configurable-threshold badge.

Tests: HealthMonitoring 115 (9 new aggregator tests incl. purge-burst-drains,
unreplicated-never-anchors, null-mid-climb, exact-threshold, stale-report; 5 new
validator tests; 4 new sampler tests incl. the null-semantics and false-vs-null
cases); CentralUI 1042 (5 new badge/chart tests incl. the purge-burst negative
control); Commons Kpi 93; KpiHistory 54.
2026-08-15 13:02:35 -04:00

258 lines
11 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.Kpi;
/// <summary>
/// M6 "KPI History &amp; Trends" (K9) coverage for
/// <see cref="SiteHealthKpiSampleSource"/>. The source reads the in-memory
/// <see cref="ICentralHealthAggregator"/> and emits per-site
/// (<see cref="KpiScopes.Site"/>) <see cref="KpiSample"/> rows for each site that
/// has reported a <see cref="SiteHealthReport"/>; sites known only via heartbeats
/// (null <see cref="SiteHealthState.LatestReport"/>) contribute nothing.
/// </summary>
public class SiteHealthKpiSampleSourceTests
{
private static readonly DateTime CapturedAt =
new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Source_Is_SiteHealth()
{
var source = new SiteHealthKpiSampleSource(new StubAggregator());
Assert.Equal(KpiSources.SiteHealth, source.Source);
}
[Fact]
public async Task CollectAsync_PopulatedSite_EmitsAllMetrics_NullReportSite_EmitsNothing()
{
// site-a: fully-populated report. site-b: heartbeat-only (null report).
var report = new SiteHealthReport(
SiteId: "site-a",
SequenceNumber: 5,
ReportTimestamp: CapturedAt,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
{
["conn-1"] = ConnectionHealth.Connected,
["conn-2"] = ConnectionHealth.Connected,
["conn-3"] = ConnectionHealth.Disconnected,
["conn-4"] = ConnectionHealth.Connecting,
["conn-5"] = ConnectionHealth.Error,
},
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
ScriptErrorCount: 3,
AlarmEvaluationErrorCount: 4,
StoreAndForwardBufferDepths: new Dictionary<string, int>
{
["buf-1"] = 10,
["buf-2"] = 15,
},
DeadLetterCount: 6,
DeployedInstanceCount: 20,
EnabledInstanceCount: 18,
DisabledInstanceCount: 2,
ParkedMessageCount: 7,
SiteAuditBacklog: new SiteAuditBacklogSnapshot(
PendingCount: 9, OldestPendingUtc: null, OnDiskBytes: 0),
SiteEventLogWriteFailures: 11);
var aggregator = new StubAggregator
{
States =
{
["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report },
["site-b"] = new SiteHealthState { SiteId = "site-b", LatestReport = null },
},
};
var source = new SiteHealthKpiSampleSource(aggregator);
var samples = await source.CollectAsync(CapturedAt);
// Every sample is for site-a only — the null-report site yields nothing.
Assert.All(samples, s =>
{
Assert.Equal(KpiSources.SiteHealth, s.Source);
Assert.Equal(KpiScopes.Site, s.Scope);
Assert.Equal("site-a", s.ScopeKey);
Assert.Equal(CapturedAt, s.CapturedAtUtc);
});
Assert.DoesNotContain(samples, s => s.ScopeKey == "site-b");
// Exact (Metric, Value) tuples for the populated site.
var byMetric = samples.ToDictionary(s => s.Metric, s => s.Value);
Assert.Equal(2, byMetric["connectionsUp"]); // 2 Connected
Assert.Equal(3, byMetric["connectionsDown"]); // Disconnected + Connecting + Error
Assert.Equal(3, byMetric["scriptErrors"]);
Assert.Equal(4, byMetric["alarmEvalErrors"]);
Assert.Equal(25, byMetric["sfBufferDepth"]); // 10 + 15
Assert.Equal(6, byMetric["deadLetters"]);
Assert.Equal(7, byMetric["parkedMessages"]);
Assert.Equal(20, byMetric["deployedInstances"]);
Assert.Equal(18, byMetric["enabledInstances"]);
Assert.Equal(2, byMetric["disabledInstances"]);
Assert.Equal(9, byMetric["auditBacklogPending"]);
Assert.Equal(11, byMetric["eventLogWriteFailures"]);
// 12 metrics for an UNREPLICATED site — this fixture leaves both LocalDb replication
// fields null, and register row 33 emits those two conditionally rather than coercing
// null to 0. A replicated site emits 14; see the LocalDb tests below.
Assert.Equal(12, byMetric.Count);
Assert.Equal(12, samples.Count);
}
// ------------------------------------------------------------------
// LocalDb replication metrics (register row 33)
// ------------------------------------------------------------------
[Fact]
public async Task CollectAsync_NullLocalDbFields_EmitsNeitherLocalDbMetric()
{
// THE null-semantics case, and the reason these two are not `?? 0` like the audit
// backlog above. Null means "no reading" — an unreplicated site (rig site-b/site-c) or
// a failed poll. A fabricated 0 backlog would render a pair that cannot read its own
// oplog, or one deliberately not replicated, as perfectly healthy once a minute forever.
var report = MinimalReport("site-a") with
{
LocalDbOplogBacklog = null,
LocalDbReplicationConnected = null,
};
var aggregator = new StubAggregator
{
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.DoesNotContain(samples, s => s.Metric == "localDbOplogBacklog");
Assert.DoesNotContain(samples, s => s.Metric == "localDbReplicationConnected");
Assert.Equal(12, samples.Count);
}
[Fact]
public async Task CollectAsync_ReplicatedSite_EmitsBothLocalDbMetrics()
{
var report = MinimalReport("site-a") with
{
LocalDbOplogBacklog = 4242,
LocalDbReplicationConnected = true,
};
var aggregator = new StubAggregator
{
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.Equal(4242, samples.Single(s => s.Metric == "localDbOplogBacklog").Value);
Assert.Equal(1, samples.Single(s => s.Metric == "localDbReplicationConnected").Value);
Assert.Equal(14, samples.Count);
}
[Fact]
public async Task CollectAsync_ZeroBacklog_IsStillEmitted()
{
// A real, measured 0 is genuinely different from "no reading" and must be recorded —
// otherwise a healthy replicated site would be indistinguishable from an unreplicated
// one, and the drain half of a purge burst would never appear in the series.
var report = MinimalReport("site-a") with { LocalDbOplogBacklog = 0 };
var aggregator = new StubAggregator
{
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.Equal(0, samples.Single(s => s.Metric == "localDbOplogBacklog").Value);
}
[Fact]
public async Task CollectAsync_DisconnectedReplication_EmitsZeroNotAbsent()
{
// false and null are different states: a configured link that is DOWN versus no link
// at all. Only the former is a fault, and it must show up as a 0 sample rather than a
// gap, or a broken link would look exactly like an unreplicated site.
var report = MinimalReport("site-a") with { LocalDbReplicationConnected = false };
var aggregator = new StubAggregator
{
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.Equal(0, samples.Single(s => s.Metric == "localDbReplicationConnected").Value);
}
[Fact]
public async Task CollectAsync_NullAuditBacklog_EmitsZeroAuditBacklogPending()
{
var report = MinimalReport("site-a") with { SiteAuditBacklog = null };
var aggregator = new StubAggregator
{
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.Equal(0, samples.Single(s => s.Metric == "auditBacklogPending").Value);
}
[Fact]
public async Task CollectAsync_NoSitesWithReports_ReturnsEmptyList()
{
var aggregator = new StubAggregator
{
States = { ["site-b"] = new SiteHealthState { SiteId = "site-b", LatestReport = null } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.NotNull(samples);
Assert.Empty(samples);
}
private static SiteHealthReport MinimalReport(string siteId) =>
new(
SiteId: siteId,
SequenceNumber: 1,
ReportTimestamp: CapturedAt,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
ScriptErrorCount: 0,
AlarmEvaluationErrorCount: 0,
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
DeadLetterCount: 0,
DeployedInstanceCount: 0,
EnabledInstanceCount: 0,
DisabledInstanceCount: 0);
/// <summary>
/// Hand-rolled <see cref="ICentralHealthAggregator"/> stub — the
/// HealthMonitoring.Tests project has no mocking library. Only
/// <see cref="GetAllSiteStates"/> is exercised by the source under test.
/// </summary>
private sealed class StubAggregator : ICentralHealthAggregator
{
public Dictionary<string, SiteHealthState> States { get; } = new();
public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => States;
public SiteHealthState? GetSiteState(string siteId) =>
States.TryGetValue(siteId, out var state) ? state : null;
public void ProcessReport(SiteHealthReport report) =>
throw new NotSupportedException();
public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) =>
throw new NotSupportedException();
public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds) =>
throw new NotSupportedException();
}
}