I wanted to know which pages of my portfolio are visited, where the traffic comes from, and whether anyone actually reads the blog — without plugging in Google Analytics, without setting up a database, without displaying a cookie banner. The constraint: Apache + pure PHP hosting, nothing else.
Why not Google Analytics
GA4 would have taken 5 minutes. But two concrete problems:
- GDPR: the moment you use GA, personal data (IP, behavior) goes to Google. Legal obligation to display a consent banner, handle refusals, maintain a processing register. For a personal portfolio, that's disproportionate.
- Blocked: uBlock Origin, Brave, Firefox Enhanced Tracking Protection — GA is blocked by a significant portion of developers who are precisely my audience. The stats would be underreported.
Self-hosted alternatives like Umami or Plausible require Node.js + PostgreSQL. Not available here.
The solution: file log, hashed IP, zero cookies
A tracker.php file included at the top of each page. It writes a line
to logs/visits.tsv on every visit. That's it.
The real value is in the security decisions:
1. No cookies set — therefore no consent required
GDPR mandates consent only when storing personal data on the user's side (cookies) or when identifying a natural person. None of that here. The server records a line and that's it — the user is unaware, feels nothing, and it's legal.
2. IP anonymized before storage
An IP address is personal data under GDPR. We never store it in plain text. Instead, we compute a salted SHA-256 hash, truncated to 16 characters:
$ip_hash = substr(
hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? '') . 'cv_salt_xK9!2026'),
0, 16
);
The salt makes the hash irreversible even with a rainbow table. The result allows counting unique visitors without ever being able to recover the original IP. This is exactly what France's CNIL recommends for the consent exemption.
3. Bot filtering upfront
Without filtering, the log file grows quickly with useless traffic —
Google crawlers, Bing, scrapers, monitoring tools.
A regex on the User-Agent eliminates the majority:
if (preg_match('/bot|crawl|spider|slurp|wget|curl|libwww|python|java|Go-http/i', $ua)) {
return; // Exit immediately, nothing is logged
}
This doesn't catch bots that disguise themselves as humans, but it covers 95% of real automated traffic.
4. Logs directory inaccessible from the web
The visits.tsv file contains IP hashes, URLs, and timestamps.
Even anonymized, it must not be publicly accessible.
A .htaccess in the logs/ directory is sufficient:
Deny from all
Apache blocks any HTTP request to this directory. PHP on the server can still write to it internally — only browser access is blocked.
5. Isolated variables to avoid polluting the global scope
The tracker is included via require in index.php,
so it shares the same PHP scope. To avoid overwriting existing variables
or exposing internal data, all tracker variables are prefixed with
$_ and deleted with unset() at the end of the script.
It's rudimentary but effective without having to encapsulate everything in a function.
The dashboard
A password-protected stats.php reads the TSV file, calculates KPIs,
and displays them with Chart.js (CDN). No complex persistent session —
just a standard PHP $_SESSION and a plaintext password comparison
(acceptable for a purely local/admin access on a personal portfolio).
KPIs displayed:
- Total visits and unique visitors over 7 / 30 / 90 days
- Visits today vs yesterday
- Visits-per-day chart
- Top pages visited
- Traffic sources (direct / external / internal navigation)
- Desktop / mobile / tablet breakdown
- Hourly visit heatmap
What it doesn't do
No heatmaps, no funnels, no reconstructed sessions, no visit duration. For a portfolio, that level of detail is pointless. What matters: are people arriving, and which pages are they looking at.
The log file will grow. It will need periodic purging or log rotation (one file per month, for example). For now, a 100,000-line log weighs about 8 MB — PHP reads it in under a second.
Result
Zero cookies. Zero consent banner. Zero external production dependencies. Zero identifiable personal data stored. Functional dashboard in under 200 lines of PHP.
That's the right level of complexity for the actual need.