feat(kpi): K8 — AuditLog sample source

This commit is contained in:
Joseph Doherty
2026-06-17 19:53:41 -04:00
parent 0d6c026dff
commit 6f6157ce89
3 changed files with 219 additions and 0 deletions
@@ -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;
/// <summary>
/// Audit Log (#23) M6 "KPI History &amp; Trends" (K8) — the
/// <see cref="IKpiSampleSource"/> for the <see cref="KpiSources.AuditLog"/> source.
/// The central recorder singleton enumerates this provider once per sampling pass
/// and persists its samples into the central <c>KpiSample</c> history table.
/// </summary>
/// <remarks>
/// <para>
/// Snapshots the Audit Log volume + error-rate counts the live Health-dashboard
/// "Audit" tiles already read (<see cref="IAuditLogRepository.GetKpiSnapshotAsync"/>)
/// over the same trailing <see cref="Window"/>, plus the global pending backlog the
/// snapshot carries. All three metrics are <see cref="KpiScopes.Global"/> only — the
/// snapshot is a system-wide aggregate with no per-site / per-node breakdown, so
/// <see cref="KpiSample.ScopeKey"/> is always <c>null</c>.
/// </para>
/// <para>
/// The trailing window is anchored on <c>capturedAtUtc</c> (passed as the snapshot's
/// <c>nowUtc</c>) 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 <c>UtcNow</c> at call time.
/// </para>
/// </remarks>
public sealed class AuditLogKpiSampleSource : IKpiSampleSource
{
/// <summary>
/// Trailing window for the volume + error-rate aggregate. Fixed at 1 hour to
/// match the live Audit KPI tiles (<c>AuditLogQueryService.KpiWindow</c>) and
/// the <c>…LastHour</c> metric names below.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the <see cref="AuditLogKpiSampleSource"/> class.
/// </summary>
/// <param name="repository">
/// Append-only Audit Log repository — its <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/>
/// supplies the volume, error, and backlog counts snapshotted here.
/// </param>
public AuditLogKpiSampleSource(IAuditLogRepository repository)
{
ArgumentNullException.ThrowIfNull(repository);
_repository = repository;
}
/// <inheritdoc/>
public string Source => KpiSources.AuditLog;
/// <inheritdoc/>
public async Task<IReadOnlyList<KpiSample>> 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,
};
}
@@ -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<INodeIdentityProvider>()));
// 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<IKpiSampleSource, AuditLogKpiSampleSource>());
return services;
}
@@ -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;
/// <summary>
/// M6 "KPI History &amp; Trends" (K8) coverage for <see cref="AuditLogKpiSampleSource"/> —
/// the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource"/> the
/// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the
/// repository's <see cref="AuditLogKpiSnapshot"/> onto exactly three Global-scope samples
/// with the canonical metric names, the recorder-supplied capture instant, and a null
/// <c>ScopeKey</c>.
/// </summary>
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<IAuditLogRepository>();
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<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.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<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.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<CancellationToken>());
}
[Fact]
public async Task CollectAsync_returns_empty_when_snapshot_is_null()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.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<Commons.Entities.Kpi.KpiSample> samples,
string metric,
double expectedValue)
{
var sample = Assert.Single(samples, s => s.Metric == metric);
Assert.Equal(expectedValue, sample.Value);
}
}