↓ Skip to main content
AI & Data

Building about.samarthya.me, part 2: Keeping the numbers honest

Building about.samarthya.me - This article is part of a series.
Part 2: This Article
A scoreboard that counts the wind will tell you the wind is your biggest fan. Before the hub showed a single number about this blog’s readers, I checked what the raw numbers would have said. They would have been confidently wrong.
Users vs engaged sessions per year, and the four steps from counting engaged sessions to an honest top nine
Users are not readers: the gap, and the fix

Part 1 set up a script that reads Google Analytics. This part is about what to do with what it reads. All numbers are from the blog’s analytics, since 2022, as of 7 November 2026.

Why bother being honest?
#

It’s my own blog; nobody audits it. But the stats page makes three kinds of claim, and each has a reader on the other end:

  • “People read this.” The Popular tiles are recommendations. If bots pick them, a visitor spends their time on a post nobody actually read.
  • “Readers come from here.” A map is a statement about people. Publishing a wrong one is publishing a falsehood with a nice colour scale.
  • “This is what works.” I decide what to write next partly from these numbers. Bad numbers, bad decisions.

There’s also a plainer reason: this blog was hacked once (part 1 of the rebuild), and I’d rather it be known for checking its facts than for looking busy.

What Google already filters, and what it doesn’t
#

GA4 removes known bots by itself, using Google’s research and the IAB’s list of spiders and bots. That catches the polite crawlers that say who they are. It doesn’t catch automated browsers in data centres that run the page’s JavaScript like a person would. To GA4 those are users. How many? Enough to rewrite the map.

What if I had not filtered: the countries
#

GA4’s headline count is activeUsers, “the number of distinct users who visited”. Here is the top of that list next to engaged sessions (more on that measure in a moment):

CountryUsersShare of usersEngaged sessionsEngagement rateAvg. seconds per user
Singapore5,97157.7%1943.2%1.7
China2,85027.5%69824.4%3.6
United States6266.0%49453.6%24.3
Russia2152.1%19569.9%21.2
Germany540.5%4571.4%30.2
India520.5%4067.8%35.2
United Kingdom290.3%2990.6%31.3

The unfiltered map would have said: more than half my readers are in Singapore, and 85% are in two countries. The other columns say what actually happened: nearly 6,000 “people” in Singapore stayed under two seconds each, and fewer than 1 in 30 of their visits did anything a reader does. Real readers, in the rows below, engage in more than half their visits and stay half a minute.

The same thing shows over time:

YearUsersEngaged sessions
2022157108
2023131144
2024182175
20252,074937
20267,470568

For three years the columns move together: that’s people. From 2025, “users” multiply by forty while engaged sessions don’t follow.

The fix for countries: count engaged sessions
#

GA4 already has a test for “someone was really here”. An engaged session is one that lasted longer than 10 seconds, had a key event, or viewed two or more pages. A bot that loads one page and leaves in a second fails all three.

So the map and the country bars count engagedSessions, and countries with fewer than three are folded into “Other” (a small country with one reader shouldn’t be pinpointed). The result is a map that looks like the 2022–2024 blog: the US, China, Russia, Europe and India, at believable sizes.

It isn’t perfect. A crawler that waits 10 seconds passes, and China’s 24% engagement rate says a good part of its traffic is probably automated too. But engaged sessions bring the numbers back to the same scale as the years before the bots, and the method is GA4’s own, documented, not a threshold I invented.

What if I had not ranked: the Popular tiles#

The home page shows nine Popular posts. Sorting is easy; deciding by what is the work. Here are three ways to pick nine, using the same data:

Sort byWhat the top nine look likeProblem
All-time viewsAll nine are WordPress-era posts from 2020–2024 (D-Bus errors, Ansible privilege escalation, a Docker registry…). The most recent is from 2024.Old posts win forever; nothing new can enter.
Views in the last 28 daysA newest-first list, including an OWASP post read for an average of 2.8 seconds and a series finale at 4.7 seconds.Small numbers; a single burst of quick hits wins.
The score (below)A mix: the new SSDLC post (29 s average), Learning OSV part 1 (88 s), two old favourites that are still read, PAT: Personal Access Token, Git: Commit logs, and three new posts.It’s a judgement call, but an open one.

Only two of the nine all-time favourites survive into the scored list. That’s the size of the decision.

Joining old addresses first
#

