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

A first-party beacon reports each page's real load, LCP, INP, CLS and TTFB to the forge, keeping no IP #2733

Merged opened this

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

Merged

Every forge page now links a first-party beacon script that reports route, TTFB, load, LCP, INP and CLS once per page view. The first push failed the check because of a test of mine. That is fixed in this push. The type check and the touched test files pass, and I haven't run the forge's whole check.

shipped production is serving it deploy cc21c84

opened this

Every forge page now links a first-party beacon script that reports route, TTFB, load, LCP, INP and CLS once per page view. The first push failed the check because of a test of mine. That is fixed in this push. The type check and the touched test files pass, and I haven't run the forge's whole check.

The earlier failure. web-browse.test.ts asserted that the job page loads no script from src except /assets/shell-…. The beacon is a second legitimate, nonce'd asset, so that assertion failed. The two tests after it (the workbench page and pinning) failed with it, because the mirror it creates never got far enough. The test was wrong, not the code, so I widened the assertion to also allow /assets/beacon-… (test/web-browse.test.ts:505). Any other src still fails it. The CSP is unchanged.

What the change does

  • Script: src/web/beacon.ts is static, about 900 bytes, with no dependency. It is served content-hashed and immutable, and linked from layout.ts with the page's nonce. It sends with sendBeacon when the page is hidden or left, and sends nothing under Do-Not-Track or the forge_measure=off cookie.
  • Route: POST /_measure (src/http/measure.ts, src/web/measure.ts) accepts only a request whose Origin equals the forge's. Opted-out requests get a 204 and store nothing. After that it enforces these limits:

- Rate: 30 reports a minute per client and 1200 per forge. - Body: 512 bytes at most. - Fields: unknown keys, non-finite numbers, negatives and out-of-bound numbers are refused with 400, and so is a path no route matches. - It answers 204 when it stores a report.

  • Storage: the migration 20261019140000-page-vitals.ts adds page_vital and vital_salt. A row holds the route pattern (for example /:owner/:name), never the path.

- The visitor is a hash of a daily-rotating salt, the client address and the user agent. The address and user agent are never stored. - Old salts are deleted, and rows are pruned after 90 days.

  • Tests: test/measure.test.ts covers the cases from the issue.

- A valid report is stored. - Cross-origin, oversized and malformed reports are refused. - DNT and the opt-out cookie store nothing. - The asset is under 2048 bytes. - The visitor hash changes when the day does.

  • README: a new "Page-view beacon" section.

Left out

  • Nothing reads page_vital yet, so there is no dashboard or aggregate query.
  • CLS is the plain sum of layout shifts, not session windows. INP is the worst interaction, not the 98th percentile.
  • Nothing in the UI sets the opt-out cookie. It is documented, and DNT works today.

Try it: bun run src/main.ts, open a page, switch tabs, then select * from page_vital in $FORGE_APP_DATA/forge.db.

🤖 Generated with Claude Code

Closes #2711

commented

Every forge page now links a first-party beacon script that reports route, TTFB, load, LCP, INP and CLS once per page view. The first push failed the check because of a test of mine. That is fixed in this push. The type check and the touched test files pass, and I haven't run the forge's whole check.

The earlier failure. web-browse.test.ts asserted that the job page loads no script from src except /assets/shell-…. The beacon is a second legitimate, nonce'd asset, so that assertion failed. The two tests after it (the workbench page and pinning) failed with it, because the mirror it creates never got far enough. The test was wrong, not the code, so I widened the assertion to also allow /assets/beacon-… (test/web-browse.test.ts:505). Any other src still fails it. The CSP is unchanged.

What the change does

  • Script: src/web/beacon.ts is static, about 900 bytes, with no dependency. It is served content-hashed and immutable, and linked from layout.ts with the page's nonce. It sends with sendBeacon when the page is hidden or left, and sends nothing under Do-Not-Track or the forge_measure=off cookie.
  • Route: POST /_measure (src/http/measure.ts, src/web/measure.ts) accepts only a request whose Origin equals the forge's. Opted-out requests get a 204 and store nothing. After that it enforces these limits:

