From 6f6157ce892b5bebf303ae342b5b8273ada86602 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 19:53:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(kpi):=20K8=20=E2=80=94=20AuditLog=20sample?= =?UTF-8?q?=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Kpi/AuditLogKpiSampleSource.cs | 97 ++++++++++++++++ .../ServiceCollectionExtensions.cs | 14 +++ .../Kpi/AuditLogKpiSampleSourceTests.cs | 108 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.AuditLog/Kpi/AuditLogKpiSampleSource.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Kpi/AuditLogKpiSampleSource.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Kpi/AuditLogKpiSampleSource.cs new file mode 100644 index 00000000..5e69d58f --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Kpi/AuditLogKpiSampleSource.cs @@ -0,0 +1,97 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Kpi; + +/// +/// Audit Log (#23) M6 "KPI History & Trends" (K8) — the +/// for the source. +/// The central recorder singleton enumerates this provider once per sampling pass +/// and persists its samples into the central KpiSample history table. +/// +/// +/// +/// Snapshots the Audit Log volume + error-rate counts the live Health-dashboard +/// "Audit" tiles already read () +/// over the same trailing , plus the global pending backlog the +/// snapshot carries. All three metrics are only — the +/// snapshot is a system-wide aggregate with no per-site / per-node breakdown, so +/// is always null. +/// +/// +/// The trailing window is anchored on capturedAtUtc (passed as the snapshot's +/// nowUtc) so the sample reflects the same instant the recorder stamps every +/// other source with — deterministic across the whole sampling pass rather than the +/// repository's server-side UtcNow at call time. +/// +/// +public sealed class AuditLogKpiSampleSource : IKpiSampleSource +{ + /// + /// Trailing window for the volume + error-rate aggregate. Fixed at 1 hour to + /// match the live Audit KPI tiles (AuditLogQueryService.KpiWindow) and + /// the …LastHour metric names below. + /// + private static readonly TimeSpan Window = TimeSpan.FromHours(1); + + // Metric catalog — the exact strings persisted in KpiSample.Metric. Stable + // identifiers the recorder + UI trend charts key on; do not rename. + private const string TotalEventsLastHourMetric = "totalEventsLastHour"; + private const string ErrorEventsLastHourMetric = "errorEventsLastHour"; + private const string BacklogTotalMetric = "backlogTotal"; + + private readonly IAuditLogRepository _repository; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Append-only Audit Log repository — its + /// supplies the volume, error, and backlog counts snapshotted here. + /// + public AuditLogKpiSampleSource(IAuditLogRepository repository) + { + ArgumentNullException.ThrowIfNull(repository); + _repository = repository; + } + + /// + public string Source => KpiSources.AuditLog; + + /// + public async Task> CollectAsync( + DateTime capturedAtUtc, + CancellationToken cancellationToken = default) + { + var snapshot = await _repository + .GetKpiSnapshotAsync(Window, nowUtc: capturedAtUtc, cancellationToken) + .ConfigureAwait(false); + + // Defensive: a null snapshot would mean the repo broke its non-null + // contract, but the source must degrade to "nothing to report" rather + // than NRE the whole sampling pass. + if (snapshot is null) + { + return []; + } + + return + [ + Sample(TotalEventsLastHourMetric, snapshot.TotalEventsLastHour, capturedAtUtc), + Sample(ErrorEventsLastHourMetric, snapshot.ErrorEventsLastHour, capturedAtUtc), + Sample(BacklogTotalMetric, snapshot.BacklogTotal, capturedAtUtc), + ]; + } + + private static KpiSample Sample(string metric, double value, DateTime capturedAtUtc) => new() + { + Source = KpiSources.AuditLog, + Metric = metric, + Scope = KpiScopes.Global, + ScopeKey = null, + Value = value, + CapturedAtUtc = capturedAtUtc, + }; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs index 501ae180..87872164 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs @@ -7,10 +7,12 @@ using ZB.MOM.WW.Audit; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; +using ZB.MOM.WW.ScadaBridge.AuditLog.Kpi; using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; @@ -234,6 +236,18 @@ public static class ServiceCollectionExtensions // provider as a singleton on both site and central paths. sp.GetRequiredService())); + // M6 (K8) KPI History & Trends: the AuditLog source the central KPI + // recorder enumerates each sampling pass to snapshot Global-scope Audit + // Log KPIs (volume / error rate / backlog) into the KpiSample history + // table. Scoped to match its IAuditLogRepository dependency — a SCOPED + // EF Core service; the recorder opens a fresh scope per sampling pass. + // TryAdd-Enumerable so multiple sources can register the same interface + // (NotificationOutbox + SiteCallAudit + SiteHealth add their own) without + // any one composition root clobbering another, and re-registration is a + // no-op if AddAuditLog were ever called twice. + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); + return services; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs new file mode 100644 index 00000000..68b12450 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs @@ -0,0 +1,108 @@ +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.AuditLog.Kpi; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi; + +/// +/// M6 "KPI History & Trends" (K8) coverage for — +/// the the +/// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the +/// repository's onto exactly three Global-scope samples +/// with the canonical metric names, the recorder-supplied capture instant, and a null +/// ScopeKey. +/// +public class AuditLogKpiSampleSourceTests +{ + private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc); + + [Fact] + public void Source_is_AuditLog() + { + var repo = Substitute.For(); + var sut = new AuditLogKpiSampleSource(repo); + + Assert.Equal(KpiSources.AuditLog, sut.Source); + } + + [Fact] + public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot() + { + // Distinct values so a swapped mapping (e.g. error ↔ backlog) is caught. + var snapshot = new AuditLogKpiSnapshot( + TotalEventsLastHour: 4200, + ErrorEventsLastHour: 17, + BacklogTotal: 305, + AsOfUtc: new DateTime(2026, 6, 17, 12, 30, 5, DateTimeKind.Utc)); + + var repo = Substitute.For(); + repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(snapshot); + + var sut = new AuditLogKpiSampleSource(repo); + + var samples = await sut.CollectAsync(CapturedAt); + + Assert.Equal(3, samples.Count); + + // Every sample: AuditLog source, Global scope, null ScopeKey, the + // recorder-stamped capture instant. + Assert.All(samples, s => + { + Assert.Equal(KpiSources.AuditLog, s.Source); + Assert.Equal(KpiScopes.Global, s.Scope); + Assert.Null(s.ScopeKey); + Assert.Equal(CapturedAt, s.CapturedAtUtc); + }); + + // Exact (Metric, Value) catalog mapping. + AssertMetric(samples, "totalEventsLastHour", 4200); + AssertMetric(samples, "errorEventsLastHour", 17); + AssertMetric(samples, "backlogTotal", 305); + } + + [Fact] + public async Task CollectAsync_anchors_the_window_on_capturedAtUtc() + { + var repo = Substitute.For(); + repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new AuditLogKpiSnapshot(0, 0, 0, CapturedAt)); + + var sut = new AuditLogKpiSampleSource(repo); + + await sut.CollectAsync(CapturedAt); + + // The trailing 1h window is anchored on the recorder's shared instant, + // not the repository's server-side UtcNow, so the sample is reproducible. + await repo.Received(1).GetKpiSnapshotAsync( + TimeSpan.FromHours(1), + CapturedAt, + Arg.Any()); + } + + [Fact] + public async Task CollectAsync_returns_empty_when_snapshot_is_null() + { + var repo = Substitute.For(); + repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((AuditLogKpiSnapshot?)null!); + + var sut = new AuditLogKpiSampleSource(repo); + + var samples = await sut.CollectAsync(CapturedAt); + + Assert.NotNull(samples); + Assert.Empty(samples); + } + + private static void AssertMetric( + IReadOnlyList samples, + string metric, + double expectedValue) + { + var sample = Assert.Single(samples, s => s.Metric == metric); + Assert.Equal(expectedValue, sample.Value); + } +}