My Analytics Stack Is One PHP File and a gzip Stream

My Analytics Stack Is One PHP File and a gzip Stream

BackerLeader ●1 ●3 ●24
calendar_today ago • schedule3 min read

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.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

My 30-day log cleanup kept a file that was 30 and a half days old

BashSnippets - Oct 8

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

Your Tech Stack Isn’t Your Ceiling. Your Story Is

Karol Modelski - Apr 9

Telemetry: Shared Analytics and Crash Reporting in Kotlin Multiplatform

kmp-bits - Oct 9

A Textile Mill's Dyehouse Ramp Curve: Get One Step Wrong and the Sheet Belongs to the Master

aiomniu - Oct 18
chevron_left
2.3k Points • 28 Badges
Rochelle Park, New Jersey, US • bryanhamilton.info
11Posts
12Comments
13Connections
Full-stack web engineer · PHP & vanilla JS · 25+ years · Founder of The DJ Calendar · Open to work

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!