Skip to content

Instantly share code, notes, and snippets.

@taslabs-net
Created June 1, 2026 19:42
Show Gist options
  • Select an option

  • Save taslabs-net/a206d93e3f057b2bfa398b18f5d3acde to your computer and use it in GitHub Desktop.

Select an option

Save taslabs-net/a206d93e3f057b2bfa398b18f5d3acde to your computer and use it in GitHub Desktop.

Cloudflare Web Analytics (RUM) — Web Vitals API Calls

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 (rumWebVitalsEventsAdaptiveGroups and related nodes).


Auth setup

# 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>.

1. Aggregated Web Vitals (LCP / INP / CLS) by site

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"
  }
}
JSON

2. Filter to a single site (by host)

Add a filter field to the filter object:

"filter": {
  "datetime_geq": "$START",
  "datetime_leq": "$END",
  "requestHost": "<YOUR_HOST>"   // e.g. "www.example.com"
  // or: "siteTag": "<YOUR_SITE_TAG>"
}

3. Time series (15-min buckets)

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 }
}

4. Raw individual events (non-aggregated)

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
      }
    }
  }
}

5. Companion datasets

Same shape, just swap the node name:

  • rumPageloadEventsAdaptiveGroups — page views, navigation timing
  • rumPerformanceEventsAdaptiveGroups — detailed performance timings

6. Sanitized Node.js exporter (for SIEM/warehouse ingestion)

// 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);
});

Quick reference — units & gotchas

  • 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 siteTag or requestHost, not zone.
  • Permissions → token needs Account Analytics: Read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment