mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user