How to Archive Cloudflare Analytics to R2 with a Worker
Cloudflare’s analytics have a retention limit that depends on your plan. Once data ages past it, it’s gone.
The fix is simple: copy the data out every day, before it expires. This guide builds that with about 50 lines of TypeScript, a Cron Trigger, and an R2 bucket, all on Cloudflare and all on any plan.
What we’re building
- A Worker that runs every night at 02:30 UTC
- It queries the GraphQL Analytics API for yesterday’s traffic
- It writes the result to R2 as one JSON file per zone per day
cloudflare-analytics-archive/
zone=abc123/date=2026-08-11.json
zone=abc123/date=2026-08-12.json
...
The key=value folder layout also makes the archive easy to query later with tools that understand partitioned paths.
Step 1: Create the project and bucket
npm create cloudflare@latest analytics-archiver -- --type hello-world --lang ts
cd analytics-archiver
npx wrangler r2 bucket create cloudflare-analytics-archive
Step 2: Configure Wrangler
Replace wrangler.jsonc with:
{
"name": "analytics-archiver",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"triggers": { "crons": ["30 2 * * *"] },
"r2_buckets": [
{ "binding": "ARCHIVE", "bucket_name": "cloudflare-analytics-archive" }
],
"observability": { "enabled": true }
}
Running at 02:30 UTC rather than just after midnight gives Cloudflare time to finish aggregating the previous day.
Step 3: Write the Worker
src/index.ts:
interface Env {
ARCHIVE: R2Bucket;
CF_API_TOKEN: string;
ZONE_TAG: string;
}
const QUERY = `
query ($zoneTag: string, $date: Date) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(limit: 1, filter: { date: $date }) {
dimensions { date }
sum { requests bytes cachedRequests cachedBytes threats pageViews }
uniq { uniques }
}
}
}
}`;
async function archiveDay(env: Env, date: string): Promise<void> {
const res = await fetch('https://api.cloudflare.com/client/v4/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: QUERY, variables: { zoneTag: env.ZONE_TAG, date } }),
});
if (!res.ok) throw new Error(`GraphQL HTTP ${res.status}`);
const body = await res.json<{ data?: any; errors?: unknown[] }>();
// GraphQL reports failures in the body with HTTP 200.
if (body.errors?.length) throw new Error(JSON.stringify(body.errors));
const day = body.data.viewer.zones[0]?.httpRequests1dGroups[0] ?? null;
await env.ARCHIVE.put(`zone=${env.ZONE_TAG}/date=${date}.json`, JSON.stringify(day), {
httpMetadata: { contentType: 'application/json' },
});
}
export default {
async scheduled(_controller, env, _ctx) {
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
await archiveDay(env, yesterday);
},
} satisfies ExportedHandler<Env>;
A failed run throws, so it shows up as an error in the Worker’s logs and Cron Events instead of silently writing nothing.
Step 4: Add secrets and deploy
Create an API token with Zone → Analytics → Read on the zone, then:
npx wrangler secret put CF_API_TOKEN
npx wrangler secret put ZONE_TAG
npx wrangler deploy
Step 5: Test it locally
npx wrangler dev --test-scheduled
# in another terminal:
curl "http://localhost:8787/cdn-cgi/handler/scheduled"
Local dev uses a local R2 simulation, so nothing is written to your real bucket until you deploy.
Extending it
- Backfill. Loop
archiveDayover past dates, as far back as yournotOlderThanlimit allows. Do it once, right after deploying. - Security events. Add a second query for
firewallEventsAdaptiveGroupsgrouped by IP, action, and path. On Free and Pro, those events only last 24 hours, so run that part every few hours. - Multiple zones. Store a list of zone IDs and loop over them.
- Self-healing. Also re-fetch the day before yesterday and overwrite it, so a single failed night doesn’t leave a permanent gap.
What it costs
For one zone, practically nothing. Cron-triggered Workers are available on the free plan, and one small JSON file per day stays well inside R2’s free tier (10 GB-month of storage, 1 million Class A operations per month).
What it doesn’t give you
This gets the data stored. You still need to build:
- A way to query and chart it
- Alerting when a run fails
- Handling for API changes and new fields over time
If you’d rather not own that, MetricKeeper does the archiving, storage, and dashboard for you. Connect a read-only token and choose your zones.