mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
e637317bdb
Insights expansion PR1 (tasks/insights-expansion-spec.md §F1, audit §0.5): - AppInstallSummary + AppId / EspBlocking (bool?, tri-state) / AppIdCollision: sentinel-gated columns + mapper + both projections. appId adopted from app events (first-seen wins); a second appId under the same display name flags the name-keyed row as a collision (audit Q3) — in-batch and cross-batch (sticky in the reconcile). Collision rows leave every per-app fleet aggregate (app metrics payload, apps list, app analytics) with a disclosed exclusion count; the fleet DO rollup and per-app session drill-down keep them. - EspBlocking resolution at the session-terminal single-writer seam (TableSessionRepository.UpdateSessionStatusAsync, next to the counter reconcile — covers ingest, admin marks, maintenance sweep, rule engine): joins the session's app rows against the LATEST list-carrying esp_config_detected emission as a positive-evidence set (audit Q2) — listed => true, absent => stays unknown, never false. EspBlockingSets parser handles truncation via the uncapped espTracked*Count fields. - TimeAttributionCalculator (pure, AttributionVersion 1) + SessionTimeBreakdown models: exact partition of the session's authoritative DurationSeconds into device_prep / esp_apps / identity_hello / user_esp / desktop_handoff spans plus an explicit unattributed remainder — invariant sum(spans)+unattributed == wall clock enforced in every test. WhiteGlove = two observation windows in ONE session (part1 anchored on whiteglove_part1_complete, pause never attributed). Per-app blocking intervals from EVENT timestamps (first start -> LAST terminal, audit Q1 retry freeze), occupancy = merged union clipped to esp_apps. Reboot spans from event gaps around lastBootUtc (audit Q7), deduped per boot. QualityFlags: ClockSkewDropped, PartialObservation, BlockingSetUnknown, BlockingSetTruncated, WhiteGloveAnchorsIncomplete. Persistence (SessionTimeBreakdowns) and surfaces follow in PR2/PR3. Backend 3368/3368 (35 new tests: 19 calculator golden fixtures + 16 identity/blocking/aggregate-exclusion), solution build clean, web untouched (new response fields are additive). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
600 lines
29 KiB
C#
600 lines
29 KiB
C#
using System.Collections.Specialized;
|
|
using System.Threading;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Apps
|
|
{
|
|
/// <summary>
|
|
/// Shared aggregation logic for the App Dashboard endpoints.
|
|
/// Used by both per-tenant (<see cref="GetAppsListFunction"/>, etc.) and
|
|
/// global-admin (<see cref="GetGlobalAppsListFunction"/>, etc.) variants
|
|
/// so the response shape and maths stay in lockstep.
|
|
/// </summary>
|
|
internal static class AppsAnalyticsHelper
|
|
{
|
|
// ── Query param validation ──────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Validates the optional <c>?tenantId=</c> query parameter shared by all
|
|
/// three <c>global/apps/*</c> endpoints. Null / empty is allowed (means
|
|
/// "aggregate across all tenants"); any non-empty value must parse as a GUID.
|
|
/// Returns <c>true</c> when the value is acceptable; otherwise <c>false</c>
|
|
/// (caller should emit a 400).
|
|
/// </summary>
|
|
public static bool IsValidOptionalTenantIdQueryParam(string? raw)
|
|
{
|
|
if (string.IsNullOrEmpty(raw))
|
|
return true;
|
|
return Guid.TryParse(raw, out _);
|
|
}
|
|
|
|
// ── Opt-in pagination ───────────────────────────────────────────────
|
|
|
|
/// <summary>Upper bound for a single <c>apps/list</c> page when the caller opts into pagination.</summary>
|
|
public const int MaxAppsPageSize = 1000;
|
|
|
|
public readonly struct AppsPaging
|
|
{
|
|
/// <summary>Null when the caller did not pass <c>pageSize</c> (legacy full-array mode).</summary>
|
|
public int? PageSize { get; init; }
|
|
public int Skip { get; init; }
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the optional <c>?pageSize=</c> / <c>?skip=</c> pagination params. Absent
|
|
/// <c>pageSize</c> means legacy mode (return the full array). The apps list is aggregated
|
|
/// in-memory from a deterministic sort, so a plain integer offset is a stable cursor — no
|
|
/// HMAC continuation token is needed (unlike the Azure-Table-backed config/all surface).
|
|
/// </summary>
|
|
public static AppsPaging ParseAppsPaging(NameValueCollection query)
|
|
{
|
|
var pageSizeRaw = query["pageSize"];
|
|
if (string.IsNullOrEmpty(pageSizeRaw))
|
|
return new AppsPaging { PageSize = null, Skip = 0 };
|
|
|
|
if (!int.TryParse(pageSizeRaw, out var pageSize) || pageSize < 1 || pageSize > MaxAppsPageSize)
|
|
return new AppsPaging { Error = $"pageSize must be between 1 and {MaxAppsPageSize}" };
|
|
|
|
var skip = 0;
|
|
var skipRaw = query["skip"];
|
|
if (!string.IsNullOrEmpty(skipRaw) && (!int.TryParse(skipRaw, out skip) || skip < 0))
|
|
return new AppsPaging { Error = "skip must be a non-negative integer" };
|
|
|
|
return new AppsPaging { PageSize = pageSize, Skip = skip };
|
|
}
|
|
|
|
// ── Data loaders ────────────────────────────────────────────────────
|
|
|
|
/// <summary>Caps concurrent session point-reads in the device-model join so a wide app doesn't fan out hundreds of simultaneous Table reads.</summary>
|
|
private const int SessionJoinConcurrency = 10;
|
|
|
|
/// <summary>
|
|
/// Loads app install summaries for the given scope, scoped server-side to the last
|
|
/// <paramref name="days"/> days via a <c>StartedAt ge</c> filter (so a days=30 view does not
|
|
/// dematerialize the full StartedAt history). The cutoff is derived just before the in-memory
|
|
/// cutoff the Build* methods re-apply, so the server filter is never narrower than the in-memory
|
|
/// one — at worst it returns a few extra boundary rows that the in-memory filter trims.
|
|
/// - tenantId != null → tenant-scoped (per-tenant endpoint or global admin viewing one tenant)
|
|
/// - tenantId == null → all tenants (global admin aggregated view)
|
|
/// </summary>
|
|
public static Task<List<AppInstallSummary>> LoadSummariesAsync(
|
|
IMetricsRepository repo, string? tenantId, int days)
|
|
{
|
|
var sinceUtc = DateTime.UtcNow.AddDays(-days);
|
|
// Column-projected to what the Build* aggregations actually read — the DO telemetry
|
|
// block on the wide row is dashboard-irrelevant transfer (see AppsDashboardProjection).
|
|
return repo.GetAppsDashboardSummariesAsync(sinceUtc, string.IsNullOrEmpty(tenantId) ? null : tenantId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the (TenantId, SessionId) → SessionSummary lookup used by the device-model join.
|
|
/// Point-reads run with bounded concurrency (<see cref="SessionJoinConcurrency"/>) rather than the
|
|
/// previous serial await-in-loop, which cost one sequential round-trip per distinct session.
|
|
/// Keys with an empty tenant or session id are skipped; misses (deleted session) are simply absent.
|
|
/// </summary>
|
|
private static async Task<Dictionary<string, SessionSummary>> LoadSessionLookupAsync(
|
|
ISessionRepository sessionRepo, IEnumerable<(string TenantId, string SessionId)> keys)
|
|
{
|
|
var distinct = keys
|
|
.Where(k => !string.IsNullOrEmpty(k.TenantId) && !string.IsNullOrEmpty(k.SessionId))
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
using var gate = new SemaphoreSlim(SessionJoinConcurrency);
|
|
var tasks = distinct.Select(async key =>
|
|
{
|
|
await gate.WaitAsync();
|
|
try { return (key, sess: await sessionRepo.GetSessionAsync(key.TenantId, key.SessionId)); }
|
|
finally { gate.Release(); }
|
|
});
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
var lookup = new Dictionary<string, SessionSummary>();
|
|
foreach (var (key, sess) in results)
|
|
if (sess != null) lookup[$"{key.TenantId}|{key.SessionId}"] = sess;
|
|
return lookup;
|
|
}
|
|
|
|
// ── /apps/list ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds the list response body (everything except HTTP wrapping). When <paramref name="pageSize"/>
|
|
/// is null the legacy full-array body is returned (the web UI paginates client-side); when set, an
|
|
/// offset-paginated envelope is returned with <c>count</c>/<c>offset</c>/<c>pageSize</c>/<c>nextLink</c>
|
|
/// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the
|
|
/// route-specific nextLink for the next offset; it is only invoked when more pages remain.
|
|
/// </summary>
|
|
public static object BuildAppsListResponse(
|
|
List<AppInstallSummary> allSummaries,
|
|
int days,
|
|
int? pageSize = null,
|
|
int skip = 0,
|
|
Func<int, string>? nextLinkForOffset = null)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var cutoff = now.AddDays(-days);
|
|
var midpoint = now.AddDays(-days / 2.0);
|
|
|
|
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
|
|
|
|
// F1 PR1 (audit Q3): appId-collision rows merge two distinct apps under one name —
|
|
// they are excluded from every per-app group and disclosed via collisionExcluded.
|
|
var collisionExcluded = summaries.Count(s => s.AppIdCollision);
|
|
|
|
var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
|
|
{
|
|
// PR0 classification (see MetricsMath.IsSkipTerminalState / HasMeasuredDuration):
|
|
// skips leave the rate + durations; duration stats read measured rows only.
|
|
var total = g.Count();
|
|
var succeededAll = g.Where(s => s.Status == "Succeeded").ToList();
|
|
var skipped = succeededAll.Count(MetricsMath.IsSkipTerminalState);
|
|
var installed = succeededAll.Where(s => !MetricsMath.IsSkipTerminalState(s)).ToList();
|
|
var measured = installed.Where(MetricsMath.HasMeasuredDuration).ToList();
|
|
var failed = g.Count(s => s.Status == "Failed");
|
|
var failureRate = MetricsMath.TerminalFailureRatePct(failed, installed.Count);
|
|
|
|
var firstHalf = g.Where(s => s.StartedAt < midpoint).ToList();
|
|
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
|
|
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
|
|
|
|
return new
|
|
{
|
|
appName = g.Key,
|
|
appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
|
totalInstalls = total,
|
|
succeeded = installed.Count,
|
|
skipped,
|
|
unmeasured = installed.Count - measured.Count,
|
|
failed,
|
|
failureRate,
|
|
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
|
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
|
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
|
trend,
|
|
trendDelta,
|
|
lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
|
|
};
|
|
})
|
|
.OrderByDescending(a => a.failed)
|
|
.ThenByDescending(a => a.failureRate)
|
|
.ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
|
.ToList();
|
|
|
|
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
|
|
if (pageSize == null)
|
|
{
|
|
return new
|
|
{
|
|
success = true,
|
|
totalApps = apps.Count,
|
|
totalInstalls = summaries.Count,
|
|
collisionExcluded,
|
|
windowDays = days,
|
|
apps
|
|
};
|
|
}
|
|
|
|
// Opt-in pagination: offset-based slice over the deterministically sorted list.
|
|
var offset = skip < 0 ? 0 : skip;
|
|
var page = apps.Skip(offset).Take(pageSize.Value).ToList();
|
|
var nextOffset = offset + page.Count;
|
|
var hasMore = nextOffset < apps.Count;
|
|
|
|
return new
|
|
{
|
|
success = true,
|
|
totalApps = apps.Count,
|
|
totalInstalls = summaries.Count,
|
|
collisionExcluded,
|
|
windowDays = days,
|
|
count = page.Count,
|
|
offset,
|
|
pageSize = pageSize.Value,
|
|
apps = page,
|
|
nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
|
|
};
|
|
}
|
|
|
|
// ── /apps/{appName}/analytics ───────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds the analytics response body for a single app.
|
|
/// Loads sessions individually via the session repository for the device-model join.
|
|
/// </summary>
|
|
public static async Task<object> BuildAnalyticsResponseAsync(
|
|
List<AppInstallSummary> allSummaries,
|
|
ISessionRepository sessionRepo,
|
|
string appName,
|
|
int days)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var cutoff = now.AddDays(-days);
|
|
var midpoint = now.AddDays(-days / 2.0);
|
|
|
|
var inWindow = allSummaries
|
|
.Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
|
|
&& s.StartedAt >= cutoff)
|
|
.ToList();
|
|
|
|
// F1 PR1 (audit Q3): collision rows under this name mix in a second app's outcomes —
|
|
// excluded from every stat below, disclosed via collisionExcluded.
|
|
var collisionExcluded = inWindow.Count(s => s.AppIdCollision);
|
|
var summaries = inWindow.Where(s => !s.AppIdCollision).ToList();
|
|
|
|
if (summaries.Count == 0)
|
|
{
|
|
return new
|
|
{
|
|
success = true,
|
|
appName,
|
|
appType = string.Empty,
|
|
windowDays = days,
|
|
collisionExcluded,
|
|
bucket = "day",
|
|
summary = new
|
|
{
|
|
totalInstalls = 0,
|
|
succeeded = 0,
|
|
skipped = 0,
|
|
unmeasured = 0,
|
|
failed = 0,
|
|
failureRate = 0,
|
|
avgDurationSeconds = 0,
|
|
p95DurationSeconds = 0,
|
|
avgDownloadBytes = 0,
|
|
trend = "stable",
|
|
trendDelta = (double?)null,
|
|
flakinessScore = 0.0
|
|
},
|
|
timeSeries = Array.Empty<object>(),
|
|
versionBreakdown = Array.Empty<object>(),
|
|
installerPhaseBreakdown = Array.Empty<object>(),
|
|
topFailureCodes = Array.Empty<object>(),
|
|
detectionLiesCount = 0,
|
|
deviceModelBreakdown = Array.Empty<object>()
|
|
};
|
|
}
|
|
|
|
var total = summaries.Count;
|
|
// PR0 classification — skips leave rate + durations; durations read measured rows only.
|
|
var succeededAll = summaries.Where(s => s.Status == "Succeeded").ToList();
|
|
var skipped = succeededAll.Count(MetricsMath.IsSkipTerminalState);
|
|
var installed = succeededAll.Where(s => !MetricsMath.IsSkipTerminalState(s)).ToList();
|
|
var measured = installed.Where(MetricsMath.HasMeasuredDuration).ToList();
|
|
var succeeded = installed.Count;
|
|
var failed = summaries.Count(s => s.Status == "Failed");
|
|
var failureRate = MetricsMath.TerminalFailureRatePct(failed, succeeded);
|
|
var avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0;
|
|
var p95DurationSeconds = Percentile(measured.Select(s => s.DurationSeconds).ToList(), 0.95);
|
|
var avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0;
|
|
|
|
// Trend (same rule as list endpoint).
|
|
var firstHalf = summaries.Where(s => s.StartedAt < midpoint).ToList();
|
|
var secondHalf = summaries.Where(s => s.StartedAt >= midpoint).ToList();
|
|
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
|
|
|
|
var flakinessScore = total > 0
|
|
? Math.Round((double)summaries.Count(s => s.AttemptNumber > 1) / total, 3)
|
|
: 0.0;
|
|
|
|
var bucket = days <= 30 ? "day" : "week";
|
|
var timeSeries = BuildTimeSeries(summaries, cutoff, now, bucket);
|
|
|
|
var versionBreakdown = summaries
|
|
.Where(s => !string.IsNullOrEmpty(s.AppVersion))
|
|
.GroupBy(s => s.AppVersion)
|
|
.Select(g =>
|
|
{
|
|
var vTotal = g.Count();
|
|
var vFailed = g.Count(s => s.Status == "Failed");
|
|
// Same PR0 convention as the top-level rate: skips are not attempts.
|
|
var vSucceeded = g.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
|
|
return new
|
|
{
|
|
appVersion = g.Key,
|
|
installs = vTotal,
|
|
failed = vFailed,
|
|
failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded)
|
|
};
|
|
})
|
|
.OrderByDescending(v => v.installs)
|
|
.ToList();
|
|
|
|
var installerPhaseBreakdown = summaries
|
|
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase))
|
|
.GroupBy(s => s.InstallerPhase)
|
|
.Select(g => new { phase = g.Key, failed = g.Count() })
|
|
.OrderByDescending(p => p.failed)
|
|
.ToList();
|
|
|
|
var topFailureCodes = summaries
|
|
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
|
|
.GroupBy(s => s.FailureCode)
|
|
.Select(g => new
|
|
{
|
|
code = g.Key,
|
|
exitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
|
|
count = g.Count(),
|
|
sampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
|
|
})
|
|
.OrderByDescending(f => f.count)
|
|
.Take(5)
|
|
.ToList();
|
|
|
|
var detectionLiesCount = summaries.Count(s =>
|
|
s.Status == "Succeeded" &&
|
|
string.Equals(s.DetectionResult, "NotDetected", StringComparison.OrdinalIgnoreCase));
|
|
|
|
// Device-model correlation: join via session lookup.
|
|
// Global-admin (no tenant filter) summaries may span multiple tenants, so we use
|
|
// each summary's own TenantId for the lookup instead of a single passed-in tenantId.
|
|
var sessionLookup = await LoadSessionLookupAsync(
|
|
sessionRepo, summaries.Select(s => (s.TenantId, s.SessionId)));
|
|
|
|
var deviceModelBreakdown = summaries
|
|
.Where(s => sessionLookup.ContainsKey($"{s.TenantId}|{s.SessionId}"))
|
|
.Select(s => new
|
|
{
|
|
Summary = s,
|
|
Manufacturer = sessionLookup[$"{s.TenantId}|{s.SessionId}"].Manufacturer ?? "Unknown",
|
|
Model = sessionLookup[$"{s.TenantId}|{s.SessionId}"].Model ?? "Unknown"
|
|
})
|
|
.GroupBy(x => new { x.Manufacturer, x.Model })
|
|
// Sample floor on FINISHED installs — the displayed metric is the terminal-only
|
|
// failure rate, so a model with many in-flight installs but few outcomes is still
|
|
// too noisy to rank.
|
|
.Where(g => g.Count(x => x.Summary.Status == "Failed" || x.Summary.Status == "Succeeded") >= 5)
|
|
.Select(g =>
|
|
{
|
|
var modelTotal = g.Count();
|
|
var modelFailed = g.Count(x => x.Summary.Status == "Failed");
|
|
// PR0 convention: skips are not attempts (same population as the headline rate).
|
|
var modelSucceeded = g.Count(x => x.Summary.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(x.Summary));
|
|
var modelFailureRate = MetricsMath.TerminalFailureRatePct(modelFailed, modelSucceeded);
|
|
var lift = failureRate > 0
|
|
? Math.Round(modelFailureRate / failureRate, 2)
|
|
: 0;
|
|
return new
|
|
{
|
|
manufacturer = g.Key.Manufacturer,
|
|
model = g.Key.Model,
|
|
installs = modelTotal,
|
|
failed = modelFailed,
|
|
failureRate = modelFailureRate,
|
|
liftVsBaseline = lift
|
|
};
|
|
})
|
|
.OrderByDescending(m => m.liftVsBaseline)
|
|
.ToList();
|
|
|
|
var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty;
|
|
|
|
return new
|
|
{
|
|
success = true,
|
|
appName,
|
|
appType,
|
|
windowDays = days,
|
|
collisionExcluded,
|
|
bucket,
|
|
summary = new
|
|
{
|
|
totalInstalls = total,
|
|
succeeded,
|
|
skipped,
|
|
unmeasured = installed.Count - measured.Count,
|
|
failed,
|
|
failureRate,
|
|
avgDurationSeconds,
|
|
p95DurationSeconds,
|
|
avgDownloadBytes,
|
|
trend,
|
|
trendDelta,
|
|
flakinessScore
|
|
},
|
|
timeSeries,
|
|
versionBreakdown,
|
|
installerPhaseBreakdown,
|
|
topFailureCodes,
|
|
detectionLiesCount,
|
|
deviceModelBreakdown
|
|
};
|
|
}
|
|
|
|
// ── /apps/{appName}/sessions ────────────────────────────────────────
|
|
|
|
public static async Task<object> BuildSessionsResponseAsync(
|
|
List<AppInstallSummary> allSummaries,
|
|
ISessionRepository sessionRepo,
|
|
string appName,
|
|
int days,
|
|
string statusFilter,
|
|
string? modelFilter,
|
|
string? versionFilter,
|
|
int offset,
|
|
int limit)
|
|
{
|
|
var cutoff = DateTime.UtcNow.AddDays(-days);
|
|
|
|
var summaries = allSummaries
|
|
.Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
|
|
&& s.StartedAt >= cutoff)
|
|
.ToList();
|
|
|
|
if (statusFilter == "failed")
|
|
summaries = summaries.Where(s => s.Status == "Failed").ToList();
|
|
else if (statusFilter == "succeeded")
|
|
summaries = summaries.Where(s => s.Status == "Succeeded").ToList();
|
|
|
|
if (!string.IsNullOrWhiteSpace(versionFilter))
|
|
summaries = summaries
|
|
.Where(s => string.Equals(s.AppVersion, versionFilter, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
// Batch-fetch sessions for device info. Uses each summary's TenantId so global-admin
|
|
// aggregated view works correctly across tenants.
|
|
var sessionLookup = await LoadSessionLookupAsync(
|
|
sessionRepo, summaries.Select(s => (s.TenantId, s.SessionId)));
|
|
|
|
if (!string.IsNullOrWhiteSpace(modelFilter))
|
|
{
|
|
summaries = summaries
|
|
.Where(s => sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess)
|
|
&& string.Equals(sess.Model, modelFilter, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
}
|
|
|
|
var ordered = summaries
|
|
.OrderBy(s => s.Status == "Failed" ? 0 : s.Status == "InProgress" ? 1 : 2)
|
|
.ThenByDescending(s => s.StartedAt)
|
|
.ToList();
|
|
|
|
var total = ordered.Count;
|
|
var page = ordered.Skip(offset).Take(limit).ToList();
|
|
|
|
var items = page.Select(s =>
|
|
{
|
|
sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess);
|
|
return new
|
|
{
|
|
sessionId = s.SessionId,
|
|
tenantId = s.TenantId,
|
|
deviceName = sess?.DeviceName ?? string.Empty,
|
|
manufacturer = sess?.Manufacturer ?? string.Empty,
|
|
model = sess?.Model ?? string.Empty,
|
|
appVersion = s.AppVersion,
|
|
status = s.Status,
|
|
installerPhase = s.InstallerPhase,
|
|
failureCode = s.FailureCode,
|
|
exitCode = s.ExitCode,
|
|
attemptNumber = s.AttemptNumber,
|
|
startedAt = s.StartedAt,
|
|
durationSeconds = s.DurationSeconds
|
|
};
|
|
}).ToList();
|
|
|
|
return new
|
|
{
|
|
success = true,
|
|
total,
|
|
offset,
|
|
limit,
|
|
items
|
|
};
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Failure-rate trend between the two halves of the window, shared by the list and
|
|
/// analytics endpoints. Rates follow the terminal-only convention
|
|
/// (<see cref="MetricsMath.TerminalFailureRatePct"/>), and a non-stable trend requires
|
|
/// at least 5 FINISHED installs in BOTH halves — too noisy otherwise, and a half made
|
|
/// up of in-flight installs has no outcome to trend on.
|
|
/// </summary>
|
|
private static (string Trend, double? TrendDelta) ComputeFailureTrend(
|
|
List<AppInstallSummary> firstHalf, List<AppInstallSummary> secondHalf)
|
|
{
|
|
// PR0 convention: skips are not attempts — keep the trend on the same population
|
|
// as the headline failure rate, or a wave of not-applicable update policies would
|
|
// fake an "improving" trend.
|
|
var fhFailed = firstHalf.Count(s => s.Status == "Failed");
|
|
var fhSucceeded = firstHalf.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
|
|
var shFailed = secondHalf.Count(s => s.Status == "Failed");
|
|
var shSucceeded = secondHalf.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
|
|
if (fhFailed + fhSucceeded < 5 || shFailed + shSucceeded < 5)
|
|
return ("stable", null);
|
|
|
|
var delta = Math.Round(
|
|
MetricsMath.TerminalFailureRatePct(shFailed, shSucceeded)
|
|
- MetricsMath.TerminalFailureRatePct(fhFailed, fhSucceeded), 1);
|
|
var trend = delta < -1 ? "improving" : delta > 1 ? "worsening" : "stable";
|
|
return (trend, delta);
|
|
}
|
|
|
|
private static List<object> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
|
|
{
|
|
var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date;
|
|
var end = now.Date;
|
|
|
|
var bucketed = new Dictionary<DateTime, List<AppInstallSummary>>();
|
|
var cursor = start;
|
|
while (cursor <= end)
|
|
{
|
|
bucketed[cursor] = new List<AppInstallSummary>();
|
|
cursor = bucket == "week" ? cursor.AddDays(7) : cursor.AddDays(1);
|
|
}
|
|
|
|
foreach (var s in summaries)
|
|
{
|
|
var key = bucket == "week" ? StartOfWeek(s.StartedAt) : s.StartedAt.Date;
|
|
if (bucketed.ContainsKey(key))
|
|
bucketed[key].Add(s);
|
|
}
|
|
|
|
return bucketed
|
|
.OrderBy(kv => kv.Key)
|
|
.Select(kv =>
|
|
{
|
|
var items = kv.Value;
|
|
var bTotal = items.Count;
|
|
var bFailed = items.Count(s => s.Status == "Failed");
|
|
// PR0 convention: skips are not attempts; durations read measured rows only.
|
|
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
|
|
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
|
|
return (object)new
|
|
{
|
|
bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
|
|
installs = bTotal,
|
|
succeeded = bInstalled.Count,
|
|
failed = bFailed,
|
|
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
|
|
avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static DateTime StartOfWeek(DateTime dt)
|
|
{
|
|
var date = dt.Date;
|
|
int diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
|
return date.AddDays(-diff);
|
|
}
|
|
|
|
private static int Percentile(List<int> values, double percentile)
|
|
{
|
|
if (values.Count == 0) return 0;
|
|
var sorted = values.OrderBy(v => v).ToList();
|
|
var rank = (int)Math.Ceiling(percentile * sorted.Count) - 1;
|
|
if (rank < 0) rank = 0;
|
|
if (rank >= sorted.Count) rank = sorted.Count - 1;
|
|
return sorted[rank];
|
|
}
|
|
}
|
|
}
|