feat(pokedex): add opt-in Server-Timing for the pokédex load

POKEDEX_PERFORMANCE=true reports auth, ownership, scopes, entries and catches
with fixed labels only: no IDs, query strings, cookies or entry content ever
reach the header. Auth is measured in the hook, where the session is actually
resolved, and handed to the page load through locals.
This commit is contained in:
Josh Creek
2026-09-15 17:46:07 +01:00
parent 31fd959350
commit 4c268ba15c
4 changed files with 77 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@ declare global {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
supabase: SupabaseClient; supabase: SupabaseClient;
pokedexAuthMs?: number;
safeGetSession(): Promise<{ session: Session | null; user: User | null }>; safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
userid: string; userid: string;
buildDate: string; buildDate: string;
+3
View File
@@ -30,17 +30,20 @@ export const handle: Handle = async ({ event, resolve }) => {
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null; let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
event.locals.safeGetSession = () => { event.locals.safeGetSession = () => {
sessionPromise ??= (async () => { sessionPromise ??= (async () => {
const authStarted = performance.now();
const { const {
data: { user }, data: { user },
error error
} = await event.locals.supabase.auth.getUser(); } = await event.locals.supabase.auth.getUser();
if (error) { if (error) {
event.locals.pokedexAuthMs = performance.now() - authStarted;
return { session: null, user: null }; return { session: null, user: null };
} }
const { const {
data: { session } data: { session }
} = await event.locals.supabase.auth.getSession(); } = await event.locals.supabase.auth.getSession();
event.locals.pokedexAuthMs = performance.now() - authStarted;
return { session, user }; return { session, user };
})(); })();
return sessionPromise; return sessionPromise;
+37
View File
@@ -0,0 +1,37 @@
import { env } from '$env/dynamic/private';
/** Opt-in, fixed labels only: never log IDs, query strings, cookies or entry content. */
export class PokedexPerformance {
private started = performance.now();
private durations: Record<string, number> = {};
readonly enabled = env.POKEDEX_PERFORMANCE === 'true';
async measure<T>(
stage: 'auth' | 'ownership' | 'scopes' | 'entries' | 'catches',
run: () => Promise<T>
): Promise<T> {
const start = performance.now();
try {
return await run();
} finally {
if (this.enabled) this.durations[stage] = performance.now() - start;
}
}
recordAuth(duration: number | undefined) {
if (this.enabled && duration !== undefined) this.durations.auth = duration;
}
prepare<T>(run: () => T): T {
const start = performance.now();
try {
return run();
} finally {
if (this.enabled) this.durations.prepare = performance.now() - start;
}
}
finish(): string | undefined {
if (!this.enabled) return undefined;
this.durations.total = performance.now() - this.started;
return Object.entries(this.durations)
.map(([name, duration]) => `${name};dur=${duration.toFixed(1)}`)
.join(', ');
}
}
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PokedexPerformance } from '$lib/server/pokedexPerformance';
afterEach(() => vi.unstubAllEnvs());
describe('opt-in stage timing', () => {
it('emits no timing header without explicit opt-in', async () => {
vi.stubEnv('POKEDEX_PERFORMANCE', 'false');
const timing = new PokedexPerformance();
expect(await timing.measure('entries', async () => 'result')).toBe('result');
expect(timing.prepare(() => 3)).toBe(3);
timing.recordAuth(10);
expect(timing.finish()).toBeUndefined();
});
it('records failed stages without exposing exception content', async () => {
vi.stubEnv('POKEDEX_PERFORMANCE', 'true');
const timing = new PokedexPerformance();
await expect(
timing.measure('catches', async () => {
throw new Error('private note');
})
).rejects.toThrow('private note');
expect(() =>
timing.prepare(() => {
throw new Error('private ID');
})
).toThrow('private ID');
timing.recordAuth(undefined);
timing.recordAuth(12.34);
const header = timing.finish()!;
expect(header).toContain('auth;dur=12.3');
expect(header).toMatch(/catches;dur=\d+\.\d/);
expect(header).toMatch(/prepare;dur=\d+\.\d/);
expect(header).toMatch(/total;dur=\d+\.\d/);
expect(header).not.toContain('private');
});
});