---
title: "Cloudflare GraphQL Analytics: A Field-Discovery Cookbook When Introspection Is Locked"
description: "Cloudflare's GraphQL Analytics API powers the dashboard but its schema is partially undocumented and type introspection returns null. Concrete probe pattern using deliberately-bad subselections to discover node and field names without a published schema."
url: https://agent-zone.ai/knowledge/observability/cloudflare-graphql-analytics-field-discovery/
section: knowledge
date: 2026-05-20
categories: ["observability"]
tags: ["cloudflare","graphql","analytics","observability","introspection","api-discovery","debugging"]
skills: ["cloudflare-analytics-querying","graphql-schema-discovery","production-observability"]
tools: ["cloudflare","graphql","curl","wrangler"]
levels: ["intermediate"]
word_count: 934
formats:
  json: https://agent-zone.ai/knowledge/observability/cloudflare-graphql-analytics-field-discovery/index.json
  html: https://agent-zone.ai/knowledge/observability/cloudflare-graphql-analytics-field-discovery/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Cloudflare+GraphQL+Analytics%3A+A+Field-Discovery+Cookbook+When+Introspection+Is+Locked
---


# Cloudflare GraphQL Analytics: A Field-Discovery Cookbook When Introspection Is Locked

Cloudflare's GraphQL Analytics API at `https://api.cloudflare.com/client/v4/graphql` is the richest source of metrics about your CF account — Workers invocations, D1 reads/writes, KV ops, Workers AI neurons, Vectorize queries. The dashboard's charts are powered by it. The CLI is not: `wrangler` exposes a fraction of what GraphQL does.

But the schema is hostile to discovery:

- `__type(name: "WorkersInvocationsAdaptive")` returns `null` for almost every node.
- The official schema docs at `developers.cloudflare.com/analytics/graphql-api` are partial and stale by months.
- Nodes like `vectorizeQueriesAdaptiveGroups` exist, but their `sum`/`dimensions` field names are nowhere on the public internet.

You can still derive the schema. The trick is **deliberate-error probing**: send a query with a guessed field name; the error message tells you whether the parent node exists. This page is the recipe.

## TL;DR — three commands

```bash
export TOKEN=cf_pat_...       # Account → Analytics: Read
export ACCT=abc123def456...   # your account tag

gql() {
  curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    https://api.cloudflare.com/client/v4/graphql --data "{\"query\":\"$1\"}"
}

# 1. Does NODE exist?
gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){workersInvocationsAdaptive(limit:1){__typename}}}}"

# 2. Does sum.FIELD exist on NODE?
gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){workersInvocationsAdaptive(limit:1){sum{requests}}}}}"

# 3. Does dimensions.FIELD exist on NODE?
gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){kvOperationsAdaptiveGroups(limit:1){dimensions{actionType}}}}}"
```

`errors[0].message` is the oracle. `"unknown field NAME"` referencing your guess = it doesn't exist. Anything else (success, or an error pointing at a *different* token) = your guess is fine, the failure is elsewhere.

## The setup

- **Token**: a Cloudflare API token with `Account → Analytics: Read` is enough for read-only metrics. For product-specific dimensions (script names, D1 IDs), add `Account → Workers Scripts: Read`, `Account → D1: Read`, etc. Account-scoped, not zone-scoped.
- **Endpoint**: `https://api.cloudflare.com/client/v4/graphql`. Single POST. Bearer auth. JSON body with one key, `query`.
- **Account tag**: 32-hex from `wrangler whoami` or the dashboard URL.

## Discovering node names

Probe candidates against `viewer.accounts(filter:{accountTag:"..."}){...}`. The shape:

```bash
gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){CANDIDATE(limit:1){__typename}}}}"
```

Three outcomes:

1. `errors[0].message` contains `Unknown field 'CANDIDATE'` → node does not exist.
2. `data.viewer.accounts[0].CANDIDATE` is present → node exists and `__typename` is valid.
3. `errors[0].message` complains about something *other than* `CANDIDATE` (filter args, required selection) → node exists, your subselection is wrong. **Use this as a positive signal.**

Common nodes to start with: `workersInvocationsAdaptive`, `workersAnalyticsAdaptive`, `d1AnalyticsAdaptiveGroups`, `kvOperationsAdaptiveGroups`, `kvStorageAdaptiveGroups`, `r2OperationsAdaptiveGroups`, `aiInferenceAdaptiveGroups`, `vectorizeQueriesAdaptiveGroups`, `queueBacklogAdaptiveGroups`, `durableObjectsInvocationsAdaptiveGroups`.

## Discovering sum-field names

Once a node exists, loop candidates into `sum{...}`:

```bash
SINCE="2026-05-20T00:00:00Z"
for field in requests errors subrequests totalRequests cpuTime cpuTimeMs duration responseBodySize; do
  echo "--- $field ---"
  gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){workersInvocationsAdaptive(limit:1,filter:{datetime_geq:\\\"$SINCE\\\"}){sum{$field}}}}}" | python3 -c "
import json, sys
d = json.load(sys.stdin)
if d.get('errors'): print('  no:', d['errors'][0]['message'])
else: print('  YES:', d['data'])"
done
```

Output looks like:

```
--- requests --- YES: {...}
--- errors --- YES: {...}
--- subrequests --- YES: {...}
--- totalRequests --- no: Unknown field 'totalRequests' on type 'WorkersInvocationsAdaptiveSum'
--- cpuTime --- no: Unknown field 'cpuTime' on type 'WorkersInvocationsAdaptiveSum'
```

The error message leaks the **type name** (`WorkersInvocationsAdaptiveSum`). That's gold — paste it into `__type(name:"WorkersInvocationsAdaptiveSum"){fields{name}}` to maybe get full enumeration. Often it returns `null` (introspection is locked), but try it once per type — sometimes it works.

## Discovering dimensions

Same shape, inside `dimensions{...}`. Useful for read-vs-write splits, per-script breakouts, per-region:

```bash
for dim in actionType scriptName status namespaceId database date datetime datetimeHour; do
  gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){kvOperationsAdaptiveGroups(limit:1,filter:{datetime_geq:\\\"$SINCE\\\"}){dimensions{$dim}}}}}" \
    | jq -r ".errors[0].message // \"OK $dim\""
done
```

## Discovering quantile fields

Adaptive nodes typically expose percentiles under `quantiles{...}`. Naming convention: `<metric>P50`, `<metric>P95`, `<metric>P99`. Try those first; fall back to `<metric>Quantile50`.

```bash
gql "query{viewer{accounts(filter:{accountTag:\\\"$ACCT\\\"}){workersInvocationsAdaptive(limit:1,filter:{datetime_geq:\\\"$SINCE\\\"}){quantiles{cpuTimeP50 cpuTimeP99 wallTimeP50 wallTimeP99}}}}}"
```

If `quantiles` itself is unknown, that node has no percentile channel — only `sum`/`avg`.

## Verified schema map (probed 2026-05-20)

Reuse these directly; do not re-probe live.

| Node | Verified `sum` fields | Verified `dimensions` |
|---|---|---|
| `workersInvocationsAdaptive` | `requests`, `errors`, `subrequests` | (none probed this session) |
| `workersInvocationsAdaptive` `quantiles` | `cpuTimeP50/P95/P99`, `wallTimeP50/P95/P99` | — |
| `d1AnalyticsAdaptiveGroups` | `readQueries`, `writeQueries`, `rowsRead`, `rowsWritten` | — |
| `kvOperationsAdaptiveGroups` | `requests` | `actionType` (`"read"` / `"write"`) |
| `aiInferenceAdaptiveGroups` | `totalNeurons` only | none working as of probe |

Filters that work across all of the above: `datetime_geq:"2026-05-20T05:48:00Z"`, `datetime_leq:"..."`, `scriptName:"my-worker"`, `databaseId:"<uuid>"`, `namespaceId:"<id>"`. Use ISO-8601 UTC; the API rejects timezone offsets on some nodes.

## Gotchas

- **Type introspection is locked.** `__type(name:"WorkersInvocationsAdaptiveSum"){fields{name}}` returns `data.__type: null` for most analytics types. Don't rely on it. Probe instead.
- **No "did you mean" hints.** Error messages list the bad field but never suggest alternatives. Maintain your own candidate list.
- **Field/node renames between releases.** Cloudflare silently renames things between major releases (e.g. `workersAnalyticsAdaptive` → `workersInvocationsAdaptive` happened in 2024). Always re-probe after a long gap (~quarterly).
- **Account-tag filter is mandatory.** `viewer.accounts {...}` without `filter:{accountTag:"..."}` returns an empty array. Errors don't tell you this — you just get zero rows.
- **`limit:1` is your friend.** Probes don't need data; small `limit` keeps response time under 500ms and avoids quota burn.
- **Token scoping bites silently.** A token without `Workers Scripts: Read` will return `data` with `null` rows for `workersInvocationsAdaptive` instead of an error. If results look empty, re-check token scopes before re-probing fields.

## Pair this with a metrics skill

Once you've discovered the fields you need, encode them in a script. Do not re-probe live — bake the discovered names into the script and let it crash loudly if Cloudflare renames something. That crash is your signal to re-probe; silent drift is the failure mode you can't see.

A reference implementation of this pattern bootstrapped the `/agent-zone:metrics` skill in this codebase (`agent-zone@3c70c92`). The schema map above came out of ~10 minutes of probing against an account with Workers, D1, KV, and Workers AI enabled.

## Next steps

- **Script the probe**: wrap the three TL;DR commands in a single `cf-gql-probe.sh` that takes node + field-list args and prints a YES/NO table.
- **Bake the discoveries**: write a `cf-metrics.sh` that runs one coherent multi-node query with the verified field names; commit it next to your other observability scripts.
- **Re-probe periodically**: schedule a quarterly probe run; diff the YES/NO table against last quarter's. Any flips are a Cloudflare schema change you need to handle.

