Skip to content

fix(analytics): the bot site was recording nothing - #10477

Merged
MarkusNeusinger merged 3 commits into
mainfrom
fix/analytics-bot-ua-is-filtered
Aug 18, 2026
Merged

fix(analytics): the bot site was recording nothing#10477
MarkusNeusinger merged 3 commits into
mainfrom
fix/analytics-bot-ua-is-filtered

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

Symptom

bots.anyplot.ai showed 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:

Event sent as Landed?
Claude-User/1.0
Chrome browser UA
anyplot-server/1.0 + identity in props

All 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_id and the Browsers/OS/Devices tabs — meaningless for a crawler — while the identity that matters already rides in assistant and kind.

Cause 2 — found by reading their docs, latent but real

"If you forward a server, hosting provider, or CDN IP address instead of the actual visitor IP, Plausible's bot filtering will drop the event." … "the first valid IP address from the list is used"

Analytics was reusing client_ip from 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-ip is 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.py spells 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

  • Both resolvers checked side by side: with cf-connecting-ip they agree; without it visitor_ip203.0.113.7 (the visitor) and client_ip10.0.0.9 (ours), which is the intended divergence
  • pytest tests/unit — 1641 passed, including three new tests: bot_fetch must not forward the crawler agent, machine-side og_image_view uses the neutral one, and main-site og_image_view still sees the real Twitterbot/1.0
  • ruff check + ruff format --check — clean

Note

Three probe events (/diag2, /diag3) are sitting on bots.anyplot.ai from the diagnosis. The site had no other data, so they are harmless, but they are not real traffic.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 18, 2026 19:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 to bots.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.

Comment thread api/request_context.py
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.65217% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
api/request_context.py 94.44% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI review requested due to automatic review settings August 18, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_ip says 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 like unknown (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>
Copilot AI review requested due to automatic review settings August 18, 2026 20:20
@MarkusNeusinger

Copy link
Copy Markdown
Owner Author

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 unknown would be forwarded verbatim and the event discarded or mis-located — which is the exact failure mode this PR exists to remove, so it would have been a quiet own-goal.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 ok is not a standard reason phrase for 202 responses and reads like a typo; consider using 202 Accepted (or just 202) 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 typically 202 Accepted; using the standard phrasing avoids confusion.
  and both are acknowledged with `202 ok` — Plausible always accepts, then drops. There is no

@MarkusNeusinger
MarkusNeusinger merged commit 0460b2a into main Aug 18, 2026
10 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the fix/analytics-bot-ua-is-filtered branch August 18, 2026 20:29
MarkusNeusinger added a commit that referenced this pull request Aug 18, 2026
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>
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.

2 participants