If GA4 suddenly shows a wave of Direct traffic from Singapore, you are not alone. On my site it was 210 sessions in 28 days, one engaged session, zero seconds of engagement.
Being fairly sure it is not people is easy. Proving it is harder than it should be. The quick answer is the ASN: if the requests come from a cloud provider, you are done. But Cloudflare's free plan does not expose the ASN through its analytics API.
Here are the two checks I used instead, with code you can run as-is.
Check 1: screen resolution in GA4
This was the strongest signal. 201 of the 210 Singapore sessions reported a screen resolution of 1280x1200.
Real visitors spread across dozens of resolutions. When nearly every session from one country reports the same unusual value, that is a tool's configuration.
With the GA4 Data API in Python:
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, Dimension, Metric, DateRange, FilterExpression, Filter,
)
def by_resolution(client, property_id, country="Singapore", days=28):
req = RunReportRequest(
property=f"properties/{property_id}",
dimensions=[Dimension(name="screenResolution")],
metrics=[Metric(name="sessions"), Metric(name="engagedSessions")],
date_ranges=[DateRange(start_date=f"{days}daysAgo", end_date="today")],
dimension_filter=FilterExpression(filter=Filter(
field_name="country",
string_filter=Filter.StringFilter(value=country),
)),
)
for row in client.run_report(req).rows:
print(row.dimension_values[0].value,
[m.value for m in row.metric_values])
In the UI, an Explore report with Country and Screen resolution as dimensions shows the same thing. Add Browser and Operating system and the bot collapses into "Chrome / Windows".
1280x1200 is this particular bot's setting, so yours may differ. What matters is whether one value dominates, not the value itself.
Check 2: Chrome versions on Cloudflare's beacon
The second check is the Chrome version claimed by clients that actually ran JavaScript.
With Cloudflare Web Analytics enabled, each page sends a beacon to /cdn-cgi/rum. Only JavaScript-executing clients hit that path, so they are the same clients that fire your GA4 tag. The free plan lets you count that path by user agent:
import re, collections, datetime as dt, requests
GQL = "https://api.cloudflare.com/client/v4/graphql"
QUERY = """
query($zone: String!, $since: Time!, $until: Time!, $country: String!) {
viewer { zones(filter: { zoneTag: $zone }) {
httpRequestsAdaptiveGroups(
limit: 5000,
filter: {
datetime_geq: $since, datetime_lt: $until,
clientCountryName: $country,
clientRequestPath: "/cdn-cgi/rum"
}
) { count dimensions { userAgent } }
} }
}
"""
def chrome_versions(token, zone, country, days=7):
now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
ver = collections.Counter()
for i in range(days): # free plan: one day per query
until = now - dt.timedelta(days=i)
since = until - dt.timedelta(days=1)
r = requests.post(GQL, headers={"Authorization": f"Bearer {token}"}, json={
"query": QUERY,
"variables": {"zone": zone, "country": country,
"since": since.isoformat().replace("+00:00", "Z"),
"until": until.isoformat().replace("+00:00", "Z")},
}, timeout=60).json()
for g in r["data"]["viewer"]["zones"][0]["httpRequestsAdaptiveGroups"]:
m = re.search(r"Chrome/(\d+)", g["dimensions"]["userAgent"])
ver[m.group(1) if m else "other"] += g["count"]
return ver.most_common()
print("SG", chrome_versions(TOKEN, ZONE, "SG"))
print("JP", chrome_versions(TOKEN, ZONE, "JP")) # control group
The trick is running it for a country where your real readers are, as a control. Mine:
| Singapore | Japan (control) | |
|---|---|---|
| Chrome versions | 103 to 133, more than a dozen majors | 153 and 154 |
| OS | Windows, every one | Windows |
| UTC hours with a beacon | 23 of 24 | a few, clustered |
Chrome updates itself, so real readers cluster on the current release (153–154 as of October 2026). A steady spread of old versions from a single city is a tool picking user agents from a list.
The hour of day helps too. No night-time dip, beacons around the clock: not people.
Gotcha: clientAsn fails the whole query on the free plan
I started by asking for the ASN. On a free zone you get:
zone '...' does not have access to the field 'clientasndescription'
The whole query fails, so drop the ASN fields. User agent, path, status and datetimeHour all work on the free plan.
A rough rubric
When all four line up, I am comfortable calling it a bot without ASN data.
| Signal | Bot | People |
|---|---|---|
| Engagement | 0 s, one page and gone | scrolling, time on page |
| Screen resolution | one value dominates | dozens of values |
| Chrome version | old versions, evenly spread | the current release |
| Hour of day | flat across 24 h | follows waking hours |
GA4 does exclude known bots automatically, but you cannot see or extend that list. A headless Chrome with a normal user agent slips through, so the exclusion ends up being your job at analysis time.
Which pages the bot was actually crawling, how my "most-read posts" changed once it was filtered out, and why I chose not to block the country are on Aulvem → Aulvem | My top country in GA4 was a bot — identifying Singapore traffic without ASN data
Top comments (1)
the screen resolution check is the clever one. user agents lie, but nobody fakes 1280x1200 by accident. worth re-running the rubric every few months though, bot farms update chrome eventually and a fixed rule quietly goes stale.