From 0fc81f99ed2db89321f951e35eeb1f55b4b125b0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 13:37:52 +0200 Subject: [PATCH] perf(web,backend): cut dashboard load time + fix duplicate fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by sequential backend reads, two stray duplicate fetches, and a 2s SignalR defer. Five focused changes: - AuthContext: store the auth/me prefetch as a Promise instead of its resolved value so fetchUserInfo awaits the in-flight request rather than racing a second fetch when the prefetch hasn't settled yet. - useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/ role primitives instead of the whole user object so the effect stops refiring when AuthContext swaps prefetched user for fresh user (the duplicate /config//feature-flags call observed on /dashboard). - SignalRContext: drop the hardcoded 2000ms setTimeout before startConnection. The original "avoid competing with critical API calls" rationale doesn't apply across origins (web is HTTP/2, API is a different host); the delay only pushed live updates ~2s into the load for no benefit. - FeedbackFunction: keep the kill-switch check sequential, then run the remaining four Table reads (role, tenant config, sessions existence, feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped from ~1285ms (6 sequential reads on Flex Consumption) toward the slowest single read. - TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every function response so the portal's Resource Timing API can read API payload sizes (transferSize / nextHopProtocol) for our own RUM — cross-origin entries currently report 0/unknown. Backend tests 1058/1058, Web tsc clean, vitest 249/249. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Functions/Feedback/FeedbackFunction.cs | 22 ++++++++--- .../Middleware/TimingAllowOriginMiddleware.cs | 34 +++++++++++++++++ .../AutopilotMonitor.Functions/Program.cs | 1 + .../hooks/useTenantSecurityConfig.ts | 7 +++- .../contexts/AuthContext.tsx | 38 +++++++++---------- .../contexts/SignalRContext.tsx | 10 +++-- 6 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index 16da298d..10008463 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -52,7 +52,8 @@ namespace AutopilotMonitor.Functions.Functions.Feedback { try { - // 1. Kill-switch + // 1. Kill-switch — gate first so a disabled feature doesn't fan out + // four extra Table reads on every poll from every active user. var adminConfig = await _adminConfigService.GetConfigurationAsync(); if (!adminConfig.FeedbackEnabled) return await WriteJson(req, new { eligible = false }); @@ -61,24 +62,35 @@ namespace AutopilotMonitor.Functions.Functions.Feedback string tenantId = TenantHelper.GetTenantId(req); string upn = TenantHelper.GetUserIdentifier(req); + // 3-6. The remaining checks each hit a different Table partition and + // depend only on (tenantId, upn) — fan them out in parallel so + // the endpoint is bounded by the slowest read instead of summing + // four sequential round-trips on Flex Consumption. + var roleTask = _tenantAdminsService.GetMemberRoleAsync(tenantId, upn); + var tenantConfigTask = _tenantConfigService.GetConfigurationAsync(tenantId); + var sessionsTask = _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null); + var feedbackTask = _configRepo.GetFeedbackEntryAsync(upn); + + await Task.WhenAll(roleTask, tenantConfigTask, sessionsTask, feedbackTask); + // 3. Role check — only Admin + Operator - var roleInfo = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn); + var roleInfo = roleTask.Result; if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer) return await WriteJson(req, new { eligible = false }); // 4. Tenant age check - var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId); + var tenantConfig = tenantConfigTask.Result; if (tenantConfig.OnboardedAt == null || (DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays) return await WriteJson(req, new { eligible = false }); // 5. Sessions check — at least 1 session exists. - var sessionPage = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null); + var sessionPage = sessionsTask.Result; if (sessionPage.Items.Count == 0) return await WriteJson(req, new { eligible = false }); // 6. Cooldown check - var feedbackEntry = await _configRepo.GetFeedbackEntryAsync(upn); + var feedbackEntry = feedbackTask.Result; if (feedbackEntry?.InteractedAt != null) { // Cooldown = 0 means single wave only diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs new file mode 100644 index 00000000..09ac2c3f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs @@ -0,0 +1,34 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Middleware; + +namespace AutopilotMonitor.Functions.Middleware; + +/// +/// Adds the Timing-Allow-Origin: * response header so cross-origin +/// callers (notably the portal) can read full Resource Timing details +/// (transferSize, encodedBodySize, nextHopProtocol, ...) +/// from the browser's performance.getEntriesByType('resource'). +/// Without this header those fields stay zero / "unknown" and our own +/// real-user monitoring cannot see API payload sizes. +/// +public class TimingAllowOriginMiddleware : IFunctionsWorkerMiddleware +{ + private const string HeaderName = "Timing-Allow-Origin"; + private const string HeaderValue = "*"; + + public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + var httpContext = context.GetHttpContext(); + if (httpContext != null) + { + httpContext.Response.OnStarting(() => + { + if (!httpContext.Response.Headers.ContainsKey(HeaderName)) + httpContext.Response.Headers[HeaderName] = HeaderValue; + return Task.CompletedTask; + }); + } + + await next(context); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 44a5b166..bcde7718 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -28,6 +28,7 @@ builder.ConfigureFunctionsWebApplication(); // Order matters: request telemetry (wraps all) → correlation ID → global exception handler → JWT authentication (401) → policy enforcement (403) builder.UseMiddleware(); builder.UseMiddleware(); +builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts index 1c510326..59a23f81 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts @@ -65,8 +65,13 @@ export function useTenantSecurityConfig( }; fetchTenantSecurityConfig(); + // Depend on the user fields actually read above (primitives), not the + // whole user object — its identity flips when AuthContext swaps the + // prefetched user object for the freshly fetched one, which would + // otherwise refire this effect and produce a duplicate feature-flags + // request. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tenantId, user]); + }, [tenantId, user?.isTenantAdmin, user?.isGlobalAdmin, user?.role]); return serialValidationEnabled; } diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index eab53414..482c8990 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -12,11 +12,13 @@ const msalInstance = new PublicClientApplication(msalConfig); // Track MSAL initialization state so components can wait for it. let msalReady = false; -// Prefetch: store auth/me result fetched during MSAL init so fetchUserInfo -// can use it immediately without an extra network round-trip. +// Prefetch: store the in-flight auth/me Promise (not the resolved value) so +// fetchUserInfo can await it instead of racing a duplicate fetch when the +// prefetch hasn't settled yet. Resolves to the JSON body, or null if the +// fetch failed / token acquisition failed. // Runs as a fire-and-forget side-effect — MUST NOT block msalInitPromise, // otherwise a cold backend would keep the UI on a white screen. -let prefetchedAuthMe: Record | null = null; +let prefetchedAuthMePromise: Promise | null> | null = null; const msalInitPromise = msalInstance .initialize() @@ -24,12 +26,11 @@ const msalInitPromise = msalInstance .then(() => { msalReady = true; - // Fire-and-forget: prefetch auth/me while React is still mounting. - // Not awaited — if it finishes before fetchUserInfo runs, great; - // if not, fetchUserInfo does its own fetch as before. + // Kick off prefetch while React is still mounting. fetchUserInfo will + // await the same Promise instead of firing its own fetch. const accounts = msalInstance.getAllAccounts(); if (accounts.length > 0) { - msalInstance.acquireTokenSilent({ + prefetchedAuthMePromise = msalInstance.acquireTokenSilent({ scopes: apiRequest.scopes, account: accounts[0], }).then(async (tokenResponse) => { @@ -37,12 +38,8 @@ const msalInitPromise = msalInstance headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` }, signal: AbortSignal.timeout(8000), }); - if (res.ok) { - prefetchedAuthMe = await res.json(); - } - }).catch(() => { - // Best-effort; fetchUserInfo will retry normally. - }); + return res.ok ? (await res.json()) as Record : null; + }).catch(() => null); } }) .catch((error) => { @@ -141,12 +138,15 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { */ const fetchUserInfo = useCallback(async (account: AccountInfo): Promise => { try { - // Use prefetched auth/me result if available (fetched during MSAL init). - // Consume it exactly once to avoid stale data on subsequent calls. - if (prefetchedAuthMe) { - const data = prefetchedAuthMe; - prefetchedAuthMe = null; - return { + // Await the prefetch Promise if one is in flight (or already resolved). + // Consume exactly once so subsequent fetchUserInfo calls go through the + // normal path. If the prefetch returned null (e.g. token failed), fall + // through to a fresh fetch below rather than returning null. + if (prefetchedAuthMePromise) { + const pending = prefetchedAuthMePromise; + prefetchedAuthMePromise = null; + const data = await pending; + if (data) return { displayName: (data.displayName as string) || account.name || '', upn: (data.upn as string) || account.username || '', tenantId: (data.tenantId as string) || account.tenantId || '', diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index 3df73b8a..20fd621d 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -144,13 +144,15 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { } }; - // Defer SignalR connection to avoid competing with critical API calls - // (auth/me, sessions, tenant config) during initial page load. - const deferTimer = setTimeout(startConnection, 2000); + // Start as soon as the user is authenticated. The previous 2s setTimeout + // was meant to avoid competing with the initial dashboard fetches, but + // the web origin is HTTP/2 and the API is on a different origin, so + // there is no real connection contention — the delay only pushed live + // updates ~2s into the dashboard load for no benefit. + startConnection(); // Cleanup only when provider unmounts (app closes) return () => { - clearTimeout(deferTimer); // Clear any pending retry timeout to prevent reconnection after unmount if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current);