# Run Agent Analytics with a Cloudflare Worker

> Canonical source: [Run Agent Analytics with a Cloudflare Worker](https://ai.answer.cloud/docs/cloudflare-worker-agent-analytics/)

Use a Cloudflare Worker to send AI-agent, crawler, and human request metadata to answer.cloud without delaying your website responses.

Cloudflare can run a lightweight Worker in front of your existing website and send request metadata to answer.cloud for agent analytics. The Worker forwards each request to your origin, returns the origin response unchanged, and submits the analytics event in the background.

This setup helps answer.cloud distinguish human visits, user-triggered AI agents, search crawlers, and model-training bots. It does not send page contents, request bodies, or response bodies to answer.cloud.

## Before you begin

You need:

- An answer.cloud organization configured with the domain you want to measure.
- Access to that domain's Cloudflare account and DNS zone.
- A proxied Cloudflare DNS record (orange cloud) for every hostname you want the Worker to cover.
- The `SHARED_SECRET` shown during answer.cloud onboarding under **Set up tracking → Cloudflare**. This is the Worker's analytics password, not your answer.cloud account password. Keep it private.

Use a **Worker Route** for this integration because your existing web server remains the origin. Cloudflare recommends Routes when a Worker runs in front of an external origin. A `workers.dev` URL by itself will not observe traffic to your website. See Cloudflare's [Routes documentation](https://developers.cloudflare.com/workers/configuration/routing/routes/).

## 1. Create the Worker

1. Sign in to the Cloudflare dashboard.
2. Open **Workers & Pages**.
3. Select **Create application**, then create a Worker.
4. Name it something recognizable, such as `answercloud-agent-analytics`.
5. Deploy the starter Worker, then open **Edit code**.
6. Replace the starter code with the script below.

```js
const REQUEST_TIMEOUT_MS = 2500;
const MAX_RETRIES = 3;

export default {
  async fetch(request, env, ctx) {
    const started = Date.now();
    const response = await fetch(request);

    const pageUrl = new URL(request.url);
    pageUrl.hash = "";

    const cfRay = request.headers.get("CF-Ray") || "";

    const body = buildEdgeBody({
      ipAddress: request.headers.get("CF-Connecting-IP"),
      userAgent: request.headers.get("User-Agent"),
      referer: request.headers.get("Referer"),
      path: pageUrl.toString(),
      httpStatus: response.status,
      epochMs: started,
      responseTimeMs: Date.now() - started,
      sessionId: extractSessionIdFromCookie(request.headers.get("Cookie")),
      customData: {
        source: "cloudflare",
        request_id: cfRay,
        ray_id: cfRay,
        method: request.method,
        colo: request.cf?.colo || "",
        country: request.cf?.country || "",
      },
    });

    ctx.waitUntil(
      postJsonWithRetries(env.RAILS_URL, body, env.SHARED_SECRET)
    );

    return response;
  },
};

function buildEdgeBody({ ipAddress, userAgent, referer, path, httpStatus, epochMs, responseTimeMs, sessionId, customData }) {
  return {
    ip_address: ipAddress || "",
    user_agent: userAgent || "",
    referer: referer || "",
    path: path || "/",
    http_status: httpStatus,
    epoch_ms: epochMs,
    response_time_ms: responseTimeMs,
    event_type: "edge_view",
    session_id: sessionId || undefined,
    custom_data: customData || {},
  };
}

function extractSessionIdFromCookie(cookieHeader) {
  if (!cookieHeader) return undefined;
  const match = cookieHeader.match(/(?:^|;\s*)aeo_sid=([^;]+)/);
  return match ? decodeURIComponent(match[1]) : undefined;
}

async function postJsonWithRetries(url, obj, secret, attempt = 1) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        ...(secret ? { "X-Auth": secret } : {}),
      },
      body: JSON.stringify(obj),
      signal: controller.signal,
    });
    clearTimeout(timer);
    if (res.ok) return;
    const text = await res.text().catch(() => "");
    if (res.status >= 400 && res.status < 500) return;
    throw new Error(`Rails ${res.status}: ${text.slice(0, 200)}`);
  } catch (err) {
    clearTimeout(timer);
    if (attempt < MAX_RETRIES) {
      await new Promise(r => setTimeout(r, 200 * Math.pow(2, attempt)));
      return postJsonWithRetries(url, obj, secret, attempt + 1);
    }
  }
}
```

Save and deploy the Worker.

The script uses `ctx.waitUntil()` so the analytics request can continue after the website response is returned. Cloudflare documents this as an appropriate pattern for analytics and other background work in its [Context API reference](https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil).

## 2. Add the environment variable and secret

Open the Worker, then go to **Settings → Variables and Secrets**. Add these bindings:

| Name | Type | Value |
| --- | --- | --- |
| `RAILS_URL` | Plain text | `https://app.answer.cloud/analytics/cloudflare_event` |
| `SHARED_SECRET` | Secret | Copy the value shown under **Set up tracking → Cloudflare** in answer.cloud |

Answer Cloud generates and displays `SHARED_SECRET` in the onboarding checklist: expand **Set up tracking**, select **Cloudflare**, and copy the value shown beside `SHARED_SECRET`. It remains visible while Answer Cloud is waiting for the first analytics event. After onboarding is complete, there is not currently a customer-facing screen that reveals it again, so store it in Cloudflare when you first copy it.

Deploy the updated bindings. Add `SHARED_SECRET` as a Cloudflare **Secret**, not as visible plain text. Cloudflare hides secret values after they are saved; see [Cloudflare's secrets documentation](https://developers.cloudflare.com/workers/configuration/secrets/).

Do not put the shared secret directly in the Worker source, a Git repository, screenshots, support tickets, or public documentation.

## 3. Add routes for your website

In the Worker, open **Settings → Domains & Routes**, select **Add**, and add a Route for each hostname that should produce analytics.

For example:

```text
example.com/*
www.example.com/*
```

Add only the hostnames that actually serve your site. If you intentionally want analytics for every proxied subdomain, you can use:

```text
*.example.com/*
```

The matching hostname must already have a proxied DNS record in the same Cloudflare zone. Do not choose **Custom Domain** for a site that already has an external origin: a Custom Domain makes the Worker the origin, while a Route lets `fetch(request)` continue to your existing server.

## 4. Verify the integration

1. Open a page on the routed hostname in a new browser tab.
2. Confirm that the page still returns normally.
3. In Cloudflare, open the Worker and use **Logs → Live** to confirm the Worker is being invoked. Cloudflare's [real-time logs guide](https://developers.cloudflare.com/workers/observability/logs/real-time-logs/) describes this view.
4. Keep the answer.cloud setup checklist open at **Record your first visit**. That step checks the raw event store every five seconds and completes automatically after an event reaches your organization; it does not wait for consolidated analytics.
5. Request the site with a known crawler or test user agent if you want to validate agent classification separately from ordinary browser traffic.

The customer Analytics charts use consolidated data and may not show the new request immediately. The onboarding confirmation is available before consolidation, but it is a first-event check rather than a browsable raw-event log. After onboarding is complete, use Cloudflare's live logs for immediate request-level verification and the answer.cloud Analytics dashboard after the next consolidation.

answer.cloud stores successful `GET` requests with a `2xx` response or `304 Not Modified`. Query strings are removed before URLs are stored, and static assets and common analytics-beacon paths are excluded from reporting rollups.

## What answer.cloud receives

For each matching request, the Worker sends:

- The requested URL, with the fragment removed.
- User agent and referrer headers.
- HTTP status and origin response time.
- Cloudflare Ray ID, data-center code, country code, and HTTP method.
- The existing `aeo_sid` session ID when that cookie is present.

answer.cloud uses the request host to associate the event with the correct organization. The host must match the domain configured for that organization.

## Troubleshooting

### The website works, but no analytics appear

- Confirm the Worker Route matches the hostname you are visiting. `example.com/*` and `www.example.com/*` are separate patterns.
- Confirm the hostname's DNS record is proxied through Cloudflare.
- Confirm `RAILS_URL` is exactly `https://app.answer.cloud/analytics/cloudflare_event`.
- Confirm the website domain is configured on the intended answer.cloud organization.
- Check Cloudflare's live logs to verify the Worker is invoked.

### The analytics request returns 401

The `SHARED_SECRET` does not match the value expected by answer.cloud. Copy it again from your answer.cloud tracking setup, save it as a Secret, and deploy the new Worker version.

### The Worker is not running on the apex domain

A wildcard route such as `*.example.com/*` covers subdomains but not necessarily the apex hostname. Add `example.com/*` explicitly.

### Charts include unexpected paths

Confirm that the route is not attached to unrelated subdomains. Narrow the route to the exact hostnames that serve the measured site. answer.cloud already excludes common static assets and analytics beacons from rollups.

## Disable the integration

Remove the website Route from **Settings → Domains & Routes**, or detach the Worker from the zone. Removing the route stops new events without changing the website's origin or deleting historical analytics in answer.cloud.
