feat(perf): measure the auth proxy per request (Server-Timing + proxy completed log) (#1922)

The proxy in front of every page, RSC, prefetch and /api request makes
several sequential network calls (getUser, session state, the
resolve_active_company RPC, MFA factor lookups) and nothing measured them,
while the route wrapper has logged authMs/companyMs/handlerMs per API call
for months. This is the first PR of the responsiveness plan (customer
report: "it takes time before all fields load when clicking around"): the
baseline every later change is measured against.

- lib/supabase/proxy-timing.ts: pure helpers (request classification from
  the app-router headers, route template that collapses ids and tokens,
  Server-Timing formatting, a timed() accumulator).
- lib/supabase/middleware.ts: updateSession wraps updateSessionInner, times
  each phase, sets Server-Timing on page/RSC/prefetch responses and
  X-Proxy-Timing on /api responses (withRouteContext owns Server-Timing
  there), and emits one "proxy completed" log line per request.
- scripts/perf/log-percentiles.ts: p50/p90/p99 per group over
  `vercel logs --json` output, for both "op completed" and
  "proxy completed"; scripts/perf/README.md documents the protocol and
  targets.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 13:55:42 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 6dd0e951e6
commit b2e15bbd2a
7 changed files with 738 additions and 9 deletions
+68 -9
View File
@@ -1,5 +1,15 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { createLogger } from '@/lib/logger'
import {
PROXY_TIMING_HEADER,
classifyProxyRequest,
createProxyTimings,
formatProxyServerTiming,
proxyRouteTemplate,
timed,
type ProxyTimings,
} from '@/lib/supabase/proxy-timing'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
@@ -27,7 +37,43 @@ import {
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
const log = createLogger('proxy')
/**
* Auth proxy entry point. Wraps the real work so every response carries a
* per-phase timing header and emits one structured log line, mirroring what
* withRouteContext does for API routes: without it the proxy's sequential
* network calls (getUser, session state, company RPC, MFA lookups) were the
* one part of a request nobody could measure. Page/RSC/prefetch responses
* get `Server-Timing` (visible in the browser Timing tab); /api responses
* get `X-Proxy-Timing` so the route wrapper's own Server-Timing is left
* alone. Token-carrying paths are collapsed before logging.
*/
export async function updateSession(request: NextRequest) {
const start = Date.now()
const timing = createProxyTimings()
const response = await updateSessionInner(request, timing)
const totalMs = Date.now() - start
const pathname = request.nextUrl.pathname
const kind = classifyProxyRequest(pathname, request.headers)
response.headers.set(
kind === 'api' ? PROXY_TIMING_HEADER : 'Server-Timing',
formatProxyServerTiming(timing, totalMs),
)
log.info('proxy completed', {
kind,
route: proxyRouteTemplate(pathname),
status: response.status,
...timing,
totalMs,
})
return response
}
async function updateSessionInner(
request: NextRequest,
timing: ProxyTimings,
): Promise<NextResponse> {
let supabaseResponse = NextResponse.next({
request,
})
@@ -62,7 +108,7 @@ export async function updateSession(request: NextRequest) {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
} = await timed(timing, 'authMs', () => supabase.auth.getUser())
// Get the pathname
const pathname = request.nextUrl.pathname
@@ -93,8 +139,12 @@ export async function updateSession(request: NextRequest) {
!apiRequestSkipsSessionTimeout(pathname, hasAuthorizationHeader)
) {
const encodedState = request.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
const sessionId = await getSupabaseSessionId(supabase)
const verifiedState = await verifySessionTimeoutState(encodedState)
const sessionId = await timed(timing, 'sessionMs', () =>
getSupabaseSessionId(supabase),
)
const verifiedState = await timed(timing, 'sessionMs', () =>
verifySessionTimeoutState(encodedState),
)
if (encodedState && !verifiedState) {
await signOutTimedOutSession(supabase)
@@ -121,7 +171,9 @@ export async function updateSession(request: NextRequest) {
const method = isSessionAuthMethod(hintedMethod)
? hintedMethod
: 'password'
const autoLogout = await fetchAutoLogoutPreference(supabase, user.id)
const autoLogout = await timed(timing, 'sessionMs', () =>
fetchAutoLogoutPreference(supabase, user.id),
)
// Unknown preference (failed read): mint nothing, so no fail-open
// snapshot gets persisted; the next request retries the read.
@@ -184,8 +236,9 @@ export async function updateSession(request: NextRequest) {
hasAuthorizationHeader,
)
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
const { data: aal } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
const { data: aal } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
)
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
return NextResponse.json({ error: 'MFA-verifiering krävs.' }, { status: 403 })
}
@@ -310,11 +363,15 @@ export async function updateSession(request: NextRequest) {
degraded: boolean
} | null = null
const resolveCompanyOnce = async () =>
(resolvedCompany ??= await resolveCompanyForMiddleware(supabase, user.id, request))
(resolvedCompany ??= await timed(timing, 'companyMs', () =>
resolveCompanyForMiddleware(supabase, user.id, request),
))
// MFA enforcement (application-side only, not RLS)
if (shouldEnforceMfa(user)) {
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
const { data: aal } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
)
// User has MFA enrolled but hasn't verified this session → redirect to verify
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
@@ -325,7 +382,9 @@ export async function updateSession(request: NextRequest) {
// Skip for users with no companies (still setting up)
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
if (companyIdForMfa) {
const { data: factors } = await supabase.auth.mfa.listFactors()
const { data: factors } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.listFactors(),
)
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
if (!hasVerifiedFactor) {