Files
iptvnator/docs/architecture/performance-journeys.md
T
4grayandClaude Fable 5.1 7abaad29e7 chore(performance): commit the initial-bytes baseline and ratchet checker (#1693)
Second step of the performance-journeys ratchet, stacked on #1692 (merge that first; this PR retargets to `master` automatically).

- `tools/performance/journey-baselines.json`: J1 `launch` / `renderer.initialBytes` = **2,739,510 bytes**, the ubuntu runner's production build of `apps/web` at this content (after #1692 stopped bundling `package.json` into `main.js`). A local macOS build of this pre-#1695 code is 2 bytes smaller in `main.js` (the eager locale imports); once #1695 removes them the two are byte-identical. Correction to an earlier version of this description: the "556-byte macOS vs Linux difference" was almost entirely `package.json` text embedded in `main.js`, which moved with every script edit in this stack, plus this 2-byte residue. The runner is the canonical measurer; the CI run on the stacked #1694 branch (this content plus the job) is where the number is confirmed.
- `tools/performance/check-journey-ratchet.mjs` compares a journey summary with the baselines: a counter above its value fails (exact, no slack), wall-clock entries fail above `value × toleranceRatio`, a baseline without a measurement fails so dropping a measurement cannot disable the ratchet, values below baseline print a "tighten" hint, and measured counters without a baseline are noted only. After review: checking nothing (empty file, or `--only` naming a missing entry) fails; a counter is read only from `counters` and a wall-clock entry only from `wallClock`; the repeatable `--only <journey>/<counter>` flag scopes a check.
- Root scripts: `perf:initial-bytes:check` (measures into its own `dist/performance/initial-bytes.summary.json`, then checks `--only launch/renderer.initialBytes`) and `perf:ratchet:check` (full check); `perf:tools:test` runs both test files, as does `pnpm nx test performance-tools`.
- `docs/architecture/performance-journeys.md` gains the Ratchet section (file format, rules, "baselines only move down"); the validation map lists the check.

The CI job that runs the check on every PR is #1694; C1 (lazy Angular date locales, #1695) then lowers the baseline with the measured output as evidence.

Note: `ci.yml` only triggers on pull requests targeting `master`, so this stacked PR shows no Actions runs until #1692 merges. The evidence runs above were dispatched with `gh workflow run ci.yml --ref <branch>`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:43:33 +02:00

6.0 KiB
Raw Blame History

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 meant to be ratcheted in CI: a committed baseline that 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. The measurement script lands first; the baseline file and the CI job follow in their own PRs (#1693, #1694), so until they merge the reported number is informational, not enforced.

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.html itself,
  • every same-origin <script src>, including assets/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 value fails; counters are exact, there is no slack;
  • a wall-clock entry carries toleranceRatio and fails above value × 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>.counters and a wall-clock entry (one with toleranceRatio) only from journeys.<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 --only naming 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

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

  1. Produce the value from the built output or from a deterministic probe, not from source heuristics. Missing inputs must fail the measurement.
  2. Emit it under journeys.<journey>.counters.<name> in the summary JSON.
  3. Cover the extraction and the failure modes with node --test and register the test file in tools/performance/project.json.
  4. Validate the counter before it becomes a guardrail: one PR must show that lowering it moved wall-clock in the same journey.