Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs
T
Oliver Kieselbach baa51102fd feat(web,backend,mcp): app rows without a terminal state become Incomplete instead of running forever (D-237)
An app still installing when the session's observation ended has no terminal
event and never will. The session page ticked Date.now() - startedAt for it
("11h 39m" on a session that finished hours ago), and its AppInstallSummaries
row stayed InProgress forever - ranked behind failures in the app's session
list and counted in totalInstalls with no named bucket.

Web: applyObservationEnd marks such rows Incomplete once the session is
terminal (isTerminalStatus), bounded by session.lastEventAt; the panel shows a
slate Incomplete badge and the watched span as a lower bound, the "active"
pill excludes them, and the download panel carries the parallel flag. Covers
every historic session without a backfill.

Backend: CloseOpenAppInstallsForSessionAsync closes InProgress rows without a
CompletedAt as Incomplete - at the terminal seam, again on the ingest batch
that carries agent_shutting_down for a terminal session (rows opened after
the verdict), and from the 30-day sweep. The sticky-status reconcile keeps
Incomplete against a late progress-only batch and lets a real terminal win.
App metrics and app analytics disclose the bucket (incomplete,
totalIncomplete) outside the failure rate; manifest and wire types
regenerated.

MCP: get_session_summary coverage gains an apps block and a gaps line naming
the apps the agent's last app_tracking_summary still listed as installing or
downloading, terminal sessions only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 00:02:24 +02:00

