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
The gear I run for this
Hardware from my own homelab, relevant to this guide — direct Amazon links.
Affiliate links — I earn a small commission at no extra cost to you. Browse my full homelab store →
/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-IPrequired 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
curlwith 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
- Count renders, not requests — fire a beacon from real page load.
- Add
if (navigator.webdriver) return;to skip automation. - On the endpoint, reject bot user-agents.
- Require your CDN’s real-visitor header (
CF-Connecting-IPon Cloudflare) — this kills direct-to-origin scanners. - Require a same-origin referer.
- 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.
Leave a Reply