A sanitized, ready-to-template set of calls for pulling Core Web Vitals (LCP / INP / CLS) from the Cloudflare GraphQL Analytics API. All secrets/IDs are placeholders — swap in your own. Uses a scoped Bearer token (recommended) instead of the global key.
Note: Logpush cannot deliver Web Vitals. RUM data is only available via the GraphQL Analytics API (
rumWebVitalsEventsAdaptiveGroupsand related nodes).
# Scoped API token with: Account → Account Analytics → Read
export CF_API_TOKEN="<YOUR_API_TOKEN>"
export CF_ACCOUNT_ID="<YOUR_ACCOUNT_ID>"
export CF_GQL="https://api.cloudflare.com/client/v4/graphql"
# Time window (RFC3339 / ISO-8601, UTC)
export START="2026-05-25T00:00:00Z"
export END="2026-06-01T23:59:59Z"- Web Vitals RUM datasets are US-only under data localization.
- Auth header is
Authorization: Bearer <token>for scoped tokens. - Global key alternative:
X-Auth-Email: <email>+X-Auth-Key: <global_key>.
curl -s "$CF_GQL" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data @- <<JSON
{
"query": "query WebVitals(\$acct: String!, \$start: Time!, \$end: Time!) { viewer { accounts(filter: { accountTag: \$acct }) { rumWebVitalsEventsAdaptiveGroups(limit: 100, orderBy: [count_DESC], filter: { datetime_geq: \$start, datetime_leq: \$end }) { count dimensions { siteTag requestHost requestPath deviceType userAgentBrowser } avg { largestContentfulPaint interactionToNextPaint cumulativeLayoutShift firstContentfulPaint timeToFirstByte } quantiles { largestContentfulPaintP75 largestContentfulPaintP95 interactionToNextPaintP75 interactionToNextPaintP95 cumulativeLayoutShiftP75 cumulativeLayoutShiftP95 } } } } }",
"variables": {
"acct": "$CF_ACCOUNT_ID",
"start": "$START",
"end": "$END"
}
}
JSONAdd a filter field to the filter object:
Group by a time dimension to chart trends:
rumWebVitalsEventsAdaptiveGroups(
limit: 1000
orderBy: [datetimeFifteenMinutes_ASC]
filter: { datetime_geq: $start, datetime_leq: $end, requestHost: "<YOUR_HOST>" }
) {
count
dimensions { datetimeFifteenMinutes }
quantiles { largestContentfulPaintP75 interactionToNextPaintP75 cumulativeLayoutShiftP75 }
}query RawVitals($acct: String!, $start: Time!, $end: Time!) {
viewer {
accounts(filter: { accountTag: $acct }) {
rumWebVitalsEventsAdaptive(
limit: 1000
orderBy: [datetime_DESC]
filter: { datetime_geq: $start, datetime_leq: $end }
) {
datetime
siteTag
requestHost
requestPath
deviceType
userAgentBrowser
}
}
}
}Same shape, just swap the node name:
rumPageloadEventsAdaptiveGroups— page views, navigation timingrumPerformanceEventsAdaptiveGroups— detailed performance timings
// web-vitals-export.mjs — run: node web-vitals-export.mjs
const CF_API_TOKEN = process.env.CF_API_TOKEN;
const CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID;
const ENDPOINT = "https://api.cloudflare.com/client/v4/graphql";
const QUERY = `
query WebVitals($acct: String!, $start: Time!, $end: Time!) {
viewer {
accounts(filter: { accountTag: $acct }) {
rumWebVitalsEventsAdaptiveGroups(
limit: 100
orderBy: [count_DESC]
filter: { datetime_geq: $start, datetime_leq: $end }
) {
count
dimensions { siteTag requestHost }
avg { largestContentfulPaint interactionToNextPaint cumulativeLayoutShift }
quantiles {
largestContentfulPaintP75 interactionToNextPaintP75 cumulativeLayoutShiftP75
}
}
}
}
}`;
// RUM stores LCP/INP/FCP/TTFB in microseconds; CLS is unitless. -1 = no sample.
const usToMs = (v) => (v == null || v < 0 ? null : v / 1000);
async function main() {
const now = new Date();
const start = new Date(now.getTime() - 7 * 24 * 3600 * 1000); // last 7 days
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: QUERY,
variables: {
acct: CF_ACCOUNT_ID,
start: start.toISOString(),
end: now.toISOString(),
},
}),
});
const json = await res.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
const rows = json.data.viewer.accounts[0].rumWebVitalsEventsAdaptiveGroups.map((r) => ({
site: r.dimensions.requestHost,
siteTag: r.dimensions.siteTag,
samples: r.count,
lcp_p75_ms: usToMs(r.quantiles.largestContentfulPaintP75),
inp_p75_ms: usToMs(r.quantiles.interactionToNextPaintP75),
cls_p75: r.quantiles.cumulativeLayoutShiftP75 < 0 ? null : r.quantiles.cumulativeLayoutShiftP75,
}));
// Replace with your sink (S3, BigQuery, Splunk HEC, etc.)
console.log(JSON.stringify(rows, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});- Microseconds → LCP, INP, FCP, TTFB. Divide by 1,000 for ms; 1,000,000 for s.
- Unitless → CLS.
-1→ metric had no sample for that group (e.g. no user interaction → no INP).- Scope → group/filter by
siteTagorrequestHost, not zone. - Permissions → token needs
Account Analytics: Read.