Public: anyone can read this; the crew can change it.
/search's Server-Timing names its phases: session, each read, render #3287
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3274-search-s-server-timing-names-its-phases → main 1 commit fork
2 files
changed +71
−6
src/web/pages/search.ts +16 −6
| @@ -26,6 +26,7 @@ import { | ||
| 26 | 26 | type SearchResults, |
| 27 | 27 | } from "../../core/search.ts"; |
| 28 | 28 | import type { Ctx } from "../../http/context.ts"; |
| 29 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 29 | 30 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 30 | 31 | import { html, when, type Html } from "../html.ts"; |
| 31 | 32 | import { icon } from "../icons.ts"; |
| @@ -339,6 +340,7 @@ async function find( | ||
| 339 | 340 | * connected origin, which is not a thing to do on every keystroke. |
| 340 | 341 | */ |
| 341 | 342 | export async function searchPage(ctx: Ctx): Promise<Response> { |
| 343 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 342 | 344 | const params = ctx.url.searchParams; |
| 343 | 345 | const a: Asked = { |
| 344 | 346 | query: cleanQuery(params.get("q") ?? ""), |
| @@ -373,7 +375,8 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 373 | 375 | </form>`; |
| 374 | 376 | |
| 375 | 377 | if (a.query === "") { |
| 376 | return page({ | |
| 378 | const rendering = performance.now(); | |
| 379 | const blank = page({ | |
| 377 | 380 | ctx, |
| 378 | 381 | title: "Search", |
| 379 | 382 | body: html`${pageHead("Search")} ${form} |
| @@ -383,11 +386,14 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 383 | 386 | note: "An app or repository by name or description, a word from what was told or changed, or a line of the code itself — across everything you can see, here and on every origin you have connected.", |
| 384 | 387 | })}`, |
| 385 | 388 | }); |
| 389 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 390 | return blank; | |
| 386 | 391 | } |
| 387 | 392 | |
| 388 | const { found, busy } = await find(ctx, a); | |
| 389 | const seen = appsOf(ctx, ctx.auth.user); | |
| 390 | const apps: Apps = { | |
| 393 | const { found, busy } = await timePhaseAsync(ctx.request, "search", () => find(ctx, a)); | |
| 394 | const apps: Apps = timePhase(ctx.request, "apps", () => { | |
| 395 | const seen = appsOf(ctx, ctx.auth.user); | |
| 396 | return { | |
| 391 | 397 | byId: seen.records, |
| 392 | 398 | names: new Set(seen.repos.map((r) => `${r.ownerName}/${r.name}`)), |
| 393 | 399 | enterable: enterableApps( |
| @@ -399,7 +405,9 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 399 | 405 | ctx.auth.user, |
| 400 | 406 | { named: false }, |
| 401 | 407 | ), |
| 402 | }; | |
| 408 | }; | |
| 409 | }); | |
| 410 | const rendering = performance.now(); | |
| 403 | 411 | |
| 404 | 412 | const chips = seg( |
| 405 | 413 | WORDS.map(([kind, label]) => ({ |
| @@ -464,7 +472,9 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 464 | 472 | ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind), |
| 465 | 473 | ) |
| 466 | 474 | .map((w) => section(a, w, ...rows[w[0]]()))}`; |
| 467 | return page({ ctx, title: `Search · ${a.query}`, body }); | |
| 475 | const response = page({ ctx, title: `Search · ${a.query}`, body }); | |
| 476 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 477 | return response; | |
| 468 | 478 | } |
| 469 | 479 | |
| 470 | 480 | // ---- confirmation ---- |
test/search-timing.test.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** /search's Server-Timing names its phases: session, search, apps, render (#3274). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | let dir = ""; | |
| 12 | let app: ReturnType<typeof createApp>; | |
| 13 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 14 | let bearer = ""; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "forge-search-timing-")); | |
| 18 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | const made = await createUser(app.db, { | |
| 21 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 22 | password: "a-good-passphrase", adminIfFirst: true, | |
| 23 | }); | |
| 24 | if (!made.ok) throw new Error(made.error); | |
| 25 | bearer = createToken(app.db, made.value.id, "t", "admin").token; | |
| 26 | }); | |
| 27 | ||
| 28 | afterAll(async () => { | |
| 29 | servers.stop(); | |
| 30 | app.close(); | |
| 31 | await rm(dir, { recursive: true, force: true }); | |
| 32 | }); | |
| 33 | ||
| 34 | const get = (path: string) => fetch(servers.url + path, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 35 | ||
| 36 | const phases = (res: Response) => | |
| 37 | (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); | |
| 38 | ||
| 39 | test("a search names session, search, apps and render once each, with durations", async () => { | |
| 40 | const res = await get("/search?q=forge"); | |
| 41 | expect(res.status).toBe(200); | |
| 42 | expect(await res.text()).toContain("Results for"); | |
| 43 | const header = res.headers.get("server-timing") ?? ""; | |
| 44 | for (const n of ["session", "search", "apps", "render"]) { | |
| 45 | expect(phases(res).filter((x) => x === n).length).toBe(1); | |
| 46 | const part = header.split(", ").find((p) => p.startsWith(`${n};`))!; | |
| 47 | expect(Number(/dur=([\d.]+)/.exec(part)?.[1])).not.toBeNaN(); | |
| 48 | } | |
| 49 | }); | |
| 50 | ||
| 51 | test("the empty search names session and render", async () => { | |
| 52 | const res = await get("/search"); | |
| 53 | expect(res.status).toBe(200); | |
| 54 | expect(phases(res)).toEqual(expect.arrayContaining(["session", "render"])); | |
| 55 | }); |
new above
offline