Skip to content

feat(console): relative freshness, a miss taxonomy, and a bot filter on Traffic - #112

Merged
harper-joseph merged 2 commits into
mainfrom
feat/traffic-staleness-bots-misses
Aug 20, 2026
Merged

harper-joseph merged 2 commits into
mainfrom
feat/traffic-staleness-bots-misses

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

Why

Four things about the Traffic view, three of them asked for directly.

Page age was absolute, so it was not a verdict. "p95 3h 12m" is only meaningful next to the
cadence that page was supposed to be re-rendered on, and this deployment runs routes from an hour
to a day. A render writes expiresAt = now + interval (RenderQueue), so served age ÷ that
route's renderInterval means the same thing on every route: 1.0 is exactly due, above it the
fleet is not keeping the cadence. route_page_age carries the route, ingress.routes[].renderInterval
carries the cadence, and this view already loads the config for its settings cards — so the
relative reading costs no new query.

"Miss" was one bar covering four different problems. miss is one of nine freshness verdicts,
and the others are a page served past due (swr), one past the SWR window (stale), a
cached-and-scheduled key whose body could not be read (blob-missing / blob-timeout /
peer-rescue), a serve a bulk invalidation cost us, and requests where the cache was never
consulted (skip, bypass). Coverage, cadence, blob integrity and nothing-to-fix are not the
same finding.

No way to look at one crawler. Three metrics carry a bot name; the rest do not.

What changed

1. Relative freshness

  • KPI Staleness p95 — the ratio as the headline, absolute age and the yardstick beneath it.
  • Staleness at serve (÷ cadence) replaces the page-age chart: reference line at 1.0, and at
    1 + swrTtl/interval (where a page stops being served at all) when a single interval is the
    divisor. ÷ cadence / absolute toggle in the panel head — absolute is not deleted, because a
    ratio is not what anyone quotes to someone else.
  • Per route gains cadence and ÷ cadence columns. The old note told the operator to
    "compare age p95 to the route's own interval before moving it"; the table now does it.
  • Unfiltered the chart normalizes each sample by its own route's interval (route_page_age,
    the same samples page_age reports); bot-filtered it falls back to the default interval,
    because page_age carries the bot and not the route — and says so.
  • If the payload carries no interval at all (older plugin), it shows milliseconds and says why,
    rather than a ratio computed over the subset of routes that set one.

2. Bot filter

Chips above the panels, multi-select, client-side only — it narrows the payload already
fetched and calls render(), never reload(). bot_request, bot_serve, page_age and the
crawl sketches honour it; route_serve, origin_fetch, duration and response_* have no bot
dimension, so those panels wear an "all bots" pill instead of quietly showing every crawler's
numbers under one crawler's name. A selection with no traffic explains itself instead of looking
like an outage, and a selected bot always keeps its chip so the filter can be cleared.

3. Not-a-fresh-hit breakout

New panel: family subtotals (coverage / cadence / integrity / invalidated / not cacheable), then
one row per verdict with what answered it (cache / rendered / origin), that verdict's own
origin_fetch p95, and its 5xx+connect failures. An unknown verdict from a newer plugin gets its
own family rather than inheriting someone else's fix.

4. Everything else that was wrong or missing

  • A passthrough route's 100% miss rate was flagged as a coverage failure. It is the
    configuration working. Route mode now comes from config, and excludePathPatterns is read
    first — the plugin prepends it, so a path that is both excluded and declared prerender is a
    passthrough, and reading the wrong entry looked entirely plausible.
  • Requests that never reached a serve outcome (bot_request − bot_serve) were invisible;
    now the serve tile's warning.
  • Serve rate (/min), so two ranges can be compared.
  • Device type was recorded on four metrics and shown on none — mobile-first indexing makes it
    a question about what gets indexed. Host too, but only when a deployment serves more than one.
  • Origin status mix (200 96% · 404 …) — the failure count deliberately excludes 404s, which
    hid a 404-heavy proxy entirely.
  • Ages discarded for computing negative (cross-node clock skew) left the distribution silently
    short; the panel says how many.
  • 5xx share served to crawlers now has a class summary and a callout past 1%.
  • Serves that matched no route at all get a callout pointing at the unrouted report.

