mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(sla): alert and dashboard judge one rolling 30-day window; an empty period is no data; a lasting breach repeats only on new enrollments
The SLA breach alert judged the calendar month while the dashboard headline showed the current ISO week, so an alert could report 78 % while the page it links to showed 0 everywhere - and an empty week rendered as 0.0 % "Breached". - SlaEvaluationWindow is the single definition of the SLA populations: success rate and P95 duration on terminal sessions of the last 30 days (rolling), app installs on the install attempts of the current ISO week. The breach evaluator and SlaMetricsService both read it, including the one-decimal rounding, so the value shown is the value judged. - SlaMetricsResponse carries EvaluationPeriod and EvaluationWindowDays; SlaSnapshot carries Period and HasData. A period without a finished enrollment meets every target flag and is marked HasData = false. - The dashboard reads from whichever starts earlier, the selected months or the evaluation window; trend and violators stay on the selected months. - A lasting breach is notified again only after the cooldown AND once something finished since the last notification. A new breach episode after a resolve still notifies. Resolve notifications are unchanged. - Alert period fact and telemetry tag follow the window constant. - SlaMetricsService takes a nowProvider; the week-boundary workaround in its tests is gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ public class SlaMetricsServiceTests
|
||||
// Use unique tenant IDs per test to avoid static cache collisions
|
||||
private static string NewTenantId() => $"sla-test-{Guid.NewGuid():N}";
|
||||
|
||||
// Fixed clock: Wednesday 2026-09-23, ISO week 39. Mid-week and mid-month, so neither a week
|
||||
// nor a month boundary can move a fixture out of its period.
|
||||
private static readonly DateTime Now = new(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static (SlaMetricsService Service, string TenantId) CreateService(
|
||||
List<SessionSummary> sessions,
|
||||
TenantConfiguration? config = null,
|
||||
@@ -44,7 +48,7 @@ public class SlaMetricsServiceTests
|
||||
|
||||
var logger = NullLogger<SlaMetricsService>.Instance;
|
||||
|
||||
return (new SlaMetricsService(maintenanceRepo.Object, metricsRepo.Object, configService.Object, logger), tenantId);
|
||||
return (new SlaMetricsService(maintenanceRepo.Object, metricsRepo.Object, configService.Object, logger, () => Now), tenantId);
|
||||
}
|
||||
|
||||
private static TenantConfiguration CreateDefaultConfig(
|
||||
@@ -65,15 +69,7 @@ public class SlaMetricsServiceTests
|
||||
int? durationSeconds = null,
|
||||
DateTime? startedAt = null)
|
||||
{
|
||||
// The CurrentWeek asserts require the session to fall into the CURRENT ISO week
|
||||
// (SlaMetricsService groups by GetIsoWeekKey, weeks start Monday 00:00 UTC).
|
||||
// A plain "now - 1h" crosses into LAST week during the first hour of every
|
||||
// Monday (UTC) and empties CurrentWeek — exactly the CI failure on
|
||||
// Mon 2026-08-31 00:20Z. Clamp the anchor to the week's Monday instead.
|
||||
var now = DateTime.UtcNow;
|
||||
var isoWeekMonday = now.Date.AddDays(-(((int)now.DayOfWeek + 6) % 7));
|
||||
var anHourAgo = now.AddHours(-1);
|
||||
var started = startedAt ?? (anHourAgo >= isoWeekMonday ? anHourAgo : isoWeekMonday);
|
||||
var started = startedAt ?? Now.AddHours(-1);
|
||||
return new SessionSummary
|
||||
{
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
@@ -134,6 +130,96 @@ public class SlaMetricsServiceTests
|
||||
Assert.Equal(0, result.CurrentWeek.SuccessRate);
|
||||
Assert.Equal(0, result.CurrentWeek.TotalCompleted);
|
||||
Assert.Empty(result.Violators);
|
||||
|
||||
// An empty period is "no data": it must not read as a breached target.
|
||||
foreach (var snapshot in new[] { result.CurrentWeek, result.EvaluationPeriod })
|
||||
{
|
||||
Assert.False(snapshot.HasData);
|
||||
Assert.True(snapshot.SuccessRateMet);
|
||||
Assert.True(snapshot.DurationTargetMet);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeSlaMetrics_EmptyWeekFilledWindow_HeadlineCarriesTheEvaluationWindow()
|
||||
{
|
||||
// The evaluation window holds finished enrollments, the current ISO week none — the
|
||||
// headline snapshot (what the breach notification evaluates) reports them, the week stays empty.
|
||||
var twoWeeksAgo = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); // W37
|
||||
var sessions = new List<SessionSummary>();
|
||||
for (var i = 0; i < 32; i++) sessions.Add(CreateSession(SessionStatus.Succeeded, 1800, twoWeeksAgo));
|
||||
for (var i = 0; i < 9; i++) sessions.Add(CreateSession(SessionStatus.Failed, 1800, twoWeeksAgo));
|
||||
|
||||
var (service, tenantId) = CreateService(sessions);
|
||||
var result = await service.ComputeSlaMetricsAsync(tenantId, 1);
|
||||
|
||||
Assert.Equal(30, result.EvaluationWindowDays);
|
||||
Assert.Equal("last-30-days", result.EvaluationPeriod.Period);
|
||||
Assert.Equal("", result.EvaluationPeriod.Week);
|
||||
Assert.True(result.EvaluationPeriod.HasData);
|
||||
Assert.Equal(41, result.EvaluationPeriod.TotalCompleted);
|
||||
Assert.Equal(78, result.EvaluationPeriod.SuccessRate);
|
||||
Assert.False(result.EvaluationPeriod.SuccessRateMet);
|
||||
|
||||
Assert.Equal("2026-W39", result.CurrentWeek.Period);
|
||||
Assert.Equal("2026-W39", result.CurrentWeek.Week);
|
||||
Assert.False(result.CurrentWeek.HasData);
|
||||
Assert.True(result.CurrentWeek.SuccessRateMet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeSlaMetrics_EvaluationWindow_IsRolling_NotTheCalendarMonth()
|
||||
{
|
||||
// Now = 2026-09-23 12:00 ⇒ the window starts 2026-08-24 12:00.
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
CreateSession(SessionStatus.Succeeded, 1800),
|
||||
// previous calendar month, inside the rolling window
|
||||
CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 8, 25, 9, 0, 0, DateTimeKind.Utc)),
|
||||
// just outside the window
|
||||
CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 8, 24, 11, 59, 59, DateTimeKind.Utc)),
|
||||
CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 7, 15, 9, 0, 0, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
var (service, tenantId) = CreateService(sessions);
|
||||
var result = await service.ComputeSlaMetricsAsync(tenantId, 3);
|
||||
|
||||
Assert.Equal(2, result.EvaluationPeriod.TotalCompleted);
|
||||
Assert.Equal(50, result.EvaluationPeriod.SuccessRate);
|
||||
Assert.Equal(4, result.WeeklyTrend.Sum(w => w.TotalCompleted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeSlaMetrics_OneMonthSelection_HeadlineStillReachesBack_TrendDoesNot()
|
||||
{
|
||||
// "1 Month" starts on the first; the evaluation window reaches into August. The headline
|
||||
// must count the August session, the trend and the violators must not grow by it.
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
CreateSession(SessionStatus.Succeeded, 1800),
|
||||
CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
var (service, tenantId) = CreateService(sessions);
|
||||
var result = await service.ComputeSlaMetricsAsync(tenantId, 1);
|
||||
|
||||
Assert.Equal(2, result.EvaluationPeriod.TotalCompleted);
|
||||
Assert.Equal(1, result.WeeklyTrend.Sum(w => w.TotalCompleted));
|
||||
Assert.Empty(result.Violators);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeSlaMetrics_SessionsWithoutDuration_DurationTargetIsNotJudged()
|
||||
{
|
||||
var config = CreateDefaultConfig(targetMaxDuration: 30);
|
||||
var sessions = new List<SessionSummary> { CreateSession(SessionStatus.Failed) };
|
||||
|
||||
var (service, tenantId) = CreateService(sessions, config);
|
||||
var result = await service.ComputeSlaMetricsAsync(tenantId, 1);
|
||||
|
||||
Assert.True(result.EvaluationPeriod.HasData);
|
||||
Assert.False(result.EvaluationPeriod.SuccessRateMet);
|
||||
Assert.True(result.EvaluationPeriod.DurationTargetMet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -213,6 +213,14 @@ public class SlaTenantStatusLifecycleTests
|
||||
.ReturnsAsync(sessions);
|
||||
}
|
||||
|
||||
/// <summary>Like the real repository: only sessions started inside the queried range come back.</summary>
|
||||
public void SetSessionsHonoringDateRange(string tenantId, List<SessionSummary> sessions)
|
||||
{
|
||||
_maintenanceRepo.Setup(r => r.GetSessionsByDateRangeAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), tenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((DateTime start, DateTime end, string? _, CancellationToken _) =>
|
||||
sessions.Where(s => s.StartedAt >= start && s.StartedAt < end).ToList());
|
||||
}
|
||||
|
||||
public void SetRecentSessions(string tenantId, List<SessionSummary> sessions)
|
||||
{
|
||||
_sessionRepo.Setup(r => r.GetSessionsPageAsync(tenantId, It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
|
||||
@@ -291,7 +299,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReBreach_AfterCooldown_SendsAnotherNotification()
|
||||
public async Task ReBreach_AfterCooldown_WithNewlyFinishedSession_SendsAnotherNotification()
|
||||
{
|
||||
var h = new Harness();
|
||||
var config = CreateConfig(successRate: true);
|
||||
@@ -307,6 +315,15 @@ public class SlaTenantStatusLifecycleTests
|
||||
row!.SuccessRate_LastNotifiedAt = DateTime.UtcNow.AddHours(-25);
|
||||
await h.StatusRepo.UpsertAsync(row);
|
||||
|
||||
// A session that finished after the last notification: the population changed.
|
||||
var sessions = Sessions(succeeded: 8, failed: 4);
|
||||
sessions.Add(new SessionSummary
|
||||
{
|
||||
TenantId = TenantId, SessionId = Guid.NewGuid().ToString(), Status = SessionStatus.Failed,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-26), CompletedAt = DateTime.UtcNow.AddHours(-1), DurationSeconds = 1800,
|
||||
});
|
||||
h.SetTerminalSessions(TenantId, sessions);
|
||||
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
|
||||
Assert.Equal(2, h.NotificationsSent.Count(n => n.Type == "sla_breach"));
|
||||
@@ -314,6 +331,67 @@ public class SlaTenantStatusLifecycleTests
|
||||
Assert.True((DateTime.UtcNow - afterRow!.SuccessRate_LastNotifiedAt!.Value).TotalMinutes < 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A lasting breach without any newly finished enrollment is not repeated, however long the
|
||||
/// cooldown has expired — a tenant that enrolls nothing must not get the same number every day.
|
||||
/// Includes a session that finished BEFORE the last notification (an imported or merely old
|
||||
/// row): it is not new evidence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReBreach_AfterCooldown_WithoutNewSession_StaysSilent()
|
||||
{
|
||||
var h = new Harness();
|
||||
var config = CreateConfig(successRate: true, duration: true, targetMaxDurationMinutes: 10);
|
||||
h.SetTenants(config);
|
||||
var sessions = Sessions(succeeded: 8, failed: 4); // 66.7 % and P95 30 min: both breach
|
||||
sessions.Add(new SessionSummary
|
||||
{
|
||||
TenantId = TenantId, SessionId = Guid.NewGuid().ToString(), Status = SessionStatus.Failed,
|
||||
StartedAt = DateTime.UtcNow.AddDays(-9), CompletedAt = DateTime.UtcNow.AddDays(-9).AddMinutes(30), DurationSeconds = 1800,
|
||||
});
|
||||
h.SetTerminalSessions(TenantId, sessions);
|
||||
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
Assert.Equal(2, h.NotificationsSent.Count(n => n.Type == "sla_breach"));
|
||||
|
||||
var row = await h.StatusRepo.GetAsync(TenantId);
|
||||
row!.SuccessRate_LastNotifiedAt = DateTime.UtcNow.AddDays(-5);
|
||||
row.Duration_LastNotifiedAt = DateTime.UtcNow.AddDays(-5);
|
||||
await h.StatusRepo.UpsertAsync(row);
|
||||
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
|
||||
Assert.Equal(2, h.NotificationsSent.Count(n => n.Type == "sla_breach"));
|
||||
var afterRow = await h.StatusRepo.GetAsync(TenantId);
|
||||
Assert.True(afterRow!.SuccessRate_IsActive); // still breaching, still tracked
|
||||
Assert.True(afterRow.Duration_IsActive);
|
||||
Assert.NotNull(afterRow.SuccessRate_LastBreachAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A new breach episode after a resolve notifies without new evidence: the rolling window can
|
||||
/// re-breach by good sessions aging out, and the admin was last told "resolved".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NewBreachEpisode_AfterResolve_NotifiesWithoutNewSession()
|
||||
{
|
||||
var h = new Harness();
|
||||
var config = CreateConfig(successRate: true);
|
||||
h.SetTenants(config);
|
||||
await h.StatusRepo.UpsertAsync(new SlaTenantStatus
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SuccessRate_IsActive = false,
|
||||
SuccessRate_LastNotifiedAt = DateTime.UtcNow.AddDays(-3),
|
||||
SuccessRate_ResolvedAt = DateTime.UtcNow.AddDays(-2),
|
||||
});
|
||||
h.SetTerminalSessions(TenantId, Sessions(succeeded: 8, failed: 4)); // no CompletedAt after the watermark
|
||||
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
|
||||
Assert.Single(h.NotificationsSent, n => n.Type == "sla_breach");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BreachResolved_SetsIsActiveFalseAndSendsResolvedNotification()
|
||||
{
|
||||
@@ -773,6 +851,68 @@ public class SlaTenantStatusLifecycleTests
|
||||
Assert.DoesNotContain(h.NotificationsSent, n => n.Type == "sla_breach");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The breach notification links to the SLA dashboard, so both must report the same number
|
||||
/// for the same target. Same sessions through the evaluator and through SlaMetricsService:
|
||||
/// the persisted breach values equal the dashboard's evaluation-period snapshot. The fixture
|
||||
/// holds sessions of the previous calendar month inside AND outside the rolling window and an
|
||||
/// empty current ISO week on purpose — a headline computed on a calendar month, on the ISO
|
||||
/// week or on the selected months diverges here. Both month selections run: with "1 Month"
|
||||
/// the dashboard's own range starts after the window does.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
public async Task Evaluator_And_DashboardEvaluationPeriod_ReportTheSameValues(int months)
|
||||
{
|
||||
var clock = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc); // Monday, ISO week 39; window starts 08-22 12:00
|
||||
SessionSummary At(DateTime startedAt, SessionStatus status, int durationSec) => new()
|
||||
{
|
||||
TenantId = TenantId, SessionId = Guid.NewGuid().ToString(), Status = status,
|
||||
StartedAt = startedAt, DurationSeconds = durationSec,
|
||||
};
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
for (int i = 0; i < 4; i++) // previous month, outside the rolling window
|
||||
sessions.Add(At(new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 9000));
|
||||
for (int i = 0; i < 2; i++) // previous month, inside the rolling window
|
||||
sessions.Add(At(new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 5400));
|
||||
for (int i = 0; i < 7; i++) // this month, earlier ISO weeks
|
||||
sessions.Add(At(new DateTime(2026, 9, 3 + i, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Succeeded, 900 + i * 600));
|
||||
sessions.Add(At(new DateTime(2026, 9, 9, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 7200));
|
||||
sessions.Add(At(new DateTime(2026, 9, 10, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 300));
|
||||
|
||||
var h = new Harness(clock: clock);
|
||||
var config = CreateConfig(successRate: true, duration: true, targetMaxDurationMinutes: 30);
|
||||
h.SetTenants(config);
|
||||
h.SetSessionsHonoringDateRange(TenantId, sessions);
|
||||
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
var row = await h.StatusRepo.GetAsync(TenantId);
|
||||
|
||||
var maintenanceRepo = new Mock<IMaintenanceRepository>();
|
||||
maintenanceRepo.Setup(r => r.GetSessionsByDateRangeAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((DateTime start, DateTime end, string? _, CancellationToken _) =>
|
||||
sessions.Where(s => s.StartedAt >= start && s.StartedAt < end).ToList());
|
||||
var configService = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
configService.Setup(c => c.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
||||
var dashboard = new SlaMetricsService(
|
||||
maintenanceRepo.Object, Mock.Of<IMetricsRepository>(), configService.Object,
|
||||
NullLogger<SlaMetricsService>.Instance, () => clock);
|
||||
|
||||
var metrics = await dashboard.ComputeSlaMetricsAsync(TenantId, months, fresh: true);
|
||||
|
||||
Assert.Equal(11, metrics.EvaluationPeriod.TotalCompleted);
|
||||
Assert.Equal(63.6, metrics.EvaluationPeriod.SuccessRate);
|
||||
Assert.Equal(row!.SuccessRate_CurrentValue, metrics.EvaluationPeriod.SuccessRate);
|
||||
Assert.Equal(row.SuccessRate_TotalSessions, metrics.EvaluationPeriod.TotalCompleted);
|
||||
Assert.Equal(row.Duration_CurrentP95Minutes, metrics.EvaluationPeriod.P95DurationMinutes);
|
||||
Assert.Equal(row.SuccessRate_IsActive, !metrics.EvaluationPeriod.SuccessRateMet);
|
||||
Assert.Equal(row.Duration_IsActive, !metrics.EvaluationPeriod.DurationTargetMet);
|
||||
Assert.False(metrics.CurrentWeek.HasData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review-finding regression: AppInstall notification + ops event must carry the actual
|
||||
/// installer totals (not 0/0). Without this, Application Insights / Ops dashboards see
|
||||
@@ -877,8 +1017,8 @@ public class SlaTenantStatusLifecycleTests
|
||||
/// ops dashboards even though they were evaluated on the current ISO week.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("SuccessRate", "CurrentMonth")]
|
||||
[InlineData("Duration", "CurrentMonth")]
|
||||
[InlineData("SuccessRate", "Last30Days")]
|
||||
[InlineData("Duration", "Last30Days")]
|
||||
[InlineData("AppInstall", "CurrentWeek")]
|
||||
public void PeriodForBreachType_MatchesEvaluationWindow(string breachType, string expectedPeriod)
|
||||
{
|
||||
|
||||
+2
-2
@@ -311,7 +311,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
label = "Success Rate";
|
||||
title = $"\ud83d\udfe1 SLA Breach: Success Rate {currentRate:F1}%";
|
||||
summary = $"SLA breach: success rate {currentRate:F1}% is below target {targetRate:F1}%";
|
||||
period = "Current Month";
|
||||
period = $"Last {SlaEvaluationWindow.WindowDays} days";
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
facts.Add(new NotificationFact { Name = "Current Rate", Value = $"{currentRate:F1}%" });
|
||||
facts.Add(new NotificationFact { Name = "Target Rate", Value = $"{targetRate:F1}%" });
|
||||
@@ -323,7 +323,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
label = "Duration (P95)";
|
||||
title = "\ud83d\udfe1 SLA Breach: P95 Duration Exceeds Target";
|
||||
summary = $"SLA breach: P95 duration {currentRate:F1}min exceeds target {targetRate:F0}min";
|
||||
period = "Current Month";
|
||||
period = $"Last {SlaEvaluationWindow.WindowDays} days";
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
facts.Add(new NotificationFact { Name = "Current P95", Value = $"{currentRate:F1} min" });
|
||||
facts.Add(new NotificationFact { Name = "Target Max", Value = $"{targetRate:F0} min" });
|
||||
|
||||
@@ -176,15 +176,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
// Compute observables once outside the retry loop — they don't change across retries.
|
||||
var now = _nowProvider();
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1);
|
||||
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(monthStart, now.AddDays(1), config.TenantId);
|
||||
var terminal = sessions
|
||||
.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed)
|
||||
.ToList();
|
||||
// Populations come from SlaEvaluationWindow — the same definitions the SLA dashboard
|
||||
// headline uses, so a notification and the page it links to show the same number.
|
||||
var windowStart = SlaEvaluationWindow.WindowStart(now);
|
||||
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(windowStart, now.AddDays(1), config.TenantId);
|
||||
var terminal = sessions.Where(SlaEvaluationWindow.IsTerminal).ToList();
|
||||
|
||||
// AppInstall is evaluated on the same current-ISO-week scope as the SLA dashboard
|
||||
// (SlaMetricsService.cs:170, SectionSlaTargets.tsx). Without this alignment a
|
||||
// notification could fire while the UI shows green for the week (or vice versa).
|
||||
List<AppInstallSummary>? appInstalls = null;
|
||||
if (config.SlaNotifyOnAppInstallBreach && config.SlaTargetAppInstallSuccessRate.HasValue)
|
||||
{
|
||||
@@ -193,13 +190,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// now-8d never excludes a current-week row). The exact week match happens in-memory
|
||||
// below; this just stops the query from scanning the tenant's entire history every 2h.
|
||||
var raw = await _metricsRepo.GetAppInstallSummariesByTenantAsync(config.TenantId, now.AddDays(-8));
|
||||
appInstalls = raw
|
||||
.Where(a => (a.Status == "Succeeded" || a.Status == "Failed")
|
||||
// PR0 (2026-07-26): skips are not install attempts — keep the breach
|
||||
// evaluation on the same population as the SLA dashboard rate.
|
||||
&& !Helpers.MetricsMath.IsSkipTerminalState(a)
|
||||
&& SlaMetricsService.GetIsoWeekKey(a.StartedAt) == currentWeekKey)
|
||||
.ToList();
|
||||
appInstalls = SlaEvaluationWindow.AppInstallAttemptsInWeek(raw, currentWeekKey);
|
||||
}
|
||||
|
||||
// Recent-page query is also stable for the cycle (timer view of "is breach still going").
|
||||
@@ -222,9 +213,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// --- SuccessRate ---
|
||||
if (config.SlaNotifyOnSuccessRateBreach && config.SlaTargetSuccessRate.HasValue && terminal.Count > 0)
|
||||
{
|
||||
var succeeded = terminal.Count(s => s.Status == SessionStatus.Succeeded);
|
||||
var failed = terminal.Count(s => s.Status == SessionStatus.Failed);
|
||||
var successRate = succeeded / (double)terminal.Count * 100;
|
||||
var successRate = SlaEvaluationWindow.SuccessRate(terminal);
|
||||
var target = (double)config.SlaTargetSuccessRate.Value;
|
||||
var threshold = config.SlaSuccessRateNotifyThreshold.HasValue
|
||||
? (double)config.SlaSuccessRateNotifyThreshold.Value
|
||||
@@ -233,7 +223,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (isBreaching) breaches++;
|
||||
|
||||
ApplySuccessRateState(status, now, isBreaching, successRate, target, threshold,
|
||||
terminal.Count, failed, cooldown, pending, config);
|
||||
terminal.Count, failed, cooldown, pending, config,
|
||||
HasNewEvidence(status.SuccessRate_LastNotifiedAt, t => SlaEvaluationWindow.HasNewSince(terminal, t)));
|
||||
}
|
||||
else if (status.SuccessRate_IsActive)
|
||||
{
|
||||
@@ -243,21 +234,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// --- Duration ---
|
||||
if (config.SlaNotifyOnDurationBreach && config.SlaTargetMaxDurationMinutes.HasValue)
|
||||
{
|
||||
var completed = terminal
|
||||
.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0)
|
||||
.Select(s => s.DurationSeconds!.Value / 60.0)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
var completed = SlaEvaluationWindow.DurationsMinutes(terminal);
|
||||
|
||||
if (completed.Count > 0)
|
||||
{
|
||||
var p95 = MetricsMath.Percentile(completed, 95);
|
||||
var p95 = SlaEvaluationWindow.P95Minutes(completed);
|
||||
var target = config.SlaTargetMaxDurationMinutes.Value;
|
||||
var isBreaching = p95 > target;
|
||||
if (isBreaching) breaches++;
|
||||
|
||||
ApplyDurationState(status, now, isBreaching, p95, target, completed.Count,
|
||||
cooldown, pending, config);
|
||||
cooldown, pending, config,
|
||||
HasNewEvidence(status.Duration_LastNotifiedAt, t => SlaEvaluationWindow.HasNewSince(terminal, t)));
|
||||
}
|
||||
else if (status.Duration_IsActive)
|
||||
{
|
||||
@@ -274,9 +262,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (appInstalls.Count >= MinAppInstallSampleSize)
|
||||
{
|
||||
var succeeded = appInstalls.Count(a => a.Status == "Succeeded");
|
||||
var failed = appInstalls.Count(a => a.Status == "Failed");
|
||||
var rate = succeeded / (double)appInstalls.Count * 100;
|
||||
var rate = SlaEvaluationWindow.AppInstallSuccessRate(appInstalls);
|
||||
var target = (double)config.SlaTargetAppInstallSuccessRate.Value;
|
||||
var topFailing = appInstalls
|
||||
.Where(a => a.Status == "Failed")
|
||||
@@ -288,7 +275,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (isBreaching) breaches++;
|
||||
|
||||
ApplyAppInstallState(status, now, isBreaching, rate, target, topFailing,
|
||||
appInstalls.Count, failed, cooldown, pending, config);
|
||||
appInstalls.Count, failed, cooldown, pending, config,
|
||||
HasNewEvidence(status.AppInstall_LastNotifiedAt, t => SlaEvaluationWindow.HasNewSince(appInstalls, t)));
|
||||
}
|
||||
else if (status.AppInstall_IsActive)
|
||||
{
|
||||
@@ -375,7 +363,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private void ApplySuccessRateState(SlaTenantStatus s, DateTime now, bool isBreaching,
|
||||
double current, double target, double threshold, int total, int failed,
|
||||
TimeSpan cooldown, List<Func<Task>> pending, TenantConfiguration config)
|
||||
TimeSpan cooldown, List<Func<Task>> pending, TenantConfiguration config, bool hasNewEvidence)
|
||||
{
|
||||
if (isBreaching)
|
||||
{
|
||||
@@ -391,8 +379,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
s.SuccessRate_LastBreachAt = now;
|
||||
s.SuccessRate_ResolvedAt = null;
|
||||
|
||||
var shouldNotify = !s.SuccessRate_LastNotifiedAt.HasValue
|
||||
|| (now - s.SuccessRate_LastNotifiedAt.Value) >= cooldown;
|
||||
var shouldNotify = ShouldNotify(s.SuccessRate_LastNotifiedAt, now, cooldown, firstBreach, hasNewEvidence);
|
||||
|
||||
if (shouldNotify)
|
||||
{
|
||||
@@ -425,7 +412,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private void ApplyDurationState(SlaTenantStatus s, DateTime now, bool isBreaching,
|
||||
double p95, int targetMinutes, int totalSessions,
|
||||
TimeSpan cooldown, List<Func<Task>> pending, TenantConfiguration config)
|
||||
TimeSpan cooldown, List<Func<Task>> pending, TenantConfiguration config, bool hasNewEvidence)
|
||||
{
|
||||
if (isBreaching)
|
||||
{
|
||||
@@ -439,8 +426,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
s.Duration_LastBreachAt = now;
|
||||
s.Duration_ResolvedAt = null;
|
||||
|
||||
var shouldNotify = !s.Duration_LastNotifiedAt.HasValue
|
||||
|| (now - s.Duration_LastNotifiedAt.Value) >= cooldown;
|
||||
var shouldNotify = ShouldNotify(s.Duration_LastNotifiedAt, now, cooldown, firstBreach, hasNewEvidence);
|
||||
|
||||
if (shouldNotify)
|
||||
{
|
||||
@@ -469,7 +455,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private void ApplyAppInstallState(SlaTenantStatus s, DateTime now, bool isBreaching,
|
||||
double currentRate, double targetRate, string? topFailingApp,
|
||||
int totalInstalls, int failedInstalls,
|
||||
TimeSpan cooldown, List<Func<Task>> pending, TenantConfiguration config)
|
||||
TimeSpan cooldown, List<Func<Task>> pending, TenantConfiguration config, bool hasNewEvidence)
|
||||
{
|
||||
if (isBreaching)
|
||||
{
|
||||
@@ -483,8 +469,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
s.AppInstall_LastBreachAt = now;
|
||||
s.AppInstall_ResolvedAt = null;
|
||||
|
||||
var shouldNotify = !s.AppInstall_LastNotifiedAt.HasValue
|
||||
|| (now - s.AppInstall_LastNotifiedAt.Value) >= cooldown;
|
||||
var shouldNotify = ShouldNotify(s.AppInstall_LastNotifiedAt, now, cooldown, firstBreach, hasNewEvidence);
|
||||
|
||||
if (shouldNotify)
|
||||
{
|
||||
@@ -747,6 +732,22 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private static int EffectiveConsecutiveFailureThreshold(TenantConfiguration config)
|
||||
=> config.SlaConsecutiveFailureThreshold < 2 ? 5 : config.SlaConsecutiveFailureThreshold;
|
||||
|
||||
private static bool HasNewEvidence(DateTime? lastNotifiedAt, Func<DateTime, bool> hasNewSince)
|
||||
=> !lastNotifiedAt.HasValue || hasNewSince(lastNotifiedAt.Value);
|
||||
|
||||
// A lasting breach is repeated only when something new finished since the last
|
||||
// notification; the cooldown stays the lower bound. A tenant without enrollments would
|
||||
// otherwise receive the same number every cooldown. A new breach episode (first breach
|
||||
// after a resolve) needs no new evidence — the rolling window can re-breach by good
|
||||
// sessions aging out, and the admin was last told "resolved".
|
||||
private static bool ShouldNotify(DateTime? lastNotifiedAt, DateTime now, TimeSpan cooldown,
|
||||
bool firstBreach, bool hasNewEvidence)
|
||||
{
|
||||
if (!lastNotifiedAt.HasValue) return true;
|
||||
if (now - lastNotifiedAt.Value < cooldown) return false;
|
||||
return firstBreach || hasNewEvidence;
|
||||
}
|
||||
|
||||
private async Task<TimeSpan> GetCooldownAsync()
|
||||
{
|
||||
var admin = await _adminConfigService.GetConfigurationAsync();
|
||||
@@ -755,12 +756,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
// Telemetry tag — must match the actual evaluation window so Application Insights /
|
||||
// ops dashboards don't group AppInstall breaches under "CurrentMonth".
|
||||
// ops dashboards don't group AppInstall breaches under the session window.
|
||||
internal static string PeriodForBreachType(string breachType) => breachType switch
|
||||
{
|
||||
SlaBreachType.AppInstall => "CurrentWeek",
|
||||
SlaBreachType.SuccessRate => "CurrentMonth",
|
||||
SlaBreachType.Duration => "CurrentMonth",
|
||||
SlaBreachType.SuccessRate => $"Last{SlaEvaluationWindow.WindowDays}Days",
|
||||
SlaBreachType.Duration => $"Last{SlaEvaluationWindow.WindowDays}Days",
|
||||
_ => "CurrentPeriod",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// The one definition of what the SLA targets are measured on. Both the breach evaluator
|
||||
/// (notifications) and the SLA dashboard headline read it, so an alert and the page it links
|
||||
/// to can never report different numbers for the same target.
|
||||
/// Success rate + P95 duration: terminal sessions started in the last <see cref="WindowDays"/> days.
|
||||
/// App installs: install attempts of the current ISO week.
|
||||
/// The window is rolling on purpose: a calendar month empties on its first day, which silently
|
||||
/// closes an active breach and blanks the headline until the next enrollment finishes.
|
||||
/// </summary>
|
||||
internal static class SlaEvaluationWindow
|
||||
{
|
||||
public const int WindowDays = 30;
|
||||
|
||||
/// <summary>Period key of the rolling window on <c>SlaSnapshot.Period</c>.</summary>
|
||||
public const string PeriodKey = "last-30-days";
|
||||
|
||||
public static DateTime WindowStart(DateTime nowUtc) => nowUtc.AddDays(-WindowDays);
|
||||
|
||||
public static bool IsTerminal(SessionSummary s)
|
||||
=> s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed;
|
||||
|
||||
public static List<SessionSummary> TerminalInWindow(IEnumerable<SessionSummary> sessions, DateTime nowUtc)
|
||||
{
|
||||
var windowStart = WindowStart(nowUtc);
|
||||
return sessions.Where(s => IsTerminal(s) && s.StartedAt >= windowStart).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Succeeded / terminal * 100, one decimal. Target comparisons use this rounded value —
|
||||
/// the number the admin reads is the number that is judged. 0 for an empty set; callers
|
||||
/// treat an empty set as "no data", never as a breach.
|
||||
/// </summary>
|
||||
public static double SuccessRate(IReadOnlyCollection<SessionSummary> terminal)
|
||||
{
|
||||
if (terminal.Count == 0) return 0;
|
||||
var succeeded = terminal.Count(s => s.Status == SessionStatus.Succeeded);
|
||||
return Math.Round(succeeded / (double)terminal.Count * 100, 1);
|
||||
}
|
||||
|
||||
/// <summary>Succeeded / attempts * 100, one decimal — same rounding rule as <see cref="SuccessRate"/>.</summary>
|
||||
public static double AppInstallSuccessRate(IReadOnlyCollection<AppInstallSummary> attempts)
|
||||
{
|
||||
if (attempts.Count == 0) return 0;
|
||||
var succeeded = attempts.Count(a => a.Status == "Succeeded");
|
||||
return Math.Round(succeeded / (double)attempts.Count * 100, 1);
|
||||
}
|
||||
|
||||
/// <summary>Durations in minutes, ascending; sessions without a positive duration are left out.</summary>
|
||||
public static List<double> DurationsMinutes(IEnumerable<SessionSummary> terminal)
|
||||
=> terminal
|
||||
.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0)
|
||||
.Select(s => s.DurationSeconds!.Value / 60.0)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
public static double P95Minutes(List<double> sortedDurationsMinutes)
|
||||
=> MetricsMath.Percentile(sortedDurationsMinutes, 95);
|
||||
|
||||
/// <summary>
|
||||
/// Install attempts of one ISO week: terminal rows only, skips excluded (a skip is not an
|
||||
/// attempt and must not pad the rate).
|
||||
/// </summary>
|
||||
public static List<AppInstallSummary> AppInstallAttemptsInWeek(IEnumerable<AppInstallSummary> installs, string isoWeekKey)
|
||||
=> installs
|
||||
.Where(a => (a.Status == "Succeeded" || a.Status == "Failed")
|
||||
&& !MetricsMath.IsSkipTerminalState(a)
|
||||
&& SlaMetricsService.GetIsoWeekKey(a.StartedAt) == isoWeekKey)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the population changed since the last breach notification. A lasting breach is
|
||||
/// repeated only when something new finished — a tenant without enrollments must not be told
|
||||
/// the same number every cooldown. Judged on the finish time, so a session that was still
|
||||
/// running at the last notification counts once it ends; a row without one falls back to its start.
|
||||
/// </summary>
|
||||
public static bool HasNewSince(IEnumerable<SessionSummary> terminal, DateTime lastNotifiedAt)
|
||||
=> terminal.Any(s => (s.CompletedAt ?? s.StartedAt) > lastNotifiedAt);
|
||||
|
||||
public static bool HasNewSince(IEnumerable<AppInstallSummary> attempts, DateTime lastNotifiedAt)
|
||||
=> attempts.Any(a => (a.CompletedAt ?? a.StartedAt) > lastNotifiedAt);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly ILogger<SlaMetricsService> _logger;
|
||||
private readonly Func<DateTime> _nowProvider;
|
||||
|
||||
// Per-tenant cache: key = "tenantId:months"
|
||||
private static readonly ConcurrentDictionary<string, (SlaMetricsResponse Metrics, DateTime Expiry)> _cache = new();
|
||||
@@ -32,12 +33,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
IMetricsRepository metricsRepo,
|
||||
TenantConfigurationService configService,
|
||||
ILogger<SlaMetricsService> logger)
|
||||
ILogger<SlaMetricsService> logger,
|
||||
Func<DateTime>? nowProvider = null)
|
||||
{
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_metricsRepo = metricsRepo;
|
||||
_configService = configService;
|
||||
_logger = logger;
|
||||
_nowProvider = nowProvider ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,28 +98,39 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private async Task<SlaMetricsResponse> ComputeInternalAsync(
|
||||
string tenantId, int months, Shared.Models.TenantConfiguration? config)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _nowProvider();
|
||||
var startDate = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1));
|
||||
var endDate = now.AddDays(1);
|
||||
|
||||
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(startDate, endDate, tenantId);
|
||||
// The rolling evaluation window reaches back before the first of the month, which a
|
||||
// 1-month selection does not cover — read from whichever starts earlier, one read.
|
||||
var windowStart = SlaEvaluationWindow.WindowStart(now);
|
||||
var readFrom = windowStart < startDate ? windowStart : startDate;
|
||||
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(readFrom, endDate, tenantId);
|
||||
|
||||
// Only consider terminal sessions for SLA computation
|
||||
var terminal = sessions
|
||||
.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed)
|
||||
.ToList();
|
||||
var loadedTerminal = sessions.Where(SlaEvaluationWindow.IsTerminal).ToList();
|
||||
// Trend, week cards and violators stay on the selected months — the wider read must
|
||||
// not grow "1 Month" by the days the evaluation window reaches back.
|
||||
var terminal = loadedTerminal.Where(s => s.StartedAt >= startDate).ToList();
|
||||
|
||||
var response = new SlaMetricsResponse
|
||||
{
|
||||
TargetSuccessRate = config?.SlaTargetSuccessRate,
|
||||
TargetMaxDurationMinutes = config?.SlaTargetMaxDurationMinutes,
|
||||
TargetAppInstallSuccessRate = config?.SlaTargetAppInstallSuccessRate,
|
||||
EvaluationWindowDays = SlaEvaluationWindow.WindowDays,
|
||||
};
|
||||
|
||||
// Headline: the window the breach notifications evaluate (SlaEvaluationWindow).
|
||||
response.EvaluationPeriod = BuildSnapshot(
|
||||
SlaEvaluationWindow.TerminalInWindow(loadedTerminal, now),
|
||||
SlaEvaluationWindow.PeriodKey, isoWeek: false, config);
|
||||
|
||||
// Current week snapshot (ISO 8601 week)
|
||||
var currentWeekKey = GetIsoWeekKey(now);
|
||||
var currentWeekSessions = terminal.Where(s => GetIsoWeekKey(s.StartedAt) == currentWeekKey).ToList();
|
||||
response.CurrentWeek = BuildSnapshot(currentWeekSessions, currentWeekKey, config);
|
||||
response.CurrentWeek = BuildSnapshot(currentWeekSessions, currentWeekKey, isoWeek: true, config);
|
||||
|
||||
// Weekly trend (newest first)
|
||||
var weekGroups = terminal
|
||||
@@ -140,24 +154,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var group in weekGroups)
|
||||
{
|
||||
var weekSessions = group.ToList();
|
||||
var snapshot = BuildSnapshot(weekSessions, group.Key, config);
|
||||
var snapshot = BuildSnapshot(weekSessions, group.Key, isoWeek: true, config);
|
||||
|
||||
// App install rate for this week
|
||||
double appInstallRate = 0;
|
||||
bool appInstallMet = true;
|
||||
if (appInstalls != null)
|
||||
{
|
||||
var weekApps = appInstalls
|
||||
.Where(a => GetIsoWeekKey(a.StartedAt) == group.Key &&
|
||||
(a.Status == "Succeeded" || a.Status == "Failed") &&
|
||||
// PR0 (2026-07-26): skips are not install attempts — they must
|
||||
// not pad the SLA app-install success rate.
|
||||
!Helpers.MetricsMath.IsSkipTerminalState(a))
|
||||
.ToList();
|
||||
var weekApps = SlaEvaluationWindow.AppInstallAttemptsInWeek(appInstalls, group.Key);
|
||||
if (weekApps.Count >= 5)
|
||||
{
|
||||
var appSucceeded = weekApps.Count(a => a.Status == "Succeeded");
|
||||
appInstallRate = Math.Round((appSucceeded / (double)weekApps.Count) * 100, 1);
|
||||
appInstallRate = SlaEvaluationWindow.AppInstallSuccessRate(weekApps);
|
||||
appInstallMet = config?.SlaTargetAppInstallSuccessRate == null ||
|
||||
appInstallRate >= (double)config.SlaTargetAppInstallSuccessRate;
|
||||
}
|
||||
@@ -179,18 +186,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// App install SLA snapshot (current week)
|
||||
if (appInstalls != null)
|
||||
{
|
||||
var currentWeekApps = appInstalls
|
||||
.Where(a => GetIsoWeekKey(a.StartedAt) == currentWeekKey &&
|
||||
(a.Status == "Succeeded" || a.Status == "Failed") &&
|
||||
// PR0 (2026-07-26): same convention as the weekly trend above.
|
||||
!Helpers.MetricsMath.IsSkipTerminalState(a))
|
||||
.ToList();
|
||||
var currentWeekApps = SlaEvaluationWindow.AppInstallAttemptsInWeek(appInstalls, currentWeekKey);
|
||||
|
||||
if (currentWeekApps.Count >= 5)
|
||||
{
|
||||
var appSucceeded = currentWeekApps.Count(a => a.Status == "Succeeded");
|
||||
var appFailed = currentWeekApps.Count(a => a.Status == "Failed");
|
||||
var appRate = Math.Round((appSucceeded / (double)currentWeekApps.Count) * 100, 1);
|
||||
var appRate = SlaEvaluationWindow.AppInstallSuccessRate(currentWeekApps);
|
||||
|
||||
var topFailing = currentWeekApps
|
||||
.Where(a => a.Status == "Failed")
|
||||
@@ -276,33 +278,30 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
private static SlaSnapshot BuildSnapshot(
|
||||
List<SessionSummary> sessions, string weekKey,
|
||||
List<SessionSummary> sessions, string periodKey, bool isoWeek,
|
||||
Shared.Models.TenantConfiguration? config)
|
||||
{
|
||||
var total = sessions.Count;
|
||||
var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded);
|
||||
var failed = sessions.Count(s => s.Status == SessionStatus.Failed);
|
||||
var successRate = total > 0 ? Math.Round((succeeded / (double)total) * 100, 1) : 0;
|
||||
|
||||
var completed = sessions
|
||||
.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0)
|
||||
.ToList();
|
||||
var durations = completed
|
||||
.Select(s => s.DurationSeconds!.Value / 60.0)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
var successRate = SlaEvaluationWindow.SuccessRate(sessions);
|
||||
|
||||
var durations = SlaEvaluationWindow.DurationsMinutes(sessions);
|
||||
var avgDuration = durations.Count > 0 ? Math.Round(durations.Average(), 1) : 0;
|
||||
var p95Duration = MetricsMath.Percentile(durations, 95);
|
||||
var p95Duration = SlaEvaluationWindow.P95Minutes(durations);
|
||||
|
||||
var durationTarget = config?.SlaTargetMaxDurationMinutes;
|
||||
var durationViolations = durationTarget.HasValue
|
||||
? completed.Count(s => s.DurationSeconds!.Value > durationTarget.Value * 60)
|
||||
? durations.Count(d => d > durationTarget.Value)
|
||||
: 0;
|
||||
|
||||
// An empty period breaches nothing: with no finished enrollment (or none carrying a
|
||||
// duration) the rate/P95 are 0 by convention, and 0 must not be judged against a target.
|
||||
return new SlaSnapshot
|
||||
{
|
||||
Week = weekKey,
|
||||
Period = periodKey,
|
||||
Week = isoWeek ? periodKey : "",
|
||||
HasData = total > 0,
|
||||
TotalCompleted = total,
|
||||
Succeeded = succeeded,
|
||||
Failed = failed,
|
||||
@@ -310,8 +309,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AvgDurationMinutes = avgDuration,
|
||||
P95DurationMinutes = p95Duration,
|
||||
DurationViolationCount = durationViolations,
|
||||
SuccessRateMet = config?.SlaTargetSuccessRate == null || successRate >= (double)config.SlaTargetSuccessRate,
|
||||
DurationTargetMet = durationTarget == null || p95Duration <= durationTarget.Value,
|
||||
SuccessRateMet = total == 0 || config?.SlaTargetSuccessRate == null
|
||||
|| successRate >= (double)config.SlaTargetSuccessRate,
|
||||
DurationTargetMet = durations.Count == 0 || durationTarget == null
|
||||
|| p95Duration <= durationTarget.Value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4325,6 +4325,10 @@ export interface SlaMetricsResponse {
|
||||
targetSuccessRate?: number;
|
||||
targetMaxDurationMinutes?: number;
|
||||
targetAppInstallSuccessRate?: number;
|
||||
/** SLA snapshot of the rolling window the success-rate and duration breach notifications evaluate (EvaluationWindowDays), so the dashboard headline and the alert show the same number. */
|
||||
evaluationPeriod: SlaSnapshot;
|
||||
/** Length of the rolling evaluation window in days. */
|
||||
evaluationWindowDays: number;
|
||||
/** Current ISO week SLA snapshot. */
|
||||
currentWeek: SlaSnapshot;
|
||||
/** Weekly trend (newest first). */
|
||||
@@ -4338,10 +4342,14 @@ export interface SlaMetricsResponse {
|
||||
computeDurationMs: number;
|
||||
}
|
||||
|
||||
/** SLA compliance snapshot for a single period (ISO week). */
|
||||
/** SLA compliance snapshot for a single period (ISO week or the rolling evaluation window). */
|
||||
export interface SlaSnapshot {
|
||||
/** ISO week identifier, e.g. "2026-W15". */
|
||||
/** Period identifier: ISO week "2026-W15", or "last-30-days" for the rolling evaluation window. */
|
||||
period: string;
|
||||
/** ISO week identifier, e.g. "2026-W15". Empty on the evaluation-window snapshot. */
|
||||
week: string;
|
||||
/** False when no enrollment finished in the period. The rates are 0 then and both target flags are true — an empty period breaches nothing; clients render "no data". */
|
||||
hasData: boolean;
|
||||
totalCompleted: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
|
||||
@@ -13,6 +13,16 @@ namespace AutopilotMonitor.Shared.Models.Metrics
|
||||
public int? TargetMaxDurationMinutes { get; set; }
|
||||
public decimal? TargetAppInstallSuccessRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SLA snapshot of the rolling window the success-rate and duration breach notifications
|
||||
/// evaluate (<see cref="EvaluationWindowDays"/>), so the dashboard headline and the alert
|
||||
/// show the same number.
|
||||
/// </summary>
|
||||
public SlaSnapshot EvaluationPeriod { get; set; } = new();
|
||||
|
||||
/// <summary>Length of the rolling evaluation window in days.</summary>
|
||||
public int EvaluationWindowDays { get; set; }
|
||||
|
||||
/// <summary>Current ISO week SLA snapshot.</summary>
|
||||
public SlaSnapshot CurrentWeek { get; set; } = new();
|
||||
|
||||
@@ -31,13 +41,22 @@ namespace AutopilotMonitor.Shared.Models.Metrics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SLA compliance snapshot for a single period (ISO week).
|
||||
/// SLA compliance snapshot for a single period (ISO week or the rolling evaluation window).
|
||||
/// </summary>
|
||||
public class SlaSnapshot
|
||||
{
|
||||
/// <summary>ISO week identifier, e.g. "2026-W15".</summary>
|
||||
/// <summary>Period identifier: ISO week "2026-W15", or "last-30-days" for the rolling evaluation window.</summary>
|
||||
public string Period { get; set; } = default!;
|
||||
|
||||
/// <summary>ISO week identifier, e.g. "2026-W15". Empty on the evaluation-window snapshot.</summary>
|
||||
public string Week { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// False when no enrollment finished in the period. The rates are 0 then and both
|
||||
/// target flags are true — an empty period breaches nothing; clients render "no data".
|
||||
/// </summary>
|
||||
public bool HasData { get; set; }
|
||||
|
||||
public int TotalCompleted { get; set; }
|
||||
public int Succeeded { get; set; }
|
||||
public int Failed { get; set; }
|
||||
|
||||
@@ -19927,6 +19927,22 @@
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "evaluationPeriod",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "SlaSnapshot"
|
||||
},
|
||||
"doc": "SLA snapshot of the rolling window the success-rate and duration breach notifications evaluate (EvaluationWindowDays), so the dashboard headline and the alert show the same number."
|
||||
},
|
||||
{
|
||||
"name": "evaluationWindowDays",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Length of the rolling evaluation window in days."
|
||||
},
|
||||
{
|
||||
"name": "currentWeek",
|
||||
"type": {
|
||||
@@ -19991,15 +20007,31 @@
|
||||
},
|
||||
"SlaSnapshot": {
|
||||
"kind": "object",
|
||||
"doc": "SLA compliance snapshot for a single period (ISO week).",
|
||||
"doc": "SLA compliance snapshot for a single period (ISO week or the rolling evaluation window).",
|
||||
"fields": [
|
||||
{
|
||||
"name": "period",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Period identifier: ISO week \"2026-W15\", or \"last-30-days\" for the rolling evaluation window."
|
||||
},
|
||||
{
|
||||
"name": "week",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "ISO week identifier, e.g. \"2026-W15\"."
|
||||
"doc": "ISO week identifier, e.g. \"2026-W15\". Empty on the evaluation-window snapshot."
|
||||
},
|
||||
{
|
||||
"name": "hasData",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "False when no enrollment finished in the period. The rates are 0 then and both target flags are true — an empty period breaches nothing; clients render \"no data\"."
|
||||
},
|
||||
{
|
||||
"name": "totalCompleted",
|
||||
|
||||
@@ -4325,6 +4325,10 @@ export interface SlaMetricsResponse {
|
||||
targetSuccessRate?: number;
|
||||
targetMaxDurationMinutes?: number;
|
||||
targetAppInstallSuccessRate?: number;
|
||||
/** SLA snapshot of the rolling window the success-rate and duration breach notifications evaluate (EvaluationWindowDays), so the dashboard headline and the alert show the same number. */
|
||||
evaluationPeriod: SlaSnapshot;
|
||||
/** Length of the rolling evaluation window in days. */
|
||||
evaluationWindowDays: number;
|
||||
/** Current ISO week SLA snapshot. */
|
||||
currentWeek: SlaSnapshot;
|
||||
/** Weekly trend (newest first). */
|
||||
@@ -4338,10 +4342,14 @@ export interface SlaMetricsResponse {
|
||||
computeDurationMs: number;
|
||||
}
|
||||
|
||||
/** SLA compliance snapshot for a single period (ISO week). */
|
||||
/** SLA compliance snapshot for a single period (ISO week or the rolling evaluation window). */
|
||||
export interface SlaSnapshot {
|
||||
/** ISO week identifier, e.g. "2026-W15". */
|
||||
/** Period identifier: ISO week "2026-W15", or "last-30-days" for the rolling evaluation window. */
|
||||
period: string;
|
||||
/** ISO week identifier, e.g. "2026-W15". Empty on the evaluation-window snapshot. */
|
||||
week: string;
|
||||
/** False when no enrollment finished in the period. The rates are 0 then and both target flags are true — an empty period breaches nothing; clients render "no data". */
|
||||
hasData: boolean;
|
||||
totalCompleted: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
|
||||
Reference in New Issue
Block a user