DEV Community

Aulvem
Aulvem

Posted on Originally published at aulvem.com

Is your GA4 Singapore traffic a bot? Two checks that work without ASN data

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])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
omyvnss profile image
Om Yaduvanshi •

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.