Measure k: count the page's own script calls on both sides of net offload (browser v1.22.0, plugin v0.65.0, console v0.13.0) - #154
Conversation
…che verdict; v1.22.0 The offload figure downstream counts documents. It cannot see the other half of a page-view — the XHR/API calls a page's own scripts make once a crawler that executes JavaScript runs it — because those go crawler → CDN → origin and never pass through the plugin. This process runs the same page in the same kind of browser and reads every response's cache headers, so the per-page factor is measurable exactly once, here (#153). Every same-origin response beyond the document is classified by whether a SHARED cache would have answered it (RFC 9111): `uncacheable` (non-GET, an uncacheable status, Set-Cookie, no-store/private/no-cache, zero max-age, Vary: *, expired Expires) is the origin load a crawler's page-view carries; `cacheable` is explicit positive freshness; `unspecified` had no freshness information and depends on the CDN's defaults, so it is reported and counted on neither side. Same-origin requests the block list aborted are counted as `blocked` — requests a crawler would make, of unknown class — so the undercount is visible. Posted with every result alongside `scriptsStripped`, which is what turns the factor from a cost into a saving at serve time. Header checks on a hook that already fires per response; no body reads, nothing awaited. Per-window totals ride the stats line. Co-Authored-By: Claude Code <noreply@anthropic.com>
…offload ledger; v0.65.0 Net offload subtracts the origin requests this system makes, but a crawler that executes scripts makes the page's own XHR/API calls to the origin when it runs the page it was handed — and those never pass through here. The render fleet now measures that per page (browser 1.22.0 posts `subrequests`, whose `uncacheable` count is k, plus `scriptsStripped`), and this release stores it and applies it at serve time (#153). - PrerenderedPage gains `uncacheableSubrequests` and `scriptsStripped`, written on store; null — never 0 — when the worker predates the tally. - `render` gains series `subrequests` (method = class, one VALUE per class per result): what the fleet's own renders cost the origin beyond the documents, with `unspecified` and `blocked` reported beside `uncacheable` so k reads as the bound it is. - New metric `hydration_calls` (side × botName × source, value = k), emitted on the serve path for crawlers the registry flags `rendersJs`: `saved` when a script-stripped snapshot is cache-served (the origin is spared k), `incurred` when the snapshot kept its scripts or the page came from the origin, `unknown` when k is not known (a miss, or a pre-1.22.0 render) — value 0, count the rows. A row that does not say whether its scripts were stripped reads as kept: the side that cannot over-credit a saving. - `analytics.bots[].rendersJs` flags the documented renderers (Googlebot, the URL Inspection tool, Bingbot, Applebot, YandexBot); every AI crawler stays unflagged. It is a claim about the crawler, not an observation. One counter bump per rendering-crawler serve, two nullable fields per page. Co-Authored-By: Claude Code <noreply@anthropic.com>
… offload; v0.13.0 Reads plugin 0.65.0's `hydration_calls` (side × bot × source, value = k) and `render` `subrequests` and folds them into `originLoad`: baseline = crawler requests + Σsaved + Σincurred actual = proxied + renders + Σk_renders + probes + sitemaps + Σincurred so a script-stripped snapshot handed to a crawler that would have run the page is CREDITED the calls it spared, a snapshot that kept its scripts or a proxied page is CHARGED them, and the fleet's own renders carry theirs. The documents-only figure stays on the tile beside it; the `unknown` count — serves with no k yet — is printed as the size of the blind spot, and while it outnumbers the known serves the panel says the figure is still mostly documents-only. The `unspecified` and `blocked` bounds on k ride along. Against an older plugin nothing changes: the panel keeps v0.12.0's reading (documents-only on both sides, the exposure stated, no guessed factor). The catalog guard learns `hydrationCalls` carries a dimension in its path slot — it went red the moment the plugin half landed, which is what it is for. Co-Authored-By: Claude Code <noreply@anthropic.com>
…hes those, and they say nothing about k Co-Authored-By: Claude Code <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces tracking and counting of same-origin subrequests (XHR/fetch calls) made by pages when executed by JavaScript-running crawlers. It updates the browser renderer to classify and tally these requests based on RFC 9111 shared-cache rules, stores the results in the page cache, and integrates these metrics into the plugin and management console to provide a more accurate net offload calculation. Feedback on the changes suggests improving the robustness of Cache-Control directive parsing to allow optional whitespace around the equals sign per RFC 9111, and refactoring configuration caching in userAgent.js to pass cached values as explicit parameters for better testability.
| * entry to carry the flag. | ||
| */ | ||
| export const botRendersJs = (botName) => { | ||
| if (config.analytics.bots !== rendersJsFrom) { |
There was a problem hiding this comment.
When caching configuration values or computed sets at the module level, pass the cached value as an explicit parameter to functions rather than closing over the module-level variable to keep functions pure and testable. Use nullish coalescing assignment (??=) to handle cases where the module is imported before the configuration is first applied, and add tests to verify that live configuration changes correctly update the cache.
rendersJsSet ??= new Set(config.analytics.bots || []);References
- When caching configuration values or computed sets at the module level (e.g., using config application hooks), pass the cached value as an explicit parameter to functions rather than closing over the module-level variable to keep functions pure and testable. Use nullish coalescing assignment (??=) to handle cases where the module is imported before the configuration is first applied, and add tests to verify that live configuration changes correctly update the cache.
There was a problem hiding this comment.
Declining the ??= shape, taking the test. botRendersJs deliberately mirrors botMayDiscover and botCountsAsDemand in the same file: an identity-keyed memo on config.analytics.bots, because applyOptions replaces that array on every change. A ??= would build the set once and then ignore a registry edit made from the console for the life of the process — the failure the identity check exists to prevent. (The suggested new Set(config.analytics.bots) would also be a set of entry objects, not of names.) The valid half of the finding — prove it follows a live change — is now pinned in test/userAgent.test.js in both directions, plus the junk-flag and derived-name cases (325f72e).
| const directiveSeconds = (cc: string, name: string): number | null => { | ||
| const m = cc.match(new RegExp(`(?:^|[,\\s])${name}=(\\d+)`)); | ||
| return m ? parseInt(m[1], 10) : null; | ||
| }; |
There was a problem hiding this comment.
RFC 9111 allows optional whitespace around the = character in Cache-Control directives (e.g., max-age = 60 or max-age= 60). The current regex (?:^|[,\\s])${name}=(\\d+) does not allow spaces around =, which can cause valid cache-control headers to be incorrectly classified as 'unspecified'. Updating the regex to (?:^|[,\\s])${name}\\s*=\\s*(\\d+) makes the parsing more robust and compliant with HTTP specifications.
| const directiveSeconds = (cc: string, name: string): number | null => { | |
| const m = cc.match(new RegExp(`(?:^|[,\\s])${name}=(\\d+)`)); | |
| return m ? parseInt(m[1], 10) : null; | |
| }; | |
| const directiveSeconds = (cc: string, name: string): number | null => { | |
| const m = cc.match(new RegExp('(?:^|[,\\s])' + name + '\\s*=\\s*(\\d+)')); | |
| return m ? parseInt(m[1], 10) : null; | |
| }; |
There was a problem hiding this comment.
Taken in 325f72e — whitespace around = and a quoted delta-seconds both parse now, with a test. Strictness here would have pushed a response a CDN happily caches into unspecified.
…ve-registry recompile (review) Whitespace around `=` and a quoted delta-seconds are read the way the caches the classifier stands in for read them — strictness would push a response a CDN happily caches into `unspecified`. botRendersJs keeps the identity-keyed memo its two siblings use rather than a `??=` (which would freeze the set at first call and ignore a registry edit); the tests now prove it follows a live change in both directions. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Addressed both inline findings in 325f72e: lenient Cache-Control delta-seconds parsing (taken), and the userAgent memo (kept the identity-keyed pattern its siblings use — a |
Closes #153. Follow-up to #152, which stated that the offload figure counted documents only and that the requests a page's own scripts make — when a crawler that executes JavaScript runs the page it was handed — were missing from both sides of the ledger. This measures that term and counts it.
Three packages, one PR, three release tags (the console's catalog guard scans the plugin's emit sites, so the plugin half cannot merge alone). Each stage tolerates the others being absent.
What
kis and where it landsk(page)= same-origin requests a page load makes beyond the document whose response no shared cache would serve — the XHR/API calls that reach the origin whoever runs the page.1 + k0—ksavedkincurred1 + kincurred1 + k(the renderer runs the page too)Browser
v1.22.0— measures itThe renderer's response hook already fires per response and already reads cache headers for its own resource cache. A pure classifier (subrequests.ts) judges every same-origin response by RFC 9111 shared-cache rules:
uncacheable(non-GET, an uncacheable status,Set-Cookie,no-store/private/no-cache, zeromax-age/s-maxage,Vary: *, expiredExpires) isk;cacheableis explicit positive freshness;unspecifiedhad no freshness headers at all and depends on the CDN's default TTL — reported, counted on neither side.blockedcounts same-origin requests the fleet's block list aborted (a crawler would make them; class unknown), with static media left out since a CDN caches those regardless. Posted on every result assubrequests: { sameOrigin, cacheable, uncacheable, unspecified, blocked }plusscriptsStripped(the fleet'spostProcess.stripScripts), which is what turnskfrom a cost into a saving at serve time. Header checks only; nothing awaited; ~5 ints on the wire.Plugin
prerender-v0.65.0— stores and applies itPrerenderedPagegainsuncacheableSubrequestsandscriptsStripped(nullable — a pre-1.22.0 render stores null, never 0; the serve path reads null as unknown).rendergains seriessubrequests(one VALUE per class per result): what the fleet's own renders cost the origin beyond the documents.hydration_calls(side × botName × source, value =k), emitted on the serve path for crawlers the registry flagsrendersJs:saved(script-stripped snapshot cache-served),incurred(snapshot kept scripts, or any origin serve),unknown(nok— a miss, or a page not yet re-rendered; value 0, count the rows). A row that doesn't say whether scripts were stripped reads as kept — the side that cannot over-credit.analytics.bots[].rendersJsflags the documented renderers only: Googlebot, Google InspectionTool, Bingbot, Applebot, YandexBot. Every AI crawler stays unflagged. It's a claim about the crawler, not an observation, and the schema text says so.Console
prerender-console-v0.13.0— counts itoriginLoad()becomesThe Traffic tile reads "script calls counted"; the panel shows Script calls: N saved · M incurred with the documents-only figure beside the full one for comparison, two new bars (render script calls, crawler script calls), and prints the
unknowncount as the size of the blind spot — while it outnumbers the known serves the panel warns that the figure is still mostly documents-only (it decays over one render cycle after the fleet upgrade).unspecifiedandblockedare printed as the bounds onk. Against an older plugin every panel keeps v0.12.0's reading.Verification
npm test→ 172 pass (tsc build + suite); classifier rules pinned intest/subrequests.test.ts, wire shape intest/jobResult.test.ts.node --test→ 1009 pass;hydration_callssides pinned per source × stripped × k-known intest/botServe.test.js; tally storage/emission and the null-not-zero rule intest/renderQueueRedirect.test.js;test/metrics.test.jsenforces catalog + emitter + METRICS.md.node --test→ 262 pass; measured-mode arithmetic, the mostly-unknown warning, and the older-plugin fallback intest/trafficView.test.js.npm run lint && npm run format:checkclean. No PR CI in this repo.Rollout order (after merge + three releases)
kstarts landing on pages as they re-render.components/prerender→ 0.65.0 (hydration_callsreadsunknownuntil pages carryk).components/console→ 0.13.0.Any order is safe; this one shortens the
unknownwindow.🤖 Generated with Claude Code