From 48a75ca4af14eeb450e7409ab58b19aac02ca453 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 16 Sep 2026 18:36:41 -0400 Subject: [PATCH] =?UTF-8?q?fix(console):=20read=20prerender=5Fops.legacy?= =?UTF-8?q?=5Frenderer=20=E2=80=94=20main=20has=20been=20red=20since=20v0.?= =?UTF-8?q?66.0;=20v0.13.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metric-coverage guard in adminAssets.test.js has been failing on `main` since #156 merged: the plugin emits prerender_ops.legacy_renderer and no console view reads it — chart it, or add it to NOT_CHARTED with the reason v0.66.0 added the metric and the console never caught up. With no PR CI on this repo, nothing caught it, so anyone running the suite locally has been starting from red. This charts it rather than waiving it, because it is the kind of thing the guard exists for. WHY IT EARNS A PANEL. A renderer older than browser 1.23.0 claims a URL job, renders ONE of its devices, and posts a well-formed single-device result. Nothing else on the Queue view moves: the render counts as a success, the outcome chart stays clean, render time is normal. The devices it did not render are simply never written, so their cached pages expire on their own schedule and those bots fall through to the origin — the first visible symptom is a serve-side one, on a different view, days later. The plugin warns hourly per node rather than per result, deliberately, so the log is quiet too. The counter is the only thing that says it is happening. SHOWN ONLY WHEN NON-ZERO, and not as a tile or a chart. Zero is the entire steady state: a permanent "Legacy renderers: 0" would be noise on every healthy fleet, and the silence would stop meaning anything. Non-zero is a mid-upgrade fleet — rare, temporary, and worth interrupting for. The panel names the count and the device that DID render, which is what makes a pod's configuration recognisable; the unrendered devices are every other one in `deviceTypes`. Tests: 3 new, and the 2 asserting the panel's content were verified to fail without it. 261 console tests, 1049 plugin tests, lint and format clean. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 2 +- packages/console/package.json | 2 +- packages/console/src/admin/views/queue.js | 42 ++++++++++++++++++++++ packages/console/test/queueView.test.js | 44 +++++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7317850f..823328b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8556,7 +8556,7 @@ }, "packages/console": { "name": "@harperfast/prerender-console", - "version": "0.13.0", + "version": "0.13.1", "license": "Apache-2.0", "dependencies": { "undici": "^7.18.2" diff --git a/packages/console/package.json b/packages/console/package.json index 5d2c05b3..2c5dcce1 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/prerender-console", - "version": "0.13.0", + "version": "0.13.1", "type": "module", "description": "Standalone Harper component serving the prerender management console UI, proxying to a prerender deployment's /prerender_admin API", "license": "Apache-2.0", diff --git a/packages/console/src/admin/views/queue.js b/packages/console/src/admin/views/queue.js index bc49ca6a..1a930704 100644 --- a/packages/console/src/admin/views/queue.js +++ b/packages/console/src/admin/views/queue.js @@ -311,12 +311,54 @@ function supply(ctx) { spacer(), scanFooter(data), ]), + legacyRenderers(data), kpis, el('div', { cls: 'cols' }, [outcomeChart, timesChart]), reasons, ]); } +/** + * A renderer too old for URL jobs — `prerender_ops.legacy_renderer`, one per result posted in the + * single-device shape for a job that asked for several (a fleet pod predating browser 1.23.0). + * + * SHOWN ONLY WHEN NON-ZERO, and deliberately not a tile or a chart. Zero is the entire steady + * state, so a permanent "Legacy renderers: 0" would be noise on every healthy fleet and the + * silence would stop meaning anything. Non-zero is a mid-upgrade fleet, which is rare, temporary, + * and worth interrupting for. + * + * WHY IT IS WORTH INTERRUPTING FOR. Nothing else on this page moves. The old pod claims a URL job, + * renders ONE of its devices, and posts a well-formed result: the render counts as a success, the + * outcome chart is clean, render time is normal, and the devices it did not render simply go + * unrendered. Their cached pages then expire on their own schedule and those bots fall through to + * the origin — so the first visible symptom is a serve-side one, on a different view, days later. + * The plugin also warns hourly per node rather than per result, so the log is deliberately quiet. + */ +function legacyRenderers(data) { + const rows = pick(data, 'prerender_ops', (s) => s.path === 'legacy_renderer'); + const total = sumCount(rows); + if (!total) return null; + + // The slot carries the device the old renderer DID render, which is the useful half: the + // unrendered devices are every other one in `deviceTypes`, and naming what did arrive is what + // lets an operator recognise the pod's configuration. + const devices = [...new Set(rows.map((s) => s.method).filter(Boolean))].sort(); + + return card('Renderers that predate URL jobs', { + head: [spacer(), pill(`${num(total)} result${total === 1 ? '' : 's'}`, 'bad')], + body: [ + el('p', { cls: 'note bad' }, [ + `${num(total)} result${total === 1 ? ' was' : 's were'} posted in the single-device shape for a job `, + 'that asked for several, so some pod in the render fleet is older than browser 1.23.0. It rendered ', + devices.length ? el('code', { text: devices.join(', ') }) : 'one device', + ' and left the rest of each job unrendered — those pages are not failing, they are simply never ', + 'being written, and they will fall out of cache as they expire. Upgrade the fleet; this returns to ', + 'zero within one window of the last old pod being replaced.', + ]), + ], + }); +} + function cluster(ctx, data, setPause) { const control = data.control.cluster; diff --git a/packages/console/test/queueView.test.js b/packages/console/test/queueView.test.js index 954478d4..353cdeb7 100644 --- a/packages/console/test/queueView.test.js +++ b/packages/console/test/queueView.test.js @@ -218,3 +218,47 @@ test('an older plugin that emits none of this renders no panel at all', async () await load(ctx); assert.doesNotMatch(draw(ctx).textContent, /Render prioritisation/); }); + +/** + * `prerender_ops.legacy_renderer` — a fleet pod older than browser 1.23.0 posting a single-device + * result for a URL job. It shipped with plugin v0.66.0 and no console view read it, which the + * metric-coverage guard in adminAssets.test.js caught; these pin the reading rather than its + * mere presence. + * + * The silence is the point. An old pod's result is well-formed, so the outcome chart stays clean, + * render time stays normal, and the devices it never rendered just go unwritten — the first + * visible symptom is a serve-side one, days later, on a different view. + */ + +test('a healthy fleet draws no legacy-renderer panel at all — zero is the steady state', async () => { + const ctx = await ready(); + assert.doesNotMatch(draw(ctx).textContent, /predate URL jobs/); +}); + +test('a legacy renderer is called out with its count and the device it did render', async () => { + const ctx = makeCtx({ + ...ANALYTICS, + series: [ + ...ANALYTICS.series, + combo('prerender_ops', 'legacy_renderer', 'mobile', null, 37), + combo('prerender_ops', 'legacy_renderer', 'desktop', null, 5), + ], + }); + await load(ctx); + const text = draw(ctx).textContent; + + assert.match(text, /predate URL jobs/); + assert.match(text, /42 results/, 'counts sum across the device slot'); + assert.match(text, /desktop, mobile/, 'names what DID render, so a pod is recognisable'); + // The consequence is the part an operator cannot get from any other panel. + assert.match(text, /never being written/); +}); + +test('one legacy result is singular, and still shown — a single old pod is the whole finding', async () => { + const ctx = makeCtx({ + ...ANALYTICS, + series: [...ANALYTICS.series, combo('prerender_ops', 'legacy_renderer', 'mobile', null, 1)], + }); + await load(ctx); + assert.match(draw(ctx).textContent, /1 result was posted/); +});