Every analytics option I've ever been handed wants to live in someone else's cloud: a script tag, a consent banner, a data-processing agreement, and a dashboard that loads slower than the site it measures. I run a niche portfolio site on $3/month shared hosting. The last thing it needed was a third-party pipeline to tell me how many people visited.
But cPanel hosting ships with something most people ignore: raw access logs. Every request, every day, gzipped and sitting right there in your account. So I built the dashboard as one PHP file - /stats.php - that reads those logs directly and renders the answer. No dependencies. No JavaScript framework. No analytics vendor.
Here's what building it actually involved, because the boring parts are where the war stories live.
The gzip wall
First problem: the log files are .gz archives. I opened one with fopen, got binary garbage, and had a moment of doubt about the whole plan. The fix is two functions: gzopen + gzread to decompress the stream, and only then start parsing.
The subtle part is what comes after. I originally sliced the log buffer using filesize() - which on a .gz file returns the compressed size. Slicing a fully decompressed buffer by its compressed length quietly chops off the end of the log. My "today's hits" number was short by exactly the last chunk of the file. Thirteen lines of missing log, no error, no warning - just a dashboard that was confidently wrong about the freshest data, which is the only data a dashboard exists to show.
Lesson: decompress fully, then slice. A compressed file's size tells you nothing about its contents.
Shared-server noise
The second thing nobody warns you about: on shared hosting, the raw access log directory contains logs for more than just your site. Parse them all and your stats are quietly blended with a stranger's. One strpos filter on the log path fixed it, but only after I noticed the referrer bars were full of domains I've never linked to.
Cache the parse, not the page
Parsing a day of gzipped logs on every visit is rude to a shared CPU. The dashboard parses on visit but writes the result to a small JSON cache with a 10-minute max-age. Visitors inside the window get the cached render; after that, the next visit re-parses. Cron-free, daemon-free, and the numbers are never more than ten minutes stale - which is plenty fresh when the alternative is a vendor's own delay.
What the numbers actually say
Real data from the first live run, unfiltered:
- Direct / no-referrer traffic dominates: 21.7k hits. People type the URL, use a bookmark, or strip the referrer.
- Internal navigation: 14.4k - the site links itself properly.
- A single bot I've named ghost-rider: 287 hits. Bots inflate raw hit counts; always read logs with that in mind.
- Google: 154. Dev.to: 10.
And the most interesting stat: Coder Legion shows up at zero in my referrers - while its posts are demonstrably sending me readers. Link clients and in-app browsers strip the referrer header. The lesson generalizes: absence in your referrer log is not absence of traffic. Any channel whose audience clicks from inside an app will look invisible in logs built for the 2005 web.
The deploy lesson
The dashboard was shipped with my usual self-deleting PHP worker pattern, and it caught itself twice. The worker's own lint gate flagged a malformed payload before anything was written. Then, after refactoring the log reader to gzopen, an orphaned fclose call produced a fatal 500 - which php -l never caught, because it's a runtime error, not a syntax one. Only a mock render of the actual page caught it.
That's the rule now: mock-render every generated page, not just lint it. A syntax check proves the file parses. It does not prove the page renders.
The stack, in full
One PHP file. One JSON cache. gzopen. strpos. A bar chart drawn in CSS. It carries the same structured-data layer as everything else on the site - canonical, JSON-LD, llms.txt entry, sitemap entry, IndexNow ping on publish - because a dashboard nobody's crawler can read is just a private toy.
Total third-party dependencies: zero. Total monthly cost: included in the $3 hosting. Total time to build: an afternoon, most of it spent on the gzip bug.
If your hosting gives you raw logs, you already have better analytics than most SaaS dashboards will sell you. They're just gzipped.
What's the one number you actually check in analytics? Mine turned out to be the referrer bars - everything else, I mostly admire and ignore.