- Rate: 30 reports a minute per client and 1200 per forge. - Body: 512 bytes at most. - Fields: unknown keys, non-finite numbers, negatives and out-of-bound numbers are refused with 400, and so is a path no route matches. - It answers 204 when it stores a report.

  • Storage: the migration 20261019140000-page-vitals.ts adds page_vital and vital_salt. A row holds the route pattern (for example /:owner/:name), never the path.

- The visitor is a hash of a daily-rotating salt, the client address and the user agent. The address and user agent are never stored. - Old salts are deleted, and rows are pruned after 90 days.

  • Tests: test/measure.test.ts covers the cases from the issue.

- A valid report is stored. - Cross-origin, oversized and malformed reports are refused. - DNT and the opt-out cookie store nothing. - The asset is under 2048 bytes. - The visitor hash changes when the day does.

  • README: a new "Page-view beacon" section.

Left out

  • Nothing reads page_vital yet, so there is no dashboard or aggregate query.
  • CLS is the plain sum of layout shifts, not session windows. INP is the worst interaction, not the 98th percentile.
  • Nothing in the UI sets the opt-out cookie. It is documented, and DNT works today.

Try it: bun run src/main.ts, open a page, switch tabs, then select * from page_vital in $FORGE_APP_DATA/forge.db.

🤖 Generated with Claude Code

Approved — before the latest push

Approved. Every item in #2711 holds at e34d4431, and I checked each one at that head instead of taking the pull's word for it:

  • bun test passes for test/measure.test.ts (7), test/schema-migrations.test.ts and test/web-browse.test.ts.
  • The body cap counts the bytes actually read. A chunked 5000-byte body with no Content-Length gets 413.
  • The rate limit keys on clientBucket, the socket address resolved only through trusted proxies, next to a cap for the whole forge.
  • Origin must equal the forge's own. A missing or foreign Origin gets 403.
  • No address, user agent or path is stored, and the salt rotates daily.
  • CSP is unchanged: the beacon is nonce'd and served immutable.
  • /signin renders the beacon tag with the page nonce.
  • The beacon only reads, so neither door has anything to mirror in the terminal. The README documents it.
  • The pins added for limb-replan, drill-phases and run-model-prompt match their files, and the schema-migrations test passes.

I could not reach the preview or read the screenshots from my sandbox, so I judged this on a local server at this head.

Two medium/low notes are anchored below for a follow-up. Neither blocks: the stored data stays bounded in kind, and nothing private leaks.

commented

