fix(analytics): the bot site was recording nothing - #10477
Conversation
Plausible identifies crawler user agents and discards their events, so forwarding the real one guaranteed an empty dashboard — which is exactly what bots.anyplot.ai showed after the tracking went live. Verified against the live API rather than inferred: the same event sent as Claude-User never appears, sent as a browser agent it does, and both come back 202 ok. Plausible always acknowledges, then drops. There is no documented way to opt a bot event back in, so machine-side events are sent under a neutral agent. Nothing is lost: the user agent only feeds Plausible's browser/OS/device detection, which is meaningless for a crawler, while the identity that matters already travels in the assistant and kind properties. Reading their Events API docs to check that turned up a second silent drop. Plausible uses "the first valid IP address from the list" and discards events that forward "a server, hosting provider, or CDN IP address instead of the actual visitor IP". Analytics was reusing the feedback rate limiter's resolver, which returns the RIGHTMOST forwarded entry on purpose — the leftmost is client-controlled and trusting it once let a caller poison another user's bucket. For analytics that rule is exactly inverted: the rightmost entry is our own infrastructure. In production cf-connecting-ip covers both, so this was latent rather than active, but the fallback would have thrown events away silently. Analytics now has its own visitor_ip and request_context.py explains why the two resolvers must not be merged, since merging them is the obvious tidy-up and it breaks one of the two callers every time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes server-side Plausible analytics for the bot site (bots.anyplot.ai) where events were being silently dropped (while still returning HTTP 202), by ensuring machine-side events are sent with a neutral User-Agent and by reporting the visitor IP (not infrastructure IP).
Changes:
- Add a neutral sender User-Agent (
BOT_SENDER_UA) for events recorded tobots.anyplot.ai. - Introduce a dedicated
visitor_ip()resolver for analytics and switch analytics tracking to use it. - Add unit tests + documentation updates describing Plausible’s silent-drop behavior; add a changelog entry.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
api/analytics.py |
Sends bot-site events with a neutral UA and switches analytics IP resolution to visitor_ip(). |
api/request_context.py |
Adds visitor_ip() to resolve the visitor address for analytics (distinct from rate-limiting). |
tests/unit/api/test_analytics.py |
Updates IP expectations and adds coverage ensuring bot-site events use the neutral UA. |
docs/reference/plausible.md |
Documents two Plausible silent-drop conditions (bot UA, infrastructure IP). |
CHANGELOG.md |
Records the bot analytics fix and the rationale under Fixed. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…is-filtered # Conflicts: # CHANGELOG.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (4)
api/request_context.py:60
visitor_ipsays it follows Plausible's "first valid IP" rule, but it currently returns the first non-empty XFF entry without validating that it is an IP address. If a proxy inserts placeholders likeunknown(or a client forges a non-IP first entry), we'll forward an invalid value to Plausible and can silently lose geolocation/bot-filtering behavior again.
cf_ip = request.headers.get("cf-connecting-ip", "").strip()
if cf_ip:
return cf_ip
for entry in request.headers.get("x-forwarded-for", "").split(","):
if entry.strip():
docs/reference/plausible.md:263
- The phrase "HTTP
202 ok" is inaccurate (202 is "Accepted"); keeping the correct status text helps future debugging when comparing responses from the Events API.
Both were found by sending probe events against the live API and comparing what
appeared, and both make an event vanish with an HTTP `202 ok` — Plausible always
acknowledges, then discards.
CHANGELOG.md:239
- The changelog entry mentions "
202 ok", but HTTP 202 is "Accepted". Using the correct status name avoids confusion when correlating with logs and HTTP clients.
discards their events, so forwarding the real one guaranteed an empty dashboard. Verified against
the live API: the same event sent as `Claude-User` never appears, sent as a browser agent it does,
and both are acknowledged with `202 ok` — Plausible always accepts, then drops. There is no
documented bypass, so machine-side events now travel under a neutral agent; nothing is lost,
tests/unit/api/test_analytics.py:495
- Test class name reads awkwardly in CamelCase ("UseA..."). Renaming improves readability and makes it easier to search for related tests.
class TestBotEventsUseANeutralAgent:
From the Copilot review. visitor_ip returned the first non-empty token without checking it was an address, and proxies do insert non-addresses — `unknown` being the classic. Plausible's own wording is 'the first VALID IP address from the list', so forwarding a malformed token gets the event discarded or mis-located, which is the failure this whole PR exists to remove. Malformed entries are now skipped rather than passed on, IPv6 included, falling through to the socket peer when nothing in the chain is usable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Applied, and the wording in Plausible's own docs backs it: they use "the first valid IP address from the list". Returning an unvalidated token meant a proxy-inserted Malformed entries are now skipped rather than passed on, IPv6 included, falling through to the socket peer when nothing in the chain is usable. Five parametrised cases cover it. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
docs/reference/plausible.md:262
- The HTTP status phrasing
202 okis not a standard reason phrase for 202 responses and reads like a typo; consider using202 Accepted(or just202) for accuracy.
appeared, and both make an event vanish with an HTTP `202 ok` — Plausible always
CHANGELOG.md:238
- The changelog entry says Plausible returns
202 ok, but 202 responses are typically202 Accepted; using the standard phrasing avoids confusion.
and both are acknowledged with `202 ok` — Plausible always accepts, then drops. There is no
Three findings from a fresh-context audit of today's twelve PRs. I had spotted none of them. ## 1. The documentation contradicted itself `docs/reference/seo.md`'s bot-map section still said `gptbot`, `meta-externalagent` and `amazonbot` were *"declined in robots.txt"*, and `app/nginx.conf` repeated the claim in a comment. Both were written about an hour before #10474 opened the policy, and neither was reconciled — so the page asserted the old policy three screens from the section declaring the new one. The measured edge-state table was stale the other way round: the dashboard unblock it prescribed had since been carried out, so the table described a state that no longer existed. It now records what is actually blocked (`Bytespider`, `TikTok Spider`, and three agents whose rule-compliance is unverified rather than disproven) and says plainly that `bot-serving-check` tests the origin and will never catch edge drift. ## 2. A database outage would reopen #10453 With no catalogue to check against, the bot routes answered `200` with a fabricated, self-canonicalising page for **any** string — the precise defect #10453 removed, surviving in degraded mode. Degraded pages now carry `noindex`. I first tried returning `503`, which is arguably more correct, and backed it out: it broke eleven tests that use the no-DB path as a rendering harness. `noindex` keeps the behaviour those tests depend on and removes the indexing risk, which is the part that matters. The path is unreachable in production — but "unreachable" here means one misconfiguration away from indexable. ## 3. 404s were counted as successful page reads `bot_fetch` ran as a router dependency. A dependency executes **before** the handler and cannot see the response, so every miss was recorded as a read. It has moved to a middleware and gained a `status` property. Recording the miss is right — an assistant asking for a URL that no longer exists is how a library migration announces itself — but recording it as a page view is a lie. Filter on `status` before reading anything else; documented in `docs/reference/plausible.md`. ## Also A docstring pointed at `app/src/router.tsx`, which does not exist. Routing lives in `app/src/routes/index.tsx`. ## Verification - `pytest tests/unit` — 1640 passed, including: degraded hub and impl pages assert `noindex`, a companion test asserts normal pages do **not**, and a `bot_fetch` test pins `status: "404"` on a miss - `ruff check` + `ruff format --check` — clean - `grep` confirms no remaining reference to the superseded policy in `seo.md`, `nginx.conf` or `robots.txt` ## Not in this PR The audit's other findings are handled elsewhere: the ten-day-red monitor in #10478, and the analytics that recorded nothing in #10477. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Symptom
bots.anyplot.aishowed 0 events after the tracking deployed, despite live traffic through the instrumented path.Cause 1 — Plausible drops bot user agents
Verified by probing the live API and comparing what appeared, rather than inferred:
Claude-User/1.0anyplot-server/1.0+ identity in propsAll three returned
202 ok. Plausible always acknowledges, then discards — which is why nothing surfaced as an error and why the Cloud Run logs were clean.Forwarding the real crawler agent therefore guaranteed an empty dashboard. Their docs describe no way to opt an event back in, so machine-side events now travel under a neutral agent. Nothing is lost: the docs say the UA feeds
user_idand the Browsers/OS/Devices tabs — meaningless for a crawler — while the identity that matters already rides inassistantandkind.Cause 2 — found by reading their docs, latent but real
Analytics was reusing
client_ipfrom the feedback rate limiter, which returns the rightmost forwarded entry on purpose: the leftmost is client-controlled, and trusting it once let a caller poison another user's rate-limit bucket. For analytics that rule is exactly inverted — the rightmost entry is our own infrastructure, which Plausible discards.In production
cf-connecting-ipis present and covers both, so this was latent rather than active. The fallback would have thrown events away silently.Analytics now has its own
visitor_ip.api/request_context.pyspells out why the two must not be merged, because merging them is the obvious tidy-up and it breaks one of the two callers every time.Verification
cf-connecting-ipthey agree; without itvisitor_ip→203.0.113.7(the visitor) andclient_ip→10.0.0.9(ours), which is the intended divergencepytest tests/unit— 1641 passed, including three new tests:bot_fetchmust not forward the crawler agent, machine-sideog_image_viewuses the neutral one, and main-siteog_image_viewstill sees the realTwitterbot/1.0ruff check+ruff format --check— cleanNote
Three probe events (
/diag2,/diag3) are sitting onbots.anyplot.aifrom the diagnosis. The site had no other data, so they are harmless, but they are not real traffic.🤖 Generated with Claude Code