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.

