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
+75
View File
@@ -72,6 +72,19 @@ vi.mock('@supabase/ssr', () => ({
})),
}))
const logState = vi.hoisted(() => ({ info: vi.fn() }))
vi.mock('@/lib/logger', () => {
const logger = {
info: (...args: unknown[]) => logState.info(...args),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: () => logger,
}
return { createLogger: () => logger }
})
import { updateSession } from '../middleware'
import {
createSessionTimeoutState,
@@ -103,6 +116,7 @@ describe('updateSession redirect destinations', () => {
beforeEach(() => {
vi.clearAllMocks()
logState.info.mockClear()
state.user = null
state.sessionId = 'session-1'
state.authError = null
@@ -535,6 +549,67 @@ describe('updateSession redirect destinations', () => {
// ── MFA semantics that must not change ────────────────────────────────
describe('per-request timing header and log line', () => {
const TIMING_RE =
/^mw-auth;dur=\d+, mw-session;dur=\d+, mw-company;dur=\d+, mw-mfa;dur=\d+, mw-total;dur=\d+$/
function lastLog() {
expect(logState.info).toHaveBeenCalledTimes(1)
const [msg, ctx] = logState.info.mock.calls[0] as [string, Record<string, unknown>]
expect(msg).toBe('proxy completed')
return ctx
}
it('page responses carry Server-Timing and log kind=page with the route', async () => {
state.user = SIGNED_IN
const res = await run('/invoices')
expect(res.status).toBe(200)
expect(res.headers.get('server-timing')).toMatch(TIMING_RE)
expect(res.headers.get('x-proxy-timing')).toBeNull()
const ctx = lastLog()
expect(ctx.kind).toBe('page')
expect(ctx.route).toBe('/invoices')
expect(ctx.status).toBe(200)
expect(typeof ctx.totalMs).toBe('number')
expect(typeof ctx.authMs).toBe('number')
expect(typeof ctx.companyMs).toBe('number')
})
it('classifies prefetch and RSC requests from the app-router headers', async () => {
state.user = SIGNED_IN
await run('/invoices', { headers: { 'next-router-prefetch': '1', rsc: '1' } })
expect(lastLog().kind).toBe('prefetch')
logState.info.mockClear()
await run('/invoices', { headers: { rsc: '1' } })
expect(lastLog().kind).toBe('rsc')
})
it('/api responses use X-Proxy-Timing and leave Server-Timing to the route wrapper', async () => {
state.user = SIGNED_IN
const res = await run('/api/settings')
expect(res.headers.get('x-proxy-timing')).toMatch(TIMING_RE)
expect(res.headers.get('server-timing')).toBeNull()
expect(lastLog().kind).toBe('api')
})
it('redirect responses also carry the header and log their status', async () => {
const res = await run('/invoices')
expect(res.status).toBe(307)
expect(res.headers.get('server-timing')).toMatch(TIMING_RE)
expect(lastLog().status).toBe(307)
})
it('never logs a token-carrying path or a raw entity id', async () => {
const res = await run('/invite/9f8e7d6c5b4a3928171605f4e3d2c1b0')
expect(res.status).toBe(200)
expect(lastLog().route).toBe('/invite/*')
logState.info.mockClear()
state.user = SIGNED_IN
await run('/invoices/6f1c2a3e-1234-4bcd-9abc-0123456789ab')
expect(lastLog().route).toBe('/invoices/:id')
})
})
describe('MFA-disabled and self-hosted paths are unchanged', () => {
it('does not redirect when NEXT_PUBLIC_REQUIRE_MFA is unset', async () => {
state.user = SIGNED_IN
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import {
classifyProxyRequest,
createProxyTimings,
formatProxyServerTiming,
proxyRouteTemplate,
timed,
} from '../proxy-timing'
describe('classifyProxyRequest', () => {
it('treats /api paths as api regardless of headers', () => {
const headers = new Headers({ 'next-router-prefetch': '1', rsc: '1' })
expect(classifyProxyRequest('/api/settings', headers)).toBe('api')
})
it('recognises app-router prefetch and RSC requests by header', () => {
expect(
classifyProxyRequest('/invoices', new Headers({ 'Next-Router-Prefetch': '1', RSC: '1' })),
).toBe('prefetch')
expect(classifyProxyRequest('/invoices', new Headers({ RSC: '1' }))).toBe('rsc')
})
it('falls back to page for a plain document request', () => {
expect(classifyProxyRequest('/invoices', new Headers())).toBe('page')
})
})
describe('proxyRouteTemplate', () => {
it('replaces UUID and numeric segments with placeholders', () => {
expect(proxyRouteTemplate('/invoices/6f1c2a3e-1234-4bcd-9abc-0123456789ab/edit')).toBe(
'/invoices/:id/edit',
)
expect(proxyRouteTemplate('/salary/runs/42')).toBe('/salary/runs/:n')
})
it('collapses token-carrying prefixes so secrets never reach the log', () => {
expect(proxyRouteTemplate('/invite/9f8e7d6c5b4a3928171605f4e3d2c1b0')).toBe('/invite/*')
expect(proxyRouteTemplate('/payslip/abc')).toBe('/payslip/*')
expect(proxyRouteTemplate('/auth/callback')).toBe('/auth/*')
expect(proxyRouteTemplate('/auth')).toBe('/auth/*')
})
it('masks long opaque segments outside the known prefixes', () => {
expect(proxyRouteTemplate('/e/sector/aVeryLongOpaqueSlugThatLooksLikeAToken')).toBe(
'/e/sector/:token',
)
})
it('keeps ordinary routes and the root untouched', () => {
expect(proxyRouteTemplate('/settings/company')).toBe('/settings/company')
expect(proxyRouteTemplate('/')).toBe('/')
})
})
describe('formatProxyServerTiming', () => {
it('emits one mw-* metric per phase plus the total', () => {
const timing = { authMs: 12, sessionMs: 3, companyMs: 40, mfaMs: 0 }
expect(formatProxyServerTiming(timing, 61)).toBe(
'mw-auth;dur=12, mw-session;dur=3, mw-company;dur=40, mw-mfa;dur=0, mw-total;dur=61',
)
})
})
describe('timed', () => {
it('returns the wrapped value and accumulates elapsed time on the key', async () => {
const timing = createProxyTimings()
const value = await timed(timing, 'sessionMs', async () => 'ok')
expect(value).toBe('ok')
await timed(timing, 'sessionMs', async () => undefined)
expect(timing.sessionMs).toBeGreaterThanOrEqual(0)
expect(timing.authMs).toBe(0)
})
it('still records time when the wrapped call throws', async () => {
const timing = createProxyTimings()
await expect(
timed(timing, 'authMs', async () => {
throw new Error('boom')
}),
).rejects.toThrow('boom')
expect(timing.authMs).toBeGreaterThanOrEqual(0)
})
})
+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) {
+115
View File
@@ -0,0 +1,115 @@
/**
* Per-request timing for the auth proxy (lib/supabase/middleware.ts).
*
* The proxy runs in front of every page, RSC, prefetch and /api request and
* makes several sequential network calls (Supabase Auth, the active-company
* RPC, MFA factor lookups). Nothing measured that cost until now, while the
* route wrapper (lib/api/with-route-context.ts) has logged authMs/companyMs/
* handlerMs per API call for months. These helpers give the proxy the same
* Server-Timing header and one structured "proxy completed" log line per
* request so the fixed per-request cost can be read off Vercel logs and the
* browser Timing tab instead of guessed.
*
* Pure functions only: the middleware test suite mocks Supabase heavily, so
* classification and formatting live here where they can be unit-tested
* without that harness.
*/
export type ProxyRequestKind = 'api' | 'prefetch' | 'rsc' | 'page'
export interface ProxyTimings {
/** supabase.auth.getUser(): a network round trip to Supabase Auth. */
authMs: number
/** Session-timeout state: getClaims + HMAC verify + auto_logout read. */
sessionMs: number
/** resolve_active_company RPC (or the query fallback) incl. write-back. */
companyMs: number
/** MFA assurance level + listFactors (the latter is a network call). */
mfaMs: number
}
export type ProxyTimingKey = keyof ProxyTimings
/**
* Header used on /api responses. withRouteContext owns `Server-Timing` on
* API routes and only sets it when absent, so the proxy's numbers travel on
* a separate header there; on page/RSC responses nothing else sets
* Server-Timing and the proxy uses the standard header directly.
*/
export const PROXY_TIMING_HEADER = 'X-Proxy-Timing'
export function createProxyTimings(): ProxyTimings {
return { authMs: 0, sessionMs: 0, companyMs: 0, mfaMs: 0 }
}
/** Run `fn` and add its wall time to `timing[key]` (accumulates on repeats). */
export async function timed<T>(
timing: ProxyTimings,
key: ProxyTimingKey,
fn: () => Promise<T>,
): Promise<T> {
const start = Date.now()
try {
return await fn()
} finally {
timing[key] += Date.now() - start
}
}
/**
* Which kind of request the proxy is fronting. Prefetch and RSC requests
* are recognised by the headers the Next.js app router sends
* (`Next-Router-Prefetch: 1`, `RSC: 1`); header names are case-insensitive
* on the Fetch `Headers` interface, so lower-case lookups are fine.
*/
export function classifyProxyRequest(
pathname: string,
headers: Headers,
): ProxyRequestKind {
if (pathname.startsWith('/api')) return 'api'
if (headers.get('next-router-prefetch') === '1') return 'prefetch'
if (headers.get('rsc') === '1') return 'rsc'
return 'page'
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const NUMERIC_RE = /^\d+$/
/** Prefixes whose tail is a secret (invite tokens, payslip links, PKCE). */
const TOKEN_PREFIXES = ['/invite', '/payslip', '/auth']
/**
* Collapse a concrete pathname to a loggable route template: UUIDs become
* `:id`, numbers `:n`, long opaque segments `:token`, and paths under the
* token-carrying prefixes are cut to the prefix so a secret never lands in
* a log line.
*/
export function proxyRouteTemplate(pathname: string): string {
for (const prefix of TOKEN_PREFIXES) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return `${prefix}/*`
}
}
const segments = pathname.split('/').map((segment) => {
if (segment === '') return segment
if (UUID_RE.test(segment)) return ':id'
if (NUMERIC_RE.test(segment)) return ':n'
if (segment.length >= 24) return ':token'
return segment
})
return segments.join('/') || '/'
}
/** `Server-Timing` value: one `mw-*` metric per phase plus the total. */
export function formatProxyServerTiming(
timing: ProxyTimings,
totalMs: number,
): string {
return [
`mw-auth;dur=${timing.authMs}`,
`mw-session;dur=${timing.sessionMs}`,
`mw-company;dur=${timing.companyMs}`,
`mw-mfa;dur=${timing.mfaMs}`,
`mw-total;dur=${totalMs}`,
].join(', ')
}