492 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Shared statistical helpers for duration/SLA metrics aggregation.
/// </summary>
public static class MetricsMath
{
/// <summary>
/// App-install failure rate over finished installs only: Failed / (Failed + Succeeded), one
/// decimal, 0 when nothing finished. The same outcome-quota convention as the enrollment
/// success rate — "InProgress" rows (still installing) and "Incomplete" rows (observation
/// ended before any outcome) never dilute the rate. Shared by the app-metrics payload and the Apps
/// dashboard aggregations so the definition can't drift between panes.
/// </summary>
public static double TerminalFailureRatePct(int failed, int succeeded)
{
var finished = failed + succeeded;
return finished > 0 ? Math.Round((double)failed / finished * 100, 1) : 0;
}
/// <summary>
/// True when the row's terminal state says no real install attempt happened —
/// Skipped (not applicable, e.g. WinGet "Update for X" with the target app absent)
/// or Postponed. PR0 decision (2026-07-26): such rows are excluded from duration
/// statistics AND from the failure/success rate; they are reported as their own
/// category instead. Rows written before the TerminalState column existed return
/// false here (empty state) — they keep the legacy rate behavior because they
/// cannot be reclassified honestly.
/// </summary>
public static bool IsSkipTerminalState(AppInstallSummary s)
=> s.TerminalState == "Skipped" || s.TerminalState == "Postponed";
/// <summary>
/// Upper plausibility bound for an observed install duration: 6 h, the agent's default
/// max observation lifetime (AgentMaxLifetimeMinutes = 360) — nothing longer can have
/// been continuously watched. Historically this mostly caught full-span pathologies
/// (pre-2026-08 rows measured first observation → last terminal across all install
/// passes; verified in production 2026-07-27: app rows carrying 60 175 s in a session
/// whose own wall clock was 2 697 s; 490 rows &gt; 6 h overall, worst 117 days). Since
/// the 2026-08 attempt-duration change DurationSeconds measures the last attempt, so
/// far fewer rows trip this bound — it remains as the guard against back-stamped
/// completions, where a single ATTEMPT longer than the agent's own lifetime is still
/// impossible to have observed.
/// </summary>
public const int MaxPlausibleInstallDurationSeconds = 6 * 3600;
/// <summary>
/// True for a succeeded row whose install duration was actually observed. Zero /
/// absent duration on a non-skip succeeded row means the START was never observed
/// (agent attach window — audit finding, §0.5 of the insights spec); a duration above
/// <see cref="MaxPlausibleInstallDurationSeconds"/> means the END was never observed
/// (completion back-stamped at session end). Either way the duration is UNKNOWN —
/// averaging zeros understates (measured −18 % in production), averaging back-stamps
/// inflates. Duration statistics must only read rows where this is true; everything
/// else is reported as "unmeasured".
/// </summary>
public static bool HasMeasuredDuration(AppInstallSummary s)
=> s.DurationSeconds > 0 && s.DurationSeconds <= MaxPlausibleInstallDurationSeconds;
/// <summary>
/// Builds the complete app-metrics response object from a (pre-time-filtered) set of app
/// install summaries. Single source of truth for both the tenant (<c>metrics/app</c>) and
/// global (<c>global/metrics/app</c>) functions, which previously carried a verbatim copy of
/// this GroupBy aggregation — keeping the Delivery Optimization rollup and the slowest/failing
/// ranking in one place removes that drift risk.
///
/// The Delivery Optimization rollup sums bytes across every row in an app group (not just the
/// successful ones): DO telemetry is recorded during the download regardless of the install's
/// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately —
/// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN".
/// </summary>
public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
{
var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList();
// F1 PR1 (audit Q3): a name-keyed row that merged two distinct appIds carries an
// unattributable status/duration mix — it must not shape any per-app group. Excluded
// here with a disclosed count (truthfulness rule 7); the fleet-wide DO rollup below
// keeps every row, since transferred bytes are real regardless of identity mixing.
var totalCollisionExcluded = summaryList.Count(s => s.AppIdCollision);
var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
{
// PR0 (2026-07-26) classification — see IsSkipTerminalState / HasMeasuredDuration:
// skipped = no real install attempt (TerminalState Skipped/Postponed)
// installed = succeeded rows that are not known skips (includes legacy rows
// without TerminalState — they cannot be reclassified)
// measured = installed rows with an observed duration — the ONLY duration input
// unmeasured = installed rows whose start was never observed (duration unknown)
var succeededAll = g.Where(s => s.Status == "Succeeded").ToList();
var skipped = succeededAll.Where(IsSkipTerminalState).ToList();
var installed = succeededAll.Where(s => !IsSkipTerminalState(s)).ToList();
var measured = installed.Where(HasMeasuredDuration).ToList();
var failed = g.Where(s => s.Status == "Failed").ToList();
// Observation ended before any outcome — disclosed, outside the rate and the durations.
var incomplete = g.Count(s => s.Status == "Incomplete");
var total = g.Count();
// DoAggregator is the single source for the DO rollup: it filters rows that actually
// carry DO telemetry (DoDownloadMode >= 0) and falls back to peers + http when a legacy
// row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost.
var doG = DoAggregator.Compute(g);
return new AppMetricsAppGroup
{
AppName = g.Key,
TotalInstalls = total,
Succeeded = installed.Count,
Skipped = skipped.Count,
Unmeasured = installed.Count - measured.Count,
Failed = failed.Count,
Incomplete = incomplete,
// Skips leave the rate (they are not attempts); legacy rows stay in it.
FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
MeasuredInstalls = measured.Count,
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
DoTotalBytesDownloaded = doG.TotalBytesDownloaded,
DoBytesFromPeers = doG.BytesFromPeers,
DoBytesFromCacheServer = doG.BytesFromCacheServer,
DoBytesFromHttp = doG.BytesFromHttp,
PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
TopFailureCodes = failed
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
.GroupBy(f => f.FailureCode)
.OrderByDescending(fc => fc.Count())
.Take(3)
.Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() })
.ToList()
};
}).ToList();
// Slowest ranking gates on MEASURED installs — an app whose durations were mostly
// unobserved must not rank as "fast" on a handful of zeros (audit §0.5).
var slowestApps = SelectSlowestApps(
appGroups, a => a.MeasuredInstalls, a => a.AvgDurationSeconds, minSamples: 3, take: 10);
var topFailingApps = appGroups
.Where(a => a.Failed > 0)
.OrderByDescending(a => a.Failed)
.ThenByDescending(a => a.FailureRate)
.Take(10)
.ToList();
var doAll = DoAggregator.Compute(summaryList);
return new AppMetricsResponse
{
Success = true,
TotalApps = appGroups.Count,
TotalInstalls = summaryList.Count,
TotalSkipped = appGroups.Sum(a => a.Skipped),
TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
TotalIncomplete = appGroups.Sum(a => a.Incomplete),
TotalCollisionExcluded = totalCollisionExcluded,
SlowestApps = slowestApps,
TopFailingApps = topFailingApps,
DeliveryOptimization = new AppMetricsDeliveryOptimization
{
TotalBytesDownloaded = doAll.TotalBytesDownloaded,
FromPeers = doAll.BytesFromPeers,
FromCacheServer = doAll.BytesFromCacheServer,
FromHttp = doAll.BytesFromHttp,
PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
}
};
}
/// <summary>
/// Builds the complete Fleet Health response from the (already time-windowed) session list.
/// Single source of truth for the tenant (<c>metrics/fleet-health</c>) and global
/// (<c>global/metrics/fleet-health</c>) functions. Replaces the previous client-side path that
/// drained up to 200k raw sessions into the browser and ran these aggregations on the main
/// thread. Success rate follows the SLA convention: Succeeded / (Succeeded + Failed) — finished
/// enrollments only, so in-flight sessions and Incomplete (terminal, non-failure) never dilute
/// it. Duration stats (average, median, P90) count every non-in-progress session that carries a
/// positive duration (failures included); the cards lead with the median because enrollment
/// durations are heavily right-skewed and a handful of multi-hour outliers make the mean
/// meaningless as a "typical enrollment" answer.
/// </summary>
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
{
int succeeded = 0, failed = 0, inProgress = 0, incomplete = 0;
var completedDurationSeconds = new List<double>();
foreach (var s in sessions)
{
switch (s.Status)
{
case SessionStatus.Succeeded: succeeded++; break;
case SessionStatus.Failed: failed++; break;
case SessionStatus.InProgress: inProgress++; break;
case SessionStatus.Incomplete: incomplete++; break;
}
if (s.Status != SessionStatus.InProgress && s.DurationSeconds is int d && d > 0)
completedDurationSeconds.Add(d);
}
completedDurationSeconds.Sort(); // Percentile() expects ascending order
int total = sessions.Count;
int finished = succeeded + failed;
var stats = new FleetHealthStats
{
Total = total,
Succeeded = succeeded,
Failed = failed,
InProgress = inProgress,
Incomplete = incomplete,
SuccessRate = finished > 0 ? Math.Round((double)succeeded / finished * 100, 1) : 0,
AvgDurationMinutes = completedDurationSeconds.Count > 0
? (int)Math.Round(completedDurationSeconds.Average() / 60.0, MidpointRounding.AwayFromZero)
: 0,
MedianDurationMinutes = SecondsToMinutes(Percentile(completedDurationSeconds, 50)),
P90DurationMinutes = SecondsToMinutes(Percentile(completedDurationSeconds, 90)),
};
return new FleetHealthMetrics
{
Success = true,
Days = days,
Stats = stats,
DailyData = BuildFleetDailyData(sessions, days),
FailureReasons = BuildFleetFailureReasons(sessions),
ModelHealth = BuildFleetModelHealth(sessions),
SlowestModels = BuildFleetSlowestModels(sessions),
TopFailingModels = BuildFleetTopFailingModels(sessions),
ComputedAt = DateTime.UtcNow,
};
}
/// <summary>
/// One point per day in the window, oldest-first, so every day renders even with zero
/// enrollments. Sessions are bucketed by their StartedAt calendar day, treated as UTC to
/// match the rest of the stats pipeline (see AggregateSessionStats' UTC-midnight boundary).
/// </summary>
private static List<FleetDailyPoint> BuildFleetDailyData(IReadOnlyList<SessionSummary> sessions, int days)
{
var buckets = new Dictionary<string, (int Success, int Failed)>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Succeeded && s.Status != SessionStatus.Failed) continue;
var key = s.StartedAt.ToString("yyyy-MM-dd");
buckets.TryGetValue(key, out var cur);
if (s.Status == SessionStatus.Succeeded) cur.Success++;
else cur.Failed++;
buckets[key] = cur;
}
var result = new List<FleetDailyPoint>(days);
var today = DateTime.UtcNow.Date;
for (int i = days - 1; i >= 0; i--)
{
var date = today.AddDays(-i).ToString("yyyy-MM-dd");
buckets.TryGetValue(date, out var c);
result.Add(new FleetDailyPoint { Date = date, Success = c.Success, Failed = c.Failed });
}
return result;
}
private static List<FleetFailureReason> BuildFleetFailureReasons(IReadOnlyList<SessionSummary> sessions)
{
// Group near-identical messages by a 50-char prefix so variants collapse
// into one row, but keep the longest full reason as the display value. The
// UI truncates and expands it on demand, so the complete text must survive.
var groups = new Dictionary<string, (string Display, int Count)>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Failed) continue;
var reason = string.IsNullOrEmpty(s.FailureReason) ? "Unknown" : s.FailureReason;
var key = reason.Length > 50 ? reason.Substring(0, 50) : reason;
if (groups.TryGetValue(key, out var g))
{
var display = reason.Length > g.Display.Length ? reason : g.Display;
groups[key] = (display, g.Count + 1);
}
else
{
groups[key] = (reason, 1);
}
}
return groups
.OrderByDescending(kv => kv.Value.Count)
.Take(5)
.Select(kv => new FleetFailureReason { Reason = kv.Value.Display, Count = kv.Value.Count })
.ToList();
}
private static List<FleetModelHealth> BuildFleetModelHealth(IReadOnlyList<SessionSummary> sessions)
{
var models = new Dictionary<ModelBucket, FleetModelHealth>();
foreach (var s in sessions)
{
var key = FleetModelKey(s);
if (!models.TryGetValue(key, out var m))
{
m = new FleetModelHealth { Manufacturer = key.Manufacturer, Model = key.Model, Label = key.Label };
models[key] = m;
}
m.Total++;
if (s.Status == SessionStatus.Succeeded) m.Succeeded++;
else if (s.Status == SessionStatus.Failed) m.Failed++;
}
return models.Values
.OrderByDescending(m => m.Total)
.Take(6)
.ToList();
}
private static List<FleetSlowModel> BuildFleetSlowestModels(IReadOnlyList<SessionSummary> sessions)
{
var acc = new Dictionary<ModelBucket, (long TotalDuration, int Count)>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Succeeded) continue;
if (s.DurationSeconds is not int d || d <= 0) continue;
var key = FleetModelKey(s);
acc.TryGetValue(key, out var cur);
acc[key] = (cur.TotalDuration + d, cur.Count + 1);
}
return acc
.Select(kv => new FleetSlowModel
{
Manufacturer = kv.Key.Manufacturer,
Model = kv.Key.Model,
Label = kv.Key.Label,
AvgMinutes = (int)Math.Round((double)kv.Value.TotalDuration / kv.Value.Count / 60.0, MidpointRounding.AwayFromZero),
Count = kv.Value.Count,
})
.OrderByDescending(m => m.AvgMinutes)
.Take(5)
.ToList();
}
private static List<FleetFailingModel> BuildFleetTopFailingModels(IReadOnlyList<SessionSummary> sessions)
{
var acc = new Dictionary<ModelBucket, (int Failed, int Succeeded, int Total)>();
foreach (var s in sessions)
{
var key = FleetModelKey(s);
acc.TryGetValue(key, out var cur);
cur.Total++;
if (s.Status == SessionStatus.Failed) cur.Failed++;
else if (s.Status == SessionStatus.Succeeded) cur.Succeeded++;
acc[key] = cur;
}
// FailureRate over finished enrollments only (mirror of the success-rate convention);
// the Where(Failed > 0) guard also keeps the denominator non-zero.
return acc
.Where(kv => kv.Value.Failed > 0)
.Select(kv => new FleetFailingModel
{
Manufacturer = kv.Key.Manufacturer,
Model = kv.Key.Model,
Label = kv.Key.Label,
Failed = kv.Value.Failed,
Total = kv.Value.Total,
FailureRate = (int)Math.Round(
(double)kv.Value.Failed / (kv.Value.Failed + kv.Value.Succeeded) * 100, MidpointRounding.AwayFromZero),
})
.OrderByDescending(m => m.Failed)
.Take(5)
.ToList();
}
/// <summary>
/// Grouping key of the model buckets: the two session fields as stored (trimmed) plus the
/// display label "{Manufacturer} {Model}", or "Unknown" when both are blank. Grouping on the
/// pair keeps ("Contoso", "A B") and ("Contoso A", "B") apart even though their labels collide.
/// </summary>
private readonly record struct ModelBucket(string Manufacturer, string Model)
{
public string Label
{
get
{
var label = $"{Manufacturer} {Model}".Trim();
return string.IsNullOrEmpty(label) ? "Unknown" : label;
}
}
}
private static ModelBucket FleetModelKey(SessionSummary s)
=> new((s.Manufacturer ?? string.Empty).Trim(), (s.Model ?? string.Empty).Trim());
/// <summary>Share of total bytes (0-100, one decimal) not pulled from the CDN. 0 when no bytes.</summary>
private static double OffloadPercent(long offloaded, long total)
=> total > 0 ? Math.Round((double)offloaded / total * 100, 1) : 0;
/// <summary>
/// Calculates the nearest-rank percentile of an ascending-sorted value list,
/// rounded to one decimal place. Callers MUST pass values pre-sorted ascending.
/// Returns 0 for an empty list.
/// </summary>
/// <summary>Rounds a duration in seconds to whole minutes (away from zero, matching the avg).</summary>
public static int SecondsToMinutes(double seconds)
=> (int)Math.Round(seconds / 60.0, MidpointRounding.AwayFromZero);
public static double Percentile(List<double> sortedValues, int percentile)
{
if (sortedValues.Count == 0) return 0;
var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1;
index = Math.Max(0, Math.Min(index, sortedValues.Count - 1));
return Math.Round(sortedValues[index], 1);
}
/// <summary>
/// Ranks apps slowest-first by average duration, after dropping any app with fewer than
/// <paramref name="minSamples"/> successful installs. The sample floor stops a single N=1
/// install (often unfinished, or a legacy pre-clamp row) from dominating the ranking as an
/// artefact. Returns at most <paramref name="take"/> apps. Generic so both the tenant and
/// global app-metrics functions can rank their anonymous projections without duplication.
/// </summary>
public static List<T> SelectSlowestApps<T>(
IEnumerable<T> apps,
Func<T, int> succeededSelector,
Func<T, double> avgDurationSelector,
int minSamples,
int take)
{
return apps
.Where(a => succeededSelector(a) >= minSamples)
.OrderByDescending(avgDurationSelector)
.Take(take)
.ToList();
}
/// <summary>
/// Wilson score interval (95 % default) for a binomial proportion — the F3 regression
/// radar's statistical primitive (insights spec §F3: deterministic, unit-pinned). Bounds
/// are clamped to [0,1]; n = 0 yields the uninformative (0,1) so a zero-denominator
/// side can never claim separation.
/// </summary>
public static (double Lower, double Upper) WilsonInterval(int successes, int trials, double z = 1.96)
{
if (trials <= 0) return (0.0, 1.0);
var n = (double)trials;
var p = Math.Clamp((double)successes / n, 0.0, 1.0);
var z2 = z * z;
var denominator = 1.0 + z2 / n;
var center = (p + z2 / (2.0 * n)) / denominator;
var half = (z / denominator) * Math.Sqrt(p * (1.0 - p) / n + z2 / (4.0 * n * n));
return (Math.Max(0.0, center - half), Math.Min(1.0, center + half));
}
/// <summary>
/// One-sided two-proportion separation for a rate INCREASE: true when the current
/// window's Wilson lower bound lies strictly above the baseline's Wilson upper bound —
/// the intervals are disjoint in the regression direction, so the lift is statistically
/// real rather than small-n noise (insights spec §F3 detection gate 3).
/// </summary>
public static bool RateIncreaseSeparated(
int windowHits, int windowTrials, int baselineHits, int baselineTrials, double z = 1.96)
{
var window = WilsonInterval(windowHits, windowTrials, z);
var baseline = WilsonInterval(baselineHits, baselineTrials, z);
return window.Lower > baseline.Upper;
}
}
/// <summary>
/// Per-tenant session status tally. Every status maps to exactly one bucket, so the component
/// counts always reconcile to <see cref="Total"/> by construction: Pending and Stalled — which
/// were previously counted in the total but in no bucket, silently widening the gap — now have
/// their own buckets, and any unrecognised status (incl. Unknown) lands in <see cref="Other"/>.
/// </summary>
public readonly record struct SessionStatusBuckets(
int Total, int Succeeded, int Failed, int InProgress, int Pending, int Stalled,
int AwaitingUser, int Incomplete, int Other)
{
/// <summary>Returns a new tally with <paramref name="status"/> folded in.</summary>
public SessionStatusBuckets Add(string? status)
{
var total = Total + 1;
var succeeded = Succeeded + (status == "Succeeded" ? 1 : 0);
var failed = Failed + (status == "Failed" ? 1 : 0);
var inProgress = InProgress + (status == "InProgress" ? 1 : 0);
var pending = Pending + (status == "Pending" ? 1 : 0);
var stalled = Stalled + (status == "Stalled" ? 1 : 0);
// AwaitingUser (non-terminal, Device Setup done) and Incomplete (terminal, non-failure) get
// their own buckets so they no longer hide in Other and never inflate the failure count.
var awaitingUser = AwaitingUser + (status == "AwaitingUser" ? 1 : 0);
var incomplete = Incomplete + (status == "Incomplete" ? 1 : 0);
var other = total - (succeeded + failed + inProgress + pending + stalled + awaitingUser + incomplete);
return new SessionStatusBuckets(total, succeeded, failed, inProgress, pending, stalled, awaitingUser, incomplete, other);
}
}