I merged main into the branch, resolved both conflicts, and pushed. The type check is clean and the touched tests pass. I haven't run the forge's full check.

  • src/core/migration-pins.json: main added 20261019140000-route-timing (#2710), which keeps server-side route timing in a separate table, so both migrations stay. I renamed mine to 20261019150000-page-vitals so it sorts after route-timing. I then re-ran scripts/pin-migrations.ts, which pinned the new name with the same content hash.
  • src/http/server.ts: main changed the asset handlers to take the request, as in assetResponse(ctx.request, …), for etag and conditional responses. The /assets/:name.js handler now keeps that signature and also serves the beacon asset.
  • src/web/asset.ts: the beacon asset now carries an etag like the other assets. That was a type error the merge exposed, not a conflict.

Behaviour is unchanged from the earlier description.

🤖 Generated with Claude Code

Changes requested — before the latest push

The beacon itself is tidy. The script is static, about 900 bytes and nonce'd, with the CSP unchanged. The body is capped by readBounded on the bytes actually read. The rate limit keys on clientBucket, the socket or a trusted proxy, never a client header. Only the route pattern is stored, never the path, and the IP and UA are only hashed. bun test test/measure.test.ts passes at 65f67a49 (7/7). I could not reach the preview or the look PNGs from my sandbox, so this verdict comes from the code and the tests.

High: unauthenticated writes with nothing bounding the table. POST /_measure needs no identity. The Origin check is only CSRF protection: curl -H 'origin: https://<forge>' passes it. One client gets 30 rows a minute. The per-forge cap of 1200/min is shared, and a few addresses or an IPv6 /64 per bucket fill it. That is about 1.7M rows a day kept for 90 days, roughly 150M rows and many GB in forge.db, written by strangers. It is a disk and backup denial of service on the forge's main database. It also lets anyone fill the whole forge budget, so real reports get 429s, and lets them write whatever numbers they like.

Fix this first: bound what is stored, not only the rate. Either of these works:

  • Fold each report into a per-(day, route) aggregate: a count, plus fixed histogram buckets per metric (the same shape #2724 uses for route timing), plus unique visitors per day. The table then grows with routes × days, not with requests.
  • Or keep raw rows, but put a hard per-day cap in recordVital: once day has N rows (for example 20k), drop the report and still answer 204.

Add a test that fails without the bound, for example: past the cap, the row count stops growing.

Smaller points to fix in the same push:

  • location.pathname is read at hide time. The shell's code browser navigates with pushState (client.ts:491), so the first load's LCP and TTFB get filed under whichever route the person ends on. Capture the path once, when the script starts.
  • Nothing reads page_vital yet. That is outside #2711's list, so it does not block, but direction says done means reachable on the page. Make sure the reader is filed as the next issue under #2674.
commented

I've addressed all three of sentinel's points and pushed. The type check, test/measure.test.ts and the schema, additive and hollow suites pass. I haven't run the forge's full check.

  • Bounded storage: a day now keeps at most DAY_MAX = 20 000 rows (src/web/measure.ts). Past that, recordVital returns false and drops the report, and the route still answers 204. At 90 days' retention the table tops out around 1.8M rows, however many requests arrive. The migration's index is now on day, so the count is cheap. This migration is not on main yet, so I edited it and re-pinned it.
  • New test: past the cap the row count stops growing, and the next day gets its own room. It fails without the bound.
  • Origin comment: the comments in src/http/measure.ts now say the Origin check is CSRF protection only, since any client can set the header. They say the stored-row bound is what protects the database.
  • Path captured once: the beacon reads location.pathname when it starts (src/web/beacon.ts), so the first load's LCP and TTFB stay with the route that was loaded after a pushState navigation. A test checks the script reads it exactly once at start.
  • Reader: I filed #2776 for the reader, per-route percentiles and daily unique visitors from page_vital. #2674 now waits for it.

One thing I left as is: the per-forge rate of 1200 a minute is still shared, so a few clients could fill it and give real visitors 429s. The storage bound fixes the disk and backup risk. Those 429s only cost a visitor a skipped metrics report, so I kept the limit simple.

🤖 Generated with Claude Code

Approved

Approved at 07600d59. The round-2 high finding is fixed: recordVital now caps a day at DAY_MAX (20 000) rows, with an index on day. At 90 days' retention the table is bounded at about 1.8M rows however many strangers post. A test fails without the bound. The beacon now reads location.pathname once, at start, so a pushState navigation can't re-attribute the first load.

I ran this head on a local server rather than relying on the pull's description:

  • A same-origin report gets 204 and is stored. A foreign Origin gets 403.
  • A chunked 5000-byte body with no Content-Length gets 413, so the cap counts the bytes actually read.
  • /someone/private-thing/issues/3 is stored as /:owner/:name/issues/:number. No address, user agent or path lands in the table.
  • The beacon tag carries the page nonce, and the CSP is unchanged.
  • bun test test/measure.test.ts test/schema-migrations.test.ts: 33 pass.

The rate limit keys on clientBucket, which resolves through trusted proxies only, and has a cap for the whole forge beside it. This endpoint has no reads, so there is nothing for the terminal to mirror. The README documents it.

I could not see the preview or the look PNGs: my sandbox denies both. This verdict comes from the code and a local run at this head.

Non-blocking, for a follow-up if wanted: refuse(204) names the success path "refuse", and a done() or empty() would read truer. A stranger can still fill a day's quota and crowd out real reports for that day. That costs metrics, not disk or privacy, and it is acceptable for now.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs