From d0486de21e810255f129305d658926642eea58e6 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 25 Jun 2026 12:16:04 +0200 Subject: [PATCH] perf(backend): push app-install-summary time window server-side + parallelize session join The app dashboard and metrics endpoints loaded entire AppInstallSummaries partitions (tenant scope) or the full table (global scope) and filtered StartedAt >= cutoff in memory, even for days=30. The device-model join then issued one serial GetSessionAsync point-read per distinct session. - Add optional DateTime? sinceUtc to GetAppInstallSummariesByTenantAsync / GetAllAppInstallSummariesAsync: when set, a server-side StartedAt ge filter scopes the scan so only in-window rows are deserialized/transferred. Default null preserves the legacy full scan for callers that need all history. - AppsAnalyticsHelper.LoadSummariesAsync derives sinceUtc from days; all six /api/apps/* functions pass it. The four metrics/app + metrics/geographic functions pass their existing cutoff. The in-memory Where stays as the exact sub-second trim (the OData filter is second-granular). MCP get_app_install_metrics benefits automatically (days already flows through; response shape unchanged). - Replace the serial session point-read loop with a bounded-concurrency (SemaphoreSlim=10) LoadSessionLookupAsync, shared by the analytics and sessions builders. AppInstallSummaries is already bounded by the session-retention cascade (DeletionManifestBuilder) + tenant offboarding, so no separate table retention is added. Full Backend suite: 2406 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SlaMetricsServiceTests.cs | 2 +- .../SlaTenantStatusLifecycleTests.cs | 10 +-- .../UsageMetricsServiceWindowTests.cs | 4 +- .../TableStorage/TableMetricsRepository.cs | 8 +-- .../Functions/Apps/AppsAnalyticsHelper.cs | 67 +++++++++++++------ .../Functions/Apps/GetAppAnalyticsFunction.cs | 2 +- .../Functions/Apps/GetAppSessionsFunction.cs | 2 +- .../Functions/Apps/GetAppsListFunction.cs | 2 +- .../Apps/GetGlobalAppAnalyticsFunction.cs | 2 +- .../Apps/GetGlobalAppSessionsFunction.cs | 2 +- .../Apps/GetGlobalAppsListFunction.cs | 2 +- .../Metrics/GetAppMetricsFunction.cs | 4 +- .../Metrics/GetGeographicMetricsFunction.cs | 4 +- .../Metrics/GetGlobalAppMetricsFunction.cs | 6 +- .../GetGlobalGeographicMetricsFunction.cs | 6 +- .../Services/TableStorageService.Metrics.cs | 26 +++++-- .../DataAccess/IMetricsRepository.cs | 6 +- 17 files changed, 103 insertions(+), 52 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs index 0aa331e7..ed0d2b0e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs @@ -32,7 +32,7 @@ public class SlaMetricsServiceTests .ReturnsAsync(sessions); var metricsRepo = new Mock(); - metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId)) + metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId, It.IsAny())) .ReturnsAsync(appInstalls ?? new List()); var configService = new Mock( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs index 7a761165..93b3b939 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs @@ -196,13 +196,13 @@ public class SlaTenantStatusLifecycleTests _sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new RawPage(new List(), null)); // Default: no app installs - _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny())) + _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); } public void SetAppInstalls(string tenantId, List apps) { - _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId)).ReturnsAsync(apps); + _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId, It.IsAny())).ReturnsAsync(apps); } public void SetTenants(params TenantConfiguration[] tenants) @@ -427,7 +427,7 @@ public class SlaTenantStatusLifecycleTests var opsSvc = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); var metricsRepo = new Mock(); - metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny())) + metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); var svc = new SlaBreachEvaluationService( tenantConfigSvc, configRepoMock.Object, maintenanceRepo.Object, sessionRepo.Object, @@ -862,7 +862,7 @@ public class SlaTenantStatusLifecycleTests _sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new RawPage(new List(), null)); - _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny())) + _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); } @@ -873,7 +873,7 @@ public class SlaTenantStatusLifecycleTests => _maintenanceRepo.Setup(r => r.GetSessionsByDateRangeAsync(It.IsAny(), It.IsAny(), tenantId)).ReturnsAsync(sessions); public void SetAppInstalls(string tenantId, List apps) - => _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId)).ReturnsAsync(apps); + => _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId, It.IsAny())).ReturnsAsync(apps); } /// diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsServiceWindowTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsServiceWindowTests.cs index 7f9e992f..99645ae3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsServiceWindowTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsServiceWindowTests.cs @@ -24,9 +24,9 @@ public class UsageMetricsServiceWindowTests .ReturnsAsync(new UserActivityMetrics()); metricsRepo.Setup(r => r.GetAllUserActivityMetricsAsync()) .ReturnsAsync(new UserActivityMetrics()); - metricsRepo.Setup(r => r.GetAllAppInstallSummariesAsync()) + metricsRepo.Setup(r => r.GetAllAppInstallSummariesAsync(It.IsAny())) .ReturnsAsync(new List()); - metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny())) + metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); metricsRepo.Setup(r => r.GetPlatformStatsAsync()) .ReturnsAsync((PlatformStats?)null); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index f50ef1be..2508adb2 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -35,11 +35,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task StoreAppInstallSummaryAsync(AppInstallSummary summary) => _storage.StoreAppInstallSummaryAsync(summary); - public Task> GetAppInstallSummariesByTenantAsync(string tenantId) - => _storage.GetAppInstallSummariesByTenantAsync(tenantId); + public Task> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null) + => _storage.GetAppInstallSummariesByTenantAsync(tenantId, sinceUtc); - public Task> GetAllAppInstallSummariesAsync() - => _storage.GetAllAppInstallSummariesAsync(); + public Task> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null) + => _storage.GetAllAppInstallSummariesAsync(sinceUtc); public Task GetPlatformStatsAsync() => _storage.GetPlatformStatsAsync(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index b1f87d6e..e46e8dbe 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -1,4 +1,5 @@ using System.Collections.Specialized; +using System.Threading; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; @@ -66,17 +67,55 @@ namespace AutopilotMonitor.Functions.Functions.Apps // ── Data loaders ──────────────────────────────────────────────────── + /// Caps concurrent session point-reads in the device-model join so a wide app doesn't fan out hundreds of simultaneous Table reads. + private const int SessionJoinConcurrency = 10; + /// - /// Loads app install summaries for the given scope. + /// Loads app install summaries for the given scope, scoped server-side to the last + /// days via a StartedAt ge 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) /// public static async Task> LoadSummariesAsync( - IMetricsRepository repo, string? tenantId) + IMetricsRepository repo, string? tenantId, int days) { + var sinceUtc = DateTime.UtcNow.AddDays(-days); return string.IsNullOrEmpty(tenantId) - ? await repo.GetAllAppInstallSummariesAsync() - : await repo.GetAppInstallSummariesByTenantAsync(tenantId); + ? await repo.GetAllAppInstallSummariesAsync(sinceUtc) + : await repo.GetAppInstallSummariesByTenantAsync(tenantId, sinceUtc); + } + + /// + /// Resolves the (TenantId, SessionId) → SessionSummary lookup used by the device-model join. + /// Point-reads run with bounded concurrency () 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. + /// + private static async Task> 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(); + foreach (var (key, sess) in results) + if (sess != null) lookup[$"{key.TenantId}|{key.SessionId}"] = sess; + return lookup; } // ── /apps/list ────────────────────────────────────────────────────── @@ -308,14 +347,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps // 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 distinctKeys = summaries.Select(s => (s.TenantId, s.SessionId)).Distinct().ToList(); - var sessionLookup = new Dictionary(); - foreach (var (tid, sid) in distinctKeys) - { - if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(sid)) continue; - var sess = await sessionRepo.GetSessionAsync(tid, sid); - if (sess != null) sessionLookup[$"{tid}|{sid}"] = sess; - } + var sessionLookup = await LoadSessionLookupAsync( + sessionRepo, summaries.Select(s => (s.TenantId, s.SessionId))); var deviceModelBreakdown = summaries .Where(s => sessionLookup.ContainsKey($"{s.TenantId}|{s.SessionId}")) @@ -411,14 +444,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps // Batch-fetch sessions for device info. Uses each summary's TenantId so global-admin // aggregated view works correctly across tenants. - var distinctKeys = summaries.Select(s => (s.TenantId, s.SessionId)).Distinct().ToList(); - var sessionLookup = new Dictionary(); - foreach (var (tid, sid) in distinctKeys) - { - if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(sid)) continue; - var sess = await sessionRepo.GetSessionAsync(tid, sid); - if (sess != null) sessionLookup[$"{tid}|{sid}"] = sess; - } + var sessionLookup = await LoadSessionLookupAsync( + sessionRepo, summaries.Select(s => (s.TenantId, s.SessionId))); if (!string.IsNullOrWhiteSpace(modelFilter)) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs index d4deaeae..bdb4106f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs @@ -50,7 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) days = parsedDays; - var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId); + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( summaries, _sessionRepo, decodedAppName, days); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs index e3e2641d..9c751549 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs @@ -64,7 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0) limit = Math.Min(parsedLimit, MaxLimit); - var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId); + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync( summaries, _sessionRepo, decodedAppName, days, statusFilter, modelFilter, versionFilter, offset, limit); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs index a706dff4..6ef23e11 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs @@ -46,7 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps return bad; } - var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId); + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); var body = AppsAnalyticsHelper.BuildAppsListResponse( summaries, days, paging.PageSize, paging.Skip, nextOffset => $"/api/apps/list?days={days}&pageSize={paging.PageSize}&skip={nextOffset}"); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs index 3c2341c1..d67ec325 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs @@ -60,7 +60,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps "Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})", decodedAppName, userEmail, scopedTenantId ?? "", days); - var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId); + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days); var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( summaries, _sessionRepo, decodedAppName, days); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs index 43cffa3c..61f1548e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs @@ -75,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps "Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})", decodedAppName, userEmail, scopedTenantId ?? ""); - var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId); + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days); var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync( summaries, _sessionRepo, decodedAppName, days, statusFilter, modelFilter, versionFilter, offset, limit); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs index dbd775af..e180fc59 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs @@ -57,7 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps return bad; } - var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId); + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days); var tenantQs = string.IsNullOrEmpty(scopedTenantId) ? string.Empty : $"&tenantId={scopedTenantId}"; var body = AppsAnalyticsHelper.BuildAppsListResponse( summaries, days, paging.PageSize, paging.Skip, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs index 2707a406..2af0e35e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs @@ -40,7 +40,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); - var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId); + // Push the window server-side so only in-window rows are deserialized. The in-memory + // Where below stays as the exact trim (the OData StartedAt filter is second-granular). + var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); // Aggregation (slowest/failing ranking + Delivery Optimization rollup) is shared diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs index 47f0e53c..5894d35c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs @@ -50,7 +50,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); - var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId); + // Push the window server-side so only in-window rows are deserialized. The in-memory + // Where below stays as the exact trim (the OData StartedAt filter is second-granular). + var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); var result = ComputeGeographicMetrics(sessions, summaries, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs index 15fe6e60..815353a5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs @@ -41,9 +41,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); + // Push the window server-side so only in-window rows are deserialized. The in-memory + // Where below stays as the exact trim (the OData StartedAt filter is second-granular). var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter) - ? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter) - : await _metricsRepo.GetAllAppInstallSummariesAsync(); + ? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter, cutoff) + : await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); // Aggregation (slowest/failing ranking + Delivery Optimization rollup) is shared diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs index 68b9a49f..36e8fcca 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs @@ -47,9 +47,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var sessions = !string.IsNullOrWhiteSpace(tenantIdFilter) ? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantIdFilter) : await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); + // Push the window server-side so only in-window rows are deserialized. The in-memory + // Where below stays as the exact trim (the OData StartedAt filter is second-granular). var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter) - ? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter) - : await _metricsRepo.GetAllAppInstallSummariesAsync(); + ? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter, cutoff) + : await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index 4529a635..90e78c7b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -328,16 +328,24 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Gets all app install summaries for a tenant (fleet-level metrics) + /// Gets app install summaries for a tenant (fleet-level metrics). When + /// is supplied, a server-side StartedAt ge filter is applied so a windowed view (e.g. the + /// app dashboard's days=30) does not dematerialize the tenant's entire StartedAt history. The + /// filter still scans the partition (no secondary index on StartedAt), but only the in-window rows + /// are deserialized and returned over the wire. is a server-derived + /// DateTime (never caller-supplied text), so interpolating it into the OData filter is injection-safe. /// - public async Task> GetAppInstallSummariesByTenantAsync(string tenantId) + public async Task> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + var filter = $"PartitionKey eq '{tenantId}'"; + if (sinceUtc.HasValue) + filter += $" and StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'"; + var query = tableClient.QueryAsync(filter: filter); var summaries = new List(); await foreach (var entity in query) @@ -355,14 +363,20 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Gets all app install summaries across all tenants (for global admin mode) + /// Gets all app install summaries across all tenants (for global admin mode). When + /// is supplied, a server-side StartedAt ge filter scopes the + /// (otherwise full-table) scan to the window so only in-window rows are deserialized. + /// is server-derived, so interpolating it is injection-safe. /// - public async Task> GetAllAppInstallSummariesAsync() + public async Task> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null) { try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var query = tableClient.QueryAsync(); + var filter = sinceUtc.HasValue + ? $"StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'" + : null; + var query = tableClient.QueryAsync(filter: filter); var summaries = new List(); await foreach (var entity in query) diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index f55d8cfa..875e28ba 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -21,8 +21,10 @@ namespace AutopilotMonitor.Shared.DataAccess // --- App Install Summaries --- Task StoreAppInstallSummaryAsync(AppInstallSummary summary); - Task> GetAppInstallSummariesByTenantAsync(string tenantId); - Task> GetAllAppInstallSummariesAsync(); + /// Gets a tenant's app install summaries. Pass to push the window's StartedAt ge filter server-side; null returns the full partition (legacy behaviour). + Task> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null); + /// Gets all tenants' app install summaries. Pass to scope the (otherwise full-table) scan to the window; null returns everything (legacy behaviour). + Task> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null); // --- Platform Stats --- Task GetPlatformStatsAsync();