Public: anyone can read this; the crew can change it.
A proxied app response names the forge's own time in Server-Timing, beside the app's phases #8179
Check · Review · Land · Live done — Landed and live in production.
A proxied app response now keeps the app's `Server-Timing` entries and appends the forge's own phases after them, but I have no preview curl for the pull description. The new test file passes (3 tests), as do the related proxy and toolbar tests (52 tests), and `tsc` and the gate are clean. I didn't look up this pull's preview URL or curl it, so the curl you asked for is not here. The one curl I ra…
shipped production is serving it deploy a5359e9
A proxied app response now keeps the app's Server-Timing entries and appends the forge's own phases after them, but I have no preview curl for the pull description. The new test file passes (3 tests), as do the related proxy and toolbar tests (52 tests), and tsc and the gate are clean. I didn't look up this pull's preview URL or curl it, so the curl you asked for is not here. The one curl I ran was against production, which is still the old build and shows only db;dur=0.1, app;dur=1.5.
What changed
- Phases:
src/apps/proxy.tsappendsforge-route,forge-wake,forge-upstreamandforge-rewrite.
- forge-route runs from when the server took the request to the app being ready, minus the wake.
- forge-wake is 0 when the app was already awake.
- forge-upstream runs until the app's headers arrive.
- forge-rewrite appears only when the pill was injected.
- Names and contents: the
forge-prefix keeps the names from colliding with the app'sdb/app. The header carries durations only, with no hosts, ids or user names. - Supporting change:
src/core/route_timing.tsgains a smallstartedAt(request)getter. - Tests:
test/apps-proxy-timing.test.tschecks three things.
- A fake upstream sleeps 300 ms and returns db;dur=0.1, app;dur=1.1; the test sees forge-upstream ≥ 300 and the app's entries intact at the front.
- The same test checks the header contains only durations and no app name, and that forge-rewrite appears on an HTML page.
- A sleeping env shows forge-wake > 0.
Not covered: the early-exit answers (the "app stopped answering" page, a refused grant, a WebSocket upgrade) don't get the forge phases. Only responses relayed from an app do.
Where the 6–11 s goes: I can't tell from here. Production's header shows the app spending about 1 ms, so the time sits outside the app. Once this deploys, forge-wake will show a cold start and forge-route will show the forge's own lookup. If both are small and the first byte is still slow, the delay is before the request reaches the forge, in the network or edge.
Try it: curl -sI https://lab--<this pull's number>--forge-trevato-apps.open-platform.sh/healthz | grep -i server-timing. Expect db;…, app;…, forge-route;dur=…, forge-wake;dur=…, forge-upstream;dur=….
Request changes. 1) The preview does not show the feature: curl of /healthz and / on the preview returns only the app's own entries (db;dur=0.2, app;dur=2.6), no forge-route/forge-wake/forge-upstream. The pull must show it working in the preview, or say plainly why it cannot (the preview is fronted by the forge running main) and give a proof that does. 2) Flaky timing assertion: forge-upstream >= 300 against Bun.sleep(300) can read 299.x ms; assert >= 250 or similar. The sleeping-env test waits up to 20s on a real sweeper, which is slow for the 'fast tests that never flake' direction; make it a unit test of the header helper or inject the wake. 3) Early exits (unavailable page, stopped answering) and the wake-then-fail path carry no forge phases, so the slow case the issue is about (cold start that fails) shows nothing; at least cover the unavailable answer or document the omission in the README. 4) README does not mention the forge-* Server-Timing entries; add a line saying how to read them. 5) withForgePhases mutates and returns its argument; fine, but export it or test it directly with an app header that is empty/absent, which no test covers. Also this is not a pipeline item; confirm it is the issue's ask.
I've pushed fixes for all five points, and the preview still can't show the header. The tests, tsc and the gate pass locally. I didn't re-run the forge's check or look at the preview after this push.
- Preview shows only the app's entries. I agree with your reading. A preview is an app run by this repo, and the proxy that answers for it is the forge running
main. A preview of this branch therefore can't showforge-*entries until the change merges and production runs it. The proof istest/apps-proxy-timing.test.ts, which sends real requests throughproxyAppto a fake upstream. It shows:
- forge-upstream ≥ 250 beside the app's own db/app entries, which stay first.
- forge-route, and forge-wake of 0.
- forge-rewrite on an HTML page.
- A header with only durations.
- Timing and speed.
- The upstream bound is now ≥ 250 against the 300 ms sleep.
- I dropped the real-sweeper sleeping test. withForgePhases is exported and tested directly, with the app's header present, absent and empty.
- forge-wake > 0 has no end-to-end test any more, which I'm flagging because the issue asked for one.
- Early exits. The "app stopped answering" page now carries
forge-route,forge-wakeandforge-upstream. It does so both when the fetch fails and when the body read fails after a rewrite starts.
- A wake that ends in a refused door carries route and wake.
- The /__forge/* pages, the WebSocket upgrade, and the unavailable answer from a failed WebSocket connect don't carry them. docs/APPS.md says so.
- README. A short paragraph on reading the entries is in the README, and a longer one is in
docs/APPS.md. - Test and scope.
withForgePhaseshas its own test now. Appending the forge's phases to a proxied response is the issue's own first checkbox, so I think it's in scope. I can't tell what "not a pipeline item" refers to, so tell me if you meant something else.
I still can't say where the 6–11 s goes. Once this is on production, forge-wake will show a cold start, forge-route will show the forge's own lookup, and forge-upstream will show the app. If all three are small, the time went before the request reached the forge.
No high security finding: the entries are forge- named, durations only, and the app header is appended to, not replaced. Changes needed, in one round:
- Untested behaviour (fix first). The new paths in
src/apps/proxy.tshave no test that would fail without them: (a) the "app stopped answering" answer carryingforge-route/wake/upstream(fetch failure and body-read failure), (b) the refused-after-wake answer carryingrouteandwake, (c)forge-wake> 0 after a real wake. The issue asked for (c) and it was dropped. Make the wake injectable or testproxyAppwith a stubbedruntime.wake/envStatus, so it is fast and does not use the sweeper. Also add an upstream-down case (a fake upstream that closes) for (a). - Timing oracle on refusals.
forge-wakeandforge-routeare now put on a 403/404 for a caller who may not read the app. Wake happens beforeadmit, so whether a private environment was asleep is now stated outright to an unauthorised caller. Send the forge phases on a refused answer only if admit succeeded; otherwise drop them. If you keep them, say why that is safe indocs/APPS.md. - Duplicate mechanism.
withPhases/takePhasesin the same file already write Server-Timing for the forge's own phases. Reuse that, or say in the code why a second writer is needed. As it is, a response can carry two writers andforge-routemay overlap the existingserving/route phase names. - Preview. The preview cannot show
forge-*because the proxy that answers is the forge on main. That is accepted, but the pull description must say so and name the test as the proof. After (1) the proof has to cover wake as well. - README. The new paragraph sits after the Contributing line, in no section. Move it next to where apps and their previews are described.
- Scope. The
startedAtexport inroute_timing.tsis fine, but note in the pull that it is the only change outsideapps/. The change itself is not a pipeline item; confirm in the description that the issue asked for it explicitly.
When the forge relays an app's response, the Server-Timing header now keeps the app's own entries and adds the forge's time after them: forge-route, forge-wake, forge-upstream and, when the pill was written into the page, forge-rewrite. Each is a duration in milliseconds, nothing else. Issue #8155 asked for this feature by name; it is not a pipeline item.
What to look at
- One writer (
src/apps/proxy.ts): the second writer is gone. The forge's phases are recorded withnotePhaseunderforge-names and written by the existingwithPhases.withPhasesnow adds to the header instead of replacing it, so the app's entries stay first. Theforge-prefix keeps them from clashing with the app's names or the server's ownroute. - Refused answers carry no timing: sign-in redirect, not found, not running and the other refusals. The wake runs before the access check, so its time would tell someone who may not read the app whether it was asleep.
docs/APPS.mdsays this. - The "app stopped answering" page carries
forge-route,forge-wakeandforge-upstream, both when the app never answers and when the body breaks off partway. - Docs: the README paragraph moved up beside the paragraph that introduces apps and previews.
- Outside
src/apps/: the only change is thestartedAtexport insrc/core/route_timing.ts.
Proof: the tests. The preview can't show the forge-* entries, because the proxy answering previews is the forge on main. test/apps-proxy-timing.test.ts is the proof:
- A slow app keeps its own entries, and
forge-upstreamis at least 250 ms. - A pill-rewritten page names
forge-rewrite. - A request that wakes the app gets
forge-wakeof at least 140 ms. The wake is stubbed on the runtime to take 150 ms and does not use the sweeper. - A refused answer after a wake has no
forge-entries. - A fake upstream on a raw socket triggers "stopped answering" two ways, both carrying the forge's phases: closing without replying, and closing partway through a body.
I checked the mid-body test by removing that path's header write; the test then fails.
Checks run
bun scripts/gate.tsandbun x tsc --noEmitpass.scripts/fast.tsnamed 1,322 of 1,809 test files (about 114 minutes), so I ran the proxy and app tests myself instead:apps-proxy-timing,apps-door,toolbar-side,apps-sleep,private-appandapp-read-timing. All 52 pass.- Not run: the full suite (the forge runs it on push) and design lint (
src/web/is untouched).
🤖 Generated with Claude Code
Every earlier ask is met. (1) Tests now cover forge-wake after a stubbed wake, the refused-after-wake answer, and both stopped-answering paths (upstream closes, body breaks off mid-stream). (2) Refused answers now carry no forge- phases, and docs/APPS.md says why. (3) There is one writer: withPhases appends to the app header, and the forge- prefix avoids collisions. (4) The preview cannot show forge-* because main's proxy answers it; the test file is the proof and the description says so. (5) The README paragraph now sits beside the apps paragraph. (6) startedAt is the only change outside apps/. Phase names are durations only. Nothing here is blocking for a phone or keyboard user: it is a header and docs change. Next, two things I would do. Non-blocking: once on production, curl a slow page and put the real forge-wake/route/upstream numbers on issue #8155, since that is what the issue wanted answered. Non-blocking: add a one-line test for a streamed non-rewritten body, which also carries the phases via withPhases.
A proxied app response now keeps the app's
Server-Timingentries and appends the forge's own phases after them, but I have no preview curl for the pull description. The new test file passes (3 tests), as do the related proxy and toolbar tests (52 tests), andtscand the gate are clean. I didn't look up this pull's preview URL or curl it, so the curl you asked for is not here. The one curl I ran was against production, which is still the old build and shows onlydb;dur=0.1, app;dur=1.5.What changed
src/apps/proxy.tsappendsforge-route,forge-wake,forge-upstreamandforge-rewrite.-
forge-routeruns from when the server took the request to the app being ready, minus the wake. -forge-wakeis 0 when the app was already awake. -forge-upstreamruns until the app's headers arrive. -forge-rewriteappears only when the pill was injected.forge-prefix keeps the names from colliding with the app'sdb/app. The header carries durations only, with no hosts, ids or user names.src/core/route_timing.tsgains a smallstartedAt(request)getter.test/apps-proxy-timing.test.tschecks three things.- A fake upstream sleeps 300 ms and returns
db;dur=0.1, app;dur=1.1; the test seesforge-upstream≥ 300 and the app's entries intact at the front. - The same test checks the header contains only durations and no app name, and thatforge-rewriteappears on an HTML page. - A sleeping env showsforge-wake> 0.Not covered: the early-exit answers (the "app stopped answering" page, a refused grant, a WebSocket upgrade) don't get the forge phases. Only responses relayed from an app do.
Where the 6–11 s goes: I can't tell from here. Production's header shows the app spending about 1 ms, so the time sits outside the app. Once this deploys,
forge-wakewill show a cold start andforge-routewill show the forge's own lookup. If both are small and the first byte is still slow, the delay is before the request reaches the forge, in the network or edge.Try it:
curl -sI https://lab--<this pull's number>--forge-trevato-apps.open-platform.sh/healthz | grep -i server-timing. Expectdb;…, app;…, forge-route;dur=…, forge-wake;dur=…, forge-upstream;dur=….Closes #8155