From 556707bcd43560470c1d5b062e5194836a66ca13 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 11 Jul 2026 12:12:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(metrics):=20platform=20stats=20are=20monoto?= =?UTF-8?q?nic=20since-release=20counters=20=E2=80=94=20recompute=20may=20?= =?UTF-8?q?never=20regress=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily RecomputePlatformStatsAsync overwrote TotalEnrollments, SuccessfulEnrollments, TotalEventsProcessed, TotalTenants and UniqueDeviceModels with counts scanned from retention-pruned tables, so the public "since release" figures regressed after every retention cleanup. Only TotalUsers already had the high-water clamp. - extract BuildMonotonicPlatformStats (internal, unit-tested): every cumulative counter is Math.Max(recomputed, persisted) — the recompute can raise a counter (self-heal for lost fire-and-forget increments while sessions are still within retention) but never lower it; IssuesDetected stays carried over verbatim (increment-only) - deliberate exception: TotalSignedUpTenants remains current-state — TenantConfiguration is not retention-pruned, a drop there is real offboarding, not data loss - PlatformStats model docs updated to pin the semantics 4 new tests (PlatformStatsMonotonicTests); backend suite 2887/2887. Co-Authored-By: Claude Fable 5 --- .../PlatformStatsMonotonicTests.cs | 129 ++++++++++++++++++ .../MaintenanceService.Aggregation.cs | 68 ++++++--- .../Models/Metrics/PlatformStats.cs | 6 +- 3 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs new file mode 100644 index 00000000..26a245f5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsMonotonicTests.cs @@ -0,0 +1,129 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the "since release" semantics of the platform-stats recompute: every cumulative +/// counter is a monotonic high-water-mark. The tables the recompute scans are pruned by +/// retention, so a raw recompute only sees the retention window — persisting that verbatim +/// regressed the public landing-page figures after every cleanup. +/// +public class PlatformStatsMonotonicTests +{ + private static readonly DateTime Now = new(2026, 07, 11, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Cumulative_counters_never_regress_below_persisted_values() + { + // Retention pruned old sessions: the recompute sees far less than the persisted + // counters accumulated. Every cumulative figure must keep its high-water-mark. + var existing = new PlatformStats + { + TotalEnrollments = 50_000, + SuccessfulEnrollments = 40_000, + TotalEventsProcessed = 9_000_000, + TotalUsers = 800, + TotalTenants = 120, + UniqueDeviceModels = 450, + IssuesDetected = 6_500, + TotalSignedUpTenants = 150 + }; + + var stats = MaintenanceService.BuildMonotonicPlatformStats( + recomputedEnrollments: 7_000, + recomputedSuccessful: 6_000, + recomputedEvents: 1_200_000, + recomputedUsers: 90, + recomputedActiveTenants: 80, + recomputedUniqueModels: 200, + signedUpTenants: 155, + existing: existing, + nowUtc: Now); + + Assert.Equal(50_000, stats.TotalEnrollments); + Assert.Equal(40_000, stats.SuccessfulEnrollments); + Assert.Equal(9_000_000, stats.TotalEventsProcessed); + Assert.Equal(800, stats.TotalUsers); + Assert.Equal(120, stats.TotalTenants); + Assert.Equal(450, stats.UniqueDeviceModels); + Assert.Equal(6_500, stats.IssuesDetected); + } + + [Fact] + public void Recompute_raises_counters_when_live_data_exceeds_persisted_values() + { + // Self-heal: lost increments (fire-and-forget failures, pre-feature history) are + // recovered as long as the sessions are still within retention. + var existing = new PlatformStats + { + TotalEnrollments = 1_000, + SuccessfulEnrollments = 700, + TotalEventsProcessed = 100_000, + TotalUsers = 50, + TotalTenants = 10, + UniqueDeviceModels = 30 + }; + + var stats = MaintenanceService.BuildMonotonicPlatformStats( + recomputedEnrollments: 1_500, + recomputedSuccessful: 1_100, + recomputedEvents: 250_000, + recomputedUsers: 80, + recomputedActiveTenants: 14, + recomputedUniqueModels: 42, + signedUpTenants: 20, + existing: existing, + nowUtc: Now); + + Assert.Equal(1_500, stats.TotalEnrollments); + Assert.Equal(1_100, stats.SuccessfulEnrollments); + Assert.Equal(250_000, stats.TotalEventsProcessed); + Assert.Equal(80, stats.TotalUsers); + Assert.Equal(14, stats.TotalTenants); + Assert.Equal(42, stats.UniqueDeviceModels); + } + + [Fact] + public void SignedUpTenants_is_current_state_and_may_drop() + { + // Deliberate exception: TenantConfiguration is not retention-pruned, so its count is + // authoritative — a drop reflects real offboarding, not data loss. + var existing = new PlatformStats { TotalSignedUpTenants = 150 }; + + var stats = MaintenanceService.BuildMonotonicPlatformStats( + recomputedEnrollments: 0, recomputedSuccessful: 0, recomputedEvents: 0, + recomputedUsers: 0, recomputedActiveTenants: 0, recomputedUniqueModels: 0, + signedUpTenants: 140, + existing: existing, + nowUtc: Now); + + Assert.Equal(140, stats.TotalSignedUpTenants); + } + + [Fact] + public void First_run_without_persisted_row_uses_recomputed_values() + { + var stats = MaintenanceService.BuildMonotonicPlatformStats( + recomputedEnrollments: 10, + recomputedSuccessful: 8, + recomputedEvents: 500, + recomputedUsers: 3, + recomputedActiveTenants: 2, + recomputedUniqueModels: 4, + signedUpTenants: 5, + existing: null, + nowUtc: Now); + + Assert.Equal(10, stats.TotalEnrollments); + Assert.Equal(8, stats.SuccessfulEnrollments); + Assert.Equal(500, stats.TotalEventsProcessed); + Assert.Equal(3, stats.TotalUsers); + Assert.Equal(2, stats.TotalTenants); + Assert.Equal(4, stats.UniqueDeviceModels); + Assert.Equal(0, stats.IssuesDetected); + Assert.Equal(5, stats.TotalSignedUpTenants); + Assert.Equal(Now, stats.LastFullCompute); + Assert.Equal(Now, stats.LastUpdated); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 05c02b90..b3a92113 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -381,31 +381,24 @@ namespace AutopilotMonitor.Functions.Services var existingStats = await _metricsRepo.GetPlatformStatsAsync(); - // "Users Seen" is a cumulative, public-facing high-water-mark. UserActivity is pruned to - // 90 days, so the recomputed `totalUsers` only reflects recent loginers — clamp it to the - // previously-persisted value so the cumulative figure can never regress after a cleanup. - var cumulativeUsers = Math.Max(totalUsers, existingStats?.TotalUsers ?? 0); - - var stats = new PlatformStats - { - TotalEnrollments = totalEnrollments, - TotalUsers = cumulativeUsers, - TotalTenants = activeTenants, - TotalSignedUpTenants = allConfigs.Count, - UniqueDeviceModels = uniqueModels.Count, - TotalEventsProcessed = totalEvents, - SuccessfulEnrollments = successfulEnrollments, - IssuesDetected = existingStats?.IssuesDetected ?? 0, - LastFullCompute = DateTime.UtcNow, - LastUpdated = DateTime.UtcNow - }; + var stats = BuildMonotonicPlatformStats( + recomputedEnrollments: totalEnrollments, + recomputedSuccessful: successfulEnrollments, + recomputedEvents: totalEvents, + recomputedUsers: totalUsers, + recomputedActiveTenants: activeTenants, + recomputedUniqueModels: uniqueModels.Count, + signedUpTenants: allConfigs.Count, + existing: existingStats, + nowUtc: DateTime.UtcNow); await _metricsRepo.SavePlatformStatsAsync(stats); await TryPublishPlatformStatsJsonAsync(stats); sw.Stop(); _logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " + - $"{totalEnrollments} enrollments, {cumulativeUsers} users (cumulative), {tenantIds.Count} tenants, {uniqueModels.Count} models"); + $"{stats.TotalEnrollments} enrollments, {stats.TotalUsers} users, {tenantIds.Count} tenants, " + + $"{stats.UniqueDeviceModels} models (all cumulative high-water-marks)"); } catch (Exception ex) { @@ -413,6 +406,43 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Merges a fresh platform-stats recompute with the previously persisted row. Platform + /// stats are "since release" counters (public landing page): the tables the recompute + /// scans are pruned by session/user-activity retention, so a raw recompute can only see + /// the retention window and would regress the figures after every cleanup. Every + /// cumulative counter is therefore a monotonic high-water-mark — the recompute may raise + /// it (self-heal for lost increments), never lower it. IssuesDetected is increment-only + /// (no recompute source) and is carried over verbatim. TotalSignedUpTenants is the one + /// deliberate exception: the TenantConfiguration table is not retention-pruned, so its + /// count is authoritative current state and a drop reflects real offboarding, not data loss. + /// + internal static PlatformStats BuildMonotonicPlatformStats( + long recomputedEnrollments, + long recomputedSuccessful, + long recomputedEvents, + long recomputedUsers, + long recomputedActiveTenants, + long recomputedUniqueModels, + long signedUpTenants, + PlatformStats? existing, + DateTime nowUtc) + { + return new PlatformStats + { + TotalEnrollments = Math.Max(recomputedEnrollments, existing?.TotalEnrollments ?? 0), + SuccessfulEnrollments = Math.Max(recomputedSuccessful, existing?.SuccessfulEnrollments ?? 0), + TotalEventsProcessed = Math.Max(recomputedEvents, existing?.TotalEventsProcessed ?? 0), + TotalUsers = Math.Max(recomputedUsers, existing?.TotalUsers ?? 0), + TotalTenants = Math.Max(recomputedActiveTenants, existing?.TotalTenants ?? 0), + UniqueDeviceModels = Math.Max(recomputedUniqueModels, existing?.UniqueDeviceModels ?? 0), + TotalSignedUpTenants = signedUpTenants, + IssuesDetected = existing?.IssuesDetected ?? 0, + LastFullCompute = nowUtc, + LastUpdated = nowUtc + }; + } + /// /// Publishes versioned platform stats JSON + alias manifest to Blob Storage. /// This must never fail maintenance execution. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs index d8fb6f87..472c69ea 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/PlatformStats.cs @@ -5,7 +5,11 @@ namespace AutopilotMonitor.Shared.Models /// /// Pre-computed platform-wide statistics for the public landing page. /// Stored as a single row (PartitionKey: "global", RowKey: "current"). - /// Recomputed during daily maintenance; incremented during registration/login. + /// Incremented during registration/ingest/login; the daily maintenance recompute treats + /// every cumulative counter as a monotonic "since release" high-water-mark (raise-only — + /// the scanned tables are retention-pruned, so a raw recompute would regress the figures). + /// Only TotalSignedUpTenants is current-state (its source table is not retention-pruned). + /// See MaintenanceService.BuildMonotonicPlatformStats. /// public class PlatformStats {