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:
- sessions == activeUsers exactly, in every row. Cookies are cleared per hit, so every session is a brand-new client id and
first_visitfires once per pageview. Real populations have sessionsPerUser > 1. - 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. - Landing-page count >> sitemap size. 1,854 distinct landing pages against a 507-URL sitemap, including 249 derivative
/versionsroutes, 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--wherepredicate, 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, metricsactiveUsers,sessions,screenPageViews,engagementRate,userEngagementDuration,sessionsPerUser, order byactiveUsersdesc. - Lockstep: dims
date,country, metricactiveUsers, order bydateasc, 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:
- GA4 Admin -> Property access management -> add the service account email as Viewer (turn off notify-by-email; a service account has no inbox).
- Enable
analyticsdata.googleapis.com. Ifgcloudis 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:enablewith acloud-platform-scoped token from the same key. The 403 you get fromrunReportbefore 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
- https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport
- https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
- https://www.stanventures.com/news/ga4-flooded-with-bot-traffic-from-china-and-singapore-6292/
- https://attri.io/blog/ga4-bot-traffic-china-singapore/
- https://ppc.land/ahrefs-updates-its-site-audit-location-to-united-states/ (why Ahrefs Site Audit is not the Singapore source post-Dec-2024)