Load

No new requests, no plugin change. Same one bounded hdb_analytics scan per node per refresh,
same per-worker cache. The filter, the mode toggle and every new panel are re-renders of bytes
already in hand.

Tests

packages/console/test/trafficView.test.js — new, 20 tests. It renders the view rather than
importing it (the reason domShim exists: a view that throws building a card parses, lints and
formats perfectly) and pins the invariants a reader cannot verify: the relative reading against
per-route cadence, the family split, that a selection never refetches, that a passthrough is
not flagged, and that an unreadable config payload does not take the view down.

Local: npm run lint + npm run format:check clean, console 99/99, plugin 715/715, browser green.

Reviewer notes

  • Console only — @harperfast/prerender-console v0.4.0. No plugin bump, no schema change.
  • Screenshots would need a live deployment; the test file's fixture is a realistic window and the
    assertions quote the exact rendered numbers if you want to read the output shape.

… out the misses, filter by bot; v0.4.0

The Traffic view answered "how old was the page we served" in milliseconds, against one global
band. That is not a verdict: a 3h-old snapshot is healthy on a 6h route and three hours overdue
on an hourly one, and this deployment runs both. A render writes `expiresAt = now + interval`,
so the served age divided by that route's own `renderInterval` is a number with a fixed meaning
everywhere — 1.0 is exactly due — which is what makes routes comparable and what the absolute
chart could not do. The KPI, the chart and the per-route table now all read that way; absolute
age stays one click away, and the table shows each route's cadence beside the ratio instead of
telling the operator to go look it up.

Second, "miss" was one bar. It is nine verdicts with four different fixes: nothing cached
(coverage), served past due or past the SWR window (cadence), a cached-and-scheduled key whose
body could not be read (integrity), a bulk invalidation, and requests that were never cacheable.
The new panel is one row per verdict with what answered it and the origin latency that verdict
cost, over family subtotals — so "our miss rate is 40%" resolves to a fix within one screen.

Third, a bot filter. Three metrics carry a bot name and the rest do not, so selecting one
narrows the panels that can honour it and every other panel says "all bots" on its face rather
than showing every crawler's numbers under one crawler's name. It is a client-side narrowing of
the payload already in hand — never a refetch, because this view's whole load argument is that a
refresh is one bounded scan per node on workers that also serve bot traffic.

Along the way, the things that were quietly wrong or missing:

- a passthrough route's 100% miss rate was flagged as a coverage failure. It is the configuration
  working. `excludePathPatterns` entries are read first, as the plugin prepends them.
- requests that arrived but never reached a serve outcome (bot_request minus bot_serve) were
  invisible; they are now the serve tile's warning.
- serve counts had no rate, so two ranges could not be compared.
- device type was recorded on four metrics and shown on none, and origin fetches reported a
  failure count but not their status mix, hiding a 404-heavy proxy.
- ages discarded for computing NEGATIVE (cross-node clock skew) left the distribution silently
  short; the panel now says how many.
- 5xx served to crawlers had no callout.

New: packages/console/test/trafficView.test.js renders the view rather than importing it,
pinning the invariants a reader cannot check — the relative reading, the family split, that a
selection never refetches, and that a passthrough is not flagged.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the prerender console to version 0.4.0, introducing a major redesign of the Traffic view. Key changes include reporting page freshness relative to each route's configured cadence, categorizing non-hit verdicts by their required fixes, and adding a client-side bot filter with device and host breakdowns. It also adds a comprehensive test suite for these views. Reviewer feedback highlights several critical opportunities to prevent runtime crashes by coercing database-derived values (which may be returned as BigInt or Long) to standard JavaScript numbers before performing arithmetic or finite checks. Additionally, the reviewer recommends performing hostname comparisons case-insensitively to prevent duplicate entries.

