Repository navigation
feat(console): relative freshness, a miss taxonomy, and a bot filter on Traffic - #112
Conversation
… 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.
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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
- 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.
There was a problem hiding this comment.
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).
| 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]; | ||
| } | ||
| } |
There was a problem hiding this comment.
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
- 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.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.
| 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); |
There was a problem hiding this comment.
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.
| row.count += s.count; | ||
| const source = s.path ?? 'unknown'; | ||
| row.sources.set(source, (row.sources.get(source) ?? 0) + s.count); |
There was a problem hiding this comment.
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);There was a problem hiding this comment.
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.
| row.count += s.count; | ||
| const code = Number(s.path); | ||
| if (!Number.isFinite(code) || code <= 0 || code >= 500) row.failures += s.count; |
There was a problem hiding this comment.
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;There was a problem hiding this comment.
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.
| 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]); | ||
| }; |
There was a problem hiding this comment.
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
- Hostname comparisons should be performed case-insensitively to comply with the specification and prevent mismatches caused by different DNS or server configuration casings.
There was a problem hiding this comment.
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 })); |
There was a problem hiding this comment.
Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.
| .map((s) => ({ code: s.metric.slice('response_'.length), count: s.count })); | |
| .map((s) => ({ code: s.metric.slice('response_'.length), count: Number(s.count) })); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
Coerce s.count to a Number to prevent potential TypeError exceptions when performing arithmetic operations if the database driver returns BigInt or Long values.
| 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); | |
| } |
There was a problem hiding this comment.
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.
…t helpers do not look unguarded
Review round 1 (gemini-code-assist) — addressedEight inline comments, and after checking each against the actual data path I've dismissed all Seven comments: "coerce DB values before One comment: hostname case-insensitivity. The value is What I did change (1d769e3): the review is good evidence that the numeric contract was Lint, format, console (99) and plugin (715) suites still green. |
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 ÷ thatroute's
renderIntervalmeans the same thing on every route: 1.0 is exactly due, above it thefleet is not keeping the cadence.
route_page_agecarries the route,ingress.routes[].renderIntervalcarries 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.
missis one of nine freshness verdicts,and the others are a page served past due (
swr), one past the SWR window (stale), acached-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 neverconsulted (
skip,bypass). Coverage, cadence, blob integrity and nothing-to-fix are not thesame finding.
No way to look at one crawler. Three metrics carry a bot name; the rest do not.
What changed
1. Relative freshness
1 + swrTtl/interval(where a page stops being served at all) when a single interval is thedivisor.
÷ cadence/absolutetoggle in the panel head — absolute is not deleted, because aratio is not what anyone quotes to someone else.
cadenceand÷ cadencecolumns. The old note told the operator to"compare age p95 to the route's own interval before moving it"; the table now does it.
route_page_age,the same samples
page_agereports); bot-filtered it falls back to the default interval,because
page_agecarries the bot and not the route — and says so.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(), neverreload().bot_request,bot_serve,page_ageand thecrawl sketches honour it;
route_serve,origin_fetch,durationandresponse_*have no botdimension, 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 ownorigin_fetchp95, and its 5xx+connect failures. An unknown verdict from a newer plugin gets itsown family rather than inheriting someone else's fix.
4. Everything else that was wrong or missing
configuration working. Route mode now comes from config, and
excludePathPatternsis readfirst — the plugin prepends it, so a path that is both excluded and declared
prerenderis apassthrough, and reading the wrong entry looked entirely plausible.
bot_request−bot_serve) were invisible;now the serve tile's warning.
/min), so two ranges can be compared.a question about what gets indexed. Host too, but only when a deployment serves more than one.
200 96% · 404 …) — the failure count deliberately excludes 404s, whichhid a 404-heavy proxy entirely.
short; the panel says how many.
Load
No new requests, no plugin change. Same one bounded
hdb_analyticsscan 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 thanimporting it (the reason
domShimexists: a view that throws building a card parses, lints andformats 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:checkclean, console 99/99, plugin 715/715, browser green.Reviewer notes
@harperfast/prerender-consolev0.4.0. No plugin bump, no schema change.assertions quote the exact rendered numbers if you want to read the output shape.