perf(web,backend): cut dashboard load time + fix duplicate fetches

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/<t>/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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-07 13:37:52 +02:00
parent b61e327752
commit 0fc81f99ed
6 changed files with 83 additions and 29 deletions
@@ -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
@@ -0,0 +1,34 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Adds the <c>Timing-Allow-Origin: *</c> response header so cross-origin
/// callers (notably the portal) can read full Resource Timing details
/// (<c>transferSize</c>, <c>encodedBodySize</c>, <c>nextHopProtocol</c>, ...)
/// from the browser's <c>performance.getEntriesByType('resource')</c>.
/// Without this header those fields stay zero / "unknown" and our own
/// real-user monitoring cannot see API payload sizes.
/// </summary>
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);
}
}
@@ -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<RequestTelemetryMiddleware>();
builder.UseMiddleware<CorrelationIdMiddleware>();
builder.UseMiddleware<TimingAllowOriginMiddleware>();
builder.UseMiddleware<GlobalExceptionMiddleware>();
builder.UseMiddleware<AuthenticationMiddleware>();
builder.UseMiddleware<PolicyEnforcementMiddleware>();
@@ -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;
}
@@ -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<string, unknown> | null = null;
let prefetchedAuthMePromise: Promise<Record<string, unknown> | 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<string, unknown> : null;
}).catch(() => null);
}
})
.catch((error) => {
@@ -141,12 +138,15 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
*/
const fetchUserInfo = useCallback(async (account: AccountInfo): Promise<UserInfo | null> => {
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 || '',
@@ -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);