feat(kpi): K6 — NotificationOutbox sample source (global/site/node)

This commit is contained in:
Joseph Doherty
2026-06-17 19:53:39 -04:00
parent 9ffa34d3e7
commit 0d6c026dff
4 changed files with 331 additions and 0 deletions
@@ -0,0 +1,142 @@
using Microsoft.Extensions.Options;
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;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
/// <summary>
/// <see cref="IKpiSampleSource"/> for the Notification Outbox (#21): snapshots the same
/// point-in-time delivery KPIs the live Health-dashboard tiles surface — queue depth, stuck
/// count, parked count, delivered-last-interval, and oldest-pending age — at every sampling
/// pass of the central KPI-history recorder (M6 "KPI History &amp; Trends").
/// </summary>
/// <remarks>
/// <para>
/// Computes the same cutoffs the live KPI handlers in
/// <see cref="NotificationOutboxActor"/> use, anchored on the recorder's shared
/// <c>capturedAtUtc</c> rather than wall-clock <c>now</c>: the stuck cutoff is
/// <c>capturedAtUtc - <see cref="NotificationOutboxOptions.StuckAgeThreshold"/></c> and the
/// delivered window is <c>capturedAtUtc - <see cref="NotificationOutboxOptions.DeliveredKpiWindow"/></c>.
/// So a sample captured at the same instant equals the live tile.
/// </para>
/// <para>
/// Emits Global (<c>ScopeKey == null</c>), per-Site (<c>ScopeKey == SourceSiteId</c>), and
/// per-Node (<c>ScopeKey == SourceNode</c>) samples, mirroring the repository's three KPI
/// computation methods. The oldest-pending age (a <see cref="TimeSpan"/>?) maps to
/// <c>oldestPendingAgeSeconds</c> via <see cref="TimeSpan.TotalSeconds"/>; that one metric is
/// omitted when the age is <c>null</c>.
/// </para>
/// </remarks>
public sealed class NotificationOutboxKpiSampleSource : IKpiSampleSource
{
private const string MetricQueueDepth = "queueDepth";
private const string MetricStuckCount = "stuckCount";
private const string MetricParkedCount = "parkedCount";
private const string MetricDeliveredLastInterval = "deliveredLastInterval";
private const string MetricOldestPendingAgeSeconds = "oldestPendingAgeSeconds";
private readonly INotificationOutboxRepository _repository;
private readonly NotificationOutboxOptions _options;
/// <summary>
/// Creates the sample source.
/// </summary>
/// <param name="repository">Outbox repository providing the KPI computation methods.</param>
/// <param name="options">Outbox options carrying the stuck-age and delivered-window cutoffs.</param>
public NotificationOutboxKpiSampleSource(
INotificationOutboxRepository repository,
IOptions<NotificationOutboxOptions> options)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(options);
_repository = repository;
_options = options.Value;
}
/// <inheritdoc />
public string Source => KpiSources.NotificationOutbox;
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
// Anchor the live KPI cutoffs on the recorder's shared capture instant, so a sample
// captured at the same moment equals the live Health-dashboard tile.
var capturedAt = new DateTimeOffset(capturedAtUtc, TimeSpan.Zero);
var stuckCutoff = capturedAt - _options.StuckAgeThreshold;
var deliveredSince = capturedAt - _options.DeliveredKpiWindow;
var samples = new List<KpiSample>();
var global = await _repository.ComputeKpisAsync(stuckCutoff, deliveredSince, cancellationToken)
.ConfigureAwait(false);
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Global, scopeKey: null,
global.QueueDepth, global.StuckCount, global.ParkedCount,
global.DeliveredLastInterval, global.OldestPendingAge);
var perSite = await _repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince, cancellationToken)
.ConfigureAwait(false);
foreach (var site in perSite)
{
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Site, site.SourceSiteId,
site.QueueDepth, site.StuckCount, site.ParkedCount,
site.DeliveredLastInterval, site.OldestPendingAge);
}
var perNode = await _repository.ComputePerNodeKpisAsync(stuckCutoff, deliveredSince, cancellationToken)
.ConfigureAwait(false);
foreach (var node in perNode)
{
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Node, node.SourceNode,
node.QueueDepth, node.StuckCount, node.ParkedCount,
node.DeliveredLastInterval, node.OldestPendingAge);
}
return samples;
}
/// <summary>
/// Appends the five outbox metrics for one snapshot at the given scope, omitting
/// <c>oldestPendingAgeSeconds</c> when <paramref name="oldestPendingAge"/> is <c>null</c>.
/// </summary>
private void AddSnapshot(
List<KpiSample> samples,
DateTime capturedAtUtc,
string scope,
string? scopeKey,
int queueDepth,
int stuckCount,
int parkedCount,
int deliveredLastInterval,
TimeSpan? oldestPendingAge)
{
samples.Add(Sample(capturedAtUtc, MetricQueueDepth, scope, scopeKey, queueDepth));
samples.Add(Sample(capturedAtUtc, MetricStuckCount, scope, scopeKey, stuckCount));
samples.Add(Sample(capturedAtUtc, MetricParkedCount, scope, scopeKey, parkedCount));
samples.Add(Sample(capturedAtUtc, MetricDeliveredLastInterval, scope, scopeKey, deliveredLastInterval));
if (oldestPendingAge is { } age)
{
samples.Add(Sample(capturedAtUtc, MetricOldestPendingAgeSeconds, scope, scopeKey, age.TotalSeconds));
}
}
private KpiSample Sample(
DateTime capturedAtUtc, string metric, string scope, string? scopeKey, double value) =>
new()
{
Source = KpiSources.NotificationOutbox,
Metric = metric,
Scope = scope,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
}
@@ -1,5 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
@@ -46,6 +48,10 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationDeliveryAdapter>(
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
// KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
services.AddScoped<IKpiSampleSource, NotificationOutboxKpiSampleSource>();
return services;
}
}