diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 99ac5d9..cf04abd 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -172,26 +172,38 @@ public static class ServiceCollectionExtensions } /// - /// Audit Log (#23) M2 Bundle G — swap the default - /// registration for the real - /// bridge so the - /// FallbackAuditWriter primary-failure counter surfaces in the site health - /// report payload as SiteHealthReport.SiteAuditWriteFailures. + /// Audit Log (#23) M2 Bundle G + M5 Bundle C — swap the default + /// and + /// registrations for the + /// real / + /// bridges so the + /// FallbackAuditWriter primary-failure counter AND the + /// DefaultAuditPayloadFilter redactor-failure counter both surface in the + /// site health report payload as + /// SiteHealthReport.SiteAuditWriteFailures + + /// SiteHealthReport.AuditRedactionFailure. /// /// /// /// Must be called AFTER both (registers the - /// NoOp default this method replaces) and + /// NoOp defaults this method replaces) and /// ScadaLink.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring /// or AddSiteHealthMonitoring (registers the - /// the bridge depends on). Resolving - /// without the latter throws + /// the bridges depend on). Resolving + /// or + /// without the latter throws /// at GetRequiredService /// time — by design, since a silent NoOp would mask a misconfiguration. /// /// - /// Idempotent — calling twice replaces the descriptor each time without - /// piling up registrations. + /// Idempotent — calling twice replaces each descriptor without piling up + /// registrations. + /// + /// + /// Site-side only for M5: the central composition root keeps the NoOp + /// defaults; the central health-metric surface that would expose + /// AuditRedactionFailure next to the existing central counters + /// ships in M6. /// /// public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services) @@ -200,6 +212,8 @@ public static class ServiceCollectionExtensions services.Replace( ServiceDescriptor.Singleton()); + services.Replace( + ServiceDescriptor.Singleton()); return services; } } diff --git a/src/ScadaLink.AuditLog/Site/HealthMetricsAuditRedactionFailureCounter.cs b/src/ScadaLink.AuditLog/Site/HealthMetricsAuditRedactionFailureCounter.cs new file mode 100644 index 0000000..78454e3 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/HealthMetricsAuditRedactionFailureCounter.cs @@ -0,0 +1,48 @@ +using ScadaLink.AuditLog.Payload; +using ScadaLink.HealthMonitoring; + +namespace ScadaLink.AuditLog.Site; + +/// +/// Audit Log (#23) M5 Bundle C — bridges +/// (incremented by +/// every time a header / body / SQL +/// parameter redactor stage throws and the filter has to over-redact the +/// offending field) into so the count +/// surfaces in the site health report payload as +/// SiteHealthReport.AuditRedactionFailure. +/// +/// +/// +/// Registered by ; +/// callers must register AddHealthMonitoring() first so +/// resolves. The default +/// registration keeps for nodes +/// where Site Health Monitoring is not wired (the silent-sink contract — +/// redaction failures must NEVER abort the user-facing action, alog.md §7). +/// +/// +/// Mirrors the M2 Bundle G +/// shape one-for-one so the two health-metric bridges age together. +/// +/// +/// Site-side only for M5: the redaction filter also runs on the central +/// writers (CentralAuditWriter + AuditLogIngestActor), but the central +/// health-metric surface that would expose AuditRedactionFailure +/// alongside the existing central counters ships in M6. Until then, the +/// central composition root keeps the NoOp default — the redactions still +/// happen, they just don't get counted into a health report. +/// +/// +public sealed class HealthMetricsAuditRedactionFailureCounter : IAuditRedactionFailureCounter +{ + private readonly ISiteHealthCollector _collector; + + public HealthMetricsAuditRedactionFailureCounter(ISiteHealthCollector collector) + { + _collector = collector ?? throw new ArgumentNullException(nameof(collector)); + } + + /// + public void Increment() => _collector.IncrementAuditRedactionFailure(); +} diff --git a/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs b/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs index 516d4f3..bba4c8d 100644 --- a/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs +++ b/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs @@ -25,7 +25,14 @@ public record SiteHealthReport( // 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); + int SiteAuditWriteFailures = 0, + // Audit Log (#23) M5 Bundle C: per-interval count of payload-filter + // redactor over-redactions (header / body / SQL parameter stages all + // throwing → field replaced with the "" + // marker). Surfaces a misconfigured / catastrophic regex on + // /monitoring/health. Defaults to 0 for back-compat with existing + // producers and tests that don't construct the field. + int AuditRedactionFailure = 0); /// /// Broadcast wrapper used between central nodes to keep per-node diff --git a/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs index c16c45f..bcd5f9e 100644 --- a/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs +++ b/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs @@ -19,6 +19,15 @@ public interface ISiteHealthCollector /// AddAuditLogHealthMetricsBridge(). /// void IncrementSiteAuditWriteFailures(); + /// + /// Audit Log (#23) M5 Bundle C — increment the per-interval count of + /// payload-filter redactor over-redactions (header / body / SQL + /// parameter stage throws routed to the + /// <redacted: redactor error> marker). Bridged from the + /// IAuditRedactionFailureCounter binding registered via + /// AddAuditLogHealthMetricsBridge(). + /// + void IncrementAuditRedactionFailure(); void UpdateConnectionHealth(string connectionName, ConnectionHealth health); void RemoveConnection(string connectionName); void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved); diff --git a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs index 1a6aa48..47567c9 100644 --- a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs +++ b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs @@ -14,6 +14,7 @@ public class SiteHealthCollector : ISiteHealthCollector private int _alarmErrorCount; private int _deadLetterCount; private int _siteAuditWriteFailures; + private int _auditRedactionFailures; private readonly ConcurrentDictionary _connectionStatuses = new(); private readonly ConcurrentDictionary _tagResolutionCounts = new(); private readonly ConcurrentDictionary _connectionEndpoints = new(); @@ -74,6 +75,20 @@ public class SiteHealthCollector : ISiteHealthCollector Interlocked.Increment(ref _siteAuditWriteFailures); } + /// + /// Audit Log (#23) M5 Bundle C — increment the per-interval count of + /// payload-filter redactor over-redactions (header / body / SQL + /// parameter stages routed to the + /// <redacted: redactor error> marker). Bridged from the + /// IAuditRedactionFailureCounter binding registered via + /// AddAuditLogHealthMetricsBridge(); reset every interval together + /// with the other per-interval counters. + /// + public void IncrementAuditRedactionFailure() + { + Interlocked.Increment(ref _auditRedactionFailures); + } + /// /// Update the health status for a named data connection. /// Called by DCL when connection state changes. @@ -158,6 +173,7 @@ public class SiteHealthCollector : ISiteHealthCollector var alarmErrors = Interlocked.Exchange(ref _alarmErrorCount, 0); var deadLetters = Interlocked.Exchange(ref _deadLetterCount, 0); var siteAuditWriteFailures = Interlocked.Exchange(ref _siteAuditWriteFailures, 0); + var auditRedactionFailures = Interlocked.Exchange(ref _auditRedactionFailures, 0); // Snapshot current connection and tag resolution state var connectionStatuses = new Dictionary(_connectionStatuses); @@ -190,6 +206,7 @@ public class SiteHealthCollector : ISiteHealthCollector DataConnectionTagQuality: tagQuality, ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0), ClusterNodes: _clusterNodes?.ToList(), - SiteAuditWriteFailures: siteAuditWriteFailures); + SiteAuditWriteFailures: siteAuditWriteFailures, + AuditRedactionFailure: auditRedactionFailures); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs new file mode 100644 index 0000000..ffc09f3 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs @@ -0,0 +1,49 @@ +using NSubstitute; +using ScadaLink.AuditLog.Site; +using ScadaLink.HealthMonitoring; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle C (M5-T7) — the +/// adapter is the production binding for +/// on +/// site nodes; it forwards every +/// redactor over-redaction event into the shared +/// so the site health report surfaces the +/// count as AuditRedactionFailure. Mirrors the M2 Bundle G +/// HealthMetricsAuditWriteFailureCounter shape one-for-one. +/// +public class HealthMetricsAuditRedactionFailureCounterTests +{ + [Fact] + public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure() + { + var collector = Substitute.For(); + var counter = new HealthMetricsAuditRedactionFailureCounter(collector); + + counter.Increment(); + + collector.Received(1).IncrementAuditRedactionFailure(); + } + + [Fact] + public void Increment_Multiple_Calls_Route_To_Collector_Each_Time() + { + var collector = Substitute.For(); + var counter = new HealthMetricsAuditRedactionFailureCounter(collector); + + counter.Increment(); + counter.Increment(); + counter.Increment(); + + collector.Received(3).IncrementAuditRedactionFailure(); + } + + [Fact] + public void Construction_With_Null_Collector_Throws_ArgumentNullException() + { + Assert.Throws( + () => new HealthMetricsAuditRedactionFailureCounter(null!)); + } +} diff --git a/tests/ScadaLink.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs new file mode 100644 index 0000000..2618561 --- /dev/null +++ b/tests/ScadaLink.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs @@ -0,0 +1,57 @@ +namespace ScadaLink.HealthMonitoring.Tests; + +/// +/// Bundle C (M5-T7) regression coverage. The Audit Log payload filter +/// (DefaultAuditPayloadFilter) increments +/// IAuditRedactionFailureCounter every time a header/body/SQL-param +/// redactor stage throws and the filter has to over-redact the field with +/// the <redacted: redactor error> marker. Bundle C bridges that +/// counter into the Site Health Monitoring report payload as +/// AuditRedactionFailure so a misconfigured / catastrophic regex +/// surfaces on /monitoring/health rather than disappearing into a NoOp sink. +/// Mirrors the Bundle G SiteAuditWriteFailures metric shape — same +/// per-interval increment-and-reset semantics, same defaults-to-zero +/// contract. +/// +public class AuditRedactionFailureMetricTests +{ + private readonly SiteHealthCollector _collector = new(); + + [Fact] + public void Increment_Three_Times_Counter_Reports_3() + { + _collector.IncrementAuditRedactionFailure(); + _collector.IncrementAuditRedactionFailure(); + _collector.IncrementAuditRedactionFailure(); + + var report = _collector.CollectReport("site-1"); + + Assert.Equal(3, report.AuditRedactionFailure); + } + + [Fact] + public void Report_Payload_Includes_AuditRedactionFailure_AsZeroByDefault() + { + var report = _collector.CollectReport("site-1"); + + Assert.Equal(0, report.AuditRedactionFailure); + } + + /// + /// Mirrors the existing per-interval reset semantics for ScriptErrorCount / + /// AlarmEvaluationErrorCount / DeadLetterCount / SiteAuditWriteFailures — + /// AuditRedactionFailure is an interval count, not a running total. + /// + [Fact] + public void CollectReport_Resets_AuditRedactionFailure() + { + _collector.IncrementAuditRedactionFailure(); + _collector.IncrementAuditRedactionFailure(); + + var first = _collector.CollectReport("site-1"); + Assert.Equal(2, first.AuditRedactionFailure); + + var second = _collector.CollectReport("site-1"); + Assert.Equal(0, second.AuditRedactionFailure); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs index 9548631..aec5917 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs @@ -70,6 +70,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable public void IncrementAlarmError() { } public void IncrementDeadLetter() { } public void IncrementSiteAuditWriteFailures() { } + public void IncrementAuditRedactionFailure() { } public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { } public void RemoveConnection(string connectionName) { } public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }