Architecture

Measurement Classes

Every number this project produces belongs to one of four classes. The class decides which instrument may produce it, where it may be archived, and whether a regression in it can fail a build. Picking the wrong class is how a verdict moves without the system changing.

Class What it is Instrument CI Gated
A Counts and bytes collect-metrics.ts Yes Yes
B Same-process ratios PHP closures, paired probes No No
C Absolute wall clock None that is trustworthy Banned No
D Deployed CPU wrangler tail, cpuTime Manual No

Class A: Deterministic Counts and Bytes

Same input, same output, no clock. A bundle's gzipped size, the number of CREATE INDEX statements in the shipped pack, the charged rows one stored row costs, the chunk count of a migration, the number of collected test cases. Asking the question twice gives the same answer by definition.

This is the only class a shared CI runner can produce reliably, so it is the only class the Class A Metrics workflow collects, archives and gates. scripts/measure/collect-metrics.ts emits one JSON document; scripts/measure/metrics-gate.ts compares it to the document archived by the last passing master run and exits non-zero when a metric crosses its tolerance.

The baseline is the document archived by the last passing master run, fetched with gh run download. No baseline file is committed. --require-baseline makes a download that produced nothing a failure rather than a thin pass on the two checks that need no baseline at all.

The document carries no timestamp. Two runs on one commit produce identical bytes, so a diff between two archived documents is a real change rather than a clock reading. tests/node/metrics.spec.ts asserts that property directly.

Three rules keep the class honest:

What the Toolchain Contributes

Two Class A metrics are deterministic only against a pinned toolchain. The bundle figure is what wrangler's own compressor prints, so the wrangler version travels with the number in the document. The collected case count comes from vitest list, which is collection without execution. A version bump moves either one, the gate reports it, and the next master document records the move.

The case count is collected under DRUPFLARE_LIST_ALL=1, which keeps it a property of the checkout. vitest list otherwise honours the ARTIFACT_SPECS exclusion, so the count depends on whether the pack is present.

What Was Rejected From Class A

Coverage percentage. It depends on which specs ran. Artifact-gated specs skip when assets/drupal-sql is absent and tests/node/zlib-php.spec.ts skips without a PHP binary carrying ext-zlib, so the same source yields different coverage on different machines. The repository already enforces coverage thresholds in vitest.config.ts through the Check Test Coverage workflow, which is the right place for a number that depends on the lane.

The node lane's test-case count. tests/node/zlib-php.spec.ts builds its it.each table from a live php subprocess, so the count changes with whether PHP is installed and with what that driver reports. It is an environment reading wearing a count's clothes. The lane's spec-FILE count is collected instead, because a glob over the checkout is deterministic everywhere.

Class B: Same-Process Ratios

A ratio between two measurements taken in one process, in one invocation, on one machine: the bucket attribution that puts event dispatch at 4.3x native, cache_contexts at 5.8x, the renderer at 1.6x. Both halves move together when the machine is loaded, so the ratio survives what the absolutes do not.

These stay out of CI. A ratio is only meaningful next to the conditions it was taken under, and a runner cannot state those conditions. They are recorded in the technical report with the cache bins that were emptied, the n, and the spread, which is what makes them readable a year later.

Class C: Absolute Wall Clock

Banned outright, in CI and out of it.

A duration taken from PHP's microtime() around a synchronous PHP run reads 0 on the edge, because the clock does not advance across it. Date.now() inside the isolate returns 0 or a plausible wrong number; it has reported 114 ms for a 1,374 ms invocation. A zero is obviously broken. The 114 passes review, which makes this class dangerous as well as useless. A local wrangler dev wall clock cannot even order two profiles correctly.

Nothing in the metrics workflow may emit, archive or render a duration. tests/node/metrics.spec.ts asserts that the collected document matches no durationMs/elapsed/timestamp key and that the rendered summary carries no millisecond figure, so a build enforces the ban.

The One Wall Clock That Is Allowed

A Date.now() delta spanning I/O is a different measurement and is usable. The clock updates on I/O completion, so a delta bracketing an HTTP call measures that call; what it cannot do is span a synchronous php._run(), where the clock does not advance at all.

That is what scripts/measure/vps-compare.ts runs on. It executes under bun rather than inside a Worker, and every reading brackets a request from outside the thing serving it. It is still a Class C figure in one respect: it is a SAME-MACHINE RATIO between two arms, never an absolute, and the absolute for the drupflare side comes from Class D. The rig also measures its own ceiling first (--workload=ceiling), because the generator shares a machine with both arms.

Class D: Deployed CPU

The only trustworthy absolute: cpuTime from wrangler tail on a deployed worker, with durableObject events explicitly requested, because a tail silently omits them otherwise and an empty tail proves nothing.

It cannot be automated here. It needs a real deploy against an account that holds production workers, a cfw-* name, a teardown, and a check that the worker list returns to its prior baseline. It also needs an n and a spread with every figure: the platform's reported 400-600 ms bimodality is unverified as a standing property on the same object, so an n=1 or n=3 verdict about anything under ~500 ms is unsupportable.

That makes it a workflow_dispatch shape with a credential and a human deciding to spend it, never a nightly. scripts/measure/read-tail-cpu.mjs is the reader; the readings live in TECHNICAL_REPORT.md.

Running It

bun run metrics                         # the document, to stdout
bun run metrics --no-bundle --no-vitest # the cheap collectors only
bun run metrics --out=dist/metrics.json
bun run metrics:gate --metrics=dist/metrics.json # exits 1 on a regression
bun run metrics:gate --metrics=dist/metrics.json --baseline-metrics=master.json
bun scripts/ensure-artifacts.ts --dry-run # what a lane can and cannot measure here

bun scripts/ensure-artifacts.ts produces what the collectors read, and CI runs it before the collector. Three of the four inputs need no release payload: assets/drupal/site.sqlite is tracked so assets:sql always works, the modules come from the sibling checkouts, and the interpreter comes from the public CDN with no credential. Only assets/drupal-pf needs a payload, and no gated metric reads it, so a clean checkout gates every Class A metric, and a metric reporting itself as not collected means something broke.

A master run that could not measure a metric contributes no baseline value for it rather than a zero, so a partially hydrated master run cannot silently drop the bundle figure to nothing.