From 4c268ba15ce6a375d446f489bba2249722bf37ec Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:46:07 +0100 Subject: [PATCH] =?UTF-8?q?feat(pokedex):=20add=20opt-in=20Server-Timing?= =?UTF-8?q?=20for=20the=20pok=C3=A9dex=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.d.ts | 1 + src/hooks.server.ts | 3 +++ src/lib/server/pokedexPerformance.ts | 37 +++++++++++++++++++++++++++ tests/unit/pokedexPerformance.test.ts | 36 ++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 src/lib/server/pokedexPerformance.ts create mode 100644 tests/unit/pokedexPerformance.test.ts diff --git a/src/app.d.ts b/src/app.d.ts index 2c1facc..1a7de71 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,6 +13,7 @@ declare global { // interface Error {} interface Locals { supabase: SupabaseClient; + pokedexAuthMs?: number; safeGetSession(): Promise<{ session: Session | null; user: User | null }>; userid: string; buildDate: string; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b8b7b5d..e8d38c9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -30,17 +30,20 @@ export const handle: Handle = async ({ event, resolve }) => { let sessionPromise: ReturnType | null = null; event.locals.safeGetSession = () => { sessionPromise ??= (async () => { + const authStarted = performance.now(); const { data: { user }, error } = await event.locals.supabase.auth.getUser(); if (error) { + event.locals.pokedexAuthMs = performance.now() - authStarted; return { session: null, user: null }; } const { data: { session } } = await event.locals.supabase.auth.getSession(); + event.locals.pokedexAuthMs = performance.now() - authStarted; return { session, user }; })(); return sessionPromise; diff --git a/src/lib/server/pokedexPerformance.ts b/src/lib/server/pokedexPerformance.ts new file mode 100644 index 0000000..5254015 --- /dev/null +++ b/src/lib/server/pokedexPerformance.ts @@ -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 = {}; + readonly enabled = env.POKEDEX_PERFORMANCE === 'true'; + async measure( + stage: 'auth' | 'ownership' | 'scopes' | 'entries' | 'catches', + run: () => Promise + ): Promise { + 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(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(', '); + } +} diff --git a/tests/unit/pokedexPerformance.test.ts b/tests/unit/pokedexPerformance.test.ts new file mode 100644 index 0000000..a7a9c8e --- /dev/null +++ b/tests/unit/pokedexPerformance.test.ts @@ -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'); + }); +});