Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions packages/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ flagged when a settled deployment skips most of them, which means `reprobeAfter`
`sweepInterval`), and registry rows that could not be decoded, which is a storage-layer escalation
rather than anything a setting here reaches.

Plugin v0.58.0's `pageCheck` adds the one counter on that card that is not about the origin
changing: **page mismatches** — cached pages that disagree with the origin on a field they claim.
That is the class the signature comparison is structurally blind to (a value that changed and
changed _back_ between two passes, with a render landing inside the window), and it overlays the
outcome buckets rather than joining them: a mismatched row is also inside "Changed" or the
unchanged remainder, so it is never part of any sum on the card. The reading depends on the run
mode and the view says which applies — armed, each mismatch was hard-expired the moment it was
seen (a detection rate); in dry run nothing expires them, so the same disagreement is re-reported
every pass (a standing count of wrong pages being served). It stays at zero unless a rule sets
`pageCheck` and the render fleet posts its pages' offers (browser 1.20.0+).

**The discovery gate** is split across the two views that own its halves: how much crawl traffic
the gate is holding out of the render rotation is on Traffic (it carries a bot, so the bot filter
applies), while the purge that removes what got in before the gate went on sits on Overview beside
Expand Down
2 changes: 1 addition & 1 deletion packages/console/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender-console",
"version": "0.9.0",
"version": "0.10.0",
"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",
Expand Down
62 changes: 53 additions & 9 deletions packages/console/src/admin/views/probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,21 @@ const RANGES = [
/**
* The pass counters, in the order a pass produces them.
*
* THESE ARE SERIES SIDE BY SIDE, NOT A PARTITION, and two of them are deliberately not disjoint:
* `probed` is the total the four outcome counters divide up, and `throttled` is the slice of
* `failed` the ORIGIN caused rather than the rule. `fresh` is the one that is disjoint from
* everything — a skipped URL was never attempted, so it is not inside `probed` at all. The chart
* note under the bars says this, because a reader who assumes a partition here reads every share
* on the card wrong.
* THESE ARE SERIES SIDE BY SIDE, NOT A PARTITION, and three of them are deliberately not
* disjoint: `probed` is the total the four outcome counters divide up, `throttled` is the slice
* of `failed` the ORIGIN caused rather than the rule, and `page_mismatch` OVERLAYS the outcome
* buckets entirely — a row whose cached page disagreed with the origin is also inside `changed`
* or the unchanged count, because the plugin buckets by signature outcome alone. `fresh` is the
* one that is disjoint from everything — a skipped URL was never attempted, so it is not inside
* `probed` at all. The chart note under the bars says this, because a reader who assumes a
* partition here reads every share on the card wrong.
*/
const OUTCOMES = [
['fresh', 'Skipped (fresh)', '#6b7488'],
['probed', 'Probes', '#3d8cff'],
['seeded', 'Seeded', '#8a93a6'],
['changed', 'Changed', '#f0a02a'],
['page_mismatch', 'Page mismatch', '#22b8cf'],
['triggered', 'Triggered', '#10a87e'],
['deferred', 'Deferred', '#9d6bff'],
['failed', 'Failed', '#e0566f'],
Expand Down Expand Up @@ -334,6 +337,13 @@ function drift(ctx) {
const fresh = totalOf('fresh');
const throttled = totalOf('throttled');
const unreadable = totalOf('unreadable');
const pageMismatch = totalOf('page_mismatch');
// What a mismatch MEANS depends on the run mode, which is the status's fact and not the
// window's: armed, each one was hard-expired the moment it was seen (a detection rate); dry,
// nothing expires them, so the same disagreement is re-reported every pass (a standing gauge).
// The merged status is dry only when EVERY node is, which is exactly the reading wanted here —
// one live node means mismatches are being acted on somewhere.
const mismatchesStanding = pageMismatch > 0 && ctx.data.status?.dryRun !== false;

// Compared = probes that had a baseline to compare against. Seeds and failures had none, so
// including them in the denominator understates the drift rate by exactly the seeding backlog.
Expand Down Expand Up @@ -362,6 +372,9 @@ function drift(ctx) {
failing ? pill('probe failures dominate', 'bad') : null,
throttled > 0 ? pill('origin pushing back', 'bad') : null,
unreadable > 0 ? pill('unreadable rows', 'bad') : null,
// Only when nothing is expiring them: armed, a mismatch is the feature working and the
// tile suffices; standing, wrong pages are being served and re-found every pass.
mismatchesStanding ? pill('pages disagree with the origin', 'warn') : null,
spacer(),
legend(keys.map((key) => ({ label: OUTCOME_LABEL[key], color: OUTCOME_COLOR[key] }))),
],
Expand Down Expand Up @@ -406,6 +419,23 @@ function drift(ctx) {
'address is a storage-layer fault: it belongs with the database team, not with a setting ' +
'here.',
]),
// THE CLASS THE SIGNATURE COMPARISON CANNOT SEE. Everything else on this card asks "did
// the origin change since the last look", which is structurally blind to a value that
// changed and changed BACK between two passes — and when a render landed inside that
// window, the cached page keeps the transient value (an out-of-stock claim for something
// the origin sells, a sale price that ended). pageCheck (plugin v0.58.0) compares the
// origin against what the PAGE claims, so these are wrong pages found, not changes seen.
pageMismatch > 0 &&
note(mismatchesStanding ? 'warn' : '', [
`${fmtCount(pageMismatch)} probe${pageMismatch === 1 ? '' : 's'} found the cached page disagreeing ` +
'with the origin on a field it claims — a transient value the render captured and the ' +
'signature comparison could never see, because the origin itself never looked changed. ',
mismatchesStanding
? 'In dry run nothing expires them, so the same disagreement is re-reported every pass: read ' +
'this as a standing count of wrong pages being served, not a rate.'
: 'Each one was hard-expired the moment it was seen — bots get origin content until the ' +
're-render lands — so read this as a detection rate.',
]),
skipping &&
note('warn', [
`${pct(fresh, fresh + probed)} of the rows these passes considered were skipped because a ` +
Expand All @@ -421,6 +451,11 @@ function drift(ctx) {
el('div', { cls: 'stats' }, [
stat('Probes', fmtCount(probed), 'attempts across every finished pass'),
stat('Changed', pct(changed, compared), `${fmtCount(changed)} of ${fmtCount(compared)} compared`),
// Overlays the outcome buckets — a mismatched row is also inside Changed or the
// unchanged remainder — so the sub-label names the relationship instead of a share.
stat('Page mismatch', fmtCount(pageMismatch), 'cached page ≠ origin — overlays the buckets', {
warn: mismatchesStanding,
}),
stat(
'Triggered',
fmtCount(triggered),
Expand Down Expand Up @@ -450,9 +485,13 @@ function drift(ctx) {
'One emit per finished pass, sweep and canary alike, so the bars are passes and not probes — a tall ',
'bar is a pass that landed in that bucket, not a busier minute. These are series side by side and ',
'NOT a partition: “Probes” is the total the outcomes divide up, “Throttled” is the slice of ',
'“Failed” the origin caused, and “Skipped” sits outside “Probes” entirely because those rows were ',
'never attempted. “Changed” is measured against the probes that HAD a baseline; seeds and failures ',
'are excluded from that denominator because neither compared anything.',
'“Failed” the origin caused, “Page mismatch” overlays the outcome buckets (a mismatched row is ',
'also inside “Changed” or the unchanged remainder), and “Skipped” sits outside “Probes” entirely ',
'because those rows were never attempted. “Changed” is measured against the probes that HAD a ',
'baseline; seeds and failures are excluded from that denominator because neither compared ',
'anything. “Page mismatch” stays at zero unless a rule sets pageCheck AND the render fleet posts ',
'its pages’ offers (browser 1.20.0+) — enabled against an older fleet it records nothing, and the ',
'plugin log says so hourly.',
]),
],
foot: [scanFooter(data)],
Expand Down Expand Up @@ -520,6 +559,11 @@ function sweepCard(ctx, status) {
'Changed',
`${num(last.changed)}${compared ? ` of ${num(compared)} compared (${pct(last.changed, compared)})` : ''}`,
],
// Overlays the signature buckets — a mismatch row is also inside Changed or the
// unchanged remainder — so it joins no subtraction here. Only worth a row when there
// were any; the run-mode pill at the top of the page says whether they were expired
// on the spot or are still serving.
last.pageMismatch ? ['Pages disagreeing with the origin', pill(num(last.pageMismatch), 'warn')] : null,
['Re-renders filed', last.triggered ? pill(num(last.triggered), 'ok') : pill('0', '')],
last.deferred ? ['Deferred past the trigger cap', pill(num(last.deferred), 'warn')] : null,
['Failed probes', last.failed ? pill(num(last.failed), 'warn') : pill('0', 'ok')],
Expand Down
4 changes: 4 additions & 0 deletions packages/console/src/util/aggregate.js
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,10 @@ const PROBE_COUNTERS = [
'failed',
'fresh',
'throttled',
// OVERLAYS the outcome buckets rather than joining them: a row whose cached page disagreed
// with the origin (pageCheck, plugin v0.58.0) is ALSO inside `changed` or `unchanged`, so this
// sums on its own and belongs in no subtraction — same footing as `throttled` inside `failed`.
'pageMismatch',
'errors',
];

Expand Down
3 changes: 3 additions & 0 deletions packages/console/test/adminAssets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ test('a metric the plugin emits is charted by the console, or waived with a reas
* one the catalog says to ALERT on: it is the only signal that the probe is loading an origin that
* cannot take it.
*
* It has since earned its keep: plugin v0.58.0's pageCheck added `probe_page_mismatch`, and this
* test — not a reader, not a review — is what said the console was blind to it.
*
* Ground truth here is the catalog's machine-readable `values` list rather than the emit sites,
* because the emit sites are exactly what the regex cannot read. The two can drift — the test
* above says so and it has happened — which is why this checks the family the OTHER test is blind
Expand Down
11 changes: 11 additions & 0 deletions packages/console/test/aggregate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,17 @@ test('change probe: the resumable-sweep and pushback counters sum like every oth
assert.equal(body.sweep.lastRun.throttled, 42);
});

// pageCheck's counter (plugin v0.58.0) OVERLAYS the outcome buckets — a mismatched row is also
// inside `changed` or `unchanged` — but each node still counts its own disjoint slice, so across
// nodes it sums exactly like the rest. What it must never do is join a subtraction.
test('change probe: page mismatches sum across nodes like every other pass counter', () => {
const { body } = mergerFor('change-probe')([
ok('a', probeBody('a', { sweep: sweepWith({ pageMismatch: 3 }) })),
ok('b', probeBody('b', { sweep: sweepWith({ pageMismatch: 9 }) })),
]);
assert.equal(body.sweep.lastRun.pageMismatch, 12);
});

// THE WORST WINDOW ANY NODE ENDED ON, never a mean. The question is "was the probe being held
// back", and averaging one throttled node against three healthy ones answers it with a number that
// was true on no node — 2.75× describes nothing that happened.
Expand Down
51 changes: 51 additions & 0 deletions packages/console/test/probeView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,57 @@ test('unreadable registry rows are escalated to the database layer, not shown as
assert.match(text, /database team/);
});

// ---------------------------------------------------------------- page check (plugin v0.58.0)

// A mismatch means a different thing per run mode, and the counter cannot say which: armed, each
// one was hard-expired the moment it was seen (a detection rate); dry, nothing expires them, so
// the same disagreement is re-reported every pass (a standing count of wrong pages being served).
// The run mode is the STATUS's fact, so the note must read it there rather than guess from the
// window — and only the standing case warns, because armed the counter is the feature working.
test('page mismatches in dry run read as a standing count of wrong pages, and warn', async () => {
const ctx = await ready({
analytics: { ...ANALYTICS, series: [...ANALYTICS.series, passes('page_mismatch', 4, 5)] },
});
const text = draw(ctx).textContent;
assert.match(text, /20 probes found the cached page disagreeing/);
assert.match(text, /standing count of wrong pages/);
assert.match(text, /pages disagree with the origin/, 'earns a pill while nothing is expiring them');
});

test('page mismatches while armed read as a detection rate, not an alarm', async () => {
const ctx = await ready({
status: { ...STATUS, dryRun: false },
analytics: { ...ANALYTICS, series: [...ANALYTICS.series, passes('page_mismatch', 4, 5)] },
});
const text = draw(ctx).textContent;
assert.match(text, /hard-expired the moment it was seen/);
assert.doesNotMatch(text, /standing count/);
assert.doesNotMatch(text, /pages disagree with the origin/);
});

// Mismatched rows are ALSO inside Changed or the unchanged remainder — the plugin buckets by
// signature outcome alone — and two tiles that look like siblings invite adding them.
test('the mismatch tile names the overlay instead of posing as an outcome bucket', async () => {
const ctx = await ready({
analytics: { ...ANALYTICS, series: [...ANALYTICS.series, passes('page_mismatch', 4, 5)] },
});
assert.match(tile(ctx, 'Page mismatch').textContent, /overlays the buckets/);
});

test('zero mismatches raise nothing — pageCheck unset and a fleet predating it look identical', async () => {
const text = draw(await ready()).textContent;
assert.doesNotMatch(text, /found the cached page disagreeing/);
assert.doesNotMatch(text, /pages disagree with the origin/);
});

test('the sweep card reports pages that disagreed, and hides the row when there were none', async () => {
const withMismatches = await ready({
status: { ...STATUS, sweep: { ...STATUS.sweep, lastRun: { ...STATUS.sweep.lastRun, pageMismatch: 12 } } },
});
assert.match(draw(withMismatches).textContent, /Pages disagreeing with the origin/);
assert.doesNotMatch(draw(await ready()).textContent, /Pages disagreeing with the origin/);
});

// ---------------------------------------------------------------- how a pass ended

// Three reasons a pass stops early, and they are not interchangeable: standing down for a reseed
Expand Down