feat(health): SiteAuditWriteFailures counter + AuditLog bridge (#23)

Bundle G of Audit Log #23 M2. Bridges the FallbackAuditWriter primary-
failure counter into the Site Health Monitoring report payload so a
sustained audit-write outage surfaces on /monitoring/health instead of
disappearing into a NoOp sink.

- SiteHealthReport: add SiteAuditWriteFailures (defaulted, additive).
- ISiteHealthCollector + SiteHealthCollector: new
  IncrementSiteAuditWriteFailures() counter, per-interval reset
  semantics matching ScriptErrorCount / DeadLetterCount.
- HealthMetricsAuditWriteFailureCounter: adapter forwarding
  IAuditWriteFailureCounter.Increment() to the collector.
- AddAuditLogHealthMetricsBridge(): swaps the NoOp default
  registration for the real bridge; called from
  SiteServiceRegistration after AddSiteHealthMonitoring + AddAuditLog.
- Existing host-wiring test updated: site composition now resolves
  HealthMetricsAuditWriteFailureCounter (not NoOp).

Tests: HealthMonitoring 60 -> 63 (3 new), AuditLog 56 -> 59 (3 new),
full solution green.
This commit is contained in:
Joseph Doherty
2026-05-20 13:22:25 -04:00
parent 82a8bbf225
commit dd3351da93
11 changed files with 261 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
@@ -103,4 +104,36 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Audit Log (#23) M2 Bundle G — swap the default
/// <see cref="NoOpAuditWriteFailureCounter"/> registration for the real
/// <see cref="HealthMetricsAuditWriteFailureCounter"/> bridge so the
/// FallbackAuditWriter primary-failure counter surfaces in the site health
/// report payload as <c>SiteHealthReport.SiteAuditWriteFailures</c>.
/// </summary>
/// <remarks>
/// <para>
/// Must be called AFTER both <see cref="AddAuditLog"/> (registers the
/// NoOp default this method replaces) and
/// <c>ScadaLink.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring</c>
/// or <c>AddSiteHealthMonitoring</c> (registers the
/// <see cref="ISiteHealthCollector"/> the bridge depends on). Resolving
/// <see cref="IAuditWriteFailureCounter"/> without the latter throws
/// <see cref="InvalidOperationException"/> at <c>GetRequiredService</c>
/// time — by design, since a silent NoOp would mask a misconfiguration.
/// </para>
/// <para>
/// Idempotent — calling twice replaces the descriptor each time without
/// piling up registrations.
/// </para>
/// </remarks>
public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.Replace(
ServiceDescriptor.Singleton<IAuditWriteFailureCounter, HealthMetricsAuditWriteFailureCounter>());
return services;
}
}

View File

@@ -0,0 +1,33 @@
using ScadaLink.HealthMonitoring;
namespace ScadaLink.AuditLog.Site;
/// <summary>
/// Audit Log (#23) M2 Bundle G — bridges <see cref="IAuditWriteFailureCounter"/>
/// (incremented by <see cref="FallbackAuditWriter"/> every time the primary
/// SQLite writer throws) into <see cref="ISiteHealthCollector"/> so the count
/// surfaces in the site health report payload as
/// <c>SiteHealthReport.SiteAuditWriteFailures</c>.
/// </summary>
/// <remarks>
/// <para>
/// Registered by <see cref="ServiceCollectionExtensions.AddAuditLogHealthMetricsBridge"/>;
/// callers must register <c>AddHealthMonitoring()</c> first so
/// <see cref="ISiteHealthCollector"/> resolves. The default <see cref="AddAuditLog"/>
/// registration keeps <see cref="NoOpAuditWriteFailureCounter"/> for nodes
/// where Site Health Monitoring is not wired (the silent-sink contract — audit
/// write failures must NEVER abort the user-facing action, alog.md §7).
/// </para>
/// </remarks>
public sealed class HealthMetricsAuditWriteFailureCounter : IAuditWriteFailureCounter
{
private readonly ISiteHealthCollector _collector;
public HealthMetricsAuditWriteFailureCounter(ISiteHealthCollector collector)
{
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
}
/// <inheritdoc/>
public void Increment() => _collector.IncrementSiteAuditWriteFailures();
}

View File

