Skip to content

feat(plugin): bot_serve + page_age analytics — rollout success metrics; v0.26.0 - #64

Merged
harper-joseph merged 2 commits into
mainfrom
feat/bot-serve-metrics
Aug 5, 2026
Merged

feat(plugin): bot_serve + page_age analytics — rollout success metrics; v0.26.0#64
harper-joseph merged 2 commits into
mainfrom
feat/bot-serve-metrics

Conversation

@harper-joseph

@harper-joseph harper-joseph commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Two new bot-path analytics metrics, recorded once the request has resolved — the serve-side success metrics for the phased bot-traffic rollout:

Metric Value Dimensions (path, method, type) Answers
bot_serve count source, cacheStatus, botName origin offload (source !== 'origin' = requests the origin never saw) and cache hit rate (cacheStatus split: hit / stale / miss / skip / bypass), both per-bot
page_age ms since the served page rendered botName, deviceType freshness at serve (median/p95 age of what each crawler actually received)

bot_request is 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

  • Hot-path cost: gated behind the existing config.analytics.enabled / recordUnmatched check (hoisted, evaluated once per request). recordAnalytics is Harper's in-memory buffered counter — no storage touch, no await, nothing added to response latency.
  • page_age is cache-served only (source === 'cache'), so a render-now response doesn't drag the freshness distribution toward zero. lastCached is coerced like expiresAt (Date / number / serialized string); NaN and negative ages (cross-node clock skew) record nothing rather than poisoning the mean.
  • Non-GET/HEAD requests now stamp cacheStatus: 'bypass', which also surfaces in the x-harper-cache debug header.
  • The admin overview's unwired bot-traffic panel note is updated: the metric it was waiting on now exists; the remaining blocker is the node-local hdb_analytics vs cluster-aggregation decision (panel stays unwired per the standing decision).

Tests

New test/botServe.test.js pins the dimension order (the positional contract dashboards key on), the cache-served-only rule, the three lastCached shapes, 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)

  • Daily distinct-URLs-per-bot (needs a cardinality mechanism, not a counter)
  • Hydration-marker verification metric (browser package)

🤖 Generated with Claude Code

…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>

@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 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.

Comment on lines +76 to +79
const age = Date.now() - new Date(resource.lastCached).getTime();
if (age >= 0) {
server.recordAnalytics(age, 'page_age', request.botName, deviceType);
}

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

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);
		}

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.

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.

Comment thread packages/plugin/test/botServe.test.js Outdated
Comment on lines +81 to +86
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'));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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'));
});

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.

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>
@harper-joseph

Copy link
Copy Markdown
Contributor Author

Addressed the review in afb6691: lastCached is now truthiness-guarded before Date coercion (new Date(null) → epoch 0, which would have recorded page_age ≈ Date.now()), and the test covers the null case explicitly. Re-reviewed both this PR and stacked #65 for the same coercion class: this was the only unguarded instance — the codebase's existing date reads all guard first, and #65's date handling only ever coerces values derived from Date.now(), never table data. 342 tests pass.

harper-joseph added a commit that referenced this pull request Aug 5, 2026
…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>
@harper-joseph
harper-joseph merged commit 33008de into main Aug 5, 2026
harper-joseph added a commit that referenced this pull request Aug 5, 2026
…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>
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