Skip to main content
Builder’s Journal

How to Measure Real Human Traffic Without Google Analytics

· · 4 min read

How to Measure Real Human Traffic Without Google Analytics

Your server logs are lying to you. On my site, roughly 95% of "visits" in the raw logs are bots — crawlers, scanners and scrapers that never render a page. The cheapest honest filter for humans is a tiny first-party JavaScript beacon: it only fires when a real browser actually renders the page, so JS-less bots never count. The catch nobody mentions: the beacon’s collector endpoint is itself a public URL, and scanners will POST straight to it — so you have to reject anything that didn’t arrive through your CDN, from your own pages, in a real browser.

What you’ll learn: why logs (and even naive beacons) overcount, and a dependency-free, privacy-friendly way to count real humans — with the endpoint-hardening step most DIY versions skip.

Evidence note: the beacon code below is the live implementation on my WordPress site; the bot/human ratios are my own observations from this site’s traffic, not a universal figure.

Why server logs overcount

Every request in an access log looks like a "hit," but most aren’t people. Crawlers, uptime monitors, vulnerability scanners, SEO bots and AI crawlers hammer every URL and never execute a line of JavaScript. On my site the raw logs were about 95% non-human. Counting log lines as "traffic" is how you convince yourself you have an audience you don’t.

The method: a first-party JS beacon

Bots, overwhelmingly, don’t run JavaScript. So instead of counting requests, count renders: drop a small script that fires once the page is actually displayed in a browser, and record that.

// Only humans-with-JS reach here
if (navigator.webdriver) return;            // headless/automation flag
fetch('/wp-json/yoursite/v1/hit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ p: location.pathname })
});

That alone cut my counts from "thousands of daily visits" to a realistic human number. But it introduced a new hole.

The gotcha: your beacon endpoint is public

/wp-json/yoursite/v1/hit is a URL like any other. Scanners find it and POST directly to it — bypassing the browser, the JavaScript, everything. Suddenly your "humans" are inflated again, this time by requests hitting the collector itself. I found phantom rows doing exactly this and had to purge them (about 150 in one clean-up).

The fix is to make the endpoint reject anything that doesn’t look like a genuine beacon from a real visitor. Three server-side gates:

// 1. Reject known bot user-agents outright.
if ( mca_is_bot_ua( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) {
    return; // e.g. /bot|spider|crawl|curl|wget|python|headless|gptbot|claudebot/i
}

// 2. Must have arrived THROUGH the CDN. A request with no CF-Connecting-IP
//    hit the origin directly — that's a scanner, not a browser via Cloudflare.
if ( empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
    return;
}

// 3. Must have a same-origin referer — the beacon only ever fires from our pages.
$ref_host = wp_parse_url( $_SERVER['HTTP_REFERER'] ?? '', PHP_URL_HOST );
if ( $ref_host !== wp_parse_url( home_url(), PHP_URL_HOST ) ) {
    return;
}

Each gate blocks a different attacker:

  • Bot-UA catches the honest bots that announce themselves.
  • CF-Connecting-IP required is the important one: legitimate visitors always come through Cloudflare, so the header is always present. A request without it reached your origin directly — which real browser traffic never does when you’re behind a CDN. That single check kills direct-to-origin scanner POSTs.
  • Same-origin referer ensures the beacon fired from one of your own rendered pages, not a curl with a forged UA.

Add the client-side navigator.webdriver check and you’ve filtered automation frameworks too.

The result

  • Raw logs: ~95% bots.
  • Beacon without hardening: cleaner, but re-inflated by direct-to-origin POSTs (the ~150 phantom rows).
  • Beacon with the CF-header + referer + bot-UA gates: the count finally tracks something believable — JS-executing browsers, arriving through the CDN, from my own pages.

The point isn’t a precise headcount; it’s that each layer removes a category of non-human noise that the layer before it missed.

Limitations (be honest)

  • JS-required. A human with JavaScript disabled won’t be counted. In practice that’s a rounding error, but it’s a real bias — this measures "humans with JS," not "all humans."
  • Not full analytics. It’s a traffic-reality check, not a funnel/attribution tool.
  • CDN-dependent. The strongest gate (CF-Connecting-IP) assumes you’re behind Cloudflare. On another CDN, use its equivalent forwarded header; without a CDN, you lose that check and lean harder on UA + referer.
  • Privacy. This is first-party and script-light — no third-party analytics tag — but you’re still recording visits, so treat it under your privacy policy accordingly.

A reusable checklist

  1. Count renders, not requests — fire a beacon from real page load.
  2. Add if (navigator.webdriver) return; to skip automation.
  3. On the endpoint, reject bot user-agents.
  4. Require your CDN’s real-visitor header (CF-Connecting-IP on Cloudflare) — this kills direct-to-origin scanners.
  5. Require a same-origin referer.
  6. Periodically audit for phantom rows (no CDN header) and purge them.

Takeaway

Server logs measure requests; you want humans. A first-party JS beacon is a cheap, dependency-free way to get there — but the beacon endpoint is a public target, so the real work is hardening it: on my site, requiring Cloudflare’s CF-Connecting-IP header was the single change that stopped scanners inflating my "human" count. Measure renders, then defend the collector.

AI assisted with drafting; the beacon code, the hardening, and the traffic observations are from my own site.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *