mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user