From 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:54:23 +0800 Subject: [PATCH 1/2] docs: align readiness runtime version --- docs/production-readiness-checklist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index e38e98c7f..1596dc639 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -2,9 +2,9 @@ This is the runbook to make the app publishable in one focused pass. -Last reviewed: 2026-07-04. Applies to any feature branch or release candidate. +Last reviewed: 2026-07-10. Applies to any feature branch or release candidate. -- Runtime target: Next.js 16.2.9, Node 24.x, npm 11.x. +- Runtime target: Next.js 16.2.10, Node 24.x, npm 11.x. - Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`). ## Immediate completion targets From 46624913def3eaddaa1cc5aa4411f769e9c98b77 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:13:52 +0800 Subject: [PATCH 2/2] perf: add bundle analyzer and Server-Timing measurement rail Phase 0 of the round-2 performance initiative: measurement before change. - @next/bundle-analyzer (devDep) behind ANALYZE=true via an async config wrapper that lazy-imports it, so pruned production runtimes never load it; npm run build:analyze wraps the normal build cross-platform. - Server-Timing headers on /api/answer and /api/search/universal built from the latency fields the pipeline already computes (rag latencyTimings, per-domain universal-search latencyMs). Durations and short metric names only - no query text or user data crosses the trust boundary. The SSE stream route already carries latencyTimings in its final event. - Baseline captured (webpack prod build): 4795.4 KB raw / 1133.9 KB gzip client JS across 178 chunks; chunk 2419 (337.1 KB raw) holds the DocumentManagerPanel suite + SettingsDialog targeted by the next phase. Co-Authored-By: Claude Fable 5 --- next.config.ts | 10 +- package-lock.json | 186 ++++++++++++++++++++++++++ package.json | 2 + scripts/build-analyze.mjs | 13 ++ src/app/api/answer/route.ts | 21 ++- src/app/api/search/universal/route.ts | 23 +++- src/lib/server-timing.ts | 64 +++++++++ tests/server-timing.test.ts | 63 +++++++++ 8 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 scripts/build-analyze.mjs create mode 100644 src/lib/server-timing.ts create mode 100644 tests/server-timing.test.ts diff --git a/next.config.ts b/next.config.ts index 78045f45a..89fcad349 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,14 @@ const projectRoot = path.dirname(fileURLToPath(import.meta.url)); // request from src/proxy.ts; both derive their runtime flags from the same helper. const securityHeaders = buildSecurityHeaders(resolveRuntimeFlags()); +// Opt-in bundle analysis (npm run build:analyze). The analyzer is a devDependency +// loaded lazily so production runtimes (pruned node_modules) never import it. +async function withOptionalBundleAnalyzer(config: NextConfig): Promise { + if (process.env.ANALYZE !== "true") return config; + const { default: bundleAnalyzer } = await import("@next/bundle-analyzer"); + return bundleAnalyzer({ enabled: true })(config); +} + const nextConfig: NextConfig = { // Playwright and some local tooling hit the dev server via 127.0.0.1; without // this, Next blocks HMR/client hydration from that host and phone scroll-hide @@ -41,4 +49,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withOptionalBundleAnalyzer(nextConfig); diff --git a/package-lock.json b/package-lock.json index 64a8061f1..fd823379d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@next/bundle-analyzer": "^16.2.10", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^24.13.2", "@types/pdf-parse": "^1.1.5", @@ -312,6 +313,16 @@ "node": ">=18" } }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1777,6 +1788,16 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@next/bundle-analyzer": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.10.tgz", + "integrity": "sha512-KcepWhb3IVniZgm00GSSCQDEUQqZXuXtuXRh8J6e3Un342TcQ77iK4DedeEkct+fcx7yFEDL2J6z4Jeho5JDAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "webpack-bundle-analyzer": "4.10.1" + } + }, "node_modules/@next/env": { "version": "16.2.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", @@ -2015,6 +2036,13 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -3618,6 +3646,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4339,6 +4380,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -4528,6 +4579,13 @@ "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4648,6 +4706,13 @@ "node": ">= 0.4" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -5879,6 +5944,22 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6371,6 +6452,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7355,6 +7446,16 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7673,6 +7774,16 @@ } } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -8877,6 +8988,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -9275,6 +9401,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -9845,6 +9981,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", + "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "is-plain-object": "^5.0.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 58afcbc87..3caaa97de 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preinstall": "node scripts/check-node-engine.cjs", "ensure": "node scripts/ensure-local-server.mjs", "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack", + "build:analyze": "node scripts/build-analyze.mjs", "start": "node scripts/dev-free-port.mjs start", "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern", "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", @@ -135,6 +136,7 @@ "uuid": "^11.1.1" }, "devDependencies": { + "@next/bundle-analyzer": "^16.2.10", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^24.13.2", "@types/pdf-parse": "^1.1.5", diff --git a/scripts/build-analyze.mjs b/scripts/build-analyze.mjs new file mode 100644 index 000000000..202e6a470 --- /dev/null +++ b/scripts/build-analyze.mjs @@ -0,0 +1,13 @@ +// Cross-platform wrapper for `ANALYZE=true npm run build` (Windows shells cannot +// set inline env vars). Reuses the normal build script so the guard and heap +// settings stay in one place; next.config.ts picks up ANALYZE and wraps the +// config with @next/bundle-analyzer, which writes .next/analyze/*.html. +import { spawnSync } from "node:child_process"; + +const result = spawnSync("npm", ["run", "build"], { + stdio: "inherit", + shell: true, + env: { ...process.env, ANALYZE: "true" }, +}); + +process.exit(result.status ?? 1); diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 3dc0f25bd..ae6af8b56 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -20,6 +20,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { parseJsonBody } from "@/lib/validation/body"; +import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; @@ -69,6 +70,7 @@ function buildDemoAnswerPayload(body: AnswerRequestBody, fallbackReason?: string } export async function POST(request: Request) { + const routeStartedAt = Date.now(); let body: AnswerRequestBody | null = null; try { const answerBody = await parseJsonBody(request, answerSchema, "Invalid answer request."); @@ -154,12 +156,19 @@ export async function POST(request: Request) { logAnswerDiagnostics({ supabase, query: answerBody.query, ownerId: access.ownerId, answer }); - return NextResponse.json({ - ...answer, - degradedMode: answerDegradedModeSignal(answer), - scope: { ...scope, queryMode: answerBody.queryMode }, - sourceGovernanceWarnings: warnings, - }); + // Durations only — see server-timing.ts for the trust-boundary constraint. + const serverTiming = buildServerTimingHeader( + answerServerTimingEntries(answer.latencyTimings, Date.now() - routeStartedAt), + ); + return NextResponse.json( + { + ...answer, + degradedMode: answerDegradedModeSignal(answer), + scope: { ...scope, queryMode: answerBody.queryMode }, + sourceGovernanceWarnings: warnings, + }, + serverTiming ? { headers: { "Server-Timing": serverTiming } } : undefined, + ); } catch (error) { if (error instanceof serverAuth.AuthenticationError) { return serverAuth.unauthorizedResponse(error); diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index d48b650d8..366847a5e 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -9,9 +9,15 @@ import { import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; +import { buildServerTimingHeader, type ServerTimingEntry } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { runUniversalSearch, universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search"; +import { + runUniversalSearch, + universalSearchDomains, + type UniversalSearchDomain, + type UniversalSearchResponse, +} from "@/lib/universal-search"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -41,8 +47,19 @@ const universalSearchQuerySchema = z.object({ }), }); -function universalResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function universalResponse( + payload: Record & Partial>, +) { + const headers: Record = { "Cache-Control": "private, no-store" }; + // Per-domain + total durations for DevTools; names/durations only (no query data). + const timingEntries: ServerTimingEntry[] = (payload.groups ?? []).map((group) => ({ + name: group.kind, + durMs: group.latencyMs, + })); + if (typeof payload.tookMs === "number") timingEntries.push({ name: "total", durMs: payload.tookMs }); + const serverTiming = buildServerTimingHeader(timingEntries); + if (serverTiming) headers["Server-Timing"] = serverTiming; + return NextResponse.json(payload, { headers }); } export async function GET(request: Request) { diff --git a/src/lib/server-timing.ts b/src/lib/server-timing.ts new file mode 100644 index 000000000..e72dd29c0 --- /dev/null +++ b/src/lib/server-timing.ts @@ -0,0 +1,64 @@ +// Server-Timing response header for the hot API routes, so per-request latency is +// visible in browser DevTools / curl without querying rag_queries telemetry. +// Only durations and short metric names are emitted — never query text, ids, or +// any user data (the header crosses the trust boundary to the client). + +export type ServerTimingEntry = { + name: string; + durMs?: number; + desc?: string; +}; + +// RFC 9110 token characters, conservatively narrowed: metric names must be short +// lowercase snake-case tokens; anything else is dropped rather than escaped. +const metricNamePattern = /^[a-z][a-z0-9_-]{0,63}$/; + +function sanitizeDescription(desc: string) { + // Header values cannot contain CR/LF; double quotes would terminate the quoted-string. + return desc.replace(/[\r\n"\\]/g, " ").slice(0, 80); +} + +export function buildServerTimingHeader(entries: ServerTimingEntry[]): string | null { + const parts: string[] = []; + for (const entry of entries) { + if (!metricNamePattern.test(entry.name)) continue; + let part = entry.name; + if (typeof entry.durMs === "number" && Number.isFinite(entry.durMs)) { + part += `;dur=${Math.max(0, Math.round(entry.durMs))}`; + } + if (entry.desc) { + part += `;desc="${sanitizeDescription(entry.desc)}"`; + } + parts.push(part); + } + return parts.length ? parts.join(", ") : null; +} + +// Answer-route timings from RagAnswer.latencyTimings (all values are millisecond +// durations computed in rag.ts). Missing fields are simply omitted. +export function answerServerTimingEntries( + latencyTimings: + | { + search_latency_ms?: number; + generation_latency_ms?: number; + embedding_latency_ms?: number; + supabase_rpc_latency_ms?: number; + rerank_latency_ms?: number; + total_latency_ms?: number; + } + | undefined, + routeTotalMs: number, +): ServerTimingEntry[] { + const entries: ServerTimingEntry[] = []; + const push = (name: string, durMs: number | undefined) => { + if (typeof durMs === "number" && Number.isFinite(durMs)) entries.push({ name, durMs }); + }; + push("search", latencyTimings?.search_latency_ms); + push("rpc", latencyTimings?.supabase_rpc_latency_ms); + push("embedding", latencyTimings?.embedding_latency_ms); + push("rerank", latencyTimings?.rerank_latency_ms); + push("generation", latencyTimings?.generation_latency_ms); + push("answer", latencyTimings?.total_latency_ms); + push("total", routeTotalMs); + return entries; +} diff --git a/tests/server-timing.test.ts b/tests/server-timing.test.ts new file mode 100644 index 000000000..8f029acf8 --- /dev/null +++ b/tests/server-timing.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; + +describe("buildServerTimingHeader", () => { + it("joins entries with durations rounded to whole milliseconds", () => { + expect( + buildServerTimingHeader([ + { name: "search", durMs: 120.6 }, + { name: "total", durMs: 480 }, + ]), + ).toBe("search;dur=121, total;dur=480"); + }); + + it("returns null when nothing survives sanitization", () => { + expect(buildServerTimingHeader([])).toBeNull(); + expect(buildServerTimingHeader([{ name: "Bad Name!" }])).toBeNull(); + }); + + it("drops metric names that are not conservative tokens", () => { + expect( + buildServerTimingHeader([ + { name: "ok-metric", durMs: 5 }, + { name: "not ok", durMs: 9 }, + { name: "UPPER", durMs: 9 }, + ]), + ).toBe("ok-metric;dur=5"); + }); + + it("clamps negative and drops non-finite durations", () => { + expect( + buildServerTimingHeader([ + { name: "clamped", durMs: -12 }, + { name: "nan", durMs: Number.NaN }, + ]), + ).toBe("clamped;dur=0, nan"); + }); + + it("strips header-breaking characters from descriptions", () => { + expect(buildServerTimingHeader([{ name: "cache", desc: 'hit"\r\nmiss' }])).toBe('cache;desc="hit miss"'); + }); +}); + +describe("answerServerTimingEntries", () => { + it("maps latency timings to named entries and appends the route total", () => { + const entries = answerServerTimingEntries( + { + search_latency_ms: 300, + generation_latency_ms: 2200, + supabase_rpc_latency_ms: 180, + total_latency_ms: 2600, + }, + 2700, + ); + expect(buildServerTimingHeader(entries)).toBe( + "search;dur=300, rpc;dur=180, generation;dur=2200, answer;dur=2600, total;dur=2700", + ); + }); + + it("emits only the route total when timings are missing", () => { + expect(buildServerTimingHeader(answerServerTimingEntries(undefined, 42))).toBe("total;dur=42"); + }); +});