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
4 changes: 2 additions & 2 deletions package-lock.json

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

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.12.0",
"version": "0.13.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
21 changes: 17 additions & 4 deletions packages/console/src/admin/views/probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const OUTCOMES = [
['fresh', 'Skipped (fresh)', '#6b7488'],
['probed', 'Probes', '#3d8cff'],
['seeded', 'Seeded', '#8a93a6'],
['rebaselined', 'Re-baselined', '#7c8cc4'],
['changed', 'Changed', '#f0a02a'],
['page_mismatch', 'Page mismatch', '#22b8cf'],
['triggered', 'Triggered', '#10a87e'],
Expand Down Expand Up @@ -413,6 +414,7 @@ function drift(ctx) {
const changed = totalOf('changed');
const failed = totalOf('failed');
const seeded = totalOf('seeded');
const rebaselined = totalOf('rebaselined');
const deferred = totalOf('deferred');
const triggered = totalOf('triggered');
const trips = totalOf('canary_trip');
Expand All @@ -432,7 +434,12 @@ function drift(ctx) {

// 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.
const compared = Math.max(0, probed - seeded - failed);
// RE-BASELINED rows had one and it was not comparable — it was taken under a different rule —
// so the plugin stores the new observation and compares nothing. They belong on the same side
// as seeds. Leaving them in is not a rounding error: the pass right after a rule edit can be
// almost entirely re-baselined, and the card would read "0% changed" over a population that was
// never compared at all, which is indistinguishable from a completely static origin.
const compared = Math.max(0, probed - seeded - rebaselined - failed);
// Through `ratioOf` like every other ÷ figure in this console, and NOT because these two can be
// null — `sumValues` reduces from 0 over finite products, so they are always numbers. It is that
// the guard belongs at the site by convention rather than by an argument a reader has to
Expand Down Expand Up @@ -583,8 +590,11 @@ function drift(ctx) {
'“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 ',
'baseline; seeds, re-baselined rows and failures are excluded from that denominator because none ',
'of them compared anything. “Re-baselined” is a row whose stored baseline was taken under a ',
'DIFFERENT rule — a rule edit, not a content change — so the plugin stored the new observation ',
'and compared nothing; expect one pass of them after any rule edit, and read a steady count as a ',
'rule that keeps changing. “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.',
]),
Expand Down Expand Up @@ -639,7 +649,10 @@ function sweepCard(ctx, status) {
}

if (hasCounters) {
const compared = Math.max(0, (last.probed ?? 0) - (last.seeded ?? 0) - (last.failed ?? 0));
const compared = Math.max(
0,
(last.probed ?? 0) - (last.seeded ?? 0) - (last.rebaselined ?? 0) - (last.failed ?? 0)
);
body.push(
kv([
[
Expand Down
22 changes: 22 additions & 0 deletions packages/console/test/probeView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,28 @@ test('the change rate is measured against what was COMPARED, not against every p
assert.match(changed.textContent, /of 2\.0k compared/);
});

test('re-baselined rows leave the compared denominator, so a rule edit cannot read as a static origin', async () => {
// The pass right after a rule edit: the plugin re-baselines every row it could not compare —
// stored under a different rule fingerprint — so almost nothing was actually compared. Counting
// those as compared reports "0% changed" over a population nothing looked at, which is exactly
// how a completely static catalogue reads.
const analytics = {
...ANALYTICS,
series: [
passes('probed', 4, 1000),
passes('seeded', 4, 50),
passes('rebaselined', 4, 900),
passes('failed', 4, 10),
passes('changed', 4, 4),
],
};
const ctx = await ready({ analytics });
const changed = tile(ctx, 'Changed');
// 4,000 probed − 200 seeded − 3,600 re-baselined − 40 failed = 160 compared, and 16 changed.
assert.match(changed.textContent, /of 160 compared/);
assert.match(changed.textContent, /10%/);
});

test('a pass counter is summed by VALUE — counting emits would report the number of passes', async () => {
const ctx = await ready();
// 4 passes × 1000 = 4,000 probes. Summing `count` would say 4.
Expand Down
22 changes: 11 additions & 11 deletions packages/plugin/METRICS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender",
"version": "0.64.1",
"version": "0.65.0",
"type": "module",
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
"license": "Apache-2.0",
Expand Down
57 changes: 51 additions & 6 deletions packages/plugin/src/configSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -898,8 +898,19 @@ export const configSchema = group('Prerender plugin configuration.', {
' request.body request body string (e.g. "{}").\n' +
' extract (request mode, required) value paths into the JSON response, e.g. ' +
'"payload.products[0].prices[0].salePrice" — the extracted values ARE the watched content; ' +
'everything else in the response is ignored. An extraction where every path yields null is a ' +
'everything else in the response is ignored. A path may end at an object or array, which is ' +
'signed whole, and `[*]` projects the rest of the path over every element of an array ' +
'("payload.products[0].variants[*].availability" -> one value per variant, in the endpoint’s ' +
'order) — the way to watch per-variant state without signing fields that move on their own ' +
'(inventory counters, store data). An extraction where every path yields null is a ' +
'FAILED probe, never a new signature, so an endpoint shape change cannot mass-trigger.\n' +
' EDITING A RULE. The rule’s observation (endpoint, method, headers, body, extract, ' +
'statusSignals) is fingerprinted and stored beside every baseline. Change any of it and each ' +
'matched URL is RE-BASELINED on its next probe — new observation stored, nothing compared, ' +
'nothing triggered, not counted by the canary — instead of the new signature shape reading as ' +
'100% of the corpus changing at once. A rule edit therefore costs one pass without detection ' +
'for that rule and needs no dry-run cycle. Label, pathPattern, invalidateScope and pageCheck ' +
'are not part of the fingerprint: they change what is matched or done, not what is observed.\n' +
' statusSignals optional [{ status, signature, contains? }] — statuses this endpoint uses ' +
'to SAY something rather than to fail, mapped to a fixed signature. An endpoint that answers ' +
'a legitimate state with an error status (most usefully "no longer available" as a 4xx with ' +
Expand Down Expand Up @@ -932,9 +943,16 @@ export const configSchema = group('Prerender plugin configuration.', {
'page\u2019s offers. A page yielding no Product offers records nothing, exactly as a failed ' +
'probe changes nothing, so a markup change cannot make every page look like a disagreement \u2014 ' +
'and each dimension compares only when BOTH sides make a readable claim (availability must ' +
'reduce to a recognized schema.org verdict on the page and a boolean at the endpoint; price ' +
'must parse as a number on the page), so an unrecognized vocabulary or price format degrades ' +
'to detecting nothing rather than expiring everything. ' +
'reduce to a recognized schema.org verdict on the page, and at the endpoint to a boolean, an ' +
'availability word, or a `[*]` list of per-variant words; price must parse as a number on the ' +
'page), so an unrecognized vocabulary or price format degrades to detecting nothing rather ' +
'than expiring everything. Availability words are matched after dropping case and separators ' +
'(InStock, IN_STOCK, "In Stock" and https://schema.org/InStock are one word); the built-in ' +
'vocabulary is Google’s (InStock, InStoreOnly, OnlineOnly, LimitedAvailability, Available / ' +
'OutOfStock, SoldOut, Discontinued, Unavailable), and `pageCheck.availableValues` / ' +
'`pageCheck.unavailableValues` (arrays of words) extend or override it per rule for an endpoint ' +
'with its own vocabulary. A per-variant list reads in stock when ANY variant is, exactly as the ' +
'page’s offers are read. ' +
'Detection is one extra node-local write per render and no extra origin traffic.\n' +
' invalidateScope optional invalidation scope ("all" or "route:<match>:<path>") the canary ' +
'records on a mass change. Empty = the canary detects and logs only.\n' +
Expand All @@ -956,9 +974,36 @@ export const configSchema = group('Prerender plugin configuration.', {
'time lost to backoff are absorbed as they happen. `ratePerSecond` stays a hard ceiling. ' +
'A target that cannot be met at the ceiling is reported (`probe_cycle_behind`) rather ' +
'than silently missed — which is the whole point of the mode.\n\n' +
'Switching is safe in both directions and takes effect on the next config apply; a pass ' +
'"anchored" runs ONE full pass a day, starting at `anchorTime` in `anchorTimezone` and ' +
'paced to `anchorWindow` (0 = as fast as `ratePerSecond` allows). For an origin whose content ' +
'moves on a schedule — a retailer whose prices change only at its own midnight — this puts ' +
'the walk right after the change instead of spreading it over the day, so detection latency ' +
'is the pass length rather than up to a cycle, and the corpus is current for the day by the ' +
'time the pass ends. No pass runs at boot in this mode (baselines persist; a restart waits ' +
'for the anchor). The canary keeps its own cadence, so an off-schedule mass change is still ' +
'caught; only the full walk is anchored.\n\n' +
'Switching is safe in every direction and takes effect on the next config apply; a pass ' +
'in flight finishes under the rules it started with.',
{ enum: ['interval', 'continuous'] }
{ enum: ['interval', 'continuous', 'anchored'] }
),
anchorTime: option(
'00:15',
'ANCHORED MODE ONLY: local time of day ("HH:MM") the daily pass starts, interpreted in ' +
'`anchorTimezone`. Put it just AFTER the origin’s scheduled change lands (a few minutes ' +
'after its midnight, not at it) so the first probes see the new state rather than the ' +
'tail of the old one.',
{ nonEmpty: true }
),
anchorTimezone: option('UTC', 'ANCHORED MODE ONLY: IANA timezone `anchorTime` is interpreted in.', {
nonEmpty: true,
}),
anchorWindow: option(
0,
'ANCHORED MODE ONLY: wall-clock budget the daily pass paces itself to, like `cycleTarget` ' +
'for one pass. 0 (default) runs at the `ratePerSecond` ceiling and finishes as early as the ' +
'agreed rate allows; set it to spread the pass deliberately (e.g. 6h) when the origin would ' +
'rather see a lower steady rate than a short burst.',
{ unit: 'ms', min: 0 }
),
sweepInterval: option(
DAY,
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin/src/schemas/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,14 @@ type ProbeState @table(database: "probe_state", replicate: false) @sealed {
# was written would make the very page it certifies fail its own `lastCached >= basisAt` test.
# Written in the SAME patch as `pageSignature`, so it costs no extra read and no extra write.
pageClaimAt: Date
# The fingerprint of the RULE that produced `signature` (changeProbeSpec.js ruleFingerprint):
# endpoint, method, headers, body, extract paths, status signals. A baseline is only comparable
# to an observation made the same way, so a probe whose rule fingerprint differs from the stored
# one re-baselines the row instead of reading the new signature shape as a content change —
# which is what a rule edit used to look like, for 100% of matched URLs at once. Null on rows
# written before this column existed; those are compared as usual and stamped on their next
# quiet probe. Written by the probe only, in the same put/patch as `signature`.
ruleFingerprint: String
}

# Same anchor, same reason as CoordinationAnchor: ProbeState is `replicate: false`, and a
Expand Down
Loading