feat(plugin): bot_serve + page_age analytics — rollout success metrics; v0.26.0 - #64
Conversation
…s; v0.26.0 Two new bot-path metrics behind the existing config.analytics gate, sized for the hot read path (one in-memory counter bump per request, one numeric sample on a cache hit — no storage touch, no await): - bot_serve (source, cacheStatus, botName): origin offload is everything with source !== 'origin'; cache hit rate is the cacheStatus split — both per-bot. bot_request stays untouched as the ingress volume metric (its three dimensions are already taken). - page_age (botName, deviceType): ms since the served page rendered, cache-served responses only, so render-now responses don't drag the freshness distribution toward zero. NaN/negative ages (missing field, cross-node clock skew) are dropped. Non-GET/HEAD requests now stamp cacheStatus 'bypass' (also visible in the x-harper-cache debug header). Admin overview's unwired traffic panel note updated: the metric now exists; the node-local-aggregation decision remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces new analytics metrics (bot_serve and page_age) to track bot traffic outcomes and cache freshness, alongside corresponding documentation updates and unit tests. The review feedback highlights a critical bug where a null value for resource.lastCached would incorrectly evaluate to 0 in JavaScript's Date constructor, leading to a massive, incorrect page_age metric. It is recommended to check that lastCached is truthy before parsing and to add a corresponding test case to prevent regression.
| const age = Date.now() - new Date(resource.lastCached).getTime(); | ||
| if (age >= 0) { | ||
| server.recordAnalytics(age, 'page_age', request.botName, deviceType); | ||
| } |
There was a problem hiding this comment.
In JavaScript, new Date(null).getTime() returns 0 rather than NaN. If resource.lastCached is null (which is a common representation for missing or unset date fields in database schemas), new Date(resource.lastCached).getTime() will evaluate to 0. This results in age being calculated as Date.now(), which is a very large positive number. This will poison the page_age metric with incorrect, extremely high values.
Additionally, to prevent runtime exceptions if resource itself is null or undefined, we should use optional chaining (resource?.lastCached).
Using a conditional check to ensure resource?.lastCached is truthy before parsing avoids both issues.
const lastCached = resource?.lastCached;
const age = lastCached ? Date.now() - new Date(lastCached).getTime() : NaN;
if (age >= 0) {
server.recordAnalytics(age, 'page_age', request.botName, deviceType);
}There was a problem hiding this comment.
Fixed in afb6691 — good catch, new Date(null) is epoch 0 so the NaN reasoning in my comment didn't cover null. Now guarded with the same truthiness-first pattern as the expiresAt read above it. (Skipped the resource?. chaining: resolveResource always returns an object on this path.) Also swept both open PRs for the same class — every other date coercion already guards truthiness first.
| test('page_age is skipped when lastCached is missing or in the future', () => { | ||
| recordServeOutcome({}, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | ||
| recordServeOutcome({ lastCached: Date.now() + 60_000 }, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | ||
| assert.equal(analytics.length, 2); | ||
| assert.ok(analytics.every(([, metric]) => metric === 'bot_serve')); | ||
| }); |
There was a problem hiding this comment.
Update the test to verify that page_age is correctly skipped when lastCached is null, ensuring the bug where new Date(null).getTime() returns 0 is covered and does not regress.
| test('page_age is skipped when lastCached is missing or in the future', () => { | |
| recordServeOutcome({}, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | |
| recordServeOutcome({ lastCached: Date.now() + 60_000 }, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | |
| assert.equal(analytics.length, 2); | |
| assert.ok(analytics.every(([, metric]) => metric === 'bot_serve')); | |
| }); | |
| test('page_age is skipped when lastCached is missing, null, or in the future', () => { | |
| recordServeOutcome({}, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | |
| recordServeOutcome({ lastCached: null }, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | |
| recordServeOutcome({ lastCached: Date.now() + 60_000 }, request, { source: 'cache', cacheStatus: 'hit' }, 'desktop'); | |
| assert.equal(analytics.length, 3); | |
| assert.ok(analytics.every(([, metric]) => metric === 'bot_serve')); | |
| }); |
There was a problem hiding this comment.
Added in afb6691, with a comment explaining why null specifically is the trap case.
…) — new Date(null) is epoch 0, not NaN Unguarded, a null lastCached would record page_age ≈ Date.now() and poison the freshness distribution. Matches the expiresAt guard pattern above it. Class-swept both PRs: this was the only unguarded date coercion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the review in afb6691: |
…ketches; v0.27.0 Answers the rollout question analytics counters cannot: how many DISTINCT URLs each crawler covered per UTC day (distinct counts don't add, a URL-dimensioned metric key space would be the whole corpus, and a row-per-URL table would put a write on the bot read path). - util/hll.js: HyperLogLog, p=14 (16 KB registers, ~0.8% error), cyrb53 hash (no BigInt, no allocation). Merge = element-wise max = exact set union, so shards reassemble losslessly and merging never compounds error. - util/crawlStats.js: per-thread sketch per bot per day; hot-path cost is one hash + one byte max behind the existing analytics gate. Flush merges into ONE row per (day, bot, node) via read-merge-write under the cross-worker mutex — keeps the read side to days × bots × nodes small rows. Day rollover persists the old day; retention sweep at rollover; failed flushes re-mark dirty (cumulative + idempotent = safe retry). - schema: crawl_stats.CrawlSketch, day-indexed, not exported — reads go through GET /prerender_admin/crawl-breadth?days=N (super-user gate, withHeavySlot, capped + truncated flag per the console's query-cost rules), merging shards per bot and reporting the cross-bot union. - config.crawlStats: enabled / flushInterval / retentionDays / maxBotsPerThread (overflow bots share a '~overflow' bucket). Stacked on feat/bot-serve-metrics (#64). Tests: 356 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ketches; v0.27.0 Answers the rollout question analytics counters cannot: how many DISTINCT URLs each crawler covered per UTC day (distinct counts don't add, a URL-dimensioned metric key space would be the whole corpus, and a row-per-URL table would put a write on the bot read path). - util/hll.js: HyperLogLog, p=14 (16 KB registers, ~0.8% error), cyrb53 hash (no BigInt, no allocation). Merge = element-wise max = exact set union, so shards reassemble losslessly and merging never compounds error. - util/crawlStats.js: per-thread sketch per bot per day; hot-path cost is one hash + one byte max behind the existing analytics gate. Flush merges into ONE row per (day, bot, node) via read-merge-write under the cross-worker mutex — keeps the read side to days × bots × nodes small rows. Day rollover persists the old day; retention sweep at rollover; failed flushes re-mark dirty (cumulative + idempotent = safe retry). - schema: crawl_stats.CrawlSketch, day-indexed, not exported — reads go through GET /prerender_admin/crawl-breadth?days=N (super-user gate, withHeavySlot, capped + truncated flag per the console's query-cost rules), merging shards per bot and reporting the cross-bot union. - config.crawlStats: enabled / flushInterval / retentionDays / maxBotsPerThread (overflow bots share a '~overflow' bucket). Stacked on feat/bot-serve-metrics (#64). Tests: 356 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Two new bot-path analytics metrics, recorded once the request has resolved — the serve-side success metrics for the phased bot-traffic rollout:
bot_servesource,cacheStatus,botNamesource !== 'origin'= requests the origin never saw) and cache hit rate (cacheStatussplit: hit / stale / miss / skip / bypass), both per-botpage_agebotName,deviceTypebot_requestis unchanged and remains the ingress-volume metric — all three of its dimensions (hostname, botName, deviceType) are already in use, which is why the serve outcome is a separate metric rather than a label on it.Design notes
config.analytics.enabled/recordUnmatchedcheck (hoisted, evaluated once per request).recordAnalyticsis Harper's in-memory buffered counter — no storage touch, no await, nothing added to response latency.page_ageis cache-served only (source === 'cache'), so a render-now response doesn't drag the freshness distribution toward zero.lastCachedis coerced likeexpiresAt(Date / number / serialized string); NaN and negative ages (cross-node clock skew) record nothing rather than poisoning the mean.cacheStatus: 'bypass', which also surfaces in thex-harper-cachedebug header.hdb_analyticsvs cluster-aggregation decision (panel stays unwired per the standing decision).Tests
New
test/botServe.test.jspins the dimension order (the positional contract dashboards key on), the cache-served-only rule, the threelastCachedshapes, and the NaN/negative-age guards. Full suite: 342 pass.Version
v0.26.0— main is at 0.24.0; #62 and #63 both already claim 0.25.0.Follow-ups (deliberately out of scope)
🤖 Generated with Claude Code