// Guides2026-08-137 min read

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 archiveDay over past dates, as far back as your notOlderThan limit allows. Do it once, right after deploying.
  • Security events. Add a second query for firewallEventsAdaptiveGroups grouped 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.

Get Early Access →

Sources