Public: anyone can read this; the crew can change it.

An app's studio page names its Server-Timing phases: session, each read, render #3642

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3631-an-app-s-studio-page-names-its-server-ti → main 1 commit fork

  • Merged. scout/3631-an-app-s-studio-page-names-its-server-ti was merged into main as 46eb2d7.

Review

Sign in to review this change.
2 files changed +137 −32
src/web/studio/app.ts +57 −32
@@ -55,6 +55,12 @@ import { repoAsks } from "./asks.ts";
5555 import { appPanels } from "./previews.ts";
5656 import { noCrewLine, servingHistory, type WordCtx } from "./words.ts";
5757 import { crewKeyNote } from "./crew.ts";
58 import {
59 notePhase,
60 takeSessionTime,
61 timePhase,
62 timePhaseAsync,
63 } from "../../core/route_timing.ts";
5864 import { noteAppOpened } from "../../core/next-steps.ts";
5965
6066 /**
@@ -213,6 +219,8 @@ export async function studioApp(
213219 record: AppRecord,
214220 ): Promise<Response> {
215221 const db = ctx.app.db;
222 const req = ctx.request;
223 notePhase(req, "session", takeSessionTime(req));
216224 const base = repoBase(repo);
217225 // Money, the crew roster, autonomy limits and the decision ledger are the
218226 // owner's facts, not the app's — a viewer who cannot write here reads this
@@ -223,7 +231,9 @@ export async function studioApp(
223231 // the app IS the step, and a beacon would miss a person without
224232 // JavaScript. It is one user_setting row, written once, only on a spawned
225233 // forge, only for its owner, and changes nothing anyone else sees.
226 await noteAppOpened(ctx.app, ctx.auth.user);
234 await timePhaseAsync(req, "opened", () =>
235 noteAppOpened(ctx.app, ctx.auth.user),
236 );
227237 const wordCtx: WordCtx = {
228238 db,
229239 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
@@ -232,27 +242,33 @@ export async function studioApp(
232242 history: servingHistory(),
233243 viewer: ctx.auth.user,
234244 };
235 const crew = listCrewRoles(db, repo.id);
245 const crew = timePhase(req, "crew", () => listCrewRoles(db, repo.id));
236246 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
237 const openPulls = listPulls(db, repo.id, {
238 state: "open",
239 limit: 10,
240 ...sealedScope(db, repo, ctx.auth.user),
241 });
247 const openPulls = timePhase(req, "pulls", () =>
248 listPulls(db, repo.id, {
249 state: "open",
250 limit: 10,
251 ...sealedScope(db, repo, ctx.auth.user),
252 }),
253 );
242254 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
243255 // What landed this week, counted by the database like the runs and the
244256 // spend below — a page of closed pulls would stop at a hundred and the
245257 // cost per shipped change would read high off the short count.
246 const shipped = mergedPullCount(db, repo.id, week);
258 const shipped = timePhase(req, "shipped", () =>
259 mergedPullCount(db, repo.id, week),
260 );
247261 // The rest is spend, worked out only for a viewer who is going to see it —
248262 // counted by the database over the whole week, not off a page of rows.
249263 const weekTotals = ownerFacts
250 ? agentRunTotals(db, repo.id, week)
264 ? timePhase(req, "spend", () => agentRunTotals(db, repo.id, week))
251265 : { runs: 0, spentUsd: 0 };
252266 const spent = weekTotals.spentUsd;
253 const ledgerWeek = ownerFacts ? decisionStats(db, repo.id, week) : [];
267 const ledgerWeek = ownerFacts
268 ? timePhase(req, "decisions", () => decisionStats(db, repo.id, week))
269 : [];
254270 const weekLatency = ownerFacts
255 ? previewLatency(db, repo.id, week)
271 ? timePhase(req, "latency", () => previewLatency(db, repo.id, week))
256272 : undefined;
257273 // Whether there is any history at all — ever, not this week's — so a
258274 // fresh app reads as "nothing yet" rather than a stretch of zeros and a
@@ -261,24 +277,23 @@ export async function studioApp(
261277 // gets the quieter line. Deploys are not part of this: they happen on
262278 // their own the moment an app exists, so they say nothing about whether
263279 // anyone has done anything yet.
264 const everShipped = shipped > 0 || mergedPullCount(db, repo.id, EPOCH) > 0;
265 const everRuns = ownerFacts
266 ? weekTotals.runs > 0 || agentRunTotals(db, repo.id, EPOCH).runs > 0
267 : false;
268 const hasHistory = everShipped || everRuns;
280 const hasHistory = timePhase(req, "history", () => {
281 const everShipped = shipped > 0 || mergedPullCount(db, repo.id, EPOCH) > 0;
282 const everRuns = ownerFacts
283 ? weekTotals.runs > 0 || agentRunTotals(db, repo.id, EPOCH).runs > 0
284 : false;
285 return everShipped || everRuns;
286 });
269287 const autonomy =
270288 !ownerFacts || repo.autonomy === "off"
271289 ? undefined
272 : autonomyStatus(ctx.app, repo);
290 : timePhase(req, "autonomy", () => autonomyStatus(ctx.app, repo));
273291
274292 // ---- the app itself ----
275293 // `deployTailing` says the head card is tailing production's own deploy —
276294 // the "Now" panel's empty-state words below care about that distinction.
277 const { head, previews, deployTailing } = appPanels(
278 ctx,
279 repo,
280 record,
281 openPulls,
295 const { head, previews, deployTailing } = timePhase(req, "panels", () =>
296 appPanels(ctx, repo, record, openPulls),
282297 );
283298
284299 // ---- the box ----
@@ -318,13 +333,15 @@ export async function studioApp(
318333 </form>`;
319334
320335 // ---- now: one row per thing, in the five words (studio/now.ts) ----
321 const { card: now } = await nowCard(ctx, repo, record, {
322 openPulls,
323 builderCount: builders.length,
324 ownerFacts,
325 wordCtx,
326 deployTailing,
327 });
336 const { card: now } = await timePhaseAsync(req, "now", () =>
337 nowCard(ctx, repo, record, {
338 openPulls,
339 builderCount: builders.length,
340 ownerFacts,
341 wordCtx,
342 deployTailing,
343 }),
344 );
328345
329346 // ---- the rail ----
330347 // Crew, spend and autonomy are the owner's facts; a viewer who cannot
@@ -453,7 +470,13 @@ export async function studioApp(
453470
454471 // An app that follows another says so before anything else: whether it
455472 // is in step is the first thing its owner wants to know.
456 const following = await followingOf(ctx, repo);
473 const following = await timePhaseAsync(req, "following", () =>
474 followingOf(ctx, repo),
475 );
476 const policy = await timePhaseAsync(req, "policy", () =>
477 policyLine(ctx, repo, record),
478 );
479 const rendering = performance.now();
457480 const body = html`${repoHeader(ctx, repo, "app", {
458481 compact: true,
459482 ownTitle: true,
@@ -461,7 +484,7 @@ export async function studioApp(
461484 ${following === undefined ? "" : followingCard(ctx, repo, following)}
462485 <div id="app-head">${head}</div>
463486 ${askingLine(ctx, repo)}
464 ${await policyLine(ctx, repo, record)}
487 ${policy}
465488 ${ownerFacts ? crewKeyNote(ctx) : null}
466489 ${ownerFacts
467490 ? egressNote(
@@ -484,7 +507,7 @@ export async function studioApp(
484507 </aside>
485508 </div>
486509 ${liveMount({ repo: base, app: true })}`;
487 return page({
510 const response = page({
488511 ctx,
489512 title: `${repo.name}`,
490513 description: appLine({
@@ -503,4 +526,6 @@ export async function studioApp(
503526 body,
504527 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${appTailJs}`,
505528 });
529 notePhase(req, "render", performance.now() - rendering);
530 return response;
506531 }
test/studio-app-timing.test.ts added +80
@@ -0,0 +1,80 @@
1 /** `/:owner/:name/app` names session, each read and render in Server-Timing (#3631). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { enableApp } from "../src/actions/apps.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { proveAll } from "./helpers/verified.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let root = "";
17 let app: App;
18 let servers: Servers;
19
20 beforeAll(async () => {
21 root = mkdtempSync(join(tmpdir(), "forge-studio-app-timing-"));
22 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
23 const u = await createUser(app.db, {
24 origin: "seed",
25 trusted: true,
26 username: "alice",
27 email: "[email protected]",
28 password: "a-good-passphrase",
29 adminIfFirst: true,
30 });
31 if (!u.ok) throw new Error(u.error);
32 proveAll(app);
33 const made = await createRepo(app, { id: u.value.id, username: "alice" } as never, { name: "vault" });
34 if (!made.ok) throw new Error(String(made.error));
35 const seeded = await commitToBranch(app, u.value, made.value, {
36 message: "Start",
37 files: [{ path: "README.md", content: "v\n" }],
38 });
39 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
40 const enabled = await enableApp(app, u.value, made.value, { slug: "vault" });
41 if (!enabled.ok) throw new Error(String(enabled.error));
42 servers = await serve(app);
43 });
44 afterAll(() => {
45 servers.stop();
46 app.close();
47 rmSync(root, { recursive: true, force: true });
48 });
49
50 test("GET /:owner/:name/app names its phases", async () => {
51 const signin = await fetch(`${servers.url}/signin`, {
52 method: "POST",
53 headers: { origin: servers.url },
54 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
55 redirect: "manual",
56 });
57 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
58 const res = await fetch(`${servers.url}/alice/vault/app`, { headers: { cookie } });
59 await res.text();
60 expect(res.status).toBe(200);
61 const parts = (res.headers.get("server-timing") ?? "").split(", ");
62 for (const name of [
63 "app",
64 "session",
65 "opened",
66 "crew",
67 "pulls",
68 "shipped",
69 "spend",
70 "decisions",
71 "latency",
72 "history",
73 "panels",
74 "now",
75 "following",
76 "policy",
77 "render",
78 ])
79 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
80 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs