Cloudflare Web Analytics via GraphQL: the siteTag Filter
Why accountTag and siteTag differ in rumPageloadEventsAdaptiveGroups, how the limit argument truncates silently, and which fields we did not verify.
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 }
}
}
}
}
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.
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 } } }
}
}
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.
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.
Notion
Where we would put the weekly numbers if they needed to be readable by people without repo access. Ours go into a committed JSON file instead, which is better for diffing and worse for sharing.
Free personal tier; Plus from $10/user/month billed annually
Affiliate link · We earn a commission at no cost to you.
FAQ
Does this need a paid Cloudflare plan?
How far back can I query?
Can I get this broken down per URL?
Related tools
Beehiiv
Newsletter platform with built-in ad network and Boost referrals.
Try Beehiiv →
Webflow
Visual site builder with real CSS export and a CMS that scales.
Try Webflow →
Some links above are affiliate links. We may earn a commission if you sign up. See our disclosure for details.
Related reading
2026-05-12
Paperless-ngx: Self-Hosted Docs With a REST API
A hands-on review of the open-source DMS: Docker stack, OCR pipeline, AI workflow integration, and where Whoosh search hits its limits.
2026-08-21
701 Bad Internal Links Before 49 Good Ones
A phrase-matching script inserted 750 links across 269 MDX articles. Here is where they failed, and how claim-matching cut the review pile to 128.
2026-08-21
Bing Webmaster API's 100-URL Cap: 289 URLs, Three Days
Quota is 100 a day against 1300 a month, GetQueryStats was still empty at the end, and two error shapes will kill a cron job.
2026-08-19
IndexNow Batch Mode: 286 URLs Per Publish Down to 0
Bing flags full-sitemap submissions as batch mode. The ~40-line lastmod diff that fixes it, the precondition it needs, and two ways it silently breaks.
2026-08-19
Astro middleware can't serve 410 under output: 'static'
We deleted 434 articles. Middleware runs at build time and 404s in production; two prerender:false routes fix it on Cloudflare Workers.
Get the best tools, weekly
One email every Friday. No spam, unsubscribe anytime.