Skip to content

GA4's geo report is lying: identify a proxy-rotating bot fleet by its shared screenResolution, not by engagement

Symptom

GA4 reports thousands of users from one improbable country (Singapore and China are the common ones since ~Sept 2025) plus a long tail of unrelated world cities (Dhaka, Karachi, Tunis, Baku, Lagos, Bogota...). Traffic is 100% Direct/(none). Google has acknowledged the wave and not fixed it; GA4's built-in bot filter only excludes the IAB known-bots list.

The mistake

The standard advice is to find bots by low engagement: engagementRate ~0, session duration <3s, one pageview. Modern fleets defeat this. The one I analyzed reported engagementRate 0.23-0.50 and 4-10s engaged per user, so an engagement-threshold heuristic flagged almost none of its rows.

What actually works

Query GA4 with city x browser x operatingSystem x screenResolution and look for one fingerprint triple repeated across unrelated cities. In my case every cohort, from 3,463 Singapore users down to 18 in Casablanca, reported Chrome / Macintosh / 1366x1366. No Mac has a square 1366x1366 screen (it is a corruption of the real 1366x768), and 20 unrelated cities never share one exact browser/OS/resolution triple. That single dimension gives you a clean exclusion predicate.

Corroborating signals, in order of strength:

  1. sessions == activeUsers exactly, in every row. Cookies are cleared per hit, so every session is a brand-new client id and first_visit fires once per pageview. Real populations have sessionsPerUser > 1.
  2. Lockstep daily series across "different countries." Plot date x country: if Singapore, Brazil, Bangladesh and Vietnam are all zero on the same two days and all peak on the same day, that is one fleet behind rotating exits, not four independent populations.
  3. Landing-page count >> sitemap size. 1,854 distinct landing pages against a 507-URL sitemap, including 249 derivative /versions routes, is link-following crawl coverage, not browsing.

Ghost spam vs. a real headless fleet

Don't assume Measurement Protocol spam. Check hostName and eventName. If your own custom client-side events fire in plausible ratios (mine: 622 finn_preview_shown against 3,522 page_view), a real browser executed your real bundle and consumed real server capacity, which changes the fix from "clean the report" to "block at the edge."

Cross-check against a second analytics tool

If you also run PostHog, compare the same day. posthog-js drops events matching its default blocked-user-agent list; gtag.js has no such list. GA4 showing thousands while PostHog shows dozens is strong evidence the fleet's user agent self-identifies as a bot or headless, and it is a free discriminator.

Fix constraints worth knowing before you promise one

  • GA4 data filters cannot express this. GA4 supports only developer-traffic (debug_mode) and internal-traffic (IP-based) filters. There is no screen-resolution or user-agent data filter, so exclusion is a reporting-time segment or a --where predicate, not a permanent property setting.
  • GA4 exposes no user agent and no IP through the UI or the Data API, so vendor attribution requires CDN/server logs captured while retention still covers the burst.

Reproducible queries (GA4 Data API v1beta runReport)

  • Fingerprint: dims city,browser,operatingSystem,screenResolution, metrics activeUsers,sessions,screenPageViews,engagementRate,userEngagementDuration,sessionsPerUser, order by activeUsers desc.
  • Lockstep: dims date,country, metric activeUsers, order by date asc, then pivot.
  • Clean baseline: same query with dimensionFilter = {notExpression: {filter: {fieldName: 'screenResolution', stringFilter: {matchType: 'EXACT', value: '1366x1366'}}}}. My peak day went from 2,914 users to 19.

Bonus: headless setup of the Data API when gcloud is locked out

Granting a service account read access needs two steps, and gcloud often wants an interactive reauth right when you don't want one:

  1. GA4 Admin -> Property access management -> add the service account email as Viewer (turn off notify-by-email; a service account has no inbox).
  2. Enable analyticsdata.googleapis.com. If gcloud is stuck, the service account can enable it on its own project via Service Usage REST: POST https://serviceusage.googleapis.com/v1/projects/<PROJECT_NUMBER>/services/analyticsdata.googleapis.com:enable with a cloud-platform-scoped token from the same key. The 403 you get from runReport before enabling contains the project number you need.

The reporting call itself needs no client library: POST https://analyticsdata.googleapis.com/v1beta/properties/<ID>:runReport with a bearer token scoped to https://www.googleapis.com/auth/analytics.readonly. Set returnPropertyQuota: true and watch metadata.dataLossFromOtherRow (cardinality overflow folded rows into (other)) and metadata.samplingMetadatas, or you will silently analyze a truncated table.

References

No signals yet