Most of those all-time views were recorded under the WordPress addresses (/wps/2024/09/11/...), not today’s /posts/.... Without joining them, every old post would start from zero. The blog already keeps each old address in the post’s aliases (that’s how old links still work), so I made Hugo publish them in a /catalog.json and the build adds up views from every address a post has ever had. Reading time is a weighted average: an old address with 200 views counts more than a new one with five.

The score
#

The ranking is a small, pure function: numbers in, a number out, no surprises.

  1. Split views into non-overlapping windows: the last 28 days, the 62 days before, and everything older.
  2. Turn older views into a rate per year tracked, so a post doesn’t win by age alone.
  3. Squash each window with log1p (the logarithm of 1 + x). On a log scale 2,000 views is worth about twice as much as 40, not fifty times, so one spike can’t flatten everything else. Weight the windows 0.45, 0.30, 0.25: recent counts most.
  4. Multiply by an engagement factor from 0.1 (zero seconds) to 1.0 (two minutes or more). This is the bot filter: a page nobody actually reads keeps a tenth of its score.
from math import log1p

def popularity_score(views_28d, views_90d, views_all, avg_engagement_s, age_days, tracked_days):
    recent = max(views_28d, 0)
    middle = max(views_90d - views_28d, 0)
    older = max(views_all - views_90d, 0)
    years = max(min(age_days, tracked_days) - 90, 0) / 365
    views_score = (0.45 * log1p(recent) + 0.30 * log1p(middle)
                   + 0.25 * log1p(older / max(years, 1)))
    engagement = min(max(avg_engagement_s, 0), 120)
    return views_score * (0.1 + 0.9 * engagement / 120)

I tested it against the cases I was worried about before trusting it:

CaseScore
Bot spike: 2,000 views this month at 1 s each0.37
New post, real readers: 40 views at 90 s1.30
Old favourite: 5 / 15 / 600 views, 60 s, four years old1.53
Old and forgotten: 600 views long ago, nothing lately, 60 s0.70

Fifty times the views, and the bot spike still scores under a third of the real readers. The old favourite that’s still read beats the new post; the forgotten one doesn’t. That’s the behaviour I wanted.

The first version didn’t behave. Its engagement factor had a floor of 0.5, and the log already shrinks a tenfold spike to about one and a half times, so a zero-second page kept half its score and bots still won the test. Lowering the floor to 0.1 fixed it. The tests found that, not me reading the code.

Saying so on the page
#

Honesty also means telling the reader what they’re looking at. Each Popular tile says “N reads this month” or “N reads since 2022”, whichever is meaningful, and the grid carries a note: ranked by recent and long-term reads, weighted by reading time, so bot visits count for little, with a link to how the numbers work.

What is still not solved
#

  • Patient bots pass the 10-second test. Engaged sessions shrink the problem; they don’t end it.
  • Small numbers. Analytics on the new site started in late October 2026, so “this month” is often a handful of reads. The weights will need a look once there are a few months of data.
  • Analytics only sees what runs JavaScript. Bots that never run the tag never appear in GA4 at all. To see those, you have to look at the front door, which is where Cloudflare sits: part 4.

Takeaways
#

  • Users are not readers. Put activeUsers next to engagedSessions and engagement rate per country and per year before publishing any of it.
  • GA4 removes known bots only; data-centre browsers that run JavaScript are counted as people.
  • Count countries in engaged sessions, and fold tiny ones into “Other”.
  • Rank with a small, pure function: separate time windows, a log scale, and a reading-time factor; then test it against the bot spike you’re afraid of.
  • Tell readers on the page how the numbers were made.

References
#

Next: part 3, putting Cloudflare in front of the new subdomain.

Author
Samarthya
Engineer, learner, tinkerer. This is my trophy shelf of everyday work and leisure.
Building about.samarthya.me - This article is part of a series.
Part 2: This Article

Related

Building about.samarthya.me, part 1: Google Analytics, from page tag to API

A static blog is a shop with no till: it serves pages, but it can’t count who came in. For that I lean on Google Analytics. This post is about understanding that tool properly, and teaching a small script to read its numbers, before I trust them on a public page. From Google Analytics to a static stats page, built on my own PC The series # After the move to Hugo the blog is fast and simple, but it has no front desk: nowhere that says here is what’s new, here is what people keep reading, here is where they come from. So I’m building a small hub, about.samarthya.me, and writing down each piece: