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.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi; /// /// Site Call Audit (#22) for the M6 "KPI History /// & Trends" backbone. Each sampling pass the central recorder enumerates this /// source and calls , which snapshots the same /// point-in-time SiteCalls KPIs the live Health-dashboard tiles show — /// global, per-source-site, and per-originating-node — into flat /// rows. /// /// /// /// The cutoffs are derived from exactly as the /// live SiteCallAuditActor KPI handlers derive them /// (stuckCutoff = capturedAtUtc - StuckAgeThreshold, /// intervalSince = capturedAtUtc - KpiInterval). The COUNT metrics /// (buffered, parked, failedLastInterval, deliveredLastInterval, stuck) equal the /// live tile at the same instant; the oldestPendingAgeSeconds metric is /// computed against the repository's internal clock and may differ from the live /// tile by the query-execution latency. The recorder's capturedAtUtc is /// the single anchor for both cutoffs. /// /// /// Registered DI-scoped (next to the rest of the Site Call Audit composition) so /// each sampling pass resolves a fresh repository scope, mirroring the actor's /// scope-per-message repository access. /// /// public sealed class SiteCallAuditKpiSampleSource : IKpiSampleSource { // ── Metric catalog (the M6-agreed metric-name strings for this source) ── // Declaration order matches the emission order in AddSnapshot. private const string MetricBuffered = "buffered"; private const string MetricParked = "parked"; private const string MetricFailedLastInterval = "failedLastInterval"; private const string MetricDeliveredLastInterval = "deliveredLastInterval"; private const string MetricStuck = "stuck"; private const string MetricOldestPendingAgeSeconds = "oldestPendingAgeSeconds"; private readonly ISiteCallAuditRepository _repository; private readonly SiteCallAuditOptions _options; /// /// Creates the sample source over the central SiteCalls repository and /// the Site Call Audit options that define the stuck-age + KPI-interval /// windows. /// /// The central SiteCalls operational-state repository. /// Site Call Audit windowing options (stuck-age + KPI interval). public SiteCallAuditKpiSampleSource( ISiteCallAuditRepository repository, IOptions options) { ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(options); _repository = repository; _options = options.Value; } /// public string Source => KpiSources.SiteCallAudit; /// public async Task> CollectAsync( DateTime capturedAtUtc, CancellationToken cancellationToken = default) { // Match the live SiteCallAuditActor KPI handlers: stuck cutoff and // interval window are both anchored on the single capture instant. var stuckCutoff = capturedAtUtc - _options.StuckAgeThreshold; var intervalSince = capturedAtUtc - _options.KpiInterval; var global = await _repository .ComputeKpisAsync(stuckCutoff, intervalSince, cancellationToken) .ConfigureAwait(false); var perSite = await _repository .ComputePerSiteKpisAsync(stuckCutoff, intervalSince, cancellationToken) .ConfigureAwait(false); var perNode = await _repository .ComputePerNodeKpisAsync(stuckCutoff, intervalSince, cancellationToken) .ConfigureAwait(false); var samples = new List(); // Global scope (null ScopeKey). AddSnapshot( samples, capturedAtUtc, KpiScopes.Global, scopeKey: null, global.BufferedCount, global.ParkedCount, global.FailedLastInterval, global.DeliveredLastInterval, global.OldestPendingAge, global.StuckCount); // Per-site scope (ScopeKey = source site id). foreach (var site in perSite) { AddSnapshot( samples, capturedAtUtc, KpiScopes.Site, scopeKey: site.SourceSite, site.BufferedCount, site.ParkedCount, site.FailedLastInterval, site.DeliveredLastInterval, site.OldestPendingAge, site.StuckCount); } // Per-node scope (ScopeKey = node name). foreach (var node in perNode) { AddSnapshot( samples, capturedAtUtc, KpiScopes.Node, scopeKey: node.SourceNode, node.BufferedCount, node.ParkedCount, node.FailedLastInterval, node.DeliveredLastInterval, node.OldestPendingAge, node.StuckCount); } return samples; } /// /// Appends the six-metric catalog for one snapshot at the given scope. The /// oldest-pending-age metric is omitted when the snapshot's age is /// null (no non-terminal rows) rather than written as zero. /// private void AddSnapshot( List samples, DateTime capturedAtUtc, string scope, string? scopeKey, int buffered, int parked, int failedLastInterval, int deliveredLastInterval, TimeSpan? oldestPendingAge, int stuck) { samples.Add(Sample(capturedAtUtc, MetricBuffered, scope, scopeKey, buffered)); samples.Add(Sample(capturedAtUtc, MetricParked, scope, scopeKey, parked)); samples.Add(Sample(capturedAtUtc, MetricFailedLastInterval, scope, scopeKey, failedLastInterval)); samples.Add(Sample(capturedAtUtc, MetricDeliveredLastInterval, scope, scopeKey, deliveredLastInterval)); samples.Add(Sample(capturedAtUtc, MetricStuck, scope, scopeKey, stuck)); 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.SiteCallAudit, Metric = metric, Scope = scope, ScopeKey = scopeKey, Value = value, CapturedAtUtc = capturedAtUtc, }; }