Third step of the performance-journeys ratchet, stacked on #1693 (which is stacked on #1692; merge in order, GitHub retargets each to `master`). - New `Initial bytes ratchet` job in `.github/workflows/ci.yml` (ubuntu-latest): install, `pnpm nx build web --skip-nx-cache` (production configuration, the one users download), then `pnpm run perf:initial-bytes:check`. The job fails when `renderer.initialBytes` exceeds `tools/performance/journey-baselines.json`. - `dist/performance/` is uploaded as the `performance-journey-summary` artifact on every run, so a failing or tightenable run carries its evidence. - After review: the job first runs the new `tools/performance/check-baseline-direction.mjs`, which compares `journey-baselines.json` with the revision the change is measured against (the target branch of a pull request, `github.event.before` for a `master` push, `master` for a manual dispatch) and fails on any raised enforced limit (`value × toleranceRatio`), any widened or newly added tolerance, or any removed entry, so a PR cannot grow the payload and raise the baseline to match (lowered limits and new entries pass; a target branch without the file has nothing to weaken). Node tests cover it. - Docs: the performance-journeys contract and the validation map name the job, and the contract now states that this runner is the canonical measurer (take baseline values from its output, not from a local build). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7.3 KiB
Performance journeys and the CI ratchet
IPTVnator measures performance through a small set of everyday user journeys.
Each journey has deterministic counters that are asserted exactly, and
wall-clock timings that are recorded as evidence. Counters are ratcheted in CI:
a committed baseline may only be lowered, and only with the measured output as
evidence. This document is the contract for that loop; tools/performance/
holds the scripts.
Journeys
| Journey | Start | End |
|---|---|---|
J1 launch |
Electron process spawn | first playlist or portal card rendered on /workspace, inline splash removed |
J2 open-source |
click on a portal card | live category list and first channel page painted |
J3 playback |
click on a channel | HTML5 playing event |
J4 search |
six-character query typed into global search | results list settled |
Only the J1 counter renderer.initialBytes is instrumented today. The other
journeys and counters follow the plan in .plans/ and are added one thread at
a time; each thread names its journey and counter in the PR description.
renderer.initialBytes
The bytes a browser fetches before Angular can bootstrap, read from the built
dist/apps/web/index.html:
index.htmlitself,- every same-origin
<script src>, includingassets/app-config.js, - every
<link rel="stylesheet">, - every
<link rel="modulepreload">chunk.
Manifest, icons, external URLs, commented-out tags and lazy chunks are not
counted. A file that index.html references but the build did not emit is an
error, never zero bytes. The value is raw (uncompressed) size, which is what the
renderer parses. It is Angular's "Initial total" plus index.html and
assets/app-config.js (about 4 KB together), so it sits slightly above the
rounded figure the build prints; never copy that figure into a baseline, use
the script's output. The bundle embeds only the app version from
package.json (a named import, which esbuild tree-shakes), not the whole
file, so editing scripts or dependencies does not move the counter.
pnpm nx build web # production configuration
pnpm run perf:initial-bytes # human-readable breakdown
pnpm --silent run perf:initial-bytes -- --json # machine-readable; --silent keeps pnpm's headers out of stdout
node tools/performance/measure-initial-bytes.mjs --summary dist/performance/journey-summary.json
--summary writes the journey summary shape (journeys.<journey>.counters)
that the ratchet checker consumes. --dist <dir> points the script at another
build output, for example the electron-performance configuration.
The measurement script is tools/performance/measure-initial-bytes.mjs; its
Node tests run with pnpm nx test performance-tools (Tier B in the coverage
policy) and lint with pnpm nx lint performance-tools.
Ratchet
tools/performance/journey-baselines.json holds one entry per journey and
counter:
{
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2739510,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1693,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
}
}
tools/performance/check-journey-ratchet.mjs compares a journey summary with
that file:
- a counter above its
valuefails; counters are exact, there is no slack; - a wall-clock entry carries
toleranceRatioand fails abovevalue × toleranceRatio; - a baseline with no measurement in the summary fails, so dropping a
measurement cannot disable the ratchet; a counter is read only from
journeys.<journey>.countersand a wall-clock entry (one withtoleranceRatio) only fromjourneys.<journey>.wallClock, so a value in the wrong section also counts as missing; - a measurement below its baseline passes and prints a "tighten" hint;
- a measured counter without a baseline is noted, not failed;
- checking nothing fails: an empty baselines file, or
--onlynaming an entry that does not exist, cannot exit 0.
--only <journey>/<counter> (repeatable) restricts the check to the named
baselines. A script that measures one counter writes its own summary file
and checks only its counter, so it neither overwrites another measurement's
summary nor fails the other baselines as unmeasured.
pnpm run perf:initial-bytes:check # measure dist/apps/web into dist/performance/initial-bytes.summary.json, check only that counter
pnpm run perf:ratchet:check # check every baseline against dist/performance/journey-summary.json
CI runs perf:initial-bytes:check in the Initial bytes ratchet job of
.github/workflows/ci.yml after a production build of apps/web, and uploads
dist/performance/ as the performance-journey-summary artifact. Like the
rest of that workflow it runs for pull requests that target master and for
pushes to master; a stacked PR that targets another branch gets no run until
it is retargeted, so dispatch one with gh workflow run ci.yml --ref <branch>
when you need the number. A PR that grows the counter fails that job.
That runner is the canonical measurer: take baseline values from its output,
not from a local build. A local macOS build of the code before #1695 is 2
bytes smaller in main.js (the eager locale imports); since #1695 the two
have been byte-identical. (An apparent 556-byte platform difference during
the first measurements was otherwise package.json text embedded in
main.js, which moved with every script edit; #1692 fixed that by importing
only the version.)
The job also refuses a weakened baselines file:
tools/performance/check-baseline-direction.mjs compares
journey-baselines.json with the revision the change is measured against
(the target branch of a pull request, the previous head of a master push,
master for a manual dispatch) and fails when any
entry's enforced limit (value × toleranceRatio) went up, a tolerance widened
or an entry disappeared, so a PR cannot grow the payload and raise the
baseline to match. Lowered limits and new entries pass.
Baselines only move down. Lower value in the same PR as the change that
earned it, set updatedAt and evidencePr, and paste the measurement output
into the PR. Never raise a value to make a PR pass: if growth is a deliberate
trade-off, say so in the PR and let the maintainer decide.
Adding a counter
- Produce the value from the built output or from a deterministic probe, not from source heuristics. Missing inputs must fail the measurement.
- Emit it under
journeys.<journey>.counters.<name>in the summary JSON. - Cover the extraction and the failure modes with
node --testand register the test file intools/performance/project.json. - Validate the counter before it becomes a guardrail: one PR must show that lowering it moved wall-clock in the same journey.