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) { }