@@ -20,7 +20,12 @@ public record SiteHealthReport(
IReadOnlyDictionary<string, string>? DataConnectionEndpoints = null,
IReadOnlyDictionary<string, TagQualityCounts>? DataConnectionTagQuality = null,
int ParkedMessageCount = 0,
IReadOnlyList<NodeStatus>? ClusterNodes = null);
IReadOnlyList<NodeStatus>? ClusterNodes = null,
// Audit Log (#23) M2 Bundle G: per-interval count of FallbackAuditWriter
// primary failures (SQLite throws routed to the drop-oldest ring). Surfaces
// a sustained audit-write outage on /monitoring/health. Defaults to 0 so
// existing producers / tests that don't construct the field stay valid.
int SiteAuditWriteFailures = 0);
/// <summary>
/// Broadcast wrapper used between central nodes to keep per-node

View File

@@ -12,6 +12,13 @@ public interface ISiteHealthCollector
void IncrementScriptError();
void IncrementAlarmError();
void IncrementDeadLetter();
/// <summary>
/// Audit Log (#23) Bundle G — increment the per-interval count of
/// <c>FallbackAuditWriter</c> primary failures. Bridged from the
/// <c>IAuditWriteFailureCounter</c> binding registered via
/// <c>AddAuditLogHealthMetricsBridge()</c>.
/// </summary>
void IncrementSiteAuditWriteFailures();
void UpdateConnectionHealth(string connectionName, ConnectionHealth health);
void RemoveConnection(string connectionName);
void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved);

View File

@@ -13,6 +13,7 @@ public class SiteHealthCollector : ISiteHealthCollector
private int _scriptErrorCount;
private int _alarmErrorCount;
private int _deadLetterCount;
private int _siteAuditWriteFailures;
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
private readonly ConcurrentDictionary<string, TagResolutionStatus> _tagResolutionCounts = new();
private readonly ConcurrentDictionary<string, string> _connectionEndpoints = new();
@@ -61,6 +62,18 @@ public class SiteHealthCollector : ISiteHealthCollector
Interlocked.Increment(ref _deadLetterCount);
}
/// <summary>
/// Audit Log (#23) Bundle G — increment the per-interval count of
/// <c>FallbackAuditWriter</c> primary failures. Bridged from the
/// <c>IAuditWriteFailureCounter</c> binding registered via
/// <c>AddAuditLogHealthMetricsBridge()</c>; reset every interval together
/// with the other per-interval counters.
/// </summary>
public void IncrementSiteAuditWriteFailures()
{
Interlocked.Increment(ref _siteAuditWriteFailures);
}
/// <summary>
/// Update the health status for a named data connection.
/// Called by DCL when connection state changes.
@@ -144,6 +157,7 @@ public class SiteHealthCollector : ISiteHealthCollector
var scriptErrors = Interlocked.Exchange(ref _scriptErrorCount, 0);
var alarmErrors = Interlocked.Exchange(ref _alarmErrorCount, 0);
var deadLetters = Interlocked.Exchange(ref _deadLetterCount, 0);
var siteAuditWriteFailures = Interlocked.Exchange(ref _siteAuditWriteFailures, 0);
// Snapshot current connection and tag resolution state
var connectionStatuses = new Dictionary<string, ConnectionHealth>(_connectionStatuses);
@@ -175,6 +189,7 @@ public class SiteHealthCollector : ISiteHealthCollector
DataConnectionEndpoints: connectionEndpoints,
DataConnectionTagQuality: tagQuality,
ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0),
ClusterNodes: _clusterNodes?.ToList());
ClusterNodes: _clusterNodes?.ToList(),
SiteAuditWriteFailures: siteAuditWriteFailures);
}
}

View File

@@ -51,6 +51,13 @@ public static class SiteServiceRegistration
// ScriptRuntimeContext, when Bundle F lands) reaches for.
services.AddAuditLog(config);
// Audit Log (#23) M2 Bundle G — bridge FallbackAuditWriter primary
// failures into the site health report payload as
// SiteAuditWriteFailures. Must come AFTER both AddSiteHealthMonitoring
// (registers ISiteHealthCollector) and AddAuditLog (registers the
// NoOp default this call replaces).
services.AddAuditLogHealthMetricsBridge();
// WP-13: Akka.NET bootstrap via hosted service
services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());