Skip to content

Commit 5cbabf7

Browse files
trialclaude
andcommitted
feat(plugin): every fetch from the origin resolves the entity's canonical, and the entity serve trusts the newest word; v0.102.0
The registry now learns from renders (a canonical verdict's declared canonical included), serve-time checks and proxied origin documents as well as the nightly probe, and adopts a canonical no target holds from any of them. The entity serve answers spellings suppressed as canonical verdicts, refuses a page the registry has since heard re-slugged (moved), lets the registry break a tie, refuses query strings, and times each evaluation. Console 0.26.0 charts the registry and the new outcomes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 9d3a86c commit 5cbabf7

22 files changed

Lines changed: 1164 additions & 134 deletions

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/console/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender-console",
3-
"version": "0.25.0",
3+
"version": "0.26.0",
44
"type": "module",
55
"description": "Standalone Harper component serving the prerender management console UI, proxying to a prerender deployment's /prerender_admin API",
66
"license": "Apache-2.0",

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

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export function render(ctx) {
186186
rawCache(ctx, data, filter),
187187
negativeCache(ctx, data, filter),
188188
entityServe(ctx, data, filter),
189+
entityRegistry(ctx, data),
189190
breadth(ctx, filter),
190191
el('div', { cls: 'scan-foot' }, [scanFooter(data)]),
191192
knobs,
@@ -2310,14 +2311,16 @@ function negativeCache(ctx, data, filter) {
23102311
// Why an entity serve fell through (plugin `prerender_ops` / `entity_serve`), with what each one means.
23112312
const ENTITY_FALL_THROUGHS = [
23122313
['unconfirmed', 'canonical not rendered or checked since the anchor'],
2313-
['has-target', 'the spelling has a target of its own'],
2314+
['has-target', 'the spelling has a target the render path keeps'],
2315+
['moved', 'the registry heard a re-slug since'],
23142316
['no-page', 'no page for this device'],
23152317
['stale', 'past its expiry'],
23162318
['not-indexable', 'not a 200, or not indexable'],
23172319
['invalidated', 'predates an invalidation'],
2318-
['ambiguous', 'more than one candidate'],
2320+
['ambiguous', 'more than one candidate, none named by the registry'],
23192321
['no-sibling', 'no other target in rotation'],
23202322
['not-self-canonical', 'its canonical does not name it'],
2323+
['has-query', 'a query string'],
23212324
['unreadable', 'body unreadable'],
23222325
['no-prefix', 'no entity prefix'],
23232326
['error', 'read failed'],
@@ -2338,10 +2341,13 @@ function entityServe(ctx, data, filter) {
23382341
(entry) => entry && typeof entry === 'object' && entry.entityServe === true
23392342
);
23402343
const served = sumCount(pick(data, 'bot_serve', (s) => s.method === 'entity' && keepBot(filter, s.type)));
2344+
// Each evaluation's duration (plugin v0.102.0): what an opted-in route adds to a miss it does not answer.
2345+
const timings = pick(data, 'prerender_ops', (s) => s.path === 'entity_serve_ms');
23412346
const help = [
2342-
'A miss for a spelling with no page and no target of its own (an old or invented slug), answered from the ',
2343-
'cached render of its entity’s canonical: a fresh, indexable page that names itself, whose canonical was ',
2344-
'rendered or checked against the origin since the anchor. Switches: ',
2347+
'A miss for a spelling with no page and no target the render path keeps (an old or invented slug, or one ',
2348+
'suppressed because its page names its canonical elsewhere), answered from the cached render of its ',
2349+
'entity’s canonical: a fresh, indexable page that names itself, whose canonical was rendered or checked ',
2350+
'against the origin since the anchor, and not heard re-slugged since by the entity registry. Switches: ',
23452351
el('code', { text: 'ingress.entityServe' }),
23462352
' and ',
23472353
el('code', { text: 'entityServe' }),
@@ -2403,12 +2409,120 @@ function entityServe(ctx, data, filter) {
24032409
.join(' · ') || 'none',
24042410
{ warn: ev('error') + ev('unreadable') > 0 }
24052411
),
2412+
timings.length > 0 &&
2413+
stat(
2414+
'Cost per evaluation',
2415+
fmtMs(weighted(timings, 'median')),
2416+
`median · p95 ≈ ${fmtMs(weighted(timings, 'p95'))} · added to a miss it does not answer`
2417+
),
24062418
]),
24072419
!events.length && el('div', { cls: 'empty', text: 'No entity-serve evaluations in this range.' }),
24082420
],
24092421
});
24102422
}
24112423