Comment on lines 156 to 166
for (const combo of combos) {
if (Number.isFinite(combo[stat]) && combo.count > 0) {
total += combo[stat] * combo.count;
weight += combo.count;
if (!Number.isFinite(combo[stat]) || !(combo.count > 0)) continue;
let value = combo[stat];
if (scaleOf) {
const yardstick = scaleOf(combo);
if (!Number.isFinite(yardstick) || yardstick <= 0) continue;
value /= yardstick;
}
total += value * combo.count;
weight += combo.count;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When validating or performing arithmetic with database column values (such as combo[stat] or combo.count), they may be surfaced as BigInt or Long depending on the database driver. Using Number.isFinite directly on a BigInt returns false, and mixing BigInt with standard Number values in arithmetic operations (e.g., total += value * combo.count) will throw a TypeError. Coerce these values to Number first to ensure correctness and prevent runtime crashes. Since this check is performed in multiple places, consolidate this coerce-before-finite check into a shared helper.

	for (const combo of combos) {
		const val = Number(combo[stat]);
		const count = Number(combo.count);
		if (!Number.isFinite(val) || !(count > 0)) continue;
		let value = val;
		if (scaleOf) {
			const yardstick = Number(scaleOf(combo));
			if (!Number.isFinite(yardstick) || yardstick <= 0) continue;
			value /= yardstick;
		}
		total += value * count;
		weight += count;
	}
References
  1. When validating database column values (which may be surfaced as BigInt/Long) using Number.isFinite, always coerce the value to a Number first, as Number.isFinite returns false for BigInts. Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this one carefully, and I'm dismissing it rather than adding the coercion.

charts.js is a browser module. Its only input is res.json() (src/admin/api.js:77), so
combo[stat] and combo.count are JSON numbers by construction — JSON has no BigInt, and
JSON.stringify throws on one, so a Long could never leave the plugin, let alone reach this
file.

The coerce-before-finite rule is real and it already exists, at the layer that actually reads the
database: bucketize() in the plugin's util/analyticsRead.js runs every column through
numberOf() — a shared helper, which also stops Number(null) === 0 from flooring a weighted
average — and the console's cluster merge (src/util/aggregate.js) does the same with its
finite() helper. Both store plain numbers before anything is serialized.

Coercing a second time here would imply the payload is untrusted in a way it isn't, and would
blur which layer owns the rule. What the comment does show is that the invariant was invisible
from inside the file, so I've stated it in the payload-helpers header (1d769e3).

Comment on lines 182 to 187
for (let i = 0; i < bucketCount; i++) {
if (Number.isFinite(values[i]) && combo.counts[i] > 0) {
sums[i] += values[i] * combo.counts[i];
sums[i] += (values[i] / yardstick) * combo.counts[i];
weights[i] += combo.counts[i];
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the weighted function, the bucket values (values[i]) and counts (combo.counts[i]) can be surfaced as BigInt or Long from the database. Coerce them to Number before performing Number.isFinite checks and arithmetic operations to avoid TypeError exceptions and incorrect finite checks. Consider using a consolidated shared helper for these checks across the codebase.

		for (let i = 0; i < bucketCount; i++) {
			const val = Number(values[i]);
			const count = Number(combo.counts[i]);
			if (Number.isFinite(val) && count > 0) {
				sums[i] += (val / yardstick) * count;
				weights[i] += count;
			}
		}
References
  1. When validating database column values (which may be surfaced as BigInt/Long) using Number.isFinite, always coerce the value to a Number first, as Number.isFinite returns false for BigInts. Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the weighted thread: this is a browser module fed only by res.json(), so the value is a JSON number and a BigInt cannot reach it. The database coercion happens in bucketize() (plugin util/analyticsRead.js, via numberOf()) and in the console's util/aggregate.js. Invariant now stated in the charts.js payload-helpers header (1d769e3). No change here.

Comment on lines +191 to +192
for (const s of pick(data, 'bot_serve')) entry(s.type ?? 'other').serves += s.count;
for (const s of pick(data, 'bot_request')) entry(s.method ?? 'other').requests += s.count;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.

Suggested change
for (const s of pick(data, 'bot_serve')) entry(s.type ?? 'other').serves += s.count;
for (const s of pick(data, 'bot_request')) entry(s.method ?? 'other').requests += s.count;
for (const s of pick(data, 'bot_serve')) entry(s.type ?? 'other').serves += Number(s.count);
for (const s of pick(data, 'bot_request')) entry(s.method ?? 'other').requests += Number(s.count);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the weighted thread: this is a browser module fed only by res.json(), so the value is a JSON number and a BigInt cannot reach it. The database coercion happens in bucketize() (plugin util/analyticsRead.js, via numberOf()) and in the console's util/aggregate.js. Invariant now stated in the charts.js payload-helpers header (1d769e3). No change here.

Comment on lines +584 to +586
row.count += s.count;
const source = s.path ?? 'unknown';
row.sources.set(source, (row.sources.get(source) ?? 0) + s.count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.

		const count = Number(s.count);
		row.count += count;
		const source = s.path ?? 'unknown';
		row.sources.set(source, (row.sources.get(source) ?? 0) + count);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the weighted thread: this is a browser module fed only by res.json(), so the value is a JSON number and a BigInt cannot reach it. The database coercion happens in bucketize() (plugin util/analyticsRead.js, via numberOf()) and in the console's util/aggregate.js. Invariant now stated in the charts.js payload-helpers header (1d769e3). No change here.

Comment on lines +599 to +601
row.count += s.count;
const code = Number(s.path);
if (!Number.isFinite(code) || code <= 0 || code >= 500) row.failures += s.count;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.

		const count = Number(s.count);
		row.count += count;
		const code = Number(s.path);
		if (!Number.isFinite(code) || code <= 0 || code >= 500) row.failures += count;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the weighted thread: this is a browser module fed only by res.json(), so the value is a JSON number and a BigInt cannot reach it. The database coercion happens in bucketize() (plugin util/analyticsRead.js, via numberOf()) and in the console's util/aggregate.js. Invariant now stated in the charts.js payload-helpers header (1d769e3). No change here.

Comment on lines +756 to +760
const tally = (dim) => {
const totals = new Map();
for (const s of requests) totals.set(s[dim] ?? 'unknown', (totals.get(s[dim] ?? 'unknown') ?? 0) + s.count);
return [...totals.entries()].sort((a, b) => b[1] - a[1]);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Hostname comparisons and groupings should be performed case-insensitively to comply with specifications and prevent duplicate entries caused by different DNS or server configuration casings. Additionally, coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.

	const tally = (dim) => {
		const totals = new Map();
		for (const s of requests) {
			let key = s[dim] ?? 'unknown';
			if (dim === 'host' && typeof key === 'string') {
				key = key.toLowerCase();
			}
			totals.set(key, (totals.get(key) ?? 0) + Number(s.count));
		}
		return [...totals.entries()].sort((a, b) => b[1] - a[1]);
	};
References
  1. Hostname comparisons should be performed case-insensitively to comply with the specification and prevent mismatches caused by different DNS or server configuration casings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one either, for three reasons.

The host is already lowercase at the source. The dimension is url.hostname from a WHATWG
URL (metrics.botRequest(url.hostname, …) in the plugin's http_handlers/bot_request.js), and
the URL parser lowercases the host at parse time — new URL('https://WWW.Example.COM/').hostname
is www.example.com (verified, not assumed). A mixed-case duplicate cannot reach the metric.

If one somehow did, the fix would not belong here. Every distinct dimension value is an
hdb_analytics row per node per aggregation period, so casing variance would be a cardinality
bug at the emit site, and lowercasing it in the dashboard would hide it. This view's job is to
report the dimension values that were actually recorded.

The suggested patch would never run. It keys on dim === 'host', but tally is called with
'type' and 'path' — bot_request's slots are path=host, method=botName,
type=deviceType (see src/metrics.js). It would be dead code that reads as protection.

The Number(s.count) half is the same finding as the other seven threads — browser module,
res.json() input, coercion already done in bucketize().

function statusCodes(data, filter) {
const rows = (data.series ?? [])
.filter((s) => s.metric.startsWith('response_'))
.map((s) => ({ code: s.metric.slice('response_'.length), count: s.count }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.

Suggested change
.map((s) => ({ code: s.metric.slice('response_'.length), count: s.count }));
.map((s) => ({ code: s.metric.slice('response_'.length), count: Number(s.count) }));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the weighted thread: this is a browser module fed only by res.json(), so the value is a JSON number and a BigInt cannot reach it. The database coercion happens in bucketize() (plugin util/analyticsRead.js, via numberOf()) and in the console's util/aggregate.js. Invariant now stated in the charts.js payload-helpers header (1d769e3). No change here.

Comment on lines 897 to 906
for (const s of pick(data, 'route_serve')) {
const route = s.path ?? 'unrouted';
const entry = byRoute.get(route) ?? { total: 0, cache: 0, miss: 0, aging: 0 };
const entry = byRoute.get(route) ?? { total: 0, cache: 0, miss: 0, aging: 0, integrity: 0 };
entry.total += s.count;
if (s.method === 'hit' || s.method === 'swr' || s.method === 'peer-rescue') entry.cache += s.count;
if (s.method === 'miss') entry.miss += s.count;
if (s.method === 'swr' || s.method === 'stale') entry.aging += s.count;
if (s.method === 'blob-missing' || s.method === 'blob-timeout' || s.method === 'peer-rescue')
entry.integrity += s.count;
byRoute.set(route, entry);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.

Suggested change
for (const s of pick(data, 'route_serve')) {
const route = s.path ?? 'unrouted';
const entry = byRoute.get(route) ?? { total: 0, cache: 0, miss: 0, aging: 0 };
const entry = byRoute.get(route) ?? { total: 0, cache: 0, miss: 0, aging: 0, integrity: 0 };
entry.total += s.count;
if (s.method === 'hit' || s.method === 'swr' || s.method === 'peer-rescue') entry.cache += s.count;
if (s.method === 'miss') entry.miss += s.count;
if (s.method === 'swr' || s.method === 'stale') entry.aging += s.count;
if (s.method === 'blob-missing' || s.method === 'blob-timeout' || s.method === 'peer-rescue')
entry.integrity += s.count;
byRoute.set(route, entry);
for (const s of pick(data, 'route_serve')) {
const route = s.path ?? 'unrouted';
const entry = byRoute.get(route) ?? { total: 0, cache: 0, miss: 0, aging: 0, integrity: 0 };
const count = Number(s.count);
entry.total += count;
if (s.method === 'hit' || s.method === 'swr' || s.method === 'peer-rescue') entry.cache += count;
if (s.method === 'miss') entry.miss += count;
if (s.method === 'swr' || s.method === 'stale') entry.aging += count;
if (s.method === 'blob-missing' || s.method === 'blob-timeout' || s.method === 'peer-rescue')
entry.integrity += count;
byRoute.set(route, entry);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the weighted thread: this is a browser module fed only by res.json(), so the value is a JSON number and a BigInt cannot reach it. The database coercion happens in bucketize() (plugin util/analyticsRead.js, via numberOf()) and in the console's util/aggregate.js. Invariant now stated in the charts.js payload-helpers header (1d769e3). No change here.

@harper-joseph

Copy link
Copy Markdown
Contributor Author

Review round 1 (gemini-code-assist) — addressed

Eight inline comments, and after checking each against the actual data path I've dismissed all
eight with reasoning on their threads rather than applying them. Summary so a human reviewer
doesn't have to re-derive it:

Seven comments: "coerce DB values before Number.isFinite / arithmetic (BigInt/Long)". The
rule is right; it just doesn't apply to these files. charts.js and views/traffic.js are
browser assets whose only input is res.json() — JSON has no BigInt, and JSON.stringify throws
on one, so a Long cannot leave the plugin. The coercion already happens where the database is
actually read: bucketize() in the plugin's util/analyticsRead.js puts every column through
the shared numberOf() helper (which also handles the Number(null) === 0 trap), and the
console's cluster merge does the same via finite() in util/aggregate.js. Both emit plain
numbers before serialization.

One comment: hostname case-insensitivity. The value is url.hostname from a WHATWG URL,
which lowercases the host at parse time, so mixed-case duplicates can't be recorded. Were they
ever, that would be a cardinality bug at the emit site (one hdb_analytics row per distinct
dimension value per node per period), and normalizing it in the dashboard would hide it. The
suggested patch also keys on dim === 'host', while tally is only ever called with 'type'
and 'path'.

What I did change (1d769e3): the review is good evidence that the numeric contract was
invisible from inside charts.js, so the payload-helpers header now states which layer owns the
coercion and why these helpers deliberately don't repeat it.

Lint, format, console (99) and plugin (715) suites still green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant