DEV Community

Cover image for I didn't want Google Analytics, so I built a 60-line UTM click tracker on Cloudflare Workers
Shahriar Nasim Nafi
Shahriar Nasim Nafi

Posted on AI-assisted

I didn't want Google Analytics, so I built a 60-line UTM click tracker on Cloudflare Workers

We're a two-person team building HeedPost, a feedback board for SaaS and apps. A few weeks ago we started doing cold outreach, and we hit a simple question:

Did anyone actually click the link in our email?

The usual answers didn't sit right with us:

  • Email open tracking is mostly noise. Apple Mail preloads images, so "opens" fire even when nobody reads anything. Tracking pixels can also push you towards spam.
  • Google Analytics (or a similar tool) would answer the question, but it means cookies, a consent banner, and a third party watching every visitor to our site. We'd rather not.

All we really needed was: "someone opened heedpost.com/?utm_campaign=acme, from roughly this country, on this kind of device, at this time." Nothing more.

Our site already runs behind Cloudflare, so a Worker in front of it was the obvious place to do this.

How it works

  1. A visitor opens https://heedpost.com/?utm_source=email&utm_campaign=acme.
  2. The Worker sees the utm_ parameters and, if the request looks like a real person loading a page, writes one row to a D1 (SQLite) database.
  3. It then passes the request through to the site unchanged. The visitor notices nothing.
  4. A small, password-protected stats page groups the rows by campaign.

No cookies, no client-side JavaScript, no third parties.

The Worker

const UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
const BOT_UA =
  /bot|crawl|spider|slurp|preview|scanner|headless|python|curl|wget|httpclient|java\/|go-http|facebookexternalhit|proofpoint|mimecast|barracuda|microsoft office|safelinks|google-?read|feedfetcher/i;

export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url);

    const utm = Object.fromEntries(UTM_KEYS.map((k) => [k, url.searchParams.get(k) || ""]));
    const ua = req.headers.get("user-agent") || "";
    const isPage = req.method === "GET" && (req.headers.get("accept") || "").includes("text/html");

    if (isPage && (utm.utm_source || utm.utm_campaign) && !BOT_UA.test(ua)) {
      ctx.waitUntil(logVisit(req, env, url, utm, ua));
    }

    return fetch(req); // pass through to the real site
  },
};
Enter fullscreen mode Exit fullscreen mode

A few details that matter:

  • ctx.waitUntil lets the database write happen after the response starts, so the tracker adds no delay for the visitor.
  • isPage checks the Accept header for text/html, so images, scripts and API calls on the page don't get counted as extra visits.
  • The bot filter is the important part for cold email. Corporate mail security tools (Microsoft Safe Links, Proofpoint, Mimecast and friends) "click" every link to scan it. Without filtering them out, every email looks like it was clicked within seconds of sending.

Counting people without storing IPs

We still wanted to tell "one person clicked three times" apart from "three people clicked once". So instead of storing the IP address, we store a salted hash of IP + user agent:

async function sha256(s) {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

const ip = req.headers.get("cf-connecting-ip") || "";
const visitor = (await sha256(`${env.HASH_SALT}|${ip}|${ua}`)).slice(0, 16);
Enter fullscreen mode Exit fullscreen mode

HASH_SALT is a secret set with wrangler secret put, so the hash can't be reversed by guessing IPs. We also keep only the first 16 characters. The raw IP never touches the database.

Country comes from req.cf.country, which Cloudflare provides for free, and "device" is just a rough mobile/desktop guess from the user agent.

The database

One table in D1:

CREATE TABLE IF NOT EXISTS visits (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  ts TEXT NOT NULL,
  source TEXT, medium TEXT, campaign TEXT, term TEXT, content TEXT,
  path TEXT, country TEXT, device TEXT, visitor TEXT, referrer TEXT
);
CREATE INDEX IF NOT EXISTS idx_visits_campaign ON visits (campaign, source);
Enter fullscreen mode Exit fullscreen mode

And the stats query is plain SQL:

SELECT campaign, source,
       COUNT(DISTINCT visitor) AS people,
       COUNT(*) AS visits,
       MAX(ts) AS last
FROM visits
GROUP BY campaign, source
ORDER BY last DESC;
Enter fullscreen mode Exit fullscreen mode

The Worker serves that as a small HTML table on a hidden path, protected by a secret key. If the key is wrong it returns a plain 404, so the page doesn't even admit it exists.

Wiring it up

wrangler.toml:

name = "utm-tracker"
main = "src/index.js"
compatibility_date = "2026-10-01"
workers_dev = false

routes = [
  { pattern = "example.com/*", zone_name = "example.com" }
]

[[d1_databases]]
binding = "DB"
database_name = "utm"
database_id = "<from wrangler d1 create>"
Enter fullscreen mode Exit fullscreen mode

Deploy:

npx wrangler d1 create utm
npx wrangler d1 execute utm --remote --file=schema.sql
npx wrangler secret put STATS_KEY
npx wrangler secret put HASH_SALT
npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

Two gotchas we hit:

  • Your domain must be proxied through Cloudflare (orange cloud), or the route never runs.
  • The workers.dev URL won't show anything. The tracker only runs on the real domain's route. We spent a few confused minutes looking at the wrong URL.

What we use it for

Every outreach email gets its own campaign:

https://heedpost.com/?utm_source=email&utm_medium=outreach&utm_campaign=acme
https://heedpost.com/?utm_source=linkedin&utm_medium=dm&utm_campaign=acme
Enter fullscreen mode Exit fullscreen mode

When acme shows up on the stats page, we know someone actually looked, and from which channel. That's enough to decide who to follow up with, without tracking anyone around the web.

Limits, honestly

  • It only counts visits that arrive with UTM tags. It's not a full analytics tool, and that's the point.
  • The bot filter catches most scanners, but not all. A "click" a few seconds after sending is still probably a robot.
  • The hashed fingerprint changes if someone switches networks or browsers, so "people" is an estimate.

For our use case (a few dozen hand-written emails a week) it's exactly enough, it costs nothing on Cloudflare's free tier, and our visitors don't get a cookie banner.


We're building HeedPost, feedback boards for SaaS and apps with one fixed price and no per-user billing. Yes, that link is tracked with the Worker above. 🙂

Top comments (0)