DEV Community

pickuma
pickuma

Posted on Originally published at pickuma.com

Cloudflare Web Analytics via GraphQL: the siteTag Filter and the Dimensions That Split Bot Traffic

The Cloudflare Web Analytics dashboard reported 11,760 pageviews for this site over the 30 days ending 2026-08-21. The number we record in our weekly snapshot for the same window is 2,860. Same dataset, same account — the difference is a filter on two dimensions that the dashboard will not combine for you. The query that produces it is about twelve lines against https://api.cloudflare.com/client/v4/graphql:

query {
  viewer {
    accounts(filter: { accountTag: "<32-hex account id>" }) {
      rumPageloadEventsAdaptiveGroups(
        limit: 5000
        orderBy: [count_DESC]
        filter: {
          siteTag: "<32-hex site tag>"
          datetime_geq: "2026-07-22T00:00:00Z"
          datetime_leq: "2026-08-21T00:00:00Z"
        }
      ) {
        count
        dimensions { countryName requestHost }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Auth is a plain Authorization: Bearer <token> header with an API token carrying account-level Analytics read. That is the entire surface needed to count pageviews. What follows is the part that cost us time.

accountTag, siteTag and requestHost are three different things

The filter takes two 32-hex identifiers and they are not interchangeable. accountTag is your Cloudflare account ID — the same value you already have in CLOUDFLARE_ACCOUNT_ID for Wrangler. siteTag is issued per Web Analytics property and is a distinct value; ours is hardcoded as a separate constant in the snapshot script precisely because it is neither the account ID nor the zone ID. If you are hunting for it, it is the same token that appears in the beacon snippet Cloudflare gives you, the data-cf-beacon attribute.

The third one is the surprise. A single site tag can carry more than one hostname. Our tag covers both pickuma.com and play.pickuma.com, a sister project on a different worker. Of the 11,760 pageviews in that window, 760 — about 6.5% — belonged to the sister site. Without grouping by requestHost and discarding rows that do not match, every number you compute silently sums two properties. That error does not announce itself; it just makes your traffic look better than it is, consistently, forever.

One more mechanical detail: query-level failures come back with HTTP 200 and an errors array in the body. Our script checks json.errors and never checks response.ok, and that is deliberate — a bad siteTag or a malformed datetime returns a perfectly successful HTTP response containing nothing useful. If you branch on status code you will treat a broken query as an empty week.

limit is a row cap and it truncates silently

The adaptive-group selectors take limit as an argument, and it caps returned groups, not events. With a low-cardinality grouping this is invisible. Our query groups by country crossed with host, which is a few hundred rows at the outside, so limit: 5000 has never been close to binding.

Add a path dimension and the arithmetic changes fast. This site has 289 URLs in its sitemap; crossed with roughly a hundred countries, the theoretical row count is well past 5,000 before you have added a device or referer dimension. You do not get an error when you cross the line. You get the top N rows by whatever orderBy you specified and a total that is quietly short.

If you sum count across rows to get a total, that sum is only correct while the row count is under your limit. Check the length of the returned array against the limit on every run. If they are equal, treat the total as a lower bound, not a measurement — and either raise the limit or drop a dimension. A truncated total that looks plausible is worse than an error.

Which quantile fields exist: introspect, do not trust a list

Our production query uses count and two dimensions. It does not use quantiles, and we are not going to publish a field list we never exercised — that is exactly the kind of paraphrase that is wrong six months later when the schema moves.

The reliable answer is introspection against your own account, because what is available varies with plan and with which RUM dataset you are actually in. Cloudflare's GraphQL endpoint answers introspection queries with the same bearer token:

query {
  __type(name: "AccountRumPageloadEventsAdaptiveGroups") {
    fields { name type { name kind ofType { name } } }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run that once, save the output next to your query, and you have a field list that is true for your account rather than true for someone's blog post. Two things to check while you are in there. First, whether the percentile fields you want sit on a quantiles sub-selection or as flat fields — this determines whether your GraphQL selection set even parses. Second, whether the metric you are after lives on this dataset at all. The dashboard's page-load timing panel and its Core Web Vitals panel are not guaranteed to be reading the same underlying dataset, so a field being visible in the UI is not evidence that it is selectable here.

Introspection is also the cheapest way to discover the filter fields. Query __type(name: "...Filter_InputObject") and read inputFields — that tells you which dimensions are filterable rather than merely groupable, which is not the same set.

What this dataset structurally cannot tell you

RUM is a browser beacon. It fires when JavaScript runs. A curl loop, a Python requests scraper, or any client that pulls HTML without a browser engine never enters the dataset at any percentile of any dimension. No filter you write recovers traffic that was never recorded.

That sets a hard ceiling on what the country and host dimensions can do. They are useful — dropping two countries with a datacentre traffic signature took our 30-day figure from 11,760 to 2,860, and that ratio is the difference between a site that looks like it is recovering and one that is not. But it separates JS-executing automation from readers. Whatever bot share you compute this way, the real share is higher.

If you need per-request truth, this is the wrong instrument and no amount of schema archaeology fixes it. Put a Worker in front of the origin and log the bot score and ASN per request. The condition that flips the choice is whether you have a request-level vantage point at all: on Astro static output served by Cloudflare Static Assets, the application never sees a request line, so RUM plus GraphQL is the fallback, not the preference.

The other reason to use the API rather than the dashboard is retention. We exercise 7-day and 30-day windows; the dataset is a rolling window, and a week you did not record is a week you cannot reconstruct. That is why our snapshot writes a dated row to a committed JSON file. Nothing about the query is clever — it is just run on a schedule, which the dashboard cannot do for you.


Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Top comments (0)