2424+
// Who told the entity registry (plugin `entity_canonical` / `canonical_adopt` context), in the order a re-slug is
2425+
// usually seen: the origin documents crawlers pull, the renders, the serve-time checks, the nightly probe.
2426+
const ENTITY_OBSERVERS = [
2427+
['origin', 'proxied origin documents'],
2428+
['render', 'renders'],
2429+
['check', 'serve-time checks'],
2430+
['probe', 'the nightly probe'],
2431+
];
2432+
2433+
/**
2434+
* The entity registry (plugin v0.101.0, `entities`): each product's current canonical, learned from every fetch
2435+
* from the origin. `entity_canonical` is every observation by what it did (`moved` is a re-slug seen, by the
2436+
* observer that saw it first); `canonical_adopt` is what was done about a canonical no target held. In an
2437+
* adoption dry run, `would-adopt` plus `capped` is what arming files.
2438+
*/
2439+
function entityRegistry(ctx, data) {
2440+
const observed = pick(data, 'prerender_ops', (s) => s.path === 'entity_canonical');
2441+
const adoptions = pick(data, 'prerender_ops', (s) => s.path === 'canonical_adopt');
2442+
const options = optionIndex(configState(ctx).payload);
2443+
const enabled = options.get('entities.enabled')?.effective === true;
2444+
const adoptOn = options.get('entities.adopt.enabled')?.effective !== false;
2445+
const adoptDry = options.get('entities.adopt.dryRun')?.effective !== false;
2446+
const help = [
2447+
'One row per product holding its current canonical, written by every fetch from the origin that names it: ',
2448+
'proxied misses, renders, serve-time checks and the nightly probe. A canonical no target holds is filed due ',
2449+
'now (adoption). Switches: ',
2450+
el('code', { text: 'entities' }),
2451+
' (',
2452+
link('Config →', () => ctx.go('config')),
2453+
').',
2454+
];
2455+
if (!enabled && !observed.length && !adoptions.length) {
2456+
return card('Entity registry', {
2457+
head: [spacer(), pill('off', '')],
2458+
help,
2459+
body: [el('div', { cls: 'empty', text: 'Off.' })],
2460+
});
2461+
}
2462+
const tally = (combos) => {
2463+
const by = new Map();
2464+
for (const s of combos) {
2465+
const key = `${s.method ?? 'unknown'}|${s.type ?? 'unknown'}`;
2466+
by.set(key, (by.get(key) ?? 0) + s.count);
2467+
}
2468+
return (outcome, from = null) =>
2469+
from === null
2470+
? [...by].filter(([key]) => key.startsWith(`${outcome}|`)).reduce((acc, [, n]) => acc + n, 0)
2471+
: (by.get(`${outcome}|${from}`) ?? 0);
2472+
};
2473+
const seen = tally(observed);
2474+
const did = tally(adoptions);
2475+
const filed = did('adopted') + did('reactivated');
2476+
const wouldFile = did('would-adopt');
2477+
const byObserver = (count) =>
2478+
ENTITY_OBSERVERS.map(([from, label]) => [label, count(from)])
2479+
.filter(([, n]) => n > 0)
2480+
.map(([label, n]) => `${num(n)} ${label}`)
2481+
.join(' · ') || 'none';
2482+
2483+
return card(`Entity registry — ${scopeLabel(data)}`, {
2484+
head: [
2485+
enabled ? pill('on', 'ok') : pill('off', 'warn'),
2486+
adoptOn ? (adoptDry ? pill('adoption dry run', 'info') : pill('adoption armed', 'ok')) : pill('adoption off', ''),
2487+
spacer(),
2488+
],
2489+
help,
2490+
body: [
2491+
stats([
2492+
stat(
2493+
'Re-slugs seen',
2494+
fmtCount(seen('moved')),
2495+
byObserver((from) => seen('moved', from))
2496+
),
2497+
stat('New entities', fmtCount(seen('new')), 'first observation of a product'),
2498+
adoptDry
2499+
? stat(
2500+
'Would adopt',
2501+
fmtCount(wouldFile + did('capped')),
2502+
`${num(wouldFile)} under the hourly budget · ${num(did('capped'))} past it`,
2503+
{ warn: did('capped') > 0 }
2504+
)
2505+
: stat(
2506+
'Adopted',
2507+
fmtCount(filed),
2508+
byObserver((from) => did('adopted', from) + did('reactivated', from)),
2509+
{
2510+
warn: did('capped') > 0,
2511+
}
2512+
),
2513+
stat(
2514+
'Not adopted',
2515+
fmtCount(did('exists') + did('suppressed') + did('recent') + did('refused') + did('error')),
2516+
`${num(did('exists'))} already held · ${num(did('recent'))} recent · ${num(did('suppressed'))} suppressed` +
2517+
(did('error') ? ` · ${num(did('error'))} errors` : ''),
2518+
{ warn: did('error') > 0 }
2519+
),
2520+
]),
2521+
!observed.length && !adoptions.length && el('div', { cls: 'empty', text: 'No registry activity in this range.' }),
2522+
],
2523+
});
2524+
}
2525+
24122526
/** Days of crawl sketch one breadth read covers — the Crawl breadth panel's trend and the miss recurrence. */
24132527
const BREADTH_DAYS = 7;
24142528

