Overview
The Cloudflare Analytics Worker runs as transparent middleware on your Cloudflare zone. It intercepts every HTTP request, collects metadata, and forwards it asynchronously to Nuno for analytics — without affecting your site's performance or functionality.
How It Works
- Request arrives — The Worker intercepts the incoming HTTP request
- Pass-through — The request is forwarded to your origin server immediately
- Metadata collection — Request headers, response status, and size are captured
- Async logging — Log data is sent to Nuno in the background using waitUntil()
- Response returned — Your visitor receives the original response with no delay
What We Collect
The Worker captures the following request metadata per request:
| Field | Description |
|---|---|
| Timestamp | When the request was made |
| Host | The hostname requested |
| Method | HTTP method (GET, POST, etc.) |
| Pathname | The URL path |
| Query parameters | Parsed query strings |
| Status | HTTP response status code |
| IP address | Client IP (from cf-connecting-ip header) |
| User agent | Browser or client information |
| Referer | HTTP referer header |
| Bytes | Response size in bytes |
What We Don't Collect
- Request or response bodies
- Form submissions
- Authentication tokens
- Cookies or session data
- Personal information
- Payment data
Performance Impact
| Metric | Value |
|---|---|
| Added latency | <5ms per request |
| Blocking | No (uses waitUntil() for async logging) |
| Origin load | Zero additional requests |
| Bandwidth | ~500 bytes per request to Nuno |
Prerequisites
- Cloudflare account with Workers enabled (free tier works)
- Node.js 18+ installed locally
- Domain proxied through Cloudflare (orange cloud enabled)
- Nuno API key (obtain from your Nuno dashboard)
Installation
1Create the Worker project
npm create cloudflare@latest -- nuno-worker
When prompted, select the “Hello World” Worker template, choose TypeScript, enable Git version control, and skip deploying for now.
cd nuno-worker
2Configure wrangler.jsonc
Rename wrangler.json to wrangler.jsonc (to allow comments), then update it with your domain configuration. Replace yourdomain.com with your actual domain.
{
"name": "nuno-worker",
"main": "src/index.ts",
"compatibility_date": "2026-03-10",
"observability": {
"enabled": true
},
"upload_source_maps": true,
"compatibility_flags": [
"nodejs_compat"
],
"vars": {
"NUNO_API_URL": "https://api.spinlink.io/api/ingestion/logs/cloudflare-worker"
},
"routes": [
{ "pattern": "yourdomain.com/*", "zone_name": "yourdomain.com" },
{ "pattern": "www.yourdomain.com/*", "zone_name": "yourdomain.com" }
]
}The observability and upload_source_maps settings enable real-time logging and source maps in the Cloudflare dashboard, making debugging easier. The nodejs_compat flag enables Node.js API compatibility.
3Add the Worker code
Replace the contents of src/index.ts with the following. The Worker proxies all requests to your origin, filters out static assets and CMS internals to reduce noise, and forwards relevant request metadata to Nuno.
interface NunoEnv extends Env {
NUNO_API_KEY: string;
}
interface LogEntry {
timestamp: number;
host: string;
method: string;
pathname: string;
query_params: Record<string, string>;
status: number;
ip: string;
user_agent: string;
referer: string;
bytes: number;
}
// Paths that should not be logged (CMS internals, admin panels)
const IGNORED_PREFIXES = [
"/wp-admin/",
"/wp-includes/",
"/wp-login",
"/wp-cron",
"/wp-content/themes/",
"/wp-content/uploads/",
];
// Static file extensions that should not be logged
const IGNORED_EXTENSIONS = new Set([
".js", ".css", ".woff", ".ttf",
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico",
".mp4",
]);
function shouldLog(pathname: string): boolean {
const lower = pathname.toLowerCase();
if (IGNORED_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
return false;
}
const dotIndex = lower.lastIndexOf(".");
if (dotIndex !== -1 && IGNORED_EXTENSIONS.has(lower.slice(dotIndex))) {
return false;
}
return true;
}
function parseQueryParams(url: URL): Record<string, string> {
const params: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
}
async function sendToNuno(
env: NunoEnv,
logs: LogEntry[]
): Promise<void> {
if (!env.NUNO_API_KEY || !env.NUNO_API_URL) {
console.error("Nuno API key or URL not configured");
return;
}
try {
const response = await fetch(env.NUNO_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": env.NUNO_API_KEY,
},
body: JSON.stringify(logs),
});
if (!response.ok) {
console.error(
`Nuno API error: ${response.status} ${response.statusText}`
);
}
} catch (error) {
console.error("Failed to send logs to Nuno:", error);
}
}
export default {
async fetch(request, env: NunoEnv, ctx): Promise<Response> {
const url = new URL(request.url);
// Pass through the request to the origin
const response = await fetch(request);
// Skip logging for static assets and CMS internals
if (!shouldLog(url.pathname)) {
return response;
}
// Use Content-Length header for response size (no body clone needed)
let responseSize = 0;
const contentLength = response.headers.get("content-length");
if (contentLength) {
responseSize = parseInt(contentLength, 10) || 0;
}
const logEntry: LogEntry = {
timestamp: Date.now(),
host: url.hostname,
method: request.method,
pathname: url.pathname,
query_params: parseQueryParams(url),
status: response.status,
ip: request.headers.get("cf-connecting-ip") || "",
user_agent: request.headers.get("user-agent") || "",
referer: request.headers.get("referer") || "",
bytes: responseSize,
};
// Send log asynchronously — don't block the response
ctx.waitUntil(sendToNuno(env, [logEntry]));
return response;
},
} satisfies ExportedHandler<NunoEnv>;4Set up your API key
Store your Nuno API key as a secret. Never commit it to code.
npx wrangler secret put NUNO_API_KEY
When prompted, paste your Nuno API key and press Enter.
5Deploy the Worker
npx wrangler login npx wrangler deploy
You should see output confirming the deployment with your Worker URL and configured routes.
Request Filtering
The Worker automatically filters out requests that add noise to your analytics. Only meaningful page and API requests are logged.
Filtered path prefixes (CMS internals)
- /wp-admin/, /wp-includes/, /wp-login, /wp-cron
- /wp-content/themes/, /wp-content/uploads/
Filtered file extensions (static assets)
- Scripts & styles: .js, .css
- Fonts: .woff, .ttf
- Images: .png, .jpg, .jpeg, .gif, .svg, .webp, .ico
- Media: .mp4
You can customize the IGNORED_PREFIXES and IGNORED_EXTENSIONS lists in src/index.ts to match your site's structure. For example, add "/admin/" if you use a different CMS, or add ".woff2" for additional font formats.
Bypass routes (optional optimization)
The code-level filtering above prevents logging, but the Worker still executes on every request. For high-traffic sites you can skip Worker execution entirely for certain path prefixes by creating routes with no worker attached. A route without a worker takes priority over more general patterns.
In the Cloudflare dashboard, go to your zone > Workers Routes and add routes like:
yourdomain.com/wp-content/* → (no worker) yourdomain.com/wp-admin/* → (no worker) yourdomain.com/wp-includes/* → (no worker) yourdomain.com/* → nuno-worker
This way Cloudflare never invokes the Worker for those paths, saving execution time and Worker invocations on your plan. Note that bypass routes only work with path prefixes — you cannot exclude by file extension (e.g. *.jpg). For extension-based filtering, the code-level filtering handles it.
Environment Variables
| Variable | Type | Description |
|---|---|---|
| NUNO_API_URL | Variable (wrangler.jsonc) | Analytics ingestion endpoint |
| NUNO_API_KEY | Secret (wrangler secret) | Your Nuno API credentials |
The API key is sent as an X-API-Key header with each request to the Nuno ingestion endpoint. Always store it as a Wrangler secret, never in your configuration file.
Multi-Domain Setup
For hotel groups with multiple properties, configure routes for each domain:
{
"routes": [
{ "pattern": "hotel-downtown.com/*", "zone_name": "hotel-downtown.com" },
{ "pattern": "hotel-beach.com/*", "zone_name": "hotel-beach.com" },
{ "pattern": "*.hotelgroup.com/*", "zone_name": "hotelgroup.com" }
]
}Verification
Check Worker deployment
- Go to your Cloudflare Dashboard
- Navigate to Workers & Pages
- Verify your worker appears in the list
- Click on it and check the Triggers tab for your routes
Generate test traffic
curl -I https://yourdomain.com
Monitor Worker logs
npx wrangler tail
This shows real-time logs from your Worker. Look for any error messages related to the Nuno API.
Verify in Nuno
Log in to your Nuno dashboard and navigate to the Analytics section. Requests should appear within a few seconds.
Debugging Step by Step
Because the Worker logs asynchronously and never blocks your site, a misconfiguration is invisible from the browser — your site keeps working while no data reaches Spinlink. The fastest way to localize the problem is to split it in two: is the Worker reaching Spinlink at all, and is Spinlink accepting the request? Everything below can be done from the Cloudflare dashboard.
1Confirm the Worker is actually running
In the Cloudflare dashboard, open Workers & Pages > your worker > Metrics. Load a page on your site and confirm the request count goes up. If it stays at zero, the Worker isn't firing — and no Spinlink setting will matter until it does.
- Open Settings > Domains & Routes (Triggers) and confirm a route matches your domain, e.g. yourdomain.com/*
- Confirm the domain is proxied through Cloudflare (orange cloud, not DNS-only grey cloud) — the Worker only runs on proxied traffic
- A Worker that lives only on a *.workers.dev URL with no route will never see real site traffic
2Watch the Worker's live logs
Observability is enabled in your configuration, so you can stream logs in real time: Workers & Pages > your worker > Logs > Begin log stream. Prefer the terminal? Run:
npx wrangler tail
With the stream open, load a real page such as / or /about — not /wp-admin or a static file like .css or .jpg, which are filtered by design and produce no log line.
3Match the message to the cause
The Worker reports every failure to the console. Match what you see in the log stream to the fix:
| Console message | What it means | Fix |
|---|---|---|
| Spinlink API key or URL not configured | The API key secret was never set | Add it under Settings > Variables and Secrets (type: Secret, name SPINLINK_API_KEY), or run npx wrangler secret put SPINLINK_API_KEY |
| Spinlink API error: 401 Unauthorized | The key is wrong, inactive, or expired | Generate a valid key from your Spinlink dashboard and re-set the secret |
| Spinlink API error: 422 | The request body is malformed (only if the Worker code was modified) | Restore the code from Installation Step 3, or contact support |
| Failed to send logs to Spinlink | The request threw before reaching Spinlink (network or DNS) | Re-check SPINLINK_API_URL, then share the error text with support |
| No message at all when you load a page | The Worker is not running on that route, or the path is filtered | Return to Step 1, and test with a normal page like / |
4Isolate the API with a direct request
To prove whether the problem is the Worker or your key, send one request straight to the ingestion endpoint from your terminal — bypassing the Worker entirely. Replace the key with yours and use your own domain.
curl -i -X POST https://api.spinlink.io/api/ingestion/logs/cloudflare-worker \
-H "Content-Type: application/json" \
-H "X-API-Key: sl_live_YOUR_KEY" \
-d '[{"timestamp":1717000000000,"host":"yourdomain.com","method":"GET","pathname":"/spinlink-test","query_params":{},"status":200,"ip":"203.0.113.10","user_agent":"curl-test","referer":"","bytes":100}]'- A 200 response with {"accepted":1,"rejected":0} means your key and the endpoint are healthy — the problem is in the Worker, so revisit Steps 1–3 (route not firing, secret not set, or a filtered test path)
- A 401 Unauthorized means the key itself is the problem — generate a fresh one from your Spinlink dashboard
The /spinlink-test row is harmless and easy to spot (or ignore) in your analytics.
Troubleshooting
| Issue | Solution |
|---|---|
| Worker not running | Verify route configuration in Cloudflare Dashboard (Triggers tab). Ensure your domain is proxied through Cloudflare (orange cloud enabled). |
| No logs in Nuno | Check API key with npx wrangler secret list. Verify the endpoint in wrangler.jsonc. Check Worker logs with npx wrangler tail. |
| Request not being logged | The Worker filters static assets (.js, .css, images) and CMS paths (/wp-admin/, etc.). Test with a page URL like / or /about instead. |
| 401 Unauthorized | API key is invalid or expired. Generate a new key from your Nuno dashboard and update with npx wrangler secret put NUNO_API_KEY. |
| Timeout errors | Optimize your origin server response time or enable Cloudflare caching to reduce origin requests. |
Alternative Deployment Methods
Enterprise (Terraform)
resource "cloudflare_worker_script" "nuno" {
name = "nuno-worker"
content = file("dist/index.js")
}CI/CD (GitHub Actions)
- name: Deploy Nuno Worker
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_TOKEN }}Updating & Uninstalling
Updating the Worker
After making changes to your code, redeploy:
npx wrangler deploy
Uninstalling
- Go to Cloudflare Dashboard > Workers & Pages and select your worker
- Click the Triggers tab and remove all routes
- Delete the Worker:
npx wrangler delete
Frequently Asked Questions
What if I don't use Cloudflare?
For analytics on non-Cloudflare sites, we offer an Nginx log forwarding option. See the Nginx Plugin documentation.
Will this slow down my website?
No. The Worker uses async processing via waitUntil() and adds less than 5ms of latency. Your visitor receives the response immediately while logging happens in the background.
Is my API key secure?
Yes. The API key is stored as a Cloudflare Worker secret and is never exposed in your configuration files or source code. It is only sent server-side to the Nuno ingestion endpoint.
For information, see the Security & Compliance documentation.
Need help? Contact support