Analyze Cloudflare Worker traffic
On this page
Related contents
When your documentation site, web application, and APIs run on Cloudflare Workers, who is actually reading your content: browsers navigating between pages, background SPA route prefetches, or AI coding agents fetching /llms.txt and .md files?
Requests that do not execute client-side JavaScript, such as AI crawlers, coding agents, RSS readers, and API callers, are invisible to browser analytics scripts. Raw Worker logs capture every invocation, but frameworks such as Next.js and SvelteKit also fire background link prefetches (Next-Router-Prefetch: 1, Sec-Purpose: prefetch) and RSC payloads (RSC: 1) that mix with real page navigations.
In this guide, you deploy a single Cloudflare Tail Worker (scopedb-tail) in your Cloudflare account and attach your producer Workers with tail_consumers without modifying application code. You then query the prebuilt ScopeQL views to separate browser document navigations, background prefetches, and AI crawler or Markdown reads, and export offline interactive HTML charts with the ScopeDB Agent Skill and @scopedb/scopeview.
Allow about 15 minutes to deploy the Tail Worker and run the queries.
1. Create the table and views in ScopeDB
You need a ready ScopeDB Cloud workspace, a Cloudflare account with one or more Workers, Node.js 24 or later, pnpm, and the scope CLI connected to your workspace.
Clone the scopedb/cloudflare-tail-worker repository and install its dependencies:
git clone https://github.com/scopedb/cloudflare-tail-worker.git
cd cloudflare-tail-worker
pnpm installIn Cloudflare Workers, a single Tail Worker can receive execution events from any number of producer Workers in the same Cloudflare account. Every event delivered by Cloudflare includes the producer Worker's scriptName, which scopedb-tail writes to the service column of scopedb.public.cloudflare_worker_events (for example, scopedb-site, app-console-staging, or app-console-production).
The base table stores twelve typed envelope columns alongside a semi-structured attributes (object) column and clusters rows by service and occurred_at:
name: cloudflare_worker_events
comment: Cloudflare Worker invocations, console logs, and uncaught exceptions
columns:
- name: event_id
data_type: string
comment: Unique row identifier
- name: occurred_at
data_type: timestamp
comment: Event timestamp in UTC
- name: service
data_type: string
comment: Producer Worker name (scriptName)
- name: event_type
data_type: string
comment: worker.invocation, worker.log, or worker.exception
- name: outcome
data_type: string
comment: Worker execution outcome (ok, exception, exceededCpu, etc.)
- name: level
data_type: string
comment: Normalized severity (debug, info, warn, error)
- name: method
data_type: string
comment: HTTP request method when triggered by fetch
- name: host
data_type: string
comment: Request URL hostname
- name: path
data_type: string
comment: Request URL pathname without query parameters
- name: status
data_type: int
comment: HTTP response status code
- name: ray_id
data_type: string
comment: Cloudflare Ray ID (cf-ray)
- name: message
data_type: string
comment: Human-readable event summary, log line, or exception message
- name: attributes
data_type: object
comment: Structured invocation timings, edge metadata, log arguments, or stack trace
cluster_by:
- service
- occurred_atOn top of cloudflare_worker_events, the repository defines four views in catalog/:
| Object | Kind | Purpose |
|---|---|---|
scopedb.public.cloudflare_worker_events | Table | Base table storing invocation, log, and exception events across attached Workers. |
scopedb.public.v_cloudflare_requests | View | Invocation metrics, query, UTM fields, prefetch/RSC flags, trace_id, and Cloudflare truncation status. |
scopedb.public.v_cloudflare_pageviews | View | Successful browser document navigations, excluding prefetches, RSC payloads, and known bots. |
scopedb.public.v_cloudflare_ai_bots | View | Separate verified AI, AI user-agent, and Markdown/llms.txt content signals. |
scopedb.public.v_cloudflare_errors | View | 5xx responses, non-ok Worker outcomes, error logs, and uncaught exceptions. |
Apply the table and all four views using scope catalog apply:
scope catalog apply --table scopedb.public.cloudflare_worker_events -f catalog/table.yaml --yes
scope catalog apply --view scopedb.public.v_cloudflare_requests -f catalog/v_cloudflare_requests.yaml --yes
scope catalog apply --view scopedb.public.v_cloudflare_pageviews -f catalog/v_cloudflare_pageviews.yaml --yes
scope catalog apply --view scopedb.public.v_cloudflare_errors -f catalog/v_cloudflare_errors.yaml --yes
scope catalog apply --view scopedb.public.v_cloudflare_ai_bots -f catalog/v_cloudflare_ai_bots.yaml --yes2. Deploy scopedb-tail and attach producer Workers
In Console, copy your workspace ScopeDB API endpoint and create an API key with write access to scopedb.public.cloudflare_worker_events. Upload both values as encrypted Wrangler secrets and deploy scopedb-tail:
pnpm exec wrangler secret put SCOPEDB_ENDPOINT
pnpm exec wrangler secret put SCOPEDB_API_KEY
pnpm run deployscopedb-tail sets workers_dev = false in wrangler.toml and exports only a tail() handler, so it exposes no public HTTP route and runs asynchronously after each producer Worker finishes responding.
Next, attach each producer Worker you want to observe by adding tail_consumers to its wrangler.toml and redeploying that Worker:
tail_consumers = [{ service = "scopedb-tail" }]If a producer Worker defines named environments such as [env.staging] and [env.production], add tail_consumers = [{ service = "scopedb-tail" }] under each environment block. Cloudflare automatically sets scriptName to the environment-specific Worker name (my-app-staging versus my-app-production), so both environments land in cloudflare_worker_events with distinct service values.
Trigger a few requests to your producer Worker and verify that invocations are arriving in ScopeDB:
FROM scopedb.public.v_cloudflare_requests
WHERE occurred_at >= NOW() - '15m'::interval
SELECT occurred_at, service, method, path, status, wall_time_ms
ORDER BY occurred_at DESC
LIMIT 5Customize URL capture and redaction (optional)
By default, scopedb-tail captures decoded URL query parameters as a multi-value map in attributes['query'] (Record<string, string[]>, exposed as query in the views alongside utm_* and ref convenience columns), strips URL fragments and Referer query strings, omits Cookie and Authorization headers, and applies heuristic token redaction to request and Referer path segments.
If your application requires an explicit query parameter allowlist or custom path/value redaction rules, pass UrlCaptureOptions (allowedQueryParams, redactQueryValue, redactPath) to the export default createTailWorker() call at the bottom of src/index.ts and redeploy scopedb-tail:
export default createTailWorker({
allowedQueryParams: ["utm_source", "utm_medium", "utm_campaign", "plan"],
redactQueryValue: (key, value) =>
key === "plan" && !["free", "team"].includes(value) ? null : value,
redactPath: (pathname) => pathname.replace(/^\/reset\/[^/]+/, "/reset/:token"),
});3. Distinguish browser document requests, prefetches, and AI signals
v_cloudflare_pageviews selects GET requests with status 200, Sec-Fetch-Mode: navigate, and Sec-Fetch-Dest: document, while excluding background link prefetches (is_prefetch), React Server Component payloads (is_rsc), static assets, /api/* routes, and known bots:
FROM scopedb.public.cloudflare_worker_events
WHERE event_type = 'worker.invocation'
SELECT
occurred_at,
service,
method,
host,
path,
status,
attributes['continent']::string AS continent,
attributes['country']::string AS country,
attributes['region']::string AS region,
attributes['city']::string AS city,
attributes['timezone']::string AS timezone,
attributes['colo']::string AS colo,
attributes['as_organization']::string AS as_organization,
if(
attributes['referer_host']::string = host
OR attributes['referer_host']::string = concat('www.', host)
OR concat('www.', attributes['referer_host']::string) = host,
NULL,
attributes['referer_host']::string
) AS referer_host,
if(
attributes['referer_host']::string = host
OR attributes['referer_host']::string = concat('www.', host)
OR concat('www.', attributes['referer_host']::string) = host,
NULL,
attributes['referer_path']::string
) AS referer_path,
if(attributes['query'] IS NULL, attributes['utm_source']::string, attributes['query']['utm_source'][0]::string) AS utm_source,
if(attributes['query'] IS NULL, attributes['utm_medium']::string, attributes['query']['utm_medium'][0]::string) AS utm_medium,
if(attributes['query'] IS NULL, attributes['utm_campaign']::string, attributes['query']['utm_campaign'][0]::string) AS utm_campaign,
if(attributes['query'] IS NULL, attributes['utm_term']::string, attributes['query']['utm_term'][0]::string) AS utm_term,
if(attributes['query'] IS NULL, attributes['utm_content']::string, attributes['query']['utm_content'][0]::string) AS utm_content,
if(attributes['query'] IS NULL, attributes['ref']::string, attributes['query']['ref'][0]::string) AS ref,
attributes['query'] AS query,
attributes['user_agent']::string AS user_agent,
attributes['sec_fetch_mode']::string AS sec_fetch_mode,
attributes['sec_fetch_dest']::string AS sec_fetch_dest,
attributes['is_prefetch']::boolean AS is_prefetch,
attributes['is_rsc']::boolean AS is_rsc,
attributes['verified_bot_category']::string AS verified_bot_category,
attributes['wall_time_ms']::float AS wall_time_ms,
attributes['cpu_time_ms']::float AS cpu_time_ms
WHERE method = 'GET'
AND status = 200
AND path IS NOT NULL
AND sec_fetch_mode = 'navigate'
AND sec_fetch_dest = 'document'
AND (is_prefetch IS NULL OR NOT is_prefetch)
AND (is_rsc IS NULL OR NOT is_rsc)
AND verified_bot_category IS NULL
AND NOT starts_with(path, '/api/')
AND NOT starts_with(path, '/_next/')
AND NOT starts_with(path, '/_app/')
AND NOT starts_with(path, '/.')
AND NOT ends_with(path, '/__data.json')
AND NOT regexp_like(path, regex => '(?i)[.](md|txt|xml|ico|svg|png|jpg|jpeg|webp|gif|css|js|map|json|woff2?)$')
AND (user_agent IS NULL OR NOT regexp_like(user_agent, regex => '(?i)(bot|crawler|spider|slurp|facebookexternalhit|feedbin|pingwatch|uptime|statuscake|cortex-xpanse|paloaltonetworks|censys|shodan|zgrab|curl|wget|python|go-http-client|axios|node-fetch|undici|okhttp|headless|Claude-User|ChatGPT-User|Perplexity-User|Anthropic|cohere-ai|Cursor|Codex)'))Rank document paths
The examples below query 24 hours of observed scopedb-site traffic (2026-11-08 14:00:00 UTC to 2026-11-09 14:00:00 UTC). Replace the service name and time range with your own.
Rank paths by browser document requests:
FROM scopedb.public.v_cloudflare_pageviews
WHERE service = 'scopedb-site'
AND occurred_at >= '2026-11-08T14:00:00Z'::timestamp
AND occurred_at < '2026-11-09T14:00:00Z'::timestamp
GROUP BY path
AGGREGATE count() AS document_requests
ORDER BY document_requests DESC, path ASC
LIMIT 10| path | document_requests |
|---|---|
| / | 32 |
| /blog/raft-agent-observability | 11 |
| /docs | 6 |
| /docs/reference/commands/stmt-query | 5 |
| /blog/manage-observability-data-in-petabytes | 4 |
| /contact | 4 |
| /blog | 3 |
| /blog/schema-on-the-fly | 3 |
| /blog/scopedb-cloud-public-preview | 3 |
| /blog/scopeql-origins | 3 |
Inspect external referrers and request geography
v_cloudflare_pageviews normalizes same-host and www./apex self-referrals to NULL. Group the remaining referrers with utm_source to inspect external traffic sources:
FROM scopedb.public.v_cloudflare_pageviews
WHERE service = 'scopedb-site'
AND occurred_at >= '2026-11-08T14:00:00Z'::timestamp
AND occurred_at < '2026-11-09T14:00:00Z'::timestamp
AND referer_host IS NOT NULL
GROUP BY referer_host, utm_source
AGGREGATE count() AS document_requests
ORDER BY document_requests DESC, referer_host ASC, utm_source ASC
LIMIT 10| referer_host | utm_source | document_requests |
|---|---|---|
| github.com | NULL | 7 |
| www.google.com | NULL | 6 |
| t.co | NULL | 4 |
| docs.scopedb.io | NULL | 2 |
| raft.build | NULL | 2 |
| scopedb.ai | NULL | 1 |
| vuink.com | NULL | 1 |
| www.google.io | NULL | 1 |
| www.reddit.com | NULL | 1 |
| www.superfluidity.ai | NULL | 1 |
Group by continent to inspect the geographic distribution from Cloudflare's edge GeoIP metadata:
FROM scopedb.public.v_cloudflare_pageviews
WHERE service = 'scopedb-site'
AND occurred_at >= '2026-11-08T14:00:00Z'::timestamp
AND occurred_at < '2026-11-09T14:00:00Z'::timestamp
AND continent IS NOT NULL
GROUP BY continent
AGGREGATE count() AS document_requests
ORDER BY document_requests DESC, continent ASC| continent | document_requests |
|---|---|
| AS | 82 |
| NA | 54 |
| EU | 19 |
| SA | 7 |
Query application parameters without changing the table
Query parameters are stored in the semi-structured object column attributes['query'] (projected as query on v_cloudflare_requests and v_cloudflare_pageviews). For example, if a request arrives with ?plan=team&plan=free, query['plan'] retains both values and query['plan'][0]::string selects team. With schema-on-the-fly, you can filter or group by any application parameter directly in ScopeQL without altering the table or redeploying the collector:
FROM scopedb.public.v_cloudflare_requests
WHERE service = 'app-console-production'
AND occurred_at >= NOW() - '24h'::interval
AND query['plan'][0]::string IS NOT NULL
GROUP BY query['plan'][0]::string AS plan
AGGREGATE count() AS requests
ORDER BY requests DESC, plan ASCDistinguish AI identity from content access
v_cloudflare_ai_bots exposes three boolean flags so you can analyze AI crawler traffic and Markdown documentation reads together or separately:
is_verified_ai_bot: Cloudflare's verified bot category isAI Crawler,AI Assistant, orAI Search.is_ai_user_agent: theUser-Agentheader matches known AI crawlers or coding agents (such asClaudeBot,GPTBot,ChatGPT-User,PerplexityBot,Cursor, orCodex).is_markdown_request: the request targets/llms.txt, a.mdpath, or sendsAccept: text/markdown.
Group by all three flags to inspect how these signals break down across requests:
FROM scopedb.public.v_cloudflare_ai_bots
WHERE service = 'scopedb-site'
AND occurred_at >= '2026-11-08T14:00:00Z'::timestamp
AND occurred_at < '2026-11-09T14:00:00Z'::timestamp
GROUP BY is_verified_ai_bot, is_ai_user_agent, is_markdown_request
AGGREGATE count() AS requests
ORDER BY requests DESC| is_verified_ai_bot | is_ai_user_agent | is_markdown_request | requests |
|---|---|---|---|
| false | true | false | 767 |
| false | false | true | 347 |
| true | true | false | 73 |
| true | false | false | 62 |
| true | true | true | 7 |
| true | false | true | 2 |
| false | true | true | 1 |
To drill into the specific documentation pages and crawler user agents behind AI bot traffic, filter with WHERE is_verified_ai_bot OR is_ai_user_agent and group by path and user_agent.
Correlate logs and exceptions within an invocation
When investigating a 5xx response or uncaught exception in v_cloudflare_errors (or a slow request in v_cloudflare_requests), every console.log, console.error, and exception emitted during that Worker execution shares the same attributes['invocation_id']. If your frontend or BFF propagates a W3C traceparent header, scopedb-tail also extracts the 32-character hexadecimal trace_id into attributes['trace_id'].
Copy an invocation_id from v_cloudflare_errors or v_cloudflare_requests and query cloudflare_worker_events to inspect all captured logs and stack traces for that invocation:
FROM scopedb.public.cloudflare_worker_events
WHERE attributes['invocation_id']::string = '<invocation_id>'
SELECT occurred_at, event_type, level, status, path, message, attributes
ORDER BY occurred_at ASC4. Export interactive charts with $scopedb and ScopeView
Instead of maintaining a separate analytics dashboard login, you can use the ScopeDB Agent Skill ($scopedb) and @scopedb/scopeview to query v_cloudflare_pageviews and v_cloudflare_ai_bots and export standalone HTML charts that open offline in any browser.
Paste this prompt into Claude Code, Codex, or another agent configured with $scopedb:
Use $scopedb to analyze Cloudflare traffic from scopedb.public.v_cloudflare_pageviews and scopedb.public.v_cloudflare_ai_bots where service = 'scopedb-site', from 2026-11-08T14:00:00Z to 2026-11-09T14:00:00Z, and render @scopedb/scopeview interactive HTML charts for:
1. Browser document requests over time in 1-hour buckets using floor(occurred_at, n => 1, unit => 'hour') (line chart)
2. Top 10 document paths ordered by request count descending (top-list chart)
3. Top external referrers and utm_source values (top-list chart)
4. Request continents from Cloudflare GeoIP metadata (top-list chart)
5. Request counts grouped by is_verified_ai_bot, is_ai_user_agent, and is_markdown_request (top-list chart)Your agent executes each read-only query, formats the rows into a ScopeView JSON document, and exports a self-contained .html file:
{
"version": 1,
"title": "scopedb.io — Top 10 document paths",
"description": "Source: scopedb.public.v_cloudflare_pageviews (service = scopedb-site, 2026-11-08 14:00 UTC to 2026-11-09 14:00 UTC, LIMIT 10).",
"data": {
"fields": [
{ "name": "path", "data_type": "string" },
{ "name": "document_requests", "data_type": "number" }
],
"rows": [
["/", "32"],
["/blog/raft-agent-observability", "11"],
["/docs", "6"],
["/docs/reference/commands/stmt-query", "5"],
["/blog/manage-observability-data-in-petabytes", "4"],
["/contact", "4"],
["/blog", "3"],
["/blog/schema-on-the-fly", "3"],
["/blog/scopedb-cloud-public-preview", "3"],
["/blog/scopeql-origins", "3"]
]
},
"spec": {
"visualization": "top-list",
"encoding": {
"category": "path",
"value": "document_requests"
}
},
"semantics": {
"provenance": {
"kind": "rows",
"scope": "returned"
}
}
}To validate and export top-pages.json directly from your terminal:
npx @scopedb/scopeview render top-pages.json -o top-pages.html --theme systemOpen top-pages.html in your browser to inspect the interactive chart offline.
Continue with Model event data, Query events, or Set data retention to adapt the schema and retention policy for your own Workers.