‎packages/console/test/adminAssets.test.js‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -297,16 +297,6 @@ test('a metric the plugin emits is charted by the console, or waived with a reas
297297
'prerender_ops.entity_gate',
298298
'dry-run census; read raw from /prerender_admin/analytics — armed refusals land in discovery_gated',
299299
],
300-
// The entity registry (plugin v0.101.0) ships off, and its adoption in dry run. Its two series are read
301-
// raw from the analytics endpoint during that window; a console panel for them is the follow-up.
302-
[
303-
'prerender_ops.entity_canonical',
304-
'registry ships off; read raw from /prerender_admin/analytics during the dry run — panel follows',
305-
],
306-
[
307-
'prerender_ops.canonical_adopt',
308-
'adoption ships in dry run; read raw from /prerender_admin/analytics (would-adopt) — panel follows',
309-
],
310300
// `render.change_lag_ms` and `prerender_ops.due_now_forward` (plugin v0.97.0) are charted on the Queue
311301
// view since console v0.22.0 (Change to cache, Filing).
312302
]);

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,3 +1378,70 @@ test('miss rows fold route and bot per cause, and an unknown cause gets its own
13781378
// A device outside deviceTypes.default is configuration, not capacity: it never waits on a render.
13791379
assert.equal(missRows([combo('bot_miss', 'device', '/a', 'googlebot', 1)])[0].family, 'rule');
13801380
});
1381+
1382+
// ---- the entity registry (plugin entities) ------------------------------------------
1383+
1384+
test('the entity-serve panel names the newer fall-throughs and what an evaluation costs', async () => {
1385+
const analytics = {
1386+
...ANALYTICS,
1387+
series: [
1388+
...ANALYTICS.series,
1389+
combo('prerender_ops', 'entity_serve', 'served', 'bingbot', 500),
1390+
combo('prerender_ops', 'entity_serve', 'moved', 'bingbot', 30),
1391+
combo('prerender_ops', 'entity_serve', 'has-query', 'bingbot', 20),
1392+
combo('prerender_ops', 'entity_serve_ms', null, null, 550, 1.5, 6),
1393+
],
1394+
};
1395+
const config = {
1396+
...CONFIG,
1397+
layers: [
1398+
...CONFIG.layers.filter((layer) => layer.path !== 'ingress.routes'),
1399+
{ path: 'ingress.routes', effective: [{ match: 'prefix', path: '/product/', entityServe: true }] },
1400+
{ path: 'ingress.entityServe.dryRun', effective: false },
1401+
],
1402+
};
1403+
const ctx = makeCtx({ analytics, config });
1404+
await load(ctx);
1405+
const text = everything(ctx);
1406+
assert.match(text, /30 the registry heard a re-slug since · 20 a query string/);
1407+
assert.match(text, /Cost per evaluation/);
1408+
assert.match(text, /median · p95/);
1409+
});
1410+
1411+
test('the registry panel: re-slugs seen by the observer that saw them, and the adoption dry-run number', async () => {
1412+
const analytics = {
1413+
...ANALYTICS,
1414+
series: [
1415+
...ANALYTICS.series,
1416+
combo('prerender_ops', 'entity_canonical', 'moved', 'origin', 60),
1417+
combo('prerender_ops', 'entity_canonical', 'moved', 'render', 25),
1418+
combo('prerender_ops', 'entity_canonical', 'moved', 'probe', 15),
1419+
combo('prerender_ops', 'entity_canonical', 'new', 'probe', 9000),
1420+
combo('prerender_ops', 'entity_canonical', 'same', 'probe', 900000),
1421+
combo('prerender_ops', 'canonical_adopt', 'would-adopt', 'origin', 70),
1422+
combo('prerender_ops', 'canonical_adopt', 'capped', 'probe', 5),
1423+
combo('prerender_ops', 'canonical_adopt', 'exists', 'probe', 1200),
1424+
],
1425+
};
1426+
const config = {
1427+
...CONFIG,
1428+
layers: [...CONFIG.layers, { path: 'entities.enabled', effective: true }],
1429+
};
1430+
const ctx = makeCtx({ analytics, config });
1431+
await load(ctx);
1432+
const text = everything(ctx);
1433+
assert.match(text, /Entity registry/);
1434+
assert.match(text, /adoption dry run/);
1435+
assert.match(text, /Re-slugs seen/);
1436+
assert.match(text, /60 proxied origin documents · 25 renders · 15 the nightly probe/);
1437+
assert.match(text, /Would adopt/);
1438+
assert.match(text, /70 under the hourly budget · 5 past it/);
1439+
assert.match(text, /1,200 already held/);
1440+
});
1441+
1442+
test('the registry panel is a quiet “off” when the registry is off and silent', async () => {
1443+
const ctx = makeCtx({ analytics: ANALYTICS });
1444+
await load(ctx);
1445+
const text = everything(ctx);
1446+
assert.match(text, /Entity registry/);
1447+
});

