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
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import {
extractRecord,
parseArgs,
percentile,
renderMarkdown,
summarize,
} from '../perf/log-percentiles'
const opLine = (operation: string, durationMs: number, authMs = 2) =>
JSON.stringify({
level: 'info',
module: `api/${operation}`,
msg: 'op completed',
operation,
durationMs,
authMs,
})
describe('extractRecord', () => {
it('parses a bare logger line', () => {
expect(extractRecord(opLine('period.list', 77))).toMatchObject({
operation: 'period.list',
durationMs: 77,
})
})
it('unwraps the JSON embedded in a vercel logs --json envelope', () => {
const envelope = JSON.stringify({
timestamp: 1,
source: 'serverless',
requestPath: '/api/bookkeeping/fiscal-periods',
message: ` ${opLine('period.list', 77)}`,
})
expect(extractRecord(envelope)).toMatchObject({
source: 'serverless',
operation: 'period.list',
durationMs: 77,
})
})
it('keeps a non-JSON message as the envelope only', () => {
expect(extractRecord(JSON.stringify({ message: 'plain text' }))).toEqual({
message: 'plain text',
})
})
it('ignores blank and unparseable lines', () => {
expect(extractRecord('')).toBeNull()
expect(extractRecord('not json')).toBeNull()
expect(extractRecord('{broken')).toBeNull()
})
})
describe('percentile', () => {
it('uses nearest rank', () => {
const sorted = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
expect(percentile(sorted, 50)).toBe(5)
expect(percentile(sorted, 90)).toBe(9)
expect(percentile(sorted, 99)).toBe(10)
expect(percentile([42], 50)).toBe(42)
expect(Number.isNaN(percentile([], 50))).toBe(true)
})
})
describe('summarize', () => {
const records = [
{ operation: 'a', durationMs: 10, authMs: 1 },
{ operation: 'a', durationMs: 30, authMs: 1 },
{ operation: 'b', durationMs: 100, authMs: 50 },
{ operation: 'b', durationMs: 'oops', authMs: 5 },
]
it('groups by the requested keys and ranks slowest first', () => {
const rows = summarize(records, { fields: ['durationMs', 'authMs'], groupBy: ['operation'] })
expect(rows.map((r) => r.group)).toEqual(['b', 'a'])
expect(rows[1].count).toBe(2)
expect(rows[1].fields.durationMs).toEqual({ p50: 10, p90: 30, p99: 30, max: 30 })
// The non-numeric durationMs is skipped for that field but the row still counts the sample.
expect(rows[0].count).toBe(2)
expect(rows[0].fields.durationMs.max).toBe(100)
})
it('applies exact-match filters and min-count', () => {
const rows = summarize(records, {
fields: ['durationMs'],
groupBy: ['operation'],
filters: [{ key: 'operation', value: 'a' }],
})
expect(rows).toHaveLength(1)
expect(rows[0].group).toBe('a')
expect(summarize(records, { fields: ['durationMs'], groupBy: ['operation'], minCount: 3 })).toEqual([])
})
it('produces a single "all" row without grouping', () => {
const rows = summarize(records, { fields: ['durationMs'] })
expect(rows).toHaveLength(1)
expect(rows[0].group).toBe('all')
expect(rows[0].count).toBe(4)
})
})
describe('renderMarkdown + parseArgs', () => {
it('renders a markdown table with one column set per field', () => {
const rows = summarize([{ k: 'x', v: 5 }], { fields: ['v'], groupBy: ['k'] })
const md = renderMarkdown(rows, ['v'])
expect(md.split('\n')[0]).toBe('| group | n | v p50 | v p90 | v p99 | v max |')
expect(md).toContain('| x | 1 | 5 | 5 | 5 | 5 |')
})
it('parses the documented CLI flags', () => {
expect(
parseArgs(['--field', 'a,b', '--group', 'kind,route', '--filter', 'msg=op completed', '--min-count', '5']),
).toEqual({
fields: ['a', 'b'],
groupBy: ['kind', 'route'],
filters: [{ key: 'msg', value: 'op completed' }],
minCount: 5,
})
expect(() => parseArgs([])).toThrow('--field is required')
expect(() => parseArgs(['--bogus'])).toThrow('unknown option')
})
})
+69
View File
@@ -0,0 +1,69 @@
# Request latency measurement (auth proxy + route wrapper)
Why this exists: a customer reported that "it takes time before all fields load when clicking around" (2026-08-26). The API handlers themselves are fast (p50 38 ms); the cost is the number of sequential calls a page makes and the fixed per-request tax in front of each one (auth proxy + route wrapper). This page is the protocol for measuring that tax before and after every change in the responsiveness plan, so no PR claims a win without a number.
## What is instrumented
| Surface | Where | Header | Log line |
|---|---|---|---|
| Auth proxy (every page, RSC, prefetch, `/api` request) | `lib/supabase/middleware.ts` via `lib/supabase/proxy-timing.ts` | `Server-Timing: mw-auth, mw-session, mw-company, mw-mfa, mw-total` on page/RSC/prefetch responses; `X-Proxy-Timing` (same value) on `/api` responses | `proxy completed` with `kind` (`page`, `rsc`, `prefetch`, `api`), `route` (ids and tokens collapsed), `status`, `authMs`, `sessionMs`, `companyMs`, `mfaMs`, `totalMs` |
| Route wrapper (`withRouteContext`, 367 of 529 routes) | `lib/api/with-route-context.ts` | `Server-Timing: auth, company, handler` | `op completed` with `operation`, `status`, `durationMs`, `authMs`, `companyMs`, `handlerMs` |
| Browser | `@vercel/speed-insights` mounted in `app/layout.tsx` | n/a | Vercel dashboard > Speed Insights > Routes (p75 TTFB, FCP, LCP, INP, CLS per route) |
Phase meanings for the proxy: `authMs` = `supabase.auth.getUser()` (a network call to Supabase Auth); `sessionMs` = session-timeout cookie state (`getClaims`, HMAC verify, `auto_logout` read when re-minting); `companyMs` = `resolve_active_company` RPC including the write-back on fallback; `mfaMs` = assurance-level check plus `listFactors()` (a second network call) on the enforced-MFA path.
## Reading the numbers
Percentiles per route from production logs (the `vercel` CLI is linked to the project; `--limit` defaults to 100, raise it):
```bash
# route wrapper, grouped by operation
vercel logs --environment production --since 24h --limit 1000 --json --query "op completed" \
| npx tsx scripts/perf/log-percentiles.ts --field durationMs,authMs,companyMs,handlerMs --group operation
# auth proxy, grouped by request kind and route
vercel logs --environment production --since 24h --limit 1000 --json --query "proxy completed" \
| npx tsx scripts/perf/log-percentiles.ts --field totalMs,authMs,sessionMs,companyMs,mfaMs --group kind,route
# auth proxy, one row per kind (the headline fixed cost)
vercel logs --environment production --since 24h --limit 1000 --json --query "proxy completed" \
| npx tsx scripts/perf/log-percentiles.ts --field totalMs,authMs,companyMs,mfaMs --group kind
```
If `--limit` above 100 is not honoured by the installed CLI, loop `--since`/`--until` windows and concatenate before piping. The Vercel MCP `get_runtime_logs` tool (`group_by: route`) gives request counts per route, not percentiles; use it for prefetch volume (`kind=prefetch` per page load).
In the browser: DevTools > Network, pick a document or `_rsc` request, Timing tab, the `mw-*` metrics show the proxy phases; `/api` responses show `auth`/`company`/`handler` from the route wrapper and the proxy numbers in the `X-Proxy-Timing` response header.
Request count per interaction (the number that maps to "fields load late"), in the console after each action on a production build (`next build && next start`):
```js
performance.getEntriesByType('resource')
.filter((r) => /\/api\/|\/rest\/v1\//.test(r.name))
.map((r) => `${Math.round(r.startTime)} ${Math.round(r.duration)}ms ${r.name}`)
```
Interactions to record every time: `/transactions` soft navigation; open Bokför on a row; `/bookkeeping` then Nytt verifikat; `/invoices` then Ny faktura; `/reports`; `/supplier-invoices/new`; a list row to its detail page on customers and invoices. Note the request count and how many dependent rounds (start times that wait on an earlier response).
## Targets (p75 unless stated)
| Metric | Target | Why |
|---|---|---|
| Proxy `page`/`rsc` totalMs | p50 < 40 ms, p90 < 100 ms | Two parallel DB waves at most, no auth network call |
| Proxy `api` totalMs | p50 < 5 ms | Local JWT verification only |
| Route wrapper auth + company | p50 < 45 ms (read), same for write | One company resolution per call, never two |
| TTFB (hard load) | < 400 ms | Layout on two DB waves |
| LCP | < 1.5 s | |
| INP | < 200 ms | The metric "clicking around" maps to |
| Reference-data requests per form open | 0 blocking | Seeded and cached client-side |
## Protocol per PR
1. Before merging: record the current numbers for the interactions above on a production build, and the last 24 h of `proxy completed` / `op completed` percentiles, in the PR description.
2. 24 h after the production deploy: re-run the same three commands and the same interactions; append one row per PR to the table below.
3. A PR that claims a latency win without a before/after row is not done.
## Baseline and results
| Date | Change | Proxy page p50/p90 | Proxy api p50/p90 | Route auth+company p50 | Notes |
|---|---|---|---|---|---|
| 2026-08-26 | Route wrapper only (proxy unmeasured) | n/a | n/a | 44 ms (auth 3, company 41) | 100-call sample; handler p50 38 ms, total p50 96 ms, p90 274 ms |
+205
View File
@@ -0,0 +1,205 @@
/**
* Percentiles over structured log lines.
*
* Reads JSON Lines on stdin (the shape `vercel logs --json` emits, or raw
* logger output) and prints a markdown table of count / p50 / p90 / p99 /
* max per group for the numeric fields asked for. Used for the
* "op completed" lines from lib/api/with-route-context.ts and the
* "proxy completed" lines from lib/supabase/middleware.ts.
*
* vercel logs --environment production --since 24h --limit 1000 --json \
* --query "op completed" \
* | npx tsx scripts/perf/log-percentiles.ts \
* --field durationMs,authMs,companyMs,handlerMs --group operation
*
* vercel logs --environment production --since 24h --limit 1000 --json \
* --query "proxy completed" \
* | npx tsx scripts/perf/log-percentiles.ts \
* --field totalMs,authMs,companyMs,mfaMs --group kind,route
*
* Options: --field a,b (required), --group x,y (default: none, one row),
* --filter key=value (repeatable; exact match on the parsed record),
* --min-count N (drop groups with fewer samples, default 1).
*
* No dependencies on purpose: this must run from a clean checkout.
*/
export type LogRecord = Record<string, unknown>
/**
* Turn one input line into a flat record. `vercel logs --json` wraps the
* application line in a `message` (or `text`) string, so an embedded JSON
* object inside that string is parsed and merged over the envelope; a bare
* JSON logger line is used as-is. Unparseable lines yield null.
*/
export function extractRecord(line: string): LogRecord | null {
const trimmed = line.trim()
if (!trimmed.startsWith('{')) return null
let envelope: LogRecord
try {
envelope = JSON.parse(trimmed) as LogRecord
} catch {
return null
}
const message = envelope.message ?? envelope.text
if (typeof message === 'string') {
const start = message.indexOf('{')
if (start >= 0) {
try {
const embedded = JSON.parse(message.slice(start)) as LogRecord
return { ...envelope, ...embedded }
} catch {
// Not a JSON payload: fall through and use the envelope alone.
}
}
}
return envelope
}
/** Nearest-rank percentile on an ascending-sorted array. */
export function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return Number.NaN
const rank = Math.ceil((p / 100) * sorted.length)
return sorted[Math.min(sorted.length, Math.max(1, rank)) - 1]
}
export interface FieldStats {
p50: number
p90: number
p99: number
max: number
}
export interface GroupRow {
group: string
count: number
fields: Record<string, FieldStats>
}
export interface SummarizeOptions {
fields: string[]
groupBy?: string[]
filters?: Array<{ key: string; value: string }>
minCount?: number
}
function matchesFilters(record: LogRecord, filters: SummarizeOptions['filters']): boolean {
if (!filters || filters.length === 0) return true
return filters.every(({ key, value }) => String(record[key]) === value)
}
export function summarize(records: LogRecord[], options: SummarizeOptions): GroupRow[] {
const groupBy = options.groupBy ?? []
const minCount = options.minCount ?? 1
const buckets = new Map<string, { count: number; values: Record<string, number[]> }>()
for (const record of records) {
if (!matchesFilters(record, options.filters)) continue
const groupKey = groupBy.length
? groupBy.map((key) => String(record[key] ?? '')).join(' / ')
: 'all'
let bucket = buckets.get(groupKey)
if (!bucket) {
bucket = {
count: 0,
values: Object.fromEntries(options.fields.map((f) => [f, [] as number[]])),
}
buckets.set(groupKey, bucket)
}
bucket.count += 1
for (const field of options.fields) {
const value = record[field]
if (typeof value === 'number' && Number.isFinite(value)) bucket.values[field].push(value)
}
}
const rows: GroupRow[] = []
for (const [group, bucket] of buckets) {
const count = bucket.count
if (count < minCount) continue
const fields: Record<string, FieldStats> = {}
for (const field of options.fields) {
const sorted = [...bucket.values[field]].sort((a, b) => a - b)
fields[field] = {
p50: percentile(sorted, 50),
p90: percentile(sorted, 90),
p99: percentile(sorted, 99),
max: sorted.length ? sorted[sorted.length - 1] : Number.NaN,
}
}
rows.push({ group, count, fields })
}
// Slowest first by the first field's p50 so the table reads as a ranking.
const primary = options.fields[0]
rows.sort((a, b) => (b.fields[primary]?.p50 ?? 0) - (a.fields[primary]?.p50 ?? 0))
return rows
}
function fmt(n: number): string {
return Number.isNaN(n) ? '-' : String(Math.round(n))
}
export function renderMarkdown(rows: GroupRow[], fields: string[]): string {
const header = ['group', 'n', ...fields.flatMap((f) => [`${f} p50`, `${f} p90`, `${f} p99`, `${f} max`])]
const lines = [
`| ${header.join(' | ')} |`,
`|${header.map(() => '---').join('|')}|`,
]
for (const row of rows) {
const cells = [row.group, String(row.count)]
for (const field of fields) {
const s = row.fields[field]
cells.push(fmt(s.p50), fmt(s.p90), fmt(s.p99), fmt(s.max))
}
lines.push(`| ${cells.join(' | ')} |`)
}
return lines.join('\n')
}
export function parseArgs(argv: string[]): SummarizeOptions {
const options: SummarizeOptions = { fields: [], groupBy: [], filters: [], minCount: 1 }
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
const next = () => {
i += 1
const value = argv[i]
if (value === undefined) throw new Error(`${arg} needs a value`)
return value
}
if (arg === '--field') options.fields = next().split(',').filter(Boolean)
else if (arg === '--group') options.groupBy = next().split(',').filter(Boolean)
else if (arg === '--filter') {
const [key, ...rest] = next().split('=')
options.filters!.push({ key, value: rest.join('=') })
} else if (arg === '--min-count') options.minCount = Number(next())
else throw new Error(`unknown option ${arg}`)
}
if (options.fields.length === 0) throw new Error('--field is required')
return options
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
return Buffer.concat(chunks).toString('utf8')
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2))
const input = await readStdin()
const records = input
.split('\n')
.map(extractRecord)
.filter((r): r is LogRecord => r !== null)
const rows = summarize(records, options)
process.stdout.write(`${records.length} records parsed\n\n`)
process.stdout.write(`${renderMarkdown(rows, options.fields)}\n`)
}
if (process.argv[1] && /log-percentiles\.(?:ts|mts|js|mjs)$/.test(process.argv[1])) {
main().catch((err) => {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
})
}