Five Analytics Tools, One Report
Here are three numbers for this site, all for the last 28 days. onedollarstats says 229 people visited. Cloudflare says 30,121 visits and 409,182 requests. Search Console says Google sent 7 clicks.
None of them is wrong. They count different things: people with a browser that ran a script, anything that touched the edge (bots included, and there are a lot of bots), and the few humans who came from a Google results page. Two more tools sit next to those, Ahrefs Site Audit crawling the pages for broken links and slow responses, and Google's Keyword Planner saying what people search for that I haven't written about. That's five tabs, five logins and five definitions of "visit", for two small sites.
So the question I wanted one command to answer was simple to state: what is the state of my websites this week, and what, if anything, do I have to do about it? It became pfrctl analytics, a part of pfrctl, the Go CLI that already runs my servers.
Each tool sees one slice
Before joining anything, it helps to be exact about what each source is good for, because the report leans on each one only for what it can actually see.
The table also says why one dashboard can't replace the others. A broken link hurts nobody in onedollarstats until someone clicks it, a 522 from the origin never reaches a browser script, and a keyword with ten thousand searches a month shows up nowhere until I write the post that ranks for it.
A site is a domain
The first design decision was that sites are not configured, they are discovered. A site is a registrable domain (what the Public Suffix List calls eTLD+1) that appears in at least one of the tools that list sites: the properties the Search Console service account can see, the sites of the onedollarstats account, the Site Audit projects in Ahrefs. Cloudflare doesn't count on its own, because I have about twenty zones and most of them are parked domains. Once a site exists, its zone is looked up by the same domain.
That turns the join into string normalisation. Each tool names the same site differently, and all of them collapse to the same key:
pfrctl analytics sites shows the result, and for each site, the tools it's missing from. Right now there are two, this site and Buzontemporal, and both are in all five.
What can't be discovered goes in a small YAML file in the infra repo, keyed by domain, and every field is optional. For this site it's only the Keyword Planner search, the topics of my posts in the English-speaking countries:
pablofr.com:
keyword_planner:
- language: "1000"
geos: ["2840", "2826", "2356", "2124"]
seeds: [ai agent guardrails, ai agent permissions, self hosted vercel alternative,
vector search object storage, pg_cron, react server components, anti bot detection,
infrastructure as code]Buzontemporal's entry is longer: six searches, one per language it ranks in (Russian goes worldwide, because Google Ads doesn't let you target Russia as a country), a topic list of word roots that filters the ideas down to temporary-email ones, and a funnel. A site with no entry still gets a full report, just without keyword ideas or a funnel.
Google, the official way
Every source sits behind a credential, and each one fails on its own: when one expires, the report says which and how to renew it, and the rest of the report goes on. The two Google sources go through their official APIs:
- Search Console: the Search Console API with a service account that is a restricted, read-only user on each property. Its key lives encrypted in SOPS like every other secret, and the token is a signed JWT exchanged at Google's token endpoint, with no SDK.
- Keyword Planner: the Google Ads API's
generateKeywordIdeas, with OAuth as me.pfrctl analytics google-ads-loginruns the desktop flow (PKCE, a redirect to127.0.0.1, only theadwordsscope) and writes the refresh token straight into SOPS.
The official route has a price, and it isn't money. An OAuth app is a public thing to Google: it needs a home page that says what it does and a privacy policy that says what happens to the data. So this site now has a page for pfrctl analytics and its privacy policy: read-only, my own accounts, nothing shared, under Google's Limited Use rules. It's a strange feeling to write a privacy policy for a tool whose only user is me, but it's the honest description of what the tool reads.
The API also has gaps. It doesn't give the titles Google displays for each URL, the index coverage of every page, or Core Web Vitals. From indexing, it gives me the home page's status and not much else.
Critical first
A report is only useful if the top of it is what I'd act on. So every site's report has the same four parts, in this order: credentials that need renewing, critical findings, the analytics, and opportunities. The analytics, the part every dashboard leads with, sits third.
This is what today's report put at the top for this site:
- Ahrefs: one page links to a broken page, the pixel-art assistant post, same as the crawl before. A slow page, down from six in the previous crawl.
- Cloudflare: 89 responses with a 5xx status in 28 days, broken down by code. 53 were 500s from the app, 33 were 522s (the origin didn't answer the connection in time), and one each of 502, 521 and 525. Next to each code, one line on what it means, because I never remember which of 521 and 522 is which.
- Ahrefs again: three new noindex pages and two missing meta descriptions, all but one of them on a subdomain I didn't mean to have crawled.
Then the analytics, with a comparison against the previous period for each number, and then the firewall, which I didn't expect to find interesting. In the last 24 hours Cloudflare's managed rules blocked 37 requests trying a React remote code execution CVE against POST /, and 76 % of the 213 firewall events came from datacenter networks.
Opportunities come last and are the reason Keyword Planner is in the mix at all. The report crosses its ideas with the queries Search Console already shows me for, and keeps the ones on topic where I don't appear yet. Today it suggests "infrastructure as code" (9,900 searches a month in those four countries, low competition, down 18 % year over year) and "react server components" (2,400, down 45 %), and flags "terraform for iac" as rising, up 238 %. It also proposes "and terraform" at 110,000 searches a month and "infrastructureas code" with the same volume as the correct spelling, because Keyword Planner groups close variants and reports the group's volume on each. The report doesn't hide those; I'd rather skip a bad line than lose a good one to a clever filter.
From Search Console's side, the opportunities are queries in positions 5 to 20, where a better title or one more section can move a page onto the first screen. Alternatives to Jev sits at 11.6 with 41 impressions and no clicks, which is exactly that kind of page.
The site sends its own signals
Half of the stack lives on the site, not in pfrctl. Both sites load onedollarstats and Ahrefs Web Analytics, and every event goes through one function that sends it to both, so the two tools never disagree about what happened:
/** An event of the catalogue (lib/analytics), to both. */
export function trackEvent(name: AnalyticsEventName, props?: Props) {
if (isPrivatePath(window.location.pathname)) return;
whenLoaded(window.stonks, stonksQueue, () => window.stonks?.event(name, props));
sendToAhrefs(name, props);
}The events are deliberately poor in detail. An uncaught error sends its kind (error, rejection or chunk, the last one being what a deploy does to a page opened before it) and whose script threw it, never the message or stack, which can carry anything the page holds. A Core Web Vital sends its rating from the web-vitals library, never its value. Buzontemporal also sends error_shown when it shows an error to a person, with a fixed reason.
That last event is the most useful one I have. Today Buzontemporal showed 18 errors to 13 visitors in 28 days, with two reasons: not_yours and unknown. The first is a person opening a mailbox that belongs to another session; the second is one I have to go and find.
Buzontemporal also has a funnel in its YAML entry: visitors, then mailbox_created, first_message and message_opened, counted as unique visitors per step. Its first real report shows the seam of that design. 34 visitors created a mailbox, 2 received a first message and 13 opened one, which is 650 % of the step before. A step with more people than the previous one means the event also fires outside the funnel, or started being measured later than the others. The report prints that sentence under the numbers instead of quietly clamping the percentage, because the fix is in the event, not in the arithmetic.
From a report to an alarm
The report runs in two places. On my Mac, pfrctl analytics report prints it whenever I ask. On the VPS, a cron line sends it by email every Monday at 06:00 UTC through Resend, credentials to renew at the top.
Weekly is too slow for an outage, so the same data feeds the hourly checks that already watch the server. They compare the last 24 hours of each site against the 7 days before, and alert on three things: a jump in 5xx at Cloudflare, a jump in error_shown, and a drop in pageviews or visits.
With traffic this small, the obvious rules are noisy. A post shared once on a social network takes this site from about 20 pageviews a day to 300, and against a mean that one day makes every normal day look like a collapse. So drops are measured against the median, and every rule has a minimum volume. A rule also stays on with looser thresholds than the ones that fired it, so a number hovering at the edge doesn't send an alert and its "resolved" every hour:
// Una alerta activa se mantiene con la mitad del mínimo y un factor menos.
func anSpike(cur, mean, minV, factor float64, active bool) bool {
if active {
minV, factor = minV/2, factor-1
}
return cur >= minV && cur >= factor*mean
}The drop rule only applies when the median reaches 30 pageviews or 15 visits a day; below that, one quiet Sunday is not news. And the APIs are asked at most once an hour even though the checks run every five minutes. Keyword Planner is never asked for ideas by the alerts at all, only for whether the credential still works, once a day.
pfrctl analytics watch runs the same rules without sending anything. This afternoon it would have said that Buzontemporal showed 10 errors to 6 visitors in the last 24 hours, against a mean of 1.1 a day over the week before, with a link to the dashboard and the command for the detail.
The next site costs almost nothing
This is the part I built it for. I run more projects than these two sites on the same servers, and I wanted the next website to get all of this without writing code for it.
Because sites are discovered, adding one is mostly a matter of putting it where the tools already look:
- Search Console: add the property and give the service account read access.
- onedollarstats and Ahrefs: add the site to the account and create a Site Audit project. Cloudflare is already there if the domain is.
- Keyword Planner and a funnel, only if I want them: an entry in the YAML file, and a push to
main.
pfrctl analytics sites then shows the new site with a tick per tool, the hourly alerts start watching it, and it shows up in Monday's email. Nothing in pfrctl knows the names of my sites.
The site side is the part I'll copy rather than configure: the trackEvent module that sends each event to both tools, the rule that events carry fixed words and never what the user typed or what an error said, and error_shown with a reason on every error a person sees, because that event is what turns a weekly report into an alert I can act on the same day. Ahrefs adds one chore here: it only shows an event in its reports once someone registers it by hand in its dashboard, so pfrctl analytics ahrefs-sync registers every event it has received and the properties I name.
None of the data is mine to keep. It lives in five companies' dashboards, under their terms, and any of them can change what it gives me. What I own is the joining: the domain as the key, the order of the report, the rules that decide what's worth an email. That's a small amount of code for the one question I actually check every week.
Related articles
One AI agent, five people with different rights. Sumalos limits the agent's tools to what the person talking to it could do in the app.
Each Secondbook travel guide is a hardcover you can turn in your hand. No WebGL: twenty-five divs, a few CSS transforms and a light model run per pose.