fix(metrics): platform stats are monotonic since-release counters — recompute may never regress them

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-11 12:12:00 +02:00
parent e63a0bc2bd
commit 556707bcd4
3 changed files with 183 additions and 20 deletions
@@ -0,0 +1,129 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
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
};
}
/// <summary>
/// Publishes versioned platform stats JSON + alias manifest to Blob Storage.
/// This must never fail maintenance execution.
@@ -5,7 +5,11 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// 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.
/// </summary>
public class PlatformStats
{