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);
+ }
+}