Skip to main content

How to Measure Real Human Traffic Without Google Analytics

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.

Advertisement

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.

Written by Engineer running a 24/7 homelab since 2022. Every guide here is built and tested on my own hardware. No paid placements.

Keep reading

Leave a Reply

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