mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
e63a0bc2bd
Fresh cross-tenant usage computes took 20-43s server-side but the web app's default 30s fetch timeout aborted them client-side (HTTP 499), and every retry stacked another concurrent full compute. Backend: - UsageMetricsService: cache 5→15min, single-flight per days-window (retries join the in-flight compute), the four source scans run concurrently via Task.WhenAll, statics converted to instance fields (singleton, testable) - New column-projected queries for the usage compute only: GetUsageWindowSessionsAsync ($select 13 consumed columns, drops FailureSnapshotJson & co.) and GetAppInstallRefsAsync ($select SessionId+AppName, lean SessionAppRef DTO); GetSessionsByDateRangeAsync is untouched for its other consumers - Projection equivalence pinned by UsageMetricsProjectionEquivalenceTests (incl. the WhiteGlove Part-2 duration branch via IsPreProvisioned/ ResumedAt); single-flight/cache/failure-eviction pinned by UsageMetricsServiceSingleFlightTests Web (SectionPlatformUsage): - 180s AbortSignal for this call (backend keeps computing and caches) - Calculating card with elapsed time and a progress bar sized from the last fresh-compute duration (localStorage, cache hits excluded, capped at 95%) - Friendly timeout error explaining the retry will hit the server cache Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
10 KiB
C#
210 lines
10 KiB
C#
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Verifies UsageMetricsService passes the requested time window (days) through to the
|
|
/// underlying repository query, and that the response echoes the actual window used.
|
|
/// </summary>
|
|
public class UsageMetricsServiceWindowTests
|
|
{
|
|
private static (UsageMetricsService Service, Mock<IMaintenanceRepository> MaintenanceRepo) CreateService(
|
|
List<SessionSummary>? sessions = null, TenantStats? tenantStats = null)
|
|
{
|
|
var maintenanceRepo = new Mock<IMaintenanceRepository>();
|
|
maintenanceRepo
|
|
.Setup(r => r.GetUsageWindowSessionsAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<string?>()))
|
|
.ReturnsAsync(sessions ?? new List<SessionSummary>());
|
|
|
|
var metricsRepo = new Mock<IMetricsRepository>();
|
|
metricsRepo.Setup(r => r.GetTenantStatsAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(tenantStats);
|
|
metricsRepo.Setup(r => r.GetUserActivityMetricsAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(new UserActivityMetrics());
|
|
metricsRepo.Setup(r => r.GetAllUserActivityMetricsAsync())
|
|
.ReturnsAsync(new UserActivityMetrics());
|
|
metricsRepo.Setup(r => r.GetAppInstallRefsAsync(It.IsAny<DateTime>(), It.IsAny<string?>()))
|
|
.ReturnsAsync(new List<SessionAppRef>());
|
|
metricsRepo.Setup(r => r.GetPlatformStatsAsync())
|
|
.ReturnsAsync((PlatformStats?)null);
|
|
|
|
var service = new UsageMetricsService(metricsRepo.Object, maintenanceRepo.Object, NullLogger<UsageMetricsService>.Instance);
|
|
return (service, maintenanceRepo);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(5)]
|
|
[InlineData(7)]
|
|
[InlineData(12)]
|
|
[InlineData(60)]
|
|
public async Task ComputeTenantUsageMetrics_passes_days_window_to_repo(int days)
|
|
{
|
|
var (service, maintenanceRepo) = CreateService();
|
|
|
|
DateTime? capturedStart = null;
|
|
DateTime? capturedEnd = null;
|
|
maintenanceRepo
|
|
.Setup(r => r.GetUsageWindowSessionsAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), "tenant-x"))
|
|
.Callback<DateTime, DateTime, string?>((start, end, _) => { capturedStart = start; capturedEnd = end; })
|
|
.ReturnsAsync(new List<SessionSummary>());
|
|
|
|
var before = DateTime.UtcNow;
|
|
var result = await service.ComputeTenantUsageMetricsAsync("tenant-x", days);
|
|
var after = DateTime.UtcNow;
|
|
|
|
Assert.NotNull(capturedStart);
|
|
Assert.NotNull(capturedEnd);
|
|
|
|
var expectedStartLow = before.AddDays(-days).AddSeconds(-1);
|
|
var expectedStartHigh = after.AddDays(-days).AddSeconds(1);
|
|
Assert.InRange(capturedStart!.Value, expectedStartLow, expectedStartHigh);
|
|
|
|
Assert.Equal(days, result.WindowDays);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeTenantUsageMetrics_clamps_negative_days_to_one()
|
|
{
|
|
var (service, maintenanceRepo) = CreateService();
|
|
|
|
DateTime? capturedStart = null;
|
|
maintenanceRepo
|
|
.Setup(r => r.GetUsageWindowSessionsAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<string?>()))
|
|
.Callback<DateTime, DateTime, string?>((start, _, _) => capturedStart = start)
|
|
.ReturnsAsync(new List<SessionSummary>());
|
|
|
|
var before = DateTime.UtcNow;
|
|
var result = await service.ComputeTenantUsageMetricsAsync("tenant-y", -50);
|
|
|
|
Assert.Equal(1, result.WindowDays);
|
|
Assert.NotNull(capturedStart);
|
|
Assert.InRange(capturedStart!.Value, before.AddDays(-1).AddSeconds(-1), DateTime.UtcNow.AddDays(-1).AddSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeTenantUsageMetrics_clamps_excessive_days_to_365()
|
|
{
|
|
var (service, _) = CreateService();
|
|
var result = await service.ComputeTenantUsageMetricsAsync("tenant-z", 9999);
|
|
Assert.Equal(365, result.WindowDays);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Performance_clamps_runaway_session_duration_and_counts_it()
|
|
{
|
|
// Regression for the get_usage_metrics duration skew: a stuck non-terminal session carries
|
|
// an unclamped wall-clock duration (~40 days here). It must be capped to the shared ceiling
|
|
// before avg/percentiles, and surfaced via ClampedSessionCount so the window stays honest.
|
|
const int capMinutes = EventTimestampValidator.DefaultMaxDurationSeconds / 60; // 604800s -> 10080 min
|
|
var sessions = new List<SessionSummary>
|
|
{
|
|
new() { SessionId = "fast", TenantId = "t1", StartedAt = DateTime.UtcNow.AddMinutes(-10), Status = SessionStatus.Succeeded, DurationSeconds = 600 },
|
|
new() { SessionId = "stuck", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-40), Status = SessionStatus.InProgress, DurationSeconds = 40 * 24 * 3600 },
|
|
};
|
|
var (service, _) = CreateService(sessions);
|
|
|
|
var result = await service.ComputeTenantUsageMetricsAsync("t1", 90);
|
|
|
|
Assert.Equal(2, result.Performance.SampleCount);
|
|
Assert.Equal(1, result.Performance.ClampedSessionCount);
|
|
// Without the clamp P99 would be ~57600 min (40 days); clamped it sits at the ceiling.
|
|
Assert.Equal(capMinutes, result.Performance.P99DurationMinutes);
|
|
Assert.True(result.Performance.AvgDurationMinutes <= capMinutes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Incomplete_sessions_get_their_own_bucket_and_are_excluded_from_success_rate()
|
|
{
|
|
// The reclassification shape (tasks/enrollment-status-reclassification.md): timed-out
|
|
// sessions become terminal Incomplete, not Failed. They must be counted in their own bucket
|
|
// (previously invisible: in Total but no breakdown) and kept out of the failure-rate
|
|
// denominator — Succeeded + Failed only. 1 succeeded, 1 failed, 8 incomplete -> 50%, not 10%.
|
|
var sessions = new List<SessionSummary>
|
|
{
|
|
new() { SessionId = "ok", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-1), Status = SessionStatus.Succeeded },
|
|
new() { SessionId = "bad", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-1), Status = SessionStatus.Failed },
|
|
};
|
|
for (var i = 0; i < 8; i++)
|
|
sessions.Add(new SessionSummary { SessionId = $"inc{i}", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-1), Status = SessionStatus.Incomplete });
|
|
|
|
var (service, _) = CreateService(sessions);
|
|
|
|
var result = await service.ComputeTenantUsageMetricsAsync("t1", 90);
|
|
|
|
Assert.Equal(10, result.Sessions.Total);
|
|
Assert.Equal(1, result.Sessions.Succeeded);
|
|
Assert.Equal(1, result.Sessions.Failed);
|
|
Assert.Equal(8, result.Sessions.Incomplete);
|
|
Assert.Equal(50.0, result.Sessions.SuccessRate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TotalAllTime_comes_from_cumulative_tenant_counter()
|
|
{
|
|
// The retention-independent "since signup" counter must be surfaced verbatim when it
|
|
// exceeds the window count — that is its whole point (window shows 1, lifetime 1234).
|
|
var sessions = new List<SessionSummary>
|
|
{
|
|
new() { SessionId = "s1", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-1), Status = SessionStatus.Succeeded },
|
|
};
|
|
var (service, _) = CreateService(sessions, new TenantStats { TotalEnrollments = 1234 });
|
|
|
|
var result = await service.ComputeTenantUsageMetricsAsync("t1", 90);
|
|
|
|
Assert.Equal(1, result.Sessions.Total);
|
|
Assert.Equal(1234, result.Sessions.TotalAllTime);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TotalAllTime_is_floored_at_window_total_when_counter_missing_or_lagging()
|
|
{
|
|
// Before the first maintenance seed (or after lost increments) the counter can lag behind
|
|
// sessions still within retention. The window count is a guaranteed lower bound — the UI
|
|
// must never show a lifetime total smaller than the 90-day total on the same page.
|
|
var sessions = new List<SessionSummary>
|
|
{
|
|
new() { SessionId = "s1", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-1), Status = SessionStatus.Succeeded },
|
|
new() { SessionId = "s2", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-2), Status = SessionStatus.Failed },
|
|
new() { SessionId = "s3", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-3), Status = SessionStatus.Succeeded },
|
|
};
|
|
|
|
var (missingService, _) = CreateService(sessions, tenantStats: null);
|
|
var missing = await missingService.ComputeTenantUsageMetricsAsync("t1", 90);
|
|
Assert.Equal(3, missing.Sessions.TotalAllTime);
|
|
|
|
var (laggingService, _) = CreateService(sessions, new TenantStats { TotalEnrollments = 2 });
|
|
var lagging = await laggingService.ComputeTenantUsageMetricsAsync("t1", 90);
|
|
Assert.Equal(3, lagging.Sessions.TotalAllTime);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Smaller_window_yields_smaller_or_equal_session_total()
|
|
{
|
|
// Three sessions spanning ~80 days
|
|
var sessions = new List<SessionSummary>
|
|
{
|
|
new() { SessionId = "s1", TenantId = "t1", StartedAt = DateTime.UtcNow.AddDays(-3), Status = SessionStatus.Succeeded },
|
|
new() { SessionId = "s2", TenantId = "t2", StartedAt = DateTime.UtcNow.AddDays(-15), Status = SessionStatus.Succeeded },
|
|
new() { SessionId = "s3", TenantId = "t3", StartedAt = DateTime.UtcNow.AddDays(-60), Status = SessionStatus.Succeeded },
|
|
};
|
|
|
|
var (service, maintenanceRepo) = CreateService();
|
|
// Repo honours the cutoff like the real impl would: filter sessions by start date.
|
|
maintenanceRepo
|
|
.Setup(r => r.GetUsageWindowSessionsAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<string?>()))
|
|
.ReturnsAsync((DateTime start, DateTime _, string? _) => sessions.Where(s => s.StartedAt >= start).ToList());
|
|
|
|
var r7 = await service.ComputeTenantUsageMetricsAsync("t1", 7);
|
|
var r30 = await service.ComputeTenantUsageMetricsAsync("t1", 30);
|
|
var r90 = await service.ComputeTenantUsageMetricsAsync("t1", 90);
|
|
|
|
Assert.Equal(1, r7.Sessions.Total);
|
|
Assert.Equal(2, r30.Sessions.Total);
|
|
Assert.Equal(3, r90.Sessions.Total);
|
|
}
|
|
}
|