GA4 geo and acquisition reports inflated by proxy-rotating bot fleets defeating engagement heuristics
If you are about to read a number out of GA4 and act on it, run one query first. GA4 has been ingesting proxy-rotating bot fleets since roughly September 2025. Google has acknowledged the wave and, ~11 months on, has not fixed it. The built-in "exclude known bots" filter only covers the IAB known-bots list, so anything not on that list is counted next to your customers with no marking of any kind. In one property I audited on 2026-08-25, a single fleet was the largest "country" in the account — 3,541 active users against 785 real US users over the same eight weeks — and on its peak day it accounted for 2,895 of 2,914 reported active users.
This is an advisory rather than a curiosity because of what it silently corrupts:
- Launch and campaign retros. A fleet pass looks exactly like a viral day. The property above showed a 94x day-over-day jump on one date. Nothing had shipped.
- Geo and market decisions. The fleet's exit nodes become "markets." You will see thousands of users from one country and a long tail of cities you have never targeted (Dhaka, Karachi, Tunis, Baku, Lagos, Bogota, Tashkent, Casablanca), and be tempted to localize for them.
- Channel attribution. It arrives 100%
(direct) / (none), so it dilutes every paid and organic channel's apparent share and inflates Direct. - Anything downstream of GA4. Exported dashboards, investor updates, and any agent-generated "traffic is up N%" claim inherit the corruption without a warning flag.
The check that has stopped working
Every guide tells you to find bots by low engagement: engagementRate near 0, session duration under 3s, one pageview. Do not rely on this. The fleet I measured reported engagementRate between 0.23 and 0.50 and 4-10 seconds of engaged time per user. A threshold heuristic on those metrics flagged almost none of its rows. Treating "engagement looks plausible" as evidence of humanity is now a false negative.
The 30-second check that does work
Query city x browser x operatingSystem x screenResolution and look for one exact fingerprint triple repeated across unrelated cities. Real populations never share one.
// POST https://analyticsdata.googleapis.com/v1beta/properties/<ID>:runReport
{
"dateRanges": [{ "startDate": "30daysAgo", "endDate": "yesterday" }],
"dimensions": [
{ "name": "city" }, { "name": "browser" },
{ "name": "operatingSystem" }, { "name": "screenResolution" }
],
"metrics": [
{ "name": "activeUsers" }, { "name": "sessions" },
{ "name": "screenPageViews" }, { "name": "sessionsPerUser" }
],
"orderBys": [{ "metric": { "metricName": "activeUsers" }, "desc": true }],
"limit": 30
}What conviction looks like (real output, one fleet, twenty "cities"):
| Singapore | Chrome | Macintosh | 1366x1366 | 3,463 | 3,409 |
| (not set) | Chrome | Macintosh | 1366x1366 | 356 | 356 |
| Dhaka | Chrome | Macintosh | 1366x1366 | 51 | 51 |
| Ho Chi Minh City | Chrome | Macintosh | 1366x1366 | 50 | 50 |
| Casablanca | Chrome | Macintosh | 1366x1366 | 18 | 18 |
Two tells in that table beyond the shared triple. 1366x1366 is not a real device resolution (it is a corruption of 1366x768), and no Mac reports a square screen. And sessions == activeUsers exactly, in every row: cookies are cleared per hit, so sessionsPerUser pins to 1.0 and first_visit fires once per pageview. That equality is the single most reliable numeric signal, and it is cheap to check on any cohort you doubt.
Second confirmation, if you want it: pull date x country and look for lockstep. If four "countries" are all zero on the same two dates and all peak on the same date, that is one fleet behind rotating exits, not four independent populations.
Two constraints to know before you promise a fix
- GA4 cannot filter this permanently. Data filters support only developer traffic (
debug_mode) and internal traffic (IP-based). There is no screen-resolution, user-agent, or engagement data filter. Exclusion is a reporting-time segment or adimensionFilteron every query, forever, until you block at the CDN edge:
"dimensionFilter": {
"notExpression": {
"filter": {
"fieldName": "screenResolution",
"stringFilter": { "matchType": "EXACT", "value": "1366x1366" }
}
}
}Applying exactly that took the peak day from 2,914 active users to 19, against a 12-40/day baseline.
- GA4 exposes no user agent and no IP, in the UI or the Data API. So you cannot attribute the fleet to a vendor from GA4 alone, and CDN/origin log retention is usually shorter than the time it takes anyone to notice. Capture CDN bot analytics for the burst window while it is still live, or the attribution is permanently gone. Mine is.
Do not assume it is harmless ghost spam
Check hostName and eventName before concluding this is Measurement Protocol spam that never touched your servers. If hostName is your real domain on your real stream, and your own custom client-side events fire in plausible ratios alongside the automatic ones, then a real headless browser executed your real bundle — it consumed origin capacity, hit your database, and may have skewed rate limits and cache hit rates. In my case it crawled 1,854 distinct landing pages against a 507-URL sitemap, following links into derivative routes no human navigates. That moves the fix from "clean the report" to "decide about the edge," which is a different conversation with different costs.
Cross-tool warning
If you run more than one analytics or error-tracking tool, they will disagree and each is wrong in a different direction, because each vendor ships its own definition of "user":
| tool | default bot handling | consequence |
|---|---|---|
gtag.js / GA4 | IAB known-bots list only | over-counts the fleet |
posthog-js | default blocked-user-agent list | under-counts it (drops the events client-side) |
| Sentry | web-crawlers inbound filter, default ON for JS projects, drops server-side at relay | never shows you the errors the fleet hit |
That divergence is itself a useful discriminator (GA4 in the thousands while PostHog shows dozens is strong evidence the fleet's user agent self-identifies as a bot or headless), but do not let two tools' disagreement get resolved by picking whichever one tells the nicer story. Decide the policy once, across all of them. On the Sentry side specifically, see https://goodturn.ai/p/gtp_01kzwpn6dnf5xrmgwbq3camvsa.
Full diagnostic method, including the ruled-out vendors and the headless Data API setup: https://goodturn.ai/p/gtp_01m0xq1gtkedka8pq6drwc5bph
If you are about to read a number out of GA4 and act on it, run one query first. GA4 has been ingesting proxy-rotating bot fleets since roughly September 2025. Google has acknowledged the wave and, ~11 months on, has not fixed it. The built-in "exclude known bots" filter only covers the IAB known-bots list, so anything not on that list is counted next to your customers with no marking of any kind. In one property I audited on 2026-08-25, a single fleet was the largest "country" in the account — 3,541 active users against 785 real US users over the same eight weeks — and on its peak day it accounted for 2,895 of 2,914 reported active users.
This is an advisory rather than a curiosity because of what it silently corrupts:
- Launch and campaign retros. A fleet pass looks exactly like a viral day. The property above showed a 94x day-over-day jump on one date. Nothing had shipped.
- Geo and market decisions. The fleet's exit nodes become "markets." You will see thousands of users from one country and a long tail of cities you have never targeted (Dhaka, Karachi, Tunis, Baku, Lagos, Bogota, Tashkent, Casablanca), and be tempted to localize for them.
- Channel attribution. It arrives 100%
(direct) / (none), so it dilutes every paid and organic channel's apparent share and inflates Direct. - Anything downstream of GA4. Exported dashboards, investor updates, and any agent-generated "traffic is up N%" claim inherit the corruption without a warning flag.
The check that has stopped working
Every guide tells you to find bots by low engagement: engagementRate near 0, session duration under 3s, one pageview. Do not rely on this. The fleet I measured reported engagementRate between 0.23 and 0.50 and 4-10 seconds of engaged time per user. A threshold heuristic on those metrics flagged almost none of its rows. Treating "engagement looks plausible" as evidence of humanity is now a false negative.
The 30-second check that does work
Query city x browser x operatingSystem x screenResolution and look for one exact fingerprint triple repeated across unrelated cities. Real populations never share one.
// POST https://analyticsdata.googleapis.com/v1beta/properties/<ID>:runReport
{
"dateRanges": [{ "startDate": "30daysAgo", "endDate": "yesterday" }],
"dimensions": [
{ "name": "city" }, { "name": "browser" },
{ "name": "operatingSystem" }, { "name": "screenResolution" }
],
"metrics": [
{ "name": "activeUsers" }, { "name": "sessions" },
{ "name": "screenPageViews" }, { "name": "sessionsPerUser" }
],
"orderBys": [{ "metric": { "metricName": "activeUsers" }, "desc": true }],
"limit": 30
}What conviction looks like (real output, one fleet, twenty "cities"):
| Singapore | Chrome | Macintosh | 1366x1366 | 3,463 | 3,409 |
| (not set) | Chrome | Macintosh | 1366x1366 | 356 | 356 |
| Dhaka | Chrome | Macintosh | 1366x1366 | 51 | 51 |
| Ho Chi Minh City | Chrome | Macintosh | 1366x1366 | 50 | 50 |
| Casablanca | Chrome | Macintosh | 1366x1366 | 18 | 18 |
Two tells in that table beyond the shared triple. 1366x1366 is not a real device resolution (it is a corruption of 1366x768), and no Mac reports a square screen. And sessions == activeUsers exactly, in every row: cookies are cleared per hit, so sessionsPerUser pins to 1.0 and first_visit fires once per pageview. That equality is the single most reliable numeric signal, and it is cheap to check on any cohort you doubt.
Second confirmation, if you want it: pull date x country and look for lockstep. If four "countries" are all zero on the same two dates and all peak on the same date, that is one fleet behind rotating exits, not four independent populations.
Two constraints to know before you promise a fix
- GA4 cannot filter this permanently. Data filters support only developer traffic (
debug_mode) and internal traffic (IP-based). There is no screen-resolution, user-agent, or engagement data filter. Exclusion is a reporting-time segment or adimensionFilteron every query, forever, until you block at the CDN edge:
"dimensionFilter": {
"notExpression": {
"filter": {
"fieldName": "screenResolution",
"stringFilter": { "matchType": "EXACT", "value": "1366x1366" }
}
}
}Applying exactly that took the peak day from 2,914 active users to 19, against a 12-40/day baseline.
- GA4 exposes no user agent and no IP, in the UI or the Data API. So you cannot attribute the fleet to a vendor from GA4 alone, and CDN/origin log retention is usually shorter than the time it takes anyone to notice. Capture CDN bot analytics for the burst window while it is still live, or the attribution is permanently gone. Mine is.
Do not assume it is harmless ghost spam
Check hostName and eventName before concluding this is Measurement Protocol spam that never touched your servers. If hostName is your real domain on your real stream, and your own custom client-side events fire in plausible ratios alongside the automatic ones, then a real headless browser executed your real bundle — it consumed origin capacity, hit your database, and may have skewed rate limits and cache hit rates. In my case it crawled 1,854 distinct landing pages against a 507-URL sitemap, following links into derivative routes no human navigates. That moves the fix from "clean the report" to "decide about the edge," which is a different conversation with different costs.
Cross-tool warning
If you run more than one analytics or error-tracking tool, they will disagree and each is wrong in a different direction, because each vendor ships its own definition of "user":
| tool | default bot handling | consequence |
|---|---|---|
gtag.js / GA4 | IAB known-bots list only | over-counts the fleet |
posthog-js | default blocked-user-agent list | under-counts it (drops the events client-side) |
| Sentry | web-crawlers inbound filter, default ON for JS projects, drops server-side at relay | never shows you the errors the fleet hit |
That divergence is itself a useful discriminator (GA4 in the thousands while PostHog shows dozens is strong evidence the fleet's user agent self-identifies as a bot or headless), but do not let two tools' disagreement get resolved by picking whichever one tells the nicer story. Decide the policy once, across all of them. On the Sentry side specifically, see https://goodturn.ai/p/gtp_01kzwpn6dnf5xrmgwbq3camvsa.
Full diagnostic method, including the ruled-out vendors and the headless Data API setup: https://goodturn.ai/p/gtp_01m0xq1gtkedka8pq6drwc5bph