‎packages/plugin/METRICS.md‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -189,21 +189,29 @@ Notes that bite:
189189
traffic is answered that way. `entity_serve` is one emit per evaluation of a true miss on an
190190
opted-in route, detail = outcome, context = bot: `served`, `would-serve` (every guard passed under
191191
`ingress.entityServe.dryRun`; the miss path answered it — **the dry-run number**, to read against
192-
`bot_serve` `origin`/`miss` on the route), or the guard that fell through: `has-target`,
193-
`no-sibling`, `no-page`, `not-indexable`, `stale`, `invalidated`, `ambiguous`, `unconfirmed`,
194-
`not-self-canonical`, `unreadable`, `no-prefix`, `error`. Read `has-target` with the entity gate in
195-
mind: in a gate dry run every spelling a minting crawler asks for again has a target by then.
196-
`unconfirmed` climbs right after the anchor and falls as the pass and the serve-time checks
197-
confirm canonicals. If a route stays there, no check compares its `canonical`.
192+
`bot_serve` `origin`/`miss` on the route), or the guard that fell through: `has-query`,
193+
`has-target`, `no-sibling`, `no-page`, `not-indexable`, `stale`, `invalidated`, `ambiguous`,
194+
`unconfirmed`, `moved`, `not-self-canonical`, `unreadable`, `no-prefix`, `error`. Read `has-target`
195+
with the entity gate in mind: in a gate dry run every spelling a minting crawler asks for again has
196+
a target by then (since v0.102.0 a spelling suppressed as a canonical verdict is answered, not
197+
`has-target`). `unconfirmed` climbs right after the anchor and falls as the pass and the serve-time
198+
checks confirm canonicals. If a route stays there, no check compares its `canonical`. `moved`
199+
(v0.102.0, `entities.enabled`) is a page the registry has since heard re-slugged: it should be rare,
200+
and should not persist for an entity, because the observation that moved the registry also adopted
201+
the new canonical. `entity_serve_ms` (v0.102.0) is each evaluation's duration, every outcome: a
202+
duration, so read its percentiles.
198203
- **The entity registry is `prerender_ops` / `entity_canonical` and `canonical_adopt`** (v0.101.0,
199204
`entities.enabled`). `entity_canonical`: one emit per observation of an entity's canonical, detail =
200205
what it did to the registry (`new`, `moved`, `same`, `older`, `foreign`, `unreadable`, `error`),
201-
context = the observer (`probe` or `render`). `moved`/`probe` per day is the re-slug rate; `moved`
202-
alternating between `probe` and `render` for the same entities means the endpoint and the page
203-
disagree about the canonical. `canonical_adopt` (`entities.adopt`): one emit per adoption decision,
204-
made only when an observation names a canonical that is another URL than the one observed: `adopted`,
205-
`reactivated`, `would-adopt` (**the dry-run number**), `exists` (every duplicate spelling, nightly),
206-
`suppressed`, `recent`, `capped` (past `maxPerHour` on this node), `refused`, `error`.
206+
context = the observer: `probe`, `render` (a stored page, or a canonical verdict's declared canonical),
207+
`check` (a serve-time check) or `origin` (a proxied origin document on an `entityServe` route; the last
208+
two since v0.102.0). `moved` per day is the re-slug rate, and which observer moves it first says how
209+
fast a re-slug is seen. `moved` alternating between `probe` and `render` for the same entities means
210+
the endpoint and the page disagree about the canonical. `canonical_adopt` (`entities.adopt`): one emit
211+
per adoption decision, made only when an observation names a canonical that is another URL than the
212+
one observed, context = the observer: `adopted`, `reactivated`, `would-adopt` (**the dry-run
213+
number**), `exists` (every duplicate spelling, nightly), `suppressed`, `recent`, `capped` (past
214+
`maxPerHour` on this node), `refused`, `error`.
207215
- **The change probe's `probe_*` series changed shape in v0.97.0, and the table row above predates
208216
it.** (1) The pass counters are emitted **per probed batch as increments**, not once when a pass
209217
ends: a nine-hour pass is no longer one row that a dropped analytics window loses whole, and a pass

0 commit comments

Comments
 (0)