geo report drill down has now DO usage stats as well

This commit is contained in:
Oliver Kieselbach
2026-04-15 00:52:44 +02:00
parent a3d37c67ac
commit 66587b76d3
8 changed files with 362 additions and 22 deletions
@@ -1 +1 @@
980
982
@@ -0,0 +1,111 @@
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Regression guard: the geographic drilldown must surface per-session DO telemetry
/// so operators can troubleshoot which devices contributed to a region's DO % badge.
/// Formula must stay consistent with GetGeographicMetricsFunction (peers / total * 100,
/// preferring DoTotalBytesDownloaded as denominator, falling back to peers+http).
/// </summary>
public class GeographicLocationSessionsDoTests
{
private static AppInstallSummary App(string sessionId, int downloadMode, long peers, long http, long total,
long lan = 0, long group = 0, long internetPeers = 0)
{
return new AppInstallSummary
{
SessionId = sessionId,
DoDownloadMode = downloadMode,
DoBytesFromPeers = peers,
DoBytesFromHttp = http,
DoTotalBytesDownloaded = total,
DoBytesFromLanPeers = lan,
DoBytesFromGroupPeers = group,
DoBytesFromInternetPeers = internetPeers,
};
}
private static SessionSummary Session(string id) => new SessionSummary { SessionId = id };
[Fact]
public void BuildRows_SessionWithDoAndNonDoApps_ComputesWeightedPercent()
{
var sessions = new List<SessionSummary> { Session("S1") };
var apps = new List<AppInstallSummary>
{
App("S1", downloadMode: 0, peers: 50_000_000, http: 50_000_000, total: 100_000_000),
App("S1", downloadMode: 1, peers: 0, http: 100_000_000, total: 100_000_000),
App("S1", downloadMode: -1, peers: 0, http: 0, total: 0), // no DO telemetry
};
var rows = GetGeographicLocationSessionsFunction.BuildRows(sessions, apps);
Assert.Single(rows);
var row = rows[0];
Assert.True(row.HasDoTelemetry);
Assert.Equal(2, row.DoAppCount);
Assert.Equal(3, row.TotalAppCount);
// 50MB peers / 200MB total = 25%
Assert.Equal(25.0, row.DoPercentPeerCaching);
Assert.Equal(50_000_000, row.DoBytesFromPeers);
Assert.Equal(150_000_000, row.DoBytesFromHttp);
Assert.Equal(200_000_000, row.DoTotalBytesDownloaded);
}
[Fact]
public void BuildRows_SessionWithoutAppData_MarkedNoTelemetry()
{
var rows = GetGeographicLocationSessionsFunction.BuildRows(
new List<SessionSummary> { Session("S2") },
new List<AppInstallSummary>());
Assert.Single(rows);
Assert.False(rows[0].HasDoTelemetry);
Assert.Equal(0, rows[0].DoAppCount);
Assert.Equal(0, rows[0].TotalAppCount);
Assert.Equal(0, rows[0].DoPercentPeerCaching);
}
[Fact]
public void BuildRows_FallsBackToPeersPlusHttp_WhenTotalBytesMissing()
{
// Legacy records may not populate DoTotalBytesDownloaded — must fall back
// to peers + http so the denominator isn't zero and the % is still meaningful.
var apps = new List<AppInstallSummary>
{
App("S3", downloadMode: 0, peers: 40_000_000, http: 60_000_000, total: 0),
};
var rows = GetGeographicLocationSessionsFunction.BuildRows(
new List<SessionSummary> { Session("S3") }, apps);
Assert.True(rows[0].HasDoTelemetry);
Assert.Equal(40.0, rows[0].DoPercentPeerCaching);
Assert.Equal(100_000_000, rows[0].DoTotalBytesDownloaded);
}
[Fact]
public void BuildRows_MultipleSessions_AggregatesMatchRegionFormula()
{
// Invariant: sum(session peers) / sum(session total bytes) across the drilldown
// must equal the region-level AvgDoPercentPeerCaching computed by
// GetGeographicMetricsFunction from the same app summaries.
var sessions = new List<SessionSummary> { Session("A"), Session("B"), Session("C") };
var apps = new List<AppInstallSummary>
{
App("A", 0, peers: 30_000_000, http: 70_000_000, total: 100_000_000),
App("B", 0, peers: 80_000_000, http: 20_000_000, total: 100_000_000),
App("C", -1, 0, 0, 0),
};
var rows = GetGeographicLocationSessionsFunction.BuildRows(sessions, apps);
var withDo = rows.Where(r => r.HasDoTelemetry).ToList();
var totalPeers = withDo.Sum(r => r.DoBytesFromPeers);
var totalBytes = withDo.Sum(r => r.DoTotalBytesDownloaded);
var weighted = totalBytes > 0 ? (double)totalPeers / totalBytes * 100 : 0;
Assert.Equal(2, withDo.Count);
Assert.Equal(55.0, weighted, precision: 6);
}
}
@@ -12,11 +12,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
private readonly ILogger<GetGeographicLocationSessionsFunction> _logger;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly IMetricsRepository _metricsRepo;
public GetGeographicLocationSessionsFunction(ILogger<GetGeographicLocationSessionsFunction> logger, IMaintenanceRepository maintenanceRepo)
public GetGeographicLocationSessionsFunction(
ILogger<GetGeographicLocationSessionsFunction> logger,
IMaintenanceRepository maintenanceRepo,
IMetricsRepository metricsRepo)
{
_logger = logger;
_maintenanceRepo = maintenanceRepo;
_metricsRepo = metricsRepo;
}
[Function("GetGeographicLocationSessions")]
@@ -49,11 +54,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId);
var filtered = FilterSessionsByLocation(sessions, locationKey, groupBy);
var appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
var rows = BuildRows(filtered, appSummaries);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, sessions = filtered, totalCount = filtered.Count });
await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count });
return response;
}
catch (Exception ex)
@@ -73,5 +80,22 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
.OrderByDescending(s => s.StartedAt)
.ToList();
}
internal static List<LocationSessionRow> BuildRows(
List<SessionSummary> sessions, List<AppInstallSummary> appSummaries)
{
var appsBySession = appSummaries
.GroupBy(a => a.SessionId)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var rows = new List<LocationSessionRow>(sessions.Count);
foreach (var s in sessions)
{
var apps = appsBySession.TryGetValue(s.SessionId, out var list) ? list : new List<AppInstallSummary>();
var agg = DoAggregator.Compute(apps);
rows.Add(LocationSessionRow.From(s, agg, apps.Count));
}
return rows;
}
}
}
@@ -123,17 +123,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
.Where(s => appsBySession.ContainsKey(s.SessionId))
.SelectMany(s => appsBySession[s.SessionId])
.ToList();
var doApps = allLocationApps.Where(a => a.DoDownloadMode >= 0).ToList();
var doAgg = DoAggregator.Compute(allLocationApps);
var doSessionCount = group
.Where(s => appsBySession.ContainsKey(s.SessionId))
.Count(s => appsBySession[s.SessionId].Any(a => a.DoDownloadMode >= 0));
var totalDoPeers = doApps.Sum(a => a.DoBytesFromPeers);
var totalDoHttp = doApps.Sum(a => a.DoBytesFromHttp);
// Use DoTotalBytesDownloaded as ground truth denominator (accounts for all sources);
// fall back to peers+http sum for old records without the field
var totalDoDownloaded = doApps.Sum(a => a.DoTotalBytesDownloaded);
var totalDoBytes = totalDoDownloaded > 0 ? totalDoDownloaded : (totalDoPeers + totalDoHttp);
var avgDoPct = totalDoBytes > 0 ? (double)totalDoPeers / totalDoBytes * 100 : 0;
locations.Add(new LocationMetrics
{
@@ -155,12 +148,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
TotalDownloadBytes = totalBytes,
// Delivery Optimization
DoSessionCount = doSessionCount,
AvgDoPercentPeerCaching = Math.Round(avgDoPct, 1),
TotalDoBytesFromPeers = totalDoPeers,
TotalDoBytesFromHttp = totalDoHttp,
TotalDoBytesFromLanPeers = doApps.Sum(a => a.DoBytesFromLanPeers),
TotalDoBytesFromGroupPeers = doApps.Sum(a => a.DoBytesFromGroupPeers),
TotalDoBytesFromInternetPeers = doApps.Sum(a => a.DoBytesFromInternetPeers)
AvgDoPercentPeerCaching = Math.Round(doAgg.PercentPeerCaching, 1),
TotalDoBytesFromPeers = doAgg.BytesFromPeers,
TotalDoBytesFromHttp = doAgg.BytesFromHttp,
TotalDoBytesFromLanPeers = doAgg.BytesFromLanPeers,
TotalDoBytesFromGroupPeers = doAgg.BytesFromGroupPeers,
TotalDoBytesFromInternetPeers = doAgg.BytesFromInternetPeers
});
}
@@ -11,13 +11,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
private readonly ILogger<GetGlobalGeographicLocationSessionsFunction> _logger;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly IMetricsRepository _metricsRepo;
public GetGlobalGeographicLocationSessionsFunction(
ILogger<GetGlobalGeographicLocationSessionsFunction> logger,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
IMetricsRepository metricsRepo)
{
_logger = logger;
_maintenanceRepo = maintenanceRepo;
_metricsRepo = metricsRepo;
}
[Function("GetGlobalGeographicLocationSessions")]
@@ -50,11 +53,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1));
var filtered = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy);
var appSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync();
var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, appSummaries);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, sessions = filtered, totalCount = filtered.Count });
await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count });
return response;
}
catch (Exception ex)
@@ -75,6 +75,86 @@ namespace AutopilotMonitor.Shared.Models
public long TotalDoBytesFromInternetPeers { get; set; }
}
/// <summary>
/// Session row returned by the geographic drilldown endpoint. Extends <see cref="SessionSummary"/>
/// with per-session Delivery Optimization aggregates so the user can troubleshoot DO usage
/// without leaving the drilldown view.
/// </summary>
public class LocationSessionRow : SessionSummary
{
/// <summary>True if any app in this session has DO telemetry (DoDownloadMode &gt;= 0).</summary>
public bool HasDoTelemetry { get; set; }
/// <summary>Number of apps in this session that have DO telemetry.</summary>
public int DoAppCount { get; set; }
/// <summary>Total number of app install summaries recorded for this session.</summary>
public int TotalAppCount { get; set; }
/// <summary>Weighted peer-caching percentage for this session (0-100).</summary>
public double DoPercentPeerCaching { get; set; }
public long DoBytesFromPeers { get; set; }
public long DoBytesFromHttp { get; set; }
public long DoTotalBytesDownloaded { get; set; }
public long DoBytesFromLanPeers { get; set; }
public long DoBytesFromGroupPeers { get; set; }
public long DoBytesFromInternetPeers { get; set; }
public static LocationSessionRow From(SessionSummary s, DoAggregator.DoAggregate a, int totalAppCount)
{
return new LocationSessionRow
{
// SessionSummary fields
SessionId = s.SessionId,
TenantId = s.TenantId,
SerialNumber = s.SerialNumber,
DeviceName = s.DeviceName,
Manufacturer = s.Manufacturer,
Model = s.Model,
StartedAt = s.StartedAt,
CompletedAt = s.CompletedAt,
CurrentPhase = s.CurrentPhase,
CurrentPhaseDetail = s.CurrentPhaseDetail,
Status = s.Status,
FailureReason = s.FailureReason,
FailureSource = s.FailureSource,
EventCount = s.EventCount,
DurationSeconds = s.DurationSeconds,
EnrollmentType = s.EnrollmentType,
DiagnosticsBlobName = s.DiagnosticsBlobName,
LastEventAt = s.LastEventAt,
IsPreProvisioned = s.IsPreProvisioned,
ResumedAt = s.ResumedAt,
StalledAt = s.StalledAt,
IsHybridJoin = s.IsHybridJoin,
OsName = s.OsName,
OsBuild = s.OsBuild,
OsDisplayVersion = s.OsDisplayVersion,
OsEdition = s.OsEdition,
OsLanguage = s.OsLanguage,
IsUserDriven = s.IsUserDriven,
AgentVersion = s.AgentVersion,
ImeAgentVersion = s.ImeAgentVersion,
GeoCountry = s.GeoCountry,
GeoRegion = s.GeoRegion,
GeoCity = s.GeoCity,
GeoLoc = s.GeoLoc,
PlatformScriptCount = s.PlatformScriptCount,
RemediationScriptCount = s.RemediationScriptCount,
PendingActionsJson = s.PendingActionsJson,
PendingActionsQueuedAt = s.PendingActionsQueuedAt,
// DO aggregate
HasDoTelemetry = a.HasTelemetry,
DoAppCount = a.DoAppCount,
TotalAppCount = totalAppCount,
DoPercentPeerCaching = System.Math.Round(a.PercentPeerCaching, 1),
DoBytesFromPeers = a.BytesFromPeers,
DoBytesFromHttp = a.BytesFromHttp,
DoTotalBytesDownloaded = a.TotalBytesDownloaded,
DoBytesFromLanPeers = a.BytesFromLanPeers,
DoBytesFromGroupPeers = a.BytesFromGroupPeers,
DoBytesFromInternetPeers = a.BytesFromInternetPeers,
};
}
}
/// <summary>
/// Global average benchmarks for geographic comparison.
/// </summary>
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Computes Delivery Optimization peer-caching aggregates from a collection of
/// <see cref="AppInstallSummary"/> records. Single source of truth shared between
/// the region-level aggregate (GetGeographicMetricsFunction) and the per-session
/// aggregate surfaced by the drilldown endpoint.
/// </summary>
public static class DoAggregator
{
public class DoAggregate
{
public int DoAppCount { get; set; }
public long BytesFromPeers { get; set; }
public long BytesFromHttp { get; set; }
public long TotalBytesDownloaded { get; set; }
public long BytesFromLanPeers { get; set; }
public long BytesFromGroupPeers { get; set; }
public long BytesFromInternetPeers { get; set; }
public bool HasTelemetry => DoAppCount > 0;
public double PercentPeerCaching => TotalBytesDownloaded > 0
? (double)BytesFromPeers / TotalBytesDownloaded * 100
: 0;
}
public static DoAggregate Compute(IEnumerable<AppInstallSummary> apps)
{
var doApps = apps.Where(a => a.DoDownloadMode >= 0).ToList();
var peers = doApps.Sum(a => a.DoBytesFromPeers);
var http = doApps.Sum(a => a.DoBytesFromHttp);
var downloaded = doApps.Sum(a => a.DoTotalBytesDownloaded);
return new DoAggregate
{
DoAppCount = doApps.Count,
BytesFromPeers = peers,
BytesFromHttp = http,
TotalBytesDownloaded = downloaded > 0 ? downloaded : (peers + http),
BytesFromLanPeers = doApps.Sum(a => a.DoBytesFromLanPeers),
BytesFromGroupPeers = doApps.Sum(a => a.DoBytesFromGroupPeers),
BytesFromInternetPeers = doApps.Sum(a => a.DoBytesFromInternetPeers),
};
}
}
}
@@ -22,8 +22,26 @@ interface SessionSummary {
failureReason: string | null;
durationSeconds: number | null;
enrollmentType: string;
// Per-session Delivery Optimization aggregate (added by geographic drilldown endpoint)
hasDoTelemetry?: boolean;
doAppCount?: number;
totalAppCount?: number;
doPercentPeerCaching?: number;
doBytesFromPeers?: number;
doBytesFromHttp?: number;
doTotalBytesDownloaded?: number;
doBytesFromLanPeers?: number;
doBytesFromGroupPeers?: number;
doBytesFromInternetPeers?: number;
}
const formatBytes = (bytes: number) => {
if (!bytes || bytes <= 0) return "—";
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
};
interface LocationSessionsResponse {
success: boolean;
sessions: SessionSummary[];
@@ -139,6 +157,14 @@ function LocationSessionsContent() {
.map((s) => s.durationSeconds!);
return durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length / 60) : 0;
})(),
// Weighted peer-caching % across sessions with DO telemetry (matches region aggregate formula)
doSessions: data.sessions.filter((s) => s.hasDoTelemetry).length,
avgDoPercent: (() => {
const doSessions = data.sessions.filter((s) => s.hasDoTelemetry);
const totalPeers = doSessions.reduce((a, s) => a + (s.doBytesFromPeers ?? 0), 0);
const totalBytes = doSessions.reduce((a, s) => a + (s.doTotalBytesDownloaded ?? 0), 0);
return totalBytes > 0 ? Math.round((totalPeers / totalBytes) * 1000) / 10 : 0;
})(),
}
: null;
@@ -191,7 +217,7 @@ function LocationSessionsContent() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Sessions</div>
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
@@ -219,6 +245,20 @@ function LocationSessionsContent() {
</div>
<div className="text-xs text-gray-400">succeeded sessions</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">DO Peers</div>
<div className={`text-2xl font-bold ${
stats.doSessions === 0 ? "text-gray-400" :
stats.avgDoPercent >= 50 ? "text-green-600" :
stats.avgDoPercent >= 10 ? "text-yellow-600" :
"text-gray-600"
}`}>
{stats.doSessions > 0 ? `${stats.avgDoPercent}%` : "—"}
</div>
<div className="text-xs text-gray-400">
{stats.doSessions > 0 ? `${stats.doSessions} of ${stats.total} sessions` : "no DO telemetry"}
</div>
</div>
</div>
)}
@@ -253,6 +293,9 @@ function LocationSessionsContent() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
DO Peers
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started At
</th>
@@ -280,6 +323,42 @@ function LocationSessionsContent() {
<td className="px-4 py-3 text-sm text-gray-600">
{formatDuration(session.durationSeconds)}
</td>
<td className="px-4 py-3 text-sm">
{session.hasDoTelemetry ? (
<div
title={
`Peers: ${formatBytes(session.doBytesFromPeers ?? 0)} | HTTP: ${formatBytes(session.doBytesFromHttp ?? 0)} | Total: ${formatBytes(session.doTotalBytesDownloaded ?? 0)}\n` +
`LAN: ${formatBytes(session.doBytesFromLanPeers ?? 0)} | Group: ${formatBytes(session.doBytesFromGroupPeers ?? 0)} | Internet: ${formatBytes(session.doBytesFromInternetPeers ?? 0)}`
}
>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
(session.doPercentPeerCaching ?? 0) >= 50
? "bg-green-100 text-green-800"
: (session.doPercentPeerCaching ?? 0) >= 10
? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-600"
}`}
>
{(session.doPercentPeerCaching ?? 0).toFixed(1)}%
</span>
<span className="text-xs text-gray-400 ml-1">
({session.doAppCount}/{session.totalAppCount})
</span>
</div>
) : (
<span
className="text-gray-300"
title={
(session.totalAppCount ?? 0) > 0
? `${session.totalAppCount} app(s) installed, no DO telemetry`
: "no app install data"
}
>
—
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{formatDate(session.startedAt)}
</td>