Skip to content

Commit 32c1ec1

Browse files
trialclaude
andcommitted
feat(plugin,console): served_wrong -- every cached copy found to differ from the origin while it was being served, by detector and field, with an upper bound on how long it was out; v0.102.0
One emit per detection, from data the detector already holds: no per-serve work and no extra reads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8a8324a commit 32c1ec1

11 files changed

Lines changed: 311 additions & 7 deletions

File tree

‎packages/console/src/admin/views/traffic.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export function render(ctx) {
187187
negativeCache(ctx, data, filter),
188188
entityServe(ctx, data, filter),
189189
entityRegistry(ctx, data),
190+
servedWrong(data),
190191
breadth(ctx, filter),
191192
el('div', { cls: 'scan-foot' }, [scanFooter(data)]),
192193
knobs,
@@ -2524,6 +2525,62 @@ function entityRegistry(ctx, data) {
25242525
});
25252526
}
25262527

2528+
// Who found a served copy wrong (plugin `served_wrong` detail), in the order they usually run.
2529+
const SERVED_WRONG_DETECTORS = [
2530+
['check', 'serve-time checks'],
2531+
['check-raw', 'checks of raw documents'],
2532+
['render-check', 'render checks'],
2533+
['sweep', 'the nightly pass'],
2534+
['negative-recheck', 'stored-404 re-checks'],
2535+
['negative-fetch', 'stored 404s a crawler’s miss found live'],
2536+
];
2537+
2538+
/**
2539+
* Served while wrong (plugin v0.102.0, prerender_ops `served_wrong`): every cached copy — a page, a raw document, a
2540+
* stored 404 — found to differ from the origin while it was being served, by the detector that found it. The value
2541+
* is an UPPER BOUND on how long it was out there (since it was last known right), so the percentiles say how long
2542+
* wrong copies lived, and the count by field says what was wrong. Serves per wrong copy are not counted.
2543+
*/
2544+
function servedWrong(data) {
2545+
const found = pick(data, 'prerender_ops', (s) => s.path === 'served_wrong');
2546+
const help = [
2547+
'Cached copies found to differ from the origin while they were being served, at the moment a check, the ',
2548+
'nightly pass, a render check or a stored-404 re-check found them. Each one’s age is how long it had been ',
2549+
'served since it was last known right — an upper bound. How many times each was served is not counted.',
2550+
];
2551+
if (!found.length) {
2552+
return card('Served while wrong', {
2553+
head: [spacer()],
2554+
help,
2555+
body: [el('div', { cls: 'empty', text: 'Nothing found wrong in this range.' })],
2556+
});
2557+
}
2558+
const of = (detector) => found.filter((s) => s.method === detector);
2559+
const byField = new Map();
2560+
for (const s of found) byField.set(s.type ?? 'unknown', (byField.get(s.type ?? 'unknown') ?? 0) + s.count);
2561+
const fields = [...byField].sort((a, b) => b[1] - a[1]).slice(0, 4);
2562+
return card(`Served while wrong — ${scopeLabel(data)}`, {
2563+
head: [spacer()],
2564+
help,
2565+
body: [
2566+
stats(
2567+
SERVED_WRONG_DETECTORS.filter(([key]) => of(key).length > 0).map(([key, label]) => {
2568+
const rows = of(key);
2569+
return stat(
2570+
`Found by ${label}`,
2571+
fmtCount(sumCount(rows)),
2572+
`out for ≤ ${fmtMs(weighted(rows, 'median'))} median · ≈${fmtMs(weighted(rows, 'p95'))} p95`
2573+
);
2574+
})
2575+
),
2576+
el('div', { cls: 'note' }, [
2577+
'Most often wrong: ',
2578+
fields.map(([field, n]) => `${field} (${num(n)})`).join(' · '),
2579+
]),
2580+
],
2581+
});
2582+
}
2583+
25272584
/** Days of crawl sketch one breadth read covers — the Crawl breadth panel's trend and the miss recurrence. */
25282585
const BREADTH_DAYS = 7;
25292586

‎packages/console/test/trafficView.test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,3 +1445,33 @@ test('the registry panel is a quiet “off” when the registry is off and silen
14451445
const text = everything(ctx);
14461446
assert.match(text, /Entity registry/);
14471447
});
1448+
1449+
// ---- served while wrong (plugin served_wrong) -------------------------------------------
1450+
1451+
test('the served-while-wrong card: wrong copies by the detector that found them, how long they were out, and what was wrong', async () => {
1452+
const analytics = {
1453+
...ANALYTICS,
1454+
series: [
1455+
...ANALYTICS.series,
1456+
combo('prerender_ops', 'served_wrong', 'check', '2:product.offers', 40, 3 * 3_600_000, 9 * 3_600_000),
1457+
combo('prerender_ops', 'served_wrong', 'check', '6:canonical', 5, 2 * 3_600_000, 4 * 3_600_000),
1458+
combo('prerender_ops', 'served_wrong', 'sweep', '2:product.offers', 300, 8 * 3_600_000, 20 * 3_600_000),
1459+
combo('prerender_ops', 'served_wrong', 'negative-recheck', '404', 7, 90_000, 600_000),
1460+
],
1461+
};
1462+
const ctx = makeCtx({ analytics });
1463+
await load(ctx);
1464+
const text = everything(ctx);
1465+
assert.match(text, /Served while wrong/);
1466+
assert.match(text, /Found by serve-time checks/);
1467+
assert.match(text, /Found by the nightly pass/);
1468+
assert.match(text, /Found by stored-404 re-checks/);
1469+
assert.match(text, /out for ≤ 8\.0h median · ≈20\.0h p95/);
1470+
assert.match(text, /Most often wrong: 2:product\.offers \(340\) · 404 \(7\) · 6:canonical \(5\)/);
1471+
});
1472+
1473+
test('the served-while-wrong card says so when nothing was found wrong', async () => {
1474+
const ctx = makeCtx({ analytics: ANALYTICS });
1475+
await load(ctx);
1476+
assert.match(everything(ctx), /Nothing found wrong in this range/);
1477+
});

‎packages/plugin/METRICS.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ Notes that bite:
202202
entity, because the observation that moved the registry also filed the new canonical; under the
203203
adoption dry run, or past `maxPerHour`, it lasts until the new canonical renders by another path. `entity_serve_ms` (v0.102.0) is each evaluation's duration, every outcome: a
204204
duration, so read its percentiles.
205+
- **What was served wrong is `prerender_ops` / `served_wrong`** (v0.102.0): one emit per cached copy found
206+
to differ from the origin while it was being served (a rendered page, a raw document, a stored 404), at the
207+
moment a detector found it. Never per serve, so it costs the request path nothing. The value is an **upper
208+
bound** on how long that copy was served wrong: ms since it was last known right (its render, or the last
209+
check that agreed with that very copy; for a 404, its last confirmation). Detail = the detector: `check` /
210+
`check-raw` (a serve-time check of a page or a raw document), `sweep` (the nightly pass; the canary is
211+
left out, its cohort repeats every half hour), `render-check` (a render that landed disagreeing with the
212+
origin), `negative-recheck` / `negative-fetch` (a stored 404 the origin answers 200 for, found by the
213+
re-check or by a proxied request). Context = what disagreed: the `pageCheck` field label (`2:product.offers`,
214+
`6:canonical`, …), `claim` (the price/availability pair) or `404`. **Read the count per field** (how many
215+
wrong copies, of what) **and the percentiles per detector** (how long they were out). The number of serves
216+
each made is not here: that would need a per-key serve counter, which the plugin deliberately does not keep.
205217
- **The entity registry is `prerender_ops` / `entity_canonical` and `canonical_adopt`** (v0.101.0,
206218
`entities.enabled`, on by default for routes with an `entityPrefix`). `entity_canonical`: one emit per observation of an entity's canonical, detail =
207219
what it did to the registry (`new`, `moved`, `same`, `older`, `foreign` — another entity's prefix, or a

‎packages/plugin/src/metrics.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,16 @@ export const METRICS = Object.freeze({
725725
'this in; a route stuck here has no rule that maps `canonical`), moved (the entity registry heard the ' +
726726
'origin name another canonical after the page was last confirmed: a re-slug it predates), ' +
727727
'not-self-canonical, unreadable, no-prefix, error. ' +
728+
'served_wrong = one emit per cached copy found to differ from the origin while it was being served ' +
729+
'(a 200 page, a raw document or a stored 404), at the moment a detector found it — never per serve, so ' +
730+
'it costs nothing on the request path. The value is an UPPER BOUND on how long that copy was served ' +
731+
'wrong: ms since it was last known right (its render, or the last check that agreed with this very ' +
732+
'copy; for a 404 its last confirmation). detail = the detector: check / check-raw (a serve-time check), ' +
733+
'sweep (the nightly pass; not the canary), render-check (a render that landed wrong), negative-recheck / ' +
734+
'negative-fetch (a stored 404 the origin answers 200 for); context = what disagreed: the pageCheck field ' +
735+
'label, claim (the price/availability pair), or 404. The count is how many wrong copies were found; the ' +
736+
'percentiles are how long they were out there. How many serves each one made is not counted: that needs ' +
737+
'a per-key serve counter, which the plugin deliberately does not keep. ' +
728738
'entity_serve_ms = milliseconds one entity-serve evaluation took, every outcome: what an opted-in ' +
729739
'route adds to a miss before the miss path runs. ' +
730740
'entity_canonical = one emit per observation of an entity\u2019s canonical (entities.enabled, ' +
@@ -814,7 +824,7 @@ export const METRICS = Object.freeze({
814824
'(promoted/demoted/held/skipped_cold/single_rung/promoted_fast/fast/graded) are per-interval/per-run counts whose `total` is the meaningful ' +
815825
'sum (`count` is flushes/runs); serve_error, page_age_negative, invalidation_error, ' +
816826
'invalidation_reenqueue, probe_canary_trip, probe_invalidated, discovery_gated, entity_gate, raw_cache, negative_cache, ' +
817-
'gone_reopen, suppression_lifted, suppression_held, due_now_forward and serve_check are counters; negative_gap, probe_detection_lag and entity_serve_ms are durations (ms — read their percentiles, not their total); ' +
827+
'gone_reopen, suppression_lifted, suppression_held, due_now_forward and serve_check are counters; negative_gap, probe_detection_lag, entity_serve_ms and served_wrong are durations (ms — read their percentiles, not their total; served_wrong\u2019s count is the number of wrong copies found); ' +
818828
'config_warnings is a slow gauge (latest value); ' +
819829
'demand_fill is a per-node gauge (one worker refreshes the node\u2019s union) — never sum it, and READ ITS PEAK, NOT ITS MEAN. It is the ' +
820830
'set-bit fraction of the newest visit-filter slot, which resets to ~0 at every slice rollover ' +
@@ -880,6 +890,7 @@ export const METRICS = Object.freeze({
880890
'entity_serve_ms',
881891
'entity_canonical',
882892
'canonical_adopt',
893+
'served_wrong',
883894
'raw_cache',
884895
'negative_cache',
885896
'negative_gap',
@@ -1242,6 +1253,15 @@ export const metrics = Object.freeze({
12421253
pageVerification: (outcome) => server.recordAnalytics(true, 'prerender_ops', 'page_verification', outcome, null),
12431254
serveCheck: (outcome, source) =>
12441255
server.recordAnalytics(true, 'prerender_ops', 'serve_check', outcome, source ?? null),
1256+
/**
1257+
* A cached copy that was being SERVED, found to differ from the origin — one emit per detection, never per
1258+
* serve. The value is an UPPER BOUND on how long it was served that way: ms since it was last known right
1259+
* (its render, or the last check that agreed with this very copy; a 404's last confirmation). `detector`:
1260+
* check | check-raw | sweep | render-check | negative-recheck | negative-fetch. `what`: the field that
1261+
* disagreed (a pageCheck field label, `claim` for the price/availability pair), or `404`.
1262+
*/
1263+
servedWrong: (ms, detector, what) =>
1264+
server.recordAnalytics(Math.max(0, ms), 'prerender_ops', 'served_wrong', detector, what ?? null),
12451265
invalidationReenqueue: (outcome, scope) =>
12461266
server.recordAnalytics(true, 'prerender_ops', 'invalidation_reenqueue', outcome, scope ?? null),
12471267

‎packages/plugin/src/util/changeProbe.js‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,7 @@ const confirmStaleRender = async (url, target, claimAtMs = NaN) => {
11081108
});
11091109
}
11101110
countProbe('render_mismatch', 'confirmed');
1111+
if (Number.isFinite(claimAtMs)) metrics.servedWrong(Date.now() - claimAtMs, 'render-check', 'claim');
11111112
logger.info?.(
11121113
`[prerender] change-probe: the render of ${url} disagrees with the origin, asked again — expired and re-filed`
11131114
);
@@ -1394,6 +1395,10 @@ export const runProbePass = async ({
13941395
// Called with (rule, row) for every origin CHANGE the pass detects (the rows counted `changed`) —
13951396
// what the detection-lag metric is emitted from.
13961397
onChange = () => {},
1398+
// Called with `{ what, knownRightAt }` for every cached page found disagreeing with the origin (the rows
1399+
// counted `pageMismatch`): the field, and when the page was last known right. The sweep wires the
1400+
// `served_wrong` metric; the canary, whose cohort repeats every half hour, wires none.
1401+
onServedWrong = null,
13971402
// What an active invalidation already does for a URL the pass would act on (`coverageOf`): 'covered',
13981403
// 'healed' or null. Null = no such check (the canary, and every pass that does not wire it).
13991404
coverageOf = null,
@@ -1600,6 +1605,7 @@ export const runProbePass = async ({
16001605
let verdicts = null;
16011606
let facts = null;
16021607
let mappedDisagrees = false;
1608+
let firstWrong = null;
16031609
let mappedAgreed = 0;
16041610
const armed = (field) => !guard || guard.isArmed(rule, field);
16051611
if (mappedFields.length && stored?.pageFacts && valuesOf()) {
@@ -1621,14 +1627,25 @@ export const runProbePass = async ({
16211627
if (witnessed) guard?.witness(rule, field, verdict === false);
16221628
if (verdict === false) {
16231629
bump(stats.fieldMismatch, rule.label, field.label);
1624-
if (armed(field)) mappedDisagrees = true;
1630+
if (armed(field)) {
1631+
mappedDisagrees = true;
1632+
firstWrong ??= field.label;
1633+
}
16251634
} else if (armed(field)) {
16261635
mappedAgreed++;
16271636
}
16281637
}
16291638
}
16301639
}
1631-
if (pageDisagrees || mappedDisagrees) stats.pageMismatch++;
1640+
if (pageDisagrees || mappedDisagrees) {
1641+
stats.pageMismatch++;
1642+
// Wrong since its render; or, when the origin changed since the last pass, since that pass at most.
1643+
if (onServedWrong) {
1644+
const renderedAt = epochOf(stored?.pageClaimAt);
1645+
const knownRightAt = signatureChanged ? Math.max(renderedAt, Number(stored.probedAt)) : renderedAt;
1646+
if (Number.isFinite(knownRightAt)) onServedWrong({ what: firstWrong ?? 'claim', knownRightAt });
1647+
}
1648+
}
16321649

16331650
// WHICH SLOTS CHANGED, and whether the change needs a render at all. Two ways it may not:
16341651
//
@@ -2586,6 +2603,7 @@ export const runProbeSweepOnce = async ({
25862603
onStart: (running) => (live = running),
25872604
onBatch: () => emit(counts()),
25882605
onChange: detectionLag,
2606+
onServedWrong: ({ what, knownRightAt }) => metrics.servedWrong(Date.now() - knownRightAt, 'sweep', what),
25892607
coverageOf: (url, evidence) => coverageOf(url, evidence, epochFor),
25902608
onPace: (rate, originThrottle, loadThrottle) =>
25912609
publishSweepHeadroom(

‎packages/plugin/src/util/negativeCache.js‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,15 @@ export const negativeRechecksInFlight = () => inFlightChecks.size;
312312
* `onLive` hears about a 200 so a gone-suppressed target for the URL can be reopened (util/goneReopen.js):
313313
* the entry going away fixes what this node SERVES, and only a render fixes what the rotation HOLDS.
314314
*/
315-
export const settleNegativeRecheck = async ({ key, cacheUrl, statusCode, policy, onLive, nowMs = Date.now() }) => {
315+
export const settleNegativeRecheck = async ({
316+
key,
317+
cacheUrl,
318+
statusCode,
319+
policy,
320+
onLive,
321+
confirmedAtMs = NaN,
322+
nowMs = Date.now(),
323+
}) => {
316324
if (policy.statuses.includes(statusCode)) {
317325
await confirmNegativePage(key, policy, nowMs);
318326
metrics.negativeCache('recheck-gone');
@@ -321,6 +329,8 @@ export const settleNegativeRecheck = async ({ key, cacheUrl, statusCode, policy,
321329
if (statusCode >= 200 && statusCode < 300) {
322330
await dropNegativePage(key);
323331
metrics.negativeCache('recheck-live');
332+
// Served as a 404 since its last confirmation, at most: the request that asked for this re-check got it.
333+
if (Number.isFinite(confirmedAtMs)) metrics.servedWrong(nowMs - confirmedAtMs, 'negative-recheck', '404');
324334
if (statusCode === 200 && onLive) {
325335
try {
326336
await onLive(cacheUrl);
@@ -386,6 +396,7 @@ export const startNegativeRecheck = ({
386396
policy,
387397
onLive,
388398
refreshBody = false,
399+
confirmedAtMs = NaN,
389400
fetchOrigin = fetchOriginResource,
390401
}) => {
391402
if (inFlightChecks.has(key)) {
@@ -419,7 +430,7 @@ export const startNegativeRecheck = ({
419430
// has to be closed to release the socket — by destroying the source, not cancelling the web stream
420431
// (see `releaseOriginBody`), and not awaited, so nothing can pin the re-check slot.
421432
releaseOriginBody(resource);
422-
await settleNegativeRecheck({ key, cacheUrl, statusCode: resource.statusCode, policy, onLive });
433+
await settleNegativeRecheck({ key, cacheUrl, statusCode: resource.statusCode, policy, onLive, confirmedAtMs });
423434
})()
424435
.catch((e) => logger.warn?.(`[prerender] negative-cache re-check failed for ${key}: ${e?.message ?? String(e)}`))
425436
.finally(() => inFlightChecks.delete(key));
@@ -514,6 +525,7 @@ export const answerFromNegativeCache = async ({
514525
policy,
515526
onLive,
516527
refreshBody: negativeBodyExpired(row, policy, nowMs),
528+
confirmedAtMs: checkedAtMs,
517529
})
518530
: false;
519531
return {
@@ -576,7 +588,14 @@ export const afterNegativeProxy = (
576588
if (policy.dryRun && !excluded && status === 200 && (verdict === 'fresh' || verdict === 'revalidate')) {
577589
metrics.negativeCache('would-serve-live');
578590
}
579-
if (status >= 200 && status < 400) void dropNegativePage(key);
591+
if (status >= 200 && status < 400) {
592+
// An ARMED cache was answering other crawlers with this 404 until now (a dry run answered nobody).
593+
if (!policy.dryRun && status === 200) {
594+
const confirmedAtMs = dateColumnMs(row.checkedAt);
595+
if (Number.isFinite(confirmedAtMs)) metrics.servedWrong(nowMs - confirmedAtMs, 'negative-fetch', '404');
596+
}
597+
void dropNegativePage(key);
598+
}
580599
}
581600
return resource;
582601
};

‎packages/plugin/src/util/serveCheck.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,12 @@ const settleMismatch = async (item, verdict, prior) => {
481481
}
482482
const acted = await act(item);
483483
if (acted === null) return;
484+
// SERVED WRONG, since the copy was last known right: its render, or a check that agreed with this very copy.
485+
const agreedAt = prior?.outcome === 'agree' && prior.basisAtMs === item.lastCachedMs ? prior.checkedAtMs : -Infinity;
486+
const knownRightAt = Math.max(item.lastCachedMs, agreedAt);
487+
if (Number.isFinite(knownRightAt)) {
488+
metrics.servedWrong(Date.now() - knownRightAt, item.kind === 'raw' ? 'check-raw' : 'check', verdict.field);
489+
}
484490
await deps.writeCheck(item.url, item.lastCachedMs, {
485491
outcome: 'mismatch',
486492
field: verdict.field,

‎packages/plugin/test/changeProbe.test.js‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4088,3 +4088,48 @@ test('a baseline under another rule fingerprint is never spared by a check', asy
40884088
});
40894089
assert.deepEqual(probed, [URL_A]);
40904090
});
4091+
4092+
test('the sweep reports a page SERVED WRONG: since its render, or — when the origin moved — since the last pass at most', async () => {
4093+
const told = [];
4094+
const onServedWrong = (sample) => told.push(sample);
4095+
const unmoved = await apiSig();
4096+
await runMappedPass({
4097+
rows: [row(URL_A)],
4098+
answers: { [URL_A]: unmoved },
4099+
stored: { [URL_A]: { signature: unmoved, pageFacts: await RECORD({ title: 'Red Shoe (Old Name)' }) } },
4100+
onServedWrong,
4101+
});
4102+
assert.deepEqual(told, [{ what: '0:title', knownRightAt: RENDERED_AT.getTime() }], 'wrong since it was rendered');
4103+
4104+
// The origin changed since the baseline, rendered BEFORE that pass: right until then, at most.
4105+
told.length = 0;
4106+
const before = await apiSig({ title: 'Red Shoe (Old Name)' });
4107+
await runMappedPass({
4108+
rows: [row(URL_A)],
4109+
answers: { [URL_A]: unmoved },
4110+
stored: {
4111+
[URL_A]: {
4112+
signature: before,
4113+
probedAt: 5000,
4114+
pageClaimAt: RENDERED_AT,
4115+
pageFacts: await RECORD({ title: 'Red Shoe (Old Name)' }),
4116+
},
4117+
},
4118+
onServedWrong,
4119+
});
4120+
assert.deepEqual(told, [{ what: '0:title', knownRightAt: 5000 }]);
4121+
4122+
// A page that agrees is nothing to report; and no hook, no report (the canary wires none).
4123+
told.length = 0;
4124+
await runMappedPass({
4125+
rows: [row(URL_A)],
4126+
answers: { [URL_A]: unmoved },
4127+
stored: { [URL_A]: { signature: unmoved, pageFacts: await RECORD() } },
4128+
onServedWrong,
4129+
});
4130+
assert.deepEqual(told, []);
4131+
const SRC = await import('node:fs').then((fs) =>
4132+
fs.readFileSync(new URL('../src/util/changeProbe.js', import.meta.url), 'utf8')
4133+
);
4134+
assert.equal(SRC.match(/onServedWrong: \(/g)?.length, 1, 'wired once: the sweep');
4135+
});

0 commit comments

Comments
 (0)