merge: observability (register rows 33, 54, 56, 63, 66) — LocalDb backlog KPI history + sustained-threshold badge, honest stream-subscriber gauge rename + real data-connection gauges, central metrics port on the rig, ASPNETCORE_ENVIRONMENT on site containers, import-session rejection counter
This commit is contained in:
@@ -170,6 +170,17 @@
|
||||
IsAvailable="@_sfBufferDepthAvailable"
|
||||
ErrorMessage="@_sfBufferDepthError" />
|
||||
</div>
|
||||
@* Register row 33. The point of charting the backlog is retrospective:
|
||||
the daily purge burst is expected, so what an operator needs is to see
|
||||
whether a past spike DRAINED — which a point-in-time tile can never
|
||||
answer. An unreplicated site emits no samples, so this chart is
|
||||
legitimately empty there rather than a flat, misleading zero line. *@
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<KpiTrendChart Title="LocalDb Replication Backlog"
|
||||
Points="@_localDbBacklogSeries"
|
||||
IsAvailable="@_localDbBacklogAvailable"
|
||||
ErrorMessage="@_localDbBacklogError" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -248,6 +259,14 @@
|
||||
Metrics stale
|
||||
</span>
|
||||
}
|
||||
@if (IsLocalDbBacklogSustained(state))
|
||||
{
|
||||
<span class="badge bg-warning text-dark"
|
||||
data-test="localdb-backlog-badge"
|
||||
title="LocalDb replication oplog backlog has stayed above @HealthOptions.Value.LocalDbBacklogThreshold for over @BacklogSustainedDisplay. A short daily spike at the site_events purge is expected and is NOT this — the peer has stopped draining.">
|
||||
Replication backlog
|
||||
</span>
|
||||
}
|
||||
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
|
||||
{
|
||||
<small class="text-muted">offline since @changedAt.ToString("u")</small>
|
||||
@@ -526,6 +545,27 @@
|
||||
private string StaleTimeoutDisplay =>
|
||||
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
|
||||
|
||||
// Human-friendly rendering of the configured sustained-backlog dwell for the
|
||||
// "Replication backlog" badge tooltip (e.g. "15 minutes").
|
||||
private string BacklogSustainedDisplay =>
|
||||
FormatDuration(HealthOptions.Value.LocalDbBacklogSustainedFor);
|
||||
|
||||
// Register row 33: the sustained LocalDb replication backlog badge.
|
||||
//
|
||||
// Reads the anchor the aggregator maintains (LocalDbBacklogAboveSince) rather than the
|
||||
// instantaneous backlog on the report, because a bare "backlog > threshold" badge would
|
||||
// fire on EVERY replicated site EVERY day: the site_events retention purge is CDC-captured
|
||||
// like any other write, so the backlog jumps by the deleted batch size and then drains.
|
||||
// Healthy is "the backlog comes back down"; only a dwell can express that.
|
||||
//
|
||||
// A site that reports no backlog at all (unreplicated — rig site-b/site-c — or a failed
|
||||
// poll) never gets an anchor, so it can never be badged. That is the whole null-semantics
|
||||
// requirement: null means "no reading", not zero.
|
||||
private bool IsLocalDbBacklogSustained(SiteHealthState state) =>
|
||||
state.IsOnline
|
||||
&& state.LocalDbBacklogAboveSince is { } since
|
||||
&& DateTimeOffset.UtcNow - since >= HealthOptions.Value.LocalDbBacklogSustainedFor;
|
||||
|
||||
// Online central nodes, from the same ClusterNodes list the Nodes column renders.
|
||||
// Fewer than two means no standby, which disables the manual-failover control. This is
|
||||
// a display-side guard only — AkkaManualFailoverService re-checks against live cluster
|
||||
@@ -616,6 +656,9 @@
|
||||
private IReadOnlyList<KpiSeriesPoint>? _sfBufferDepthSeries;
|
||||
private bool _sfBufferDepthAvailable = true;
|
||||
private string? _sfBufferDepthError;
|
||||
private IReadOnlyList<KpiSeriesPoint>? _localDbBacklogSeries;
|
||||
private bool _localDbBacklogAvailable = true;
|
||||
private string? _localDbBacklogError;
|
||||
|
||||
private static bool SiteHasActiveErrors(SiteHealthState state)
|
||||
{
|
||||
@@ -739,6 +782,8 @@
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.ScriptErrors, siteId, fromUtc, toUtc);
|
||||
(_sfBufferDepthSeries, _sfBufferDepthAvailable, _sfBufferDepthError) =
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.SfBufferDepth, siteId, fromUtc, toUtc);
|
||||
(_localDbBacklogSeries, _localDbBacklogAvailable, _localDbBacklogError) =
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.LocalDbOplogBacklog, siteId, fromUtc, toUtc);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
|
||||
@@ -93,6 +94,23 @@ public static class ScadaBridgeTelemetry
|
||||
Meter.CreateCounter<long>("scadabridge.site.stream.events_dropped", unit: "1",
|
||||
description: "Events evicted from a site gRPC stream's bounded send channel, tagged by stream kind.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 66: incremented each time <c>BundleSessionStore.Open</c> refuses a
|
||||
/// bundle-import session because <c>TransportOptions.MaxConcurrentImportSessions</c>
|
||||
/// (default 8) is genuinely exhausted — i.e. the store was still at the cap AFTER
|
||||
/// expired sessions were reclaimed. Reclaim-then-succeed and re-opening an already
|
||||
/// open session id are deliberately NOT counted: neither turns an operator away.
|
||||
///
|
||||
/// Row 53 shipped the companion open-sessions gauge and a Warning log, but a log line
|
||||
/// is not alertable. The pair is what distinguishes the two failure modes: rejections
|
||||
/// climbing while the gauge oscillates below the cap is genuine concurrency (raise the
|
||||
/// cap), whereas rejections climbing while the gauge sits pinned at the cap is a
|
||||
/// session leak like register row 43's (find the leak — raising the cap only defers it).
|
||||
/// </summary>
|
||||
private static readonly Counter<long> _transportImportSessionsRejected =
|
||||
Meter.CreateCounter<long>("scadabridge.central.transport.import_sessions.rejected", unit: "1",
|
||||
description: "Bundle-import sessions rejected because the concurrent-import-session cap was exhausted.");
|
||||
|
||||
// ---------------- Histograms ----------------
|
||||
|
||||
/// <summary>
|
||||
@@ -109,8 +127,27 @@ public static class ScadaBridgeTelemetry
|
||||
|
||||
// ---------------- Observable gauges ----------------
|
||||
|
||||
/// <summary>Current count of open site connections, mutated via <see cref="Interlocked"/>.</summary>
|
||||
private static long _siteConnectionsUp;
|
||||
/// <summary>
|
||||
/// Current count of open central→site gRPC subscriber streams, mutated via
|
||||
/// <see cref="Interlocked"/>. See <see cref="_siteStreamSubscribersActive"/>'s gauge for
|
||||
/// why this is NOT a data-connection count.
|
||||
/// </summary>
|
||||
private static long _siteStreamSubscriberCount;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 54: last-known state of every data connection on this site node, keyed by
|
||||
/// connection name. Written by the DCL at its four state-transition points and read by the
|
||||
/// data-connection gauges below.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A static registry, matching <see cref="_siteStreamSubscriberCount"/>'s precedent, rather
|
||||
/// than a DI-injected holder: the DCL actors already receive <c>ISiteHealthCollector</c>
|
||||
/// through their constructors, and threading a second dependency through both actor
|
||||
/// constructors would ripple into every test that builds them. A site node hosts exactly
|
||||
/// one DCL, so a process-global registry and a per-node one are the same thing here.
|
||||
/// </remarks>
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ConnectionHealth>
|
||||
_dataConnectionStates = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Current count of running per-site live-alarm aggregators, mutated via <see cref="Interlocked"/>.</summary>
|
||||
private static long _liveAlarmAggregatorsActive;
|
||||
@@ -127,11 +164,52 @@ public static class ScadaBridgeTelemetry
|
||||
private static Func<long>? _importSessionCountProvider;
|
||||
|
||||
#pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime.
|
||||
/// <summary>Gauge reporting the number of currently open site connections.</summary>
|
||||
private static readonly ObservableGauge<long> _siteConnectionUp =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.connection.up",
|
||||
() => Interlocked.Read(ref _siteConnectionsUp),
|
||||
description: "Number of currently open site connections.");
|
||||
/// <summary>
|
||||
/// Gauge reporting the number of open central→site gRPC subscriber streams.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 54 RENAMED this from <c>scadabridge.site.connection.up</c>
|
||||
/// (<c>scadabridge_site_connection_up</c> in Prometheus). The old name read as a
|
||||
/// data-connection health signal, but it has only ever counted open subscriber streams
|
||||
/// on <c>SiteStreamGrpcServer</c> — Debug View and the site-wide alarm feed. It therefore
|
||||
/// sits at 0 on a perfectly healthy site that nobody is watching, and an alert written
|
||||
/// against the old name fired on an idle operator console rather than a broken device.
|
||||
/// For real data-connection health use the <c>scadabridge.site.data_connections.*</c>
|
||||
/// gauges below. Counts both stream kinds together (no <c>stream</c> tag).
|
||||
/// </remarks>
|
||||
private static readonly ObservableGauge<long> _siteStreamSubscribersActive =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.stream.subscribers.active",
|
||||
() => Interlocked.Read(ref _siteStreamSubscriberCount),
|
||||
unit: "1",
|
||||
description: "Open central→site gRPC subscriber streams (Debug View + site-wide alarm feed). NOT data connections.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 54: gauge reporting how many of this site node's data connections are
|
||||
/// currently in <see cref="ConnectionHealth.Connected"/> — the real data-connection
|
||||
/// signal the misnamed stream-subscriber gauge was being mistaken for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read together with <see cref="_dataConnectionsTotal"/>: <c>connected < total</c> is the
|
||||
/// alertable condition, and neither number alone distinguishes "one device down" from
|
||||
/// "nothing configured". Expect **0 on the standby node** — connections are opened by the
|
||||
/// deployment path on the active node only, so a standby reporting 0 is healthy, not a
|
||||
/// fault. Alert on the active node, or on <c>max()</c> across the pair.
|
||||
/// </remarks>
|
||||
private static readonly ObservableGauge<long> _dataConnectionsConnected =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.data_connections.connected",
|
||||
CountDataConnections,
|
||||
unit: "1",
|
||||
description: "Data connections on this site node currently in the Connected state.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 54: gauge reporting how many data connections this site node knows about,
|
||||
/// in any state. The denominator for <see cref="_dataConnectionsConnected"/>.
|
||||
/// </summary>
|
||||
private static readonly ObservableGauge<long> _dataConnectionsTotal =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.site.data_connections.total",
|
||||
() => (long)_dataConnectionStates.Count,
|
||||
unit: "1",
|
||||
description: "Data connections known to this site node, in any state.");
|
||||
|
||||
/// <summary>Gauge reporting the current StoreAndForward queue depth via the registered provider.</summary>
|
||||
private static readonly ObservableGauge<long> _storeAndForwardQueueDepth =
|
||||
@@ -186,11 +264,76 @@ public static class ScadaBridgeTelemetry
|
||||
/// <summary>Records one resync whose ack window expired.</summary>
|
||||
public static void RecordSfResyncAckMissing() => _sfResyncAckMissing.Add(1);
|
||||
|
||||
/// <summary>Records that a site connection opened (increments the up-count gauge).</summary>
|
||||
public static void SiteConnectionOpened() => Interlocked.Increment(ref _siteConnectionsUp);
|
||||
/// <summary>
|
||||
/// Records one bundle-import session rejected on a genuine concurrent-session cap hit
|
||||
/// (register row 66). Call ONLY from the post-eviction recheck in
|
||||
/// <c>BundleSessionStore.Open</c> — counting the pre-eviction check would inflate the
|
||||
/// series with reclaim-then-succeed cases that never turned an operator away.
|
||||
/// </summary>
|
||||
public static void RecordImportSessionRejected() => _transportImportSessionsRejected.Add(1);
|
||||
|
||||
/// <summary>Records that a site connection closed (decrements the up-count gauge).</summary>
|
||||
public static void SiteConnectionClosed() => Interlocked.Decrement(ref _siteConnectionsUp);
|
||||
/// <summary>
|
||||
/// Records that a central→site gRPC subscriber stream opened (increments
|
||||
/// <c>scadabridge.site.stream.subscribers.active</c>). Renamed from
|
||||
/// <c>SiteConnectionOpened</c> by register row 54 — it never counted data connections.
|
||||
/// </summary>
|
||||
public static void SiteStreamSubscriberOpened() => Interlocked.Increment(ref _siteStreamSubscriberCount);
|
||||
|
||||
/// <summary>
|
||||
/// Records that a central→site gRPC subscriber stream closed (decrements
|
||||
/// <c>scadabridge.site.stream.subscribers.active</c>). Renamed from
|
||||
/// <c>SiteConnectionClosed</c> by register row 54.
|
||||
/// </summary>
|
||||
public static void SiteStreamSubscriberClosed() => Interlocked.Decrement(ref _siteStreamSubscriberCount);
|
||||
|
||||
/// <summary>
|
||||
/// Records the current state of one data connection, feeding the
|
||||
/// <c>scadabridge.site.data_connections.*</c> gauges (register row 54). Called by the DCL
|
||||
/// at each connection state transition; safe to call repeatedly with the same value.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">The data connection's name (unique per site node).</param>
|
||||
/// <param name="state">The connection's new state.</param>
|
||||
public static void RecordDataConnectionState(string connectionName, ConnectionHealth state)
|
||||
{
|
||||
if (string.IsNullOrEmpty(connectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dataConnectionStates[connectionName] = state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops a data connection from the gauges (register row 54). Called when the DCL removes
|
||||
/// a connection, so an undeployed connection stops counting toward the total rather than
|
||||
/// lingering as a permanently-not-connected row that would alert forever.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">The data connection's name.</param>
|
||||
public static void RemoveDataConnection(string connectionName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(connectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dataConnectionStates.TryRemove(connectionName, out _);
|
||||
}
|
||||
|
||||
/// <summary>Counts data connections currently in <see cref="ConnectionHealth.Connected"/>.</summary>
|
||||
/// <returns>The connected count.</returns>
|
||||
private static long CountDataConnections()
|
||||
{
|
||||
long connected = 0;
|
||||
foreach (var state in _dataConnectionStates.Values)
|
||||
{
|
||||
if (state == ConnectionHealth.Connected)
|
||||
{
|
||||
connected++;
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
/// <summary>Records that a per-site live-alarm aggregator started (increments the active-aggregator gauge).</summary>
|
||||
public static void LiveAlarmAggregatorStarted() => Interlocked.Increment(ref _liveAlarmAggregatorsActive);
|
||||
|
||||
@@ -120,5 +120,22 @@ public static class KpiMetrics
|
||||
|
||||
/// <summary>Summed store-and-forward buffer depth across all buffers.</summary>
|
||||
public const string SfBufferDepth = "sfBufferDepth";
|
||||
|
||||
/// <summary>
|
||||
/// Unacked LocalDb replication oplog depth (register row 33). Emitted ONLY by sites
|
||||
/// that actually report a backlog — an unreplicated site reports null, and null means
|
||||
/// "no reading", never zero, so no sample is written at all rather than a fabricated 0.
|
||||
/// A gap in this series therefore means "not replicated / not reporting", which is
|
||||
/// exactly what it should mean.
|
||||
/// </summary>
|
||||
public const string LocalDbOplogBacklog = "localDbOplogBacklog";
|
||||
|
||||
/// <summary>
|
||||
/// LocalDb replication link state as 1/0 (register row 33). Emitted ONLY when the site
|
||||
/// reports a value; null (replication not configured) writes no sample. Note that
|
||||
/// <c>false</c> and null are different states here — false is a configured link that is
|
||||
/// down, null is no link configured — which is why this cannot be coerced to 0.
|
||||
/// </summary>
|
||||
public const string LocalDbReplicationConnected = "localDbReplicationConnected";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,12 +467,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
|
||||
// Telemetry follow-on: the connection is now fully established (Subscribe
|
||||
// succeeded, so no leak via the catch above). Count it up here and balance
|
||||
// it in the finally below so the scadabridge.site.connection.up gauge is
|
||||
// decremented on EVERY exit path — normal completion, client-cancel /
|
||||
// it in the finally below so the scadabridge.site.stream.subscribers.active
|
||||
// gauge is decremented on EVERY exit path — normal completion, client-cancel /
|
||||
// duplicate-replacement (OperationCanceledException), server shutdown
|
||||
// (CancelAllStreams -> Cts.Cancel), and any other exception — guaranteeing
|
||||
// exactly one Closed per Opened and a gauge that never drifts up.
|
||||
ScadaBridgeTelemetry.SiteConnectionOpened();
|
||||
ScadaBridgeTelemetry.SiteStreamSubscriberOpened();
|
||||
try
|
||||
{
|
||||
// R2 — event batching. The pump replaces the old per-event
|
||||
@@ -500,7 +500,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScadaBridgeTelemetry.SiteConnectionClosed();
|
||||
ScadaBridgeTelemetry.SiteStreamSubscriberClosed();
|
||||
_streamSubscriber.RemoveSubscriber(relayActor);
|
||||
_actorSystem!.Stop(relayActor);
|
||||
channel.Writer.TryComplete();
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Event;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
@@ -312,6 +313,10 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_log.Info("[{0}] Entering Connecting state", _connectionName);
|
||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connecting);
|
||||
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Connecting");
|
||||
// Register row 54 — mirror the state into the data-connection gauges. Kept alongside
|
||||
// the health-collector write (rather than derived from it) so the metric never
|
||||
// depends on the health-report path being alive.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connecting);
|
||||
Become(Connecting);
|
||||
Self.Tell(new AttemptConnect());
|
||||
}
|
||||
@@ -394,6 +399,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_healthCollector.UpdateTagResolution(_connectionName, TotalSubscribedTagCount, ResolvedTagCount);
|
||||
var endpointLabel = _backupConfig == null ? "Connected" : $"Connected to {_activeEndpoint.ToString().ToLower()}";
|
||||
_healthCollector.UpdateConnectionEndpoint(_connectionName, endpointLabel);
|
||||
// Register row 54 — the only transition that raises the connected gauge.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connected);
|
||||
Become(Connected);
|
||||
Stash.UnstashAll();
|
||||
}
|
||||
@@ -475,6 +482,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_log.Warning("[{0}] Entering Reconnecting state", _connectionName);
|
||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Disconnected);
|
||||
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Disconnected");
|
||||
// Register row 54 — drops this connection out of the connected gauge while the
|
||||
// total holds, which is exactly the connected < total alert condition.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Disconnected);
|
||||
|
||||
// Track unstable connections toward failover.
|
||||
// If we were connected for less than the stability threshold, this counts
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
@@ -307,6 +308,10 @@ public class DataConnectionManagerActor : ReceiveActor
|
||||
Context.Stop(actor);
|
||||
_connectionActors.Remove(command.ConnectionName);
|
||||
_healthCollector.RemoveConnection(command.ConnectionName);
|
||||
// Register row 54 — drop it from the gauges too, so an undeployed connection
|
||||
// stops counting toward the total instead of lingering as a permanently
|
||||
// not-connected row that would hold the connected < total alert on forever.
|
||||
ScadaBridgeTelemetry.RemoveDataConnection(command.ConnectionName);
|
||||
_log.Info("Removed DataConnectionActor for {0}", command.ConnectionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
FirstSeenAt = now,
|
||||
LastSequenceNumber = report.SequenceNumber,
|
||||
IsOnline = true,
|
||||
IsMetricsStale = false
|
||||
IsMetricsStale = false,
|
||||
LocalDbBacklogAboveSince = NextBacklogAboveSince(null, report, now)
|
||||
// LastStatusChangeAt stays null: first observation is not a flip.
|
||||
};
|
||||
|
||||
@@ -84,7 +85,9 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
IsMetricsStale = false,
|
||||
// Stamp a flip only when this report brings the site back online;
|
||||
// a report from an already-online site leaves the timestamp be.
|
||||
LastStatusChangeAt = existing.IsOnline ? existing.LastStatusChangeAt : now
|
||||
LastStatusChangeAt = existing.IsOnline ? existing.LastStatusChangeAt : now,
|
||||
LocalDbBacklogAboveSince =
|
||||
NextBacklogAboveSince(existing.LocalDbBacklogAboveSince, report, now)
|
||||
};
|
||||
|
||||
if (_siteStates.TryUpdate(report.SiteId, updated, existing))
|
||||
@@ -320,4 +323,43 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the sustained-backlog anchor for one incoming report (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three cases, and the null one is the subtle one:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Backlog <c>null</c> — no reading. An unreplicated site (rig site-b/site-c) reports this
|
||||
/// forever, and a replicated site reports it when a poll fails. Carry the previous anchor
|
||||
/// through UNCHANGED: null is not zero, so it is neither evidence of a drain (which would
|
||||
/// justify clearing) nor of a backlog (which would justify starting one). An unreplicated
|
||||
/// site therefore never accumulates an anchor and can never be badged.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Backlog above the threshold — start the clock if it is not already running. Keeping the
|
||||
/// ORIGINAL start instant is what makes the window sustained rather than sliding.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Backlog at or below the threshold — clear. This is the daily purge burst's exit path:
|
||||
/// the spike drains within a few reports and the anchor is discarded long before the dwell
|
||||
/// elapses, so the expected burst never badges the site.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="current">The anchor currently held for this site, if any.</param>
|
||||
/// <param name="report">The incoming health report.</param>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <returns>The anchor to store.</returns>
|
||||
private DateTimeOffset? NextBacklogAboveSince(
|
||||
DateTimeOffset? current, SiteHealthReport report, DateTimeOffset now)
|
||||
{
|
||||
if (report.LocalDbOplogBacklog is not { } backlog)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return backlog > _options.LocalDbBacklogThreshold ? current ?? now : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,32 @@ public class HealthMonitoringOptions
|
||||
/// (~4 missed 30s reports).
|
||||
/// </summary>
|
||||
public TimeSpan MetricsStaleTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Unacked LocalDb replication oplog depth above which a site is considered backlogged
|
||||
/// (register row 33). Crossing this alone shows nothing — the backlog must also stay
|
||||
/// above it for <see cref="LocalDbBacklogSustainedFor"/> before the Health dashboard
|
||||
/// badges the site. Must be positive. Default: <c>1000</c> entries.
|
||||
/// </summary>
|
||||
public long LocalDbBacklogThreshold { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// How long the oplog backlog must stay continuously above
|
||||
/// <see cref="LocalDbBacklogThreshold"/> before the Health dashboard badges the site
|
||||
/// (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This dwell time is the whole point of the signal, not a refinement of it. A bare
|
||||
/// threshold would fire every single day on a healthy replicated site: the `site_events`
|
||||
/// retention purge and the storage-cap trim are CDC-captured like any other write, so the
|
||||
/// backlog jumps by the deleted batch size and then drains as the peer acks it. That spike
|
||||
/// is expected and documented (see <c>docs/deployment/topology-guide.md</c> → *Reading the
|
||||
/// replication backlog*). Healthy means the backlog RETURNS TO ~0; unhealthy means it
|
||||
/// stays up. Only a sustained window can tell those apart, so the default is set well
|
||||
/// clear of a purge drain. Must be positive and no shorter than
|
||||
/// <see cref="ReportInterval"/> — a window shorter than one report interval could be
|
||||
/// satisfied by a single sample, which is exactly the bare threshold this exists to avoid.
|
||||
/// Default: 15 minutes.
|
||||
/// </remarks>
|
||||
public TimeSpan LocalDbBacklogSustainedFor { get; set; } = TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
@@ -64,5 +64,29 @@ public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<Heal
|
||||
$"must be >= ReportInterval ({options.ReportInterval}): a stale window shorter than one " +
|
||||
"report interval would flag every healthy site, since reports cannot arrive faster than " +
|
||||
"they are emitted.");
|
||||
|
||||
builder.RequireThat(options.LocalDbBacklogThreshold > 0,
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogThreshold must be positive " +
|
||||
$"(was {options.LocalDbBacklogThreshold}); a zero/negative threshold would badge every " +
|
||||
"replicated site permanently, since an idle backlog of 0 already sits at or above it.");
|
||||
|
||||
builder.RequireThat(options.LocalDbBacklogSustainedFor > TimeSpan.Zero,
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor must be a positive duration " +
|
||||
$"(was {options.LocalDbBacklogSustainedFor}); it is the dwell time a backlog must stay " +
|
||||
"above the threshold before the site is badged.");
|
||||
|
||||
// A dwell shorter than one report interval can be satisfied by a single sample,
|
||||
// degrading the sustained check back into the bare threshold it exists to replace —
|
||||
// which would fire on every daily purge burst. Same De Morgan guard shape as the
|
||||
// pairs above: silent unless BOTH fields are positive and the dwell is too short,
|
||||
// so the dedicated positive-value checks own that failure.
|
||||
builder.RequireThat(
|
||||
!(options.ReportInterval > TimeSpan.Zero
|
||||
&& options.LocalDbBacklogSustainedFor > TimeSpan.Zero
|
||||
&& options.LocalDbBacklogSustainedFor < options.ReportInterval),
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor ({options.LocalDbBacklogSustainedFor}) " +
|
||||
$"must be >= ReportInterval ({options.ReportInterval}): a dwell shorter than one report " +
|
||||
"interval can be satisfied by a single sample, which turns the sustained check back into " +
|
||||
"a bare threshold and re-introduces the daily purge-burst false positive it exists to avoid.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
|
||||
private const string MetricDisabledInstances = "disabledInstances";
|
||||
private const string MetricAuditBacklogPending = "auditBacklogPending";
|
||||
private const string MetricEventLogWriteFailures = KpiMetrics.SiteHealth.EventLogWriteFailures;
|
||||
private const string MetricLocalDbOplogBacklog = KpiMetrics.SiteHealth.LocalDbOplogBacklog;
|
||||
private const string MetricLocalDbReplicationConnected = KpiMetrics.SiteHealth.LocalDbReplicationConnected;
|
||||
|
||||
private readonly ICentralHealthAggregator _aggregator;
|
||||
|
||||
@@ -136,6 +138,32 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
|
||||
report.SiteAuditBacklog?.PendingCount ?? 0));
|
||||
samples.Add(Sample(
|
||||
capturedAtUtc, MetricEventLogWriteFailures, siteId, report.SiteEventLogWriteFailures));
|
||||
|
||||
// Register row 33: LocalDb replication. Both fields are NULLABLE and null is
|
||||
// load-bearing — it means "no reading" (replication not configured, as on rig
|
||||
// site-b/site-c, or a failed poll), NOT zero. So unlike the audit-backlog line
|
||||
// above, these are CONDITIONALLY emitted rather than coerced with `?? 0`:
|
||||
//
|
||||
// - A fabricated 0 backlog would render a pair that cannot read its own oplog,
|
||||
// or one that simply isn't replicated, as perfectly healthy — and would do it
|
||||
// once a minute, forever, burying the real series for the sites that are.
|
||||
// - For the connected flag, `false` and null are genuinely different states: a
|
||||
// configured link that is DOWN versus no link at all. Collapsing null to 0
|
||||
// would alarm on every unreplicated site permanently.
|
||||
//
|
||||
// The cost is that a site's sample count varies (12 unreplicated, 14 replicated).
|
||||
// That is the intended shape: a GAP in these two series means "not replicated / not
|
||||
// reporting", which is a truthful reading, whereas a flat 0 line is a lie.
|
||||
if (report.LocalDbOplogBacklog is { } backlog)
|
||||
{
|
||||
samples.Add(Sample(capturedAtUtc, MetricLocalDbOplogBacklog, siteId, backlog));
|
||||
}
|
||||
|
||||
if (report.LocalDbReplicationConnected is { } connected)
|
||||
{
|
||||
samples.Add(Sample(
|
||||
capturedAtUtc, MetricLocalDbReplicationConnected, siteId, connected ? 1 : 0));
|
||||
}
|
||||
}
|
||||
|
||||
private static KpiSample Sample(
|
||||
|
||||
@@ -67,6 +67,28 @@ public sealed record SiteHealthState
|
||||
/// </summary>
|
||||
public bool IsMetricsStale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instant this site's LocalDb replication oplog backlog first rose above
|
||||
/// <see cref="HealthMonitoringOptions.LocalDbBacklogThreshold"/> and stayed there, or
|
||||
/// <c>null</c> if the backlog is not currently above it (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The anchor for the *sustained* backlog badge: the badge shows only once
|
||||
/// <see cref="HealthMonitoringOptions.LocalDbBacklogSustainedFor"/> has elapsed since this
|
||||
/// instant. Holding a start time rather than a boolean is what makes the signal survive the
|
||||
/// expected daily purge burst — that spike raises the backlog for a drain's worth of
|
||||
/// reports and then clears this back to null, never reaching the dwell.
|
||||
/// <para>
|
||||
/// Null is set two different ways and they mean different things. A report whose backlog is
|
||||
/// at or below the threshold CLEARS it (the backlog demonstrably drained). A report whose
|
||||
/// backlog is <c>null</c> — an unreplicated site, or a failed poll — LEAVES IT UNCHANGED:
|
||||
/// null means "no reading", never zero, so a node that stops reporting mid-climb must not
|
||||
/// silently reset the clock, and an unreplicated site (which reports null forever) never
|
||||
/// starts one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public DateTimeOffset? LocalDbBacklogAboveSince { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instant of the last online↔offline flip, or <c>null</c> if the
|
||||
/// site has never changed status since it was first observed. Answers "when
|
||||
|
||||
@@ -200,6 +200,27 @@ try
|
||||
?? builder.WebHost.GetSetting("urls")
|
||||
?? "http://+:5000";
|
||||
var httpPorts = ParseHttpBindPorts(httpUrls);
|
||||
|
||||
// Register row 56: central's dedicated Prometheus listener, OPT-IN.
|
||||
//
|
||||
// /metrics is already mapped on central (app.MapZbMetrics() below) and, because it
|
||||
// rides endpoint routing, it is served on every listener this node binds — so it has
|
||||
// always been reachable on the HTTP surface (:5000 on the rig). What central lacked
|
||||
// was the site branch's dedicated MetricsPort, which is what the established
|
||||
// live-scrape recipe probes (`docker exec <node> bash -c '</dev/tcp/localhost/8084'`).
|
||||
// A probe of central on :8084 therefore failed, and central-only series (KPI/outbox
|
||||
// counters, the import-session gauge + rejection counter, alarm-cache gauges) read as
|
||||
// unscrapeable even though they were being exported.
|
||||
//
|
||||
// Read as a PRESENCE check, not GetValue's defaulted read, deliberately: NodeOptions
|
||||
// defaults MetricsPort to 8084, so a defaulted read would make every existing central
|
||||
// node start binding a new port on upgrade — and a port already in use is a hard boot
|
||||
// failure, not a degraded one. Central gains the listener only where an operator asked
|
||||
// for it. Site nodes are unaffected; they still bind theirs unconditionally.
|
||||
var centralMetricsPort = int.TryParse(configuration["ScadaBridge:Node:MetricsPort"], out var mp)
|
||||
? mp
|
||||
: (int?)null;
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
// The HTTP/1.1 (+ HTTP/2) surface from the configured URLs, re-declared so it
|
||||
@@ -217,6 +238,21 @@ try
|
||||
{
|
||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
|
||||
// The Prometheus scrape listener (register row 56), when configured. Skipped
|
||||
// when it would double-bind a port already declared above — Kestrel throws on a
|
||||
// duplicate ListenAnyIP, and /metrics is reachable on that port either way since
|
||||
// it rides endpoint routing. HTTP/1.1-capable on purpose: a standard Prometheus
|
||||
// scraper cannot negotiate h2c, which is why this cannot share the gRPC port.
|
||||
if (centralMetricsPort is { } cmp
|
||||
&& cmp != centralGrpcPort
|
||||
&& !httpPorts.Contains(cmp))
|
||||
{
|
||||
options.ListenAnyIP(cmp, listenOptions =>
|
||||
{
|
||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Shared components
|
||||
@@ -698,9 +734,11 @@ try
|
||||
builder.Configuration.AddConfiguration(configuration);
|
||||
|
||||
// Captive-dependency validation (deferred register row 50) — the SITE half of the
|
||||
// pair. This branch is the one that actually gains coverage: the docker rig sets
|
||||
// ASPNETCORE_ENVIRONMENT=Development on the central containers but NOT on the site
|
||||
// containers, so a site node has always built its provider unvalidated.
|
||||
// pair. This branch is the one that actually gains coverage: the docker rig long
|
||||
// set ASPNETCORE_ENVIRONMENT=Development on the central containers but NOT on the
|
||||
// site containers, so a site node built its provider unvalidated. Register row 63
|
||||
// closed that — all six rig site containers (and docker-env2's two) now set it, so
|
||||
// this validation actually runs there.
|
||||
builder.Host.UseDefaultServiceProvider(ServiceProviderValidation.Configure);
|
||||
|
||||
// Serilog
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
@@ -115,6 +116,15 @@ public sealed class BundleSessionStore : IBundleSessionStore
|
||||
EvictExpired();
|
||||
if (_sessions.Count >= cap)
|
||||
{
|
||||
// Register row 66: the alertable half of row 53's observability. The
|
||||
// Warning below carries the diagnosis but a log line cannot be alerted
|
||||
// on; this counter can. Deliberately incremented HERE, after
|
||||
// EvictExpired() has already failed to free a slot — the outer check
|
||||
// at the top of this block is not a rejection (a store full of stale
|
||||
// entries reclaims and succeeds), so counting there would inflate the
|
||||
// series with imports that were never actually turned away.
|
||||
ScadaBridgeTelemetry.RecordImportSessionRejected();
|
||||
|
||||
// Register row 53: this rejection used to be silent apart from the
|
||||
// exception the caller saw. A cap hit is either genuine concurrency
|
||||
// (many operators importing at once) or a session leak like register
|
||||
|
||||
Reference in New Issue
Block a user