--- title: "Cloudflare website analytics" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Cloudflare website analytics} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r} #| label: setup #| include: false knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = FALSE ) library(cloudflarer) ``` Cloudflare exposes two analytics surfaces that this package wraps: | Surface | Best for | Wrapper | |---|---|---| | Web Analytics / RUM | Site catalogue (sites with the JavaScript beacon installed) | [`cf_list_rum_sites()`], [`cf_get_rum_site()`] | | GraphQL Analytics | Everything else -- HTTP requests, bandwidth, page views, uniques, firewall events, Workers, R2, DNS | [`cf_graphql()`] | The legacy REST Zone Analytics endpoints (`/zones/{id}/analytics/dashboard` and `/analytics/colos`) were retired by Cloudflare in favour of the GraphQL API, so this package does not wrap them. Every example below uses GraphQL where the legacy REST call used to live. The [GraphQL schema explorer](https://graphql.cloudflare.com/explorer) is the fastest way to discover the right node, filter, and dimension for any analytics question. ## Web Analytics (RUM) site catalogue Cloudflare Web Analytics is the free real-user-monitoring product that uses a small JavaScript beacon -- it works on any site, not just proxied ones. List the sites configured in an account with: ```{r} accounts <- cf_list_accounts(max_pages = 1) account_id <- accounts[[1]]$id sites <- cf_list_rum_sites(account_id) sites[[1]]$host sites[[1]]$site_tag cf_get_rum_site(account_id, sites[[1]]$site_tag) ``` For per-site metrics (page views, visits, country breakdowns, ...), query the GraphQL API with the site's `site_tag`. See the [Top countries](#top-countries-for-a-web-analytics-rum-site) example below. ## GraphQL Analytics [`cf_graphql()`] accepts the query as a string and variables as named `...` arguments. The full response body is returned, so you access fields under `$data$...`. ```{r} query <- " query Viewer($accountTag: String!) { viewer { accounts(filter: { accountTag: $accountTag }) { accountTag } } } " res <- cf_graphql(query, accountTag = account_id) res$data$viewer$accounts ``` Two things worth knowing about Cloudflare's GraphQL `viewer`: * The unfiltered `viewer { accounts { ... } }` query enumerates every account the credential might touch and fails with `not authorized for that account` if any one of them is out of scope. Always supply a `filter` with the `accountTag` you care about. * The `Account` type only exposes `accountTag`. The human-readable name lives on the REST `/accounts` endpoint, which you already have from [`cf_list_accounts()`]. ### HTTP requests per day for a zone ```{r} zones <- cf_list_zones(max_pages = 1) zone_id <- zones[[1]]$id query <- " query ZoneRequests($zoneTag: String!, $since: Date!, $until: Date!) { viewer { zones(filter: { zoneTag: $zoneTag }) { httpRequests1dGroups( limit: 100, filter: { date_geq: $since, date_lt: $until }, orderBy: [date_ASC] ) { dimensions { date } sum { requests bytes pageViews threats } uniq { uniques } } } } } " res <- cf_graphql( query, zoneTag = zone_id, since = format(Sys.Date() - 7, "%Y-%m-%d"), until = format(Sys.Date(), "%Y-%m-%d") ) days <- res$data$viewer$zones[[1]]$httpRequests1dGroups data.frame( date = vapply(days, function(d) d$dimensions$date, character(1)), requests = vapply(days, function(d) d$sum$requests, integer(1)), bytes = vapply(days, function(d) d$sum$bytes, numeric(1)), pageviews = vapply(days, function(d) d$sum$pageViews, integer(1)), uniques = vapply(days, function(d) d$uniq$uniques, integer(1)) ) ``` `httpRequests1hGroups` (hourly bins) and `httpRequestsAdaptiveGroups` (sampled adaptive bins) follow the same shape. ### Top countries for a Web Analytics (RUM) site ```{r} query <- " query RumByCountry($accountTag: String!, $siteTag: String!, $since: Time!, $until: Time!) { viewer { accounts(filter: { accountTag: $accountTag }) { rumPageloadEventsAdaptiveGroups( limit: 25, filter: { siteTag: $siteTag, datetime_geq: $since, datetime_lt: $until }, orderBy: [count_DESC] ) { count dimensions { countryName } } } } } " res <- cf_graphql( query, accountTag = account_id, siteTag = sites[[1]]$site_tag, since = format(Sys.Date() - 7, "%Y-%m-%dT00:00:00Z"), until = format(Sys.Date(), "%Y-%m-%dT00:00:00Z") ) ``` ### Error handling Both REST and GraphQL paths raise a classed `cloudflarer_error` on failure, so you can wrap calls in `tryCatch()`: ```{r} tryCatch( cf_graphql("{ broken }"), cloudflarer_error = function(err) { message("Cloudflare said: ", conditionMessage(err)) NULL } ) ``` For GraphQL specifically, any non-empty `errors[]` array in the response is treated as a failure, even when the HTTP status is 200 (which is the standard GraphQL convention). ## Tidy wrappers For the most common analytics questions, cloudflarer ships ready-made data.frame wrappers on top of `cf_graphql()`. Reach for these first; drop down to `cf_graphql()` only when you need a dimension or filter they do not expose. ### HTTP traffic over time ```{r} cf_zone_requests(zone_id, Sys.Date() - 7, Sys.Date(), by = "day") cf_zone_requests(zone_id, Sys.time() - 12 * 3600, Sys.time(), by = "hour") ``` ### Cache hit ratio ```{r} cf_cache_ratio(zone_id, Sys.Date() - 7, Sys.Date()) ``` Columns: `date`, `requests`, `cached_requests`, `bytes`, `cached_bytes`, `request_hit_ratio`, `bandwidth_hit_ratio`. ### DNS queries ```{r} cf_dns_queries(zone_id, Sys.Date() - 7, Sys.Date()) ``` ### Firewall events Available on **Pro, Business, and Enterprise** plans only -- the underlying `firewallEventsAdaptiveGroups` GraphQL node is not exposed on the Free plan and returns `zone ... does not have access to the path`. ```{r} cf_firewall_events_by_day(zone_id, Sys.Date() - 7, Sys.Date()) cf_firewall_events_top( zone_id, since = Sys.Date() - 7, until = Sys.Date(), dimension = "action", limit = 10 ) ``` Other useful `cf_firewall_events_top()` dimensions: `"source"`, `"ruleId"`, `"clientCountryName"`, `"clientRequestPath"`, `"clientRequestHTTPHost"`, `"userAgent"`. ### Real-user (RUM) views ```{r} cf_rum_page_views(account_id, site_tag, since = Sys.Date() - 30, until = Sys.Date()) cf_rum_top(account_id, site_tag, since = Sys.Date() - 30, until = Sys.Date(), dimension = "countryName", limit = 10) ``` ## Picking the right query * **Counting requests, bandwidth, page views, or uniques over time:** `cf_zone_requests()` (or `httpRequestsAdaptiveGroups` directly via `cf_graphql()` for sub-hour granularity). * **Cache effectiveness:** `cf_cache_ratio()`. * **DNS query volume:** `cf_dns_queries()`. * **Security visibility:** `cf_firewall_events_by_day()` / `cf_firewall_events_top()`. * **RUM page views or unique visitors on a beacon site:** `cf_rum_page_views()` / `cf_rum_top()`. * **Anything else** -- Workers invocations, R2 usage, bandwidth by status code or hostname, etc.: `cf_graphql()` directly. The [GraphQL schema explorer](https://graphql.cloudflare.com/explorer) is the fastest way to find the right node and dimensions.