I launched a small SaaS and wanted to know whether anyone was visiting. I don't run a third-party analytics script, so I wrote a report over my CloudFront access logs instead. The first run said 749 page views in four days. That felt great for about ten minutes.
Then I looked at what was behind it:
- 543 requests from a single "Firefox 135" in Lithuania
- 246 hits from an iPhone on iOS 13.2.3, a version from 2019 that scanners love to fake
- probes for
/wp-content/plugins/...,/debug/vars,/_profiler/phpinfoand dozens of.../images/images/images/cache.phppaths. My site isn't WordPress or PHP, but it's a single-page app, so every unknown path returned the homepage with a 200 and looked like a page view - a "visitor from Google" that read every page on the site, one every five seconds
- referrals from spam domains I'd never heard of
After filtering: 31 page views, about 7 of them probably me. Two came from Google search and one from Hacker News.
Here are the filters, in the order the report applies them. The report is a Python script over CloudFront's JSON log lines; the logs hold no IP addresses, so "a browser" below means the same user agent from the same country on the same day.
1. Only count pages that exist
On a single-page app, every unknown path can come back as index.html with a 200. So I check the path against the sitemap, plus the app's own routes:
PAGES = {urlparse(u).path for u in re.findall(r"<loc>([^<]+)</loc>", open(sitemap).read())} | {"/login", "/support"}
PAGE_PATTERN = re.compile(r"^/(dashboard(/.*)?|b/ws_[0-9a-z]+/?)$")
if uri not in PAGES and not PAGE_PATTERN.match(uri):
bots["probe for a non-existent page"] += 1
Fixing the server is better still: my site now returns a real 404 for unknown paths, which also stops search engines from indexing junk URLs.
2. Only count 200 and 304 responses for HTML
Redirects (http to https, www to apex) aren't page views, and neither are the JavaScript and image requests.
3. Drop bots, and browsers that are years out of date
The obvious part is a user-agent regex for bots, curl, python, headless browsers and so on. The less obvious part: real browsers update themselves, but scanners hard-code old versions. Chrome ships every four weeks, so anything before Chrome 140 is over a year old:
OLD_BROWSER = re.compile(r"Chrome/([0-9]|[1-9][0-9]|1[0-3][0-9])\.|Firefox/([0-9]|[1-9][0-9]|10[0-9]|11[0-4])\.|iPhone OS (9|1[0-4])_|Mac OS X 10_(9|1[01])\)")
A gotcha that cost me a few days: CloudFront's logs encode spaces in the user agent as %20. My iPhone OS 13_ pattern never matched iPhone%20OS%2013_, so the fake iPhone kept counting as a visitor until I decoded the user agent first (unquote()).
4. Drop anything that reads pages faster than a person
In a single-page app, a person loads one HTML page and then navigates in the browser, so the server sees one HTML request per visit. A browser that fetches 4 HTML pages within a minute, or 20 in a day, is a crawler, whatever its user agent or referrer says:
BURST_PAGES, BURST_SECONDS, DAILY_PAGES = 4, 60, 20
if len(times) >= DAILY_PAGES or any(times[i + BURST_PAGES - 1] - times[i] <= BURST_SECONDS for i in range(len(times) - BURST_PAGES + 1)):
crawlers.add(key)
I started with 8 a day. That dropped a keen reader (and me, testing) as a crawler, so I raised it. Browsers that opened the logged-in dashboard that day are counted separately, so my own visits don't inflate the total.
5. The best one: did it run the app?
A real browser downloads the app's JavaScript bundle just after the HTML, or already has it cached from an earlier visit. A script that grabs the HTML never does. The bundle has a hashed file name (/assets/index-<hash>.js), so scanners guessing paths don't hit it either:
if not any(t <= seconds(r) + 60 for t in app_js.get(key[1:], [])):
bots["HTML only, never ran the app"] += 1
It's the simplest test of all, and the hardest for a scanner to fake without running a full browser.
What I took from it
- If you read your own logs, week one's raw number is mostly noise. Check before you celebrate, or panic.
- Bots aren't traffic, but they're useful: Googlebot crawling my blog and tool pages told me indexing was working long before any person showed up.
- 31 real views is humbling, but it's a baseline to improve on. 749 wasn't.
Has anyone else found their early numbers were mostly bots? What filters do you use?
I'm building UserNote, a feedback board and public roadmap that visitors can vote on without an account. This report is how I find out whether anyone's visiting it.
Top comments (5)
the spa returning 200 for every unknown path is the detail that makes the rest of the filtering necessary. once that is a real 404, the wordpress probes stop looking like page views at all. did fixing that alone move the 749 much, or did the bundle check do most of the work?
Good question, so I re-ran the launch-week logs with each filter on its own. Out of 768 raw HTML hits: the 404 fix alone would have removed 162 (about a fifth), leaving 606. The bundle check alone would have removed 523, leaving 245. So the bundle check did far more of the work.
The surprising part: every probe was also caught by something else. They all used bot user agents, old browser versions, or never loaded the JS, so the 404 fix didn't remove a single view the other filters missed. The two filters that caught things nobody else did were the user-agent check (64) and the bundle check (56).
The 404 is still the right fix, though, for a different reason: it stops search engines from treating junk URLs as real pages, and it makes the logs honest at the source instead of in my report.
(Side note: with today's slightly stricter filters, the same four days come out at 16, not 31. I tightened the old-browser check after writing the post.)
the 404 still earns its keep for search, even if it removed nothing the other filters missed. the 31 to 16 drop is the part i would watch, since a stricter old-browser check can quietly eat a real person on an old phone.
You're right, and I checked. Of the 15 views the stricter check removed, 10 were dropped by the old-browser rule alone, and all 10 did load the JS bundle. About four are obvious fakes (three claim to be Chrome 125 and Edge 12 at the same time, which no real browser does). The other six could easily be real people on phones or work machines that stopped updating: a Pixel 6 on Chrome 114, Chrome 101 on Windows, and so on.
So I'm changing the report: an old browser that runs the app now goes on a separate "uncertain" line instead of being thrown away, and only the impossible user agents get dropped outright. Thanks, this was a blind spot.
that split is the right call. dropping the impossible agents outright and keeping the rest visible as uncertain stops the filter from quietly deciding who counts as a person. the pixel 6 on chrome 114 is exactly the kind of visitor a stricter rule would erase without a second thought.