Skip to main content
  1. Posts/

Blowfish supports four analytics providers. Cloudflare Web Analytics isn't one.

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
For six months I assumed nobody could tell whether anyone read this blog, because I had never added analytics. Wiring up Cloudflare Web Analytics by hand taught me two things: the obvious place to paste the snippet would have shipped my Playwright suite's page views into the dashboard, and the dashboard had been quietly counting my visitors for two months anyway.
Tested with Hugo 0.163.3 · Blowfish 2.104

Publishing into the void
#

The site’s hugo.toml had a googleAnalytics line commented out since roughly the first commit. I never uncommented it. GA4 wants a cookie disclosure, ships a chunky client, and ad blockers eat it anyway, which felt like a lot of ceremony for a personal blog whose one open question was “does anybody visit.”

What I wanted was small: page views, referrers, no cookies, no banner. The site already deploys to Cloudflare Pages, and Cloudflare Web Analytics is free, cookie-less, and one script tag. Decision made in about a minute.

Then I went looking for where the tag goes.

What the theme hands you
#

Blowfish has first-class analytics support. Set one param and you are done:

# config/_default/params.toml
[umamiAnalytics]
  websiteid = "..."

[fathomAnalytics]
  site = "..."

[selineAnalytics]
  token = "..."

Plus GA4 via the top-level googleAnalytics key. Four providers, one line each, and the theme handles script injection for all of them.

Cloudflare Web Analytics is not on the list. No param, no partial. If you want it, you write the tag yourself into layouts/partials/extend-head.html, the theme’s designated extension point for exactly this kind of thing. The comment in the theme source even says “eg. for custom analytics scripts”.

So far this looks like a copy-paste job.

The part the copy-paste misses
#

Before pasting, I read how the theme injects its own analytics. From themes/blowfish/layouts/partials/head.html:

{{/* Analytics */}}
{{ if hugo.IsProduction }}
  {{ partial "analytics/main.html" . }}
{{ end }}

{{/* Extend head - eg. for custom analytics scripts, etc. */}}
{{ if templates.Exists "partials/extend-head.html" }}
  {{ partialCached "extend-head.html" .Site }}
{{ end }}

Two things in ten lines.

First: the theme’s own analytics sit behind hugo.IsProduction. Your extension point does not. Whatever you put in extend-head.html renders in every environment, including hugo server on your laptop and the hugo server instance my Playwright suite boots for every E2E run. An unguarded beacon means the dashboard counts my own dev sessions and every CI run. With traffic as modest as a personal blog’s, CI would outnumber the actual readers in the charts.

Second: partialCached with .Site as the cache key. The partial renders once per build and gets reused on every page. For a static script tag that is exactly what you want. For anything page-dependent it is a subtle trap, because your per-page logic would evaluate once, for whichever page happened to render first.

Hugo’s environment model does the work
#

The guard is cheap because Hugo already distinguishes environments: plain hugo builds default to production, hugo server defaults to development. Cloudflare Pages runs hugo --gc --minify, so production builds get the beacon with no extra configuration, and local dev plus CI never see it.

The final version of my extend-head.html addition:

{{ if hugo.IsProduction }}
<!-- Cloudflare Web Analytics -->
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js"
        data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
{{ end }}

The token comes from the Cloudflare dashboard under Web Analytics after registering the site. So I went to register the site.

The twist in the dashboard
#

nick-liu.com was already there. Created two months earlier, marked “automatic setup”, showing real visits for the past 24 hours. Cloudflare had been injecting its beacon at the proxy the whole time, into HTML I never touched. I had analytics all along and did not know where to look.

Running both is worse than running either: two beacons per page, two dashboards each holding half the story. I kept the proxy injection and reverted my hand-wired tag. Two months of history beats a fresh counter, and zero lines in the repo beats three.

The manual route still matters when your HTML serves from a host Cloudflare does not proxy. This site has a GitHub Pages mirror the proxy never sees; if I ever care about that hostname’s numbers, the guarded snippet above is exactly what goes back in.

One process note survives the revert: never commit a placeholder token. A guarded beacon with a fake token is invisible in dev, which means the one environment where you would notice the mistake is production.

Lessons
#

  • If your theme gates analytics behind hugo.IsProduction, your extend-head partial needs the same guard. The theme’s guard does not cover your code.
  • CI is a traffic source. An unguarded beacon on a low-traffic site measures your test suite, not your readers.
  • partialCached is a contract: content renders once per cache key. Static tags are fine. Per-page logic is not.
  • Before wiring any analytics snippet, check whether your platform already injected one. Proxy-level features leave no trace in the repo.
  • Manual injection covers hosts the proxy never sees; proxy injection keeps the repo clean. Pick one. Running both double-counts.

References
#

Related

My og:image URLs were broken for months. baseURL was the culprit.

Paste one of my post links into a social preview and the card comes up with no image. The site itself renders fine, every page, every browser. The culprit was one character in `hugo.toml`: `baseURL = "/"`, which quietly turns every absolute URL the site emits into a relative one that only a browser can love. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 The symptom # Share cards without images, that was the visible part. View source on any page and the metadata told the fuller story:

A typo fix shouldn't boot a browser in CI. Mine did for months.

I changed one sentence in a blog post, opened a pull request, and watched CI spend about 85 seconds installing a headless Chromium to confirm my prose still turned into HTML. The obvious fix, telling the workflow to ignore content paths, would have quietly broken every merge instead. Symptom: a browser for a one-line edit # Every pull request on this blog runs two jobs: a lint job, and a build-and-test job. The second one builds the site with Hugo, link-checks the output with htmltest, then installs a headless Chromium and runs a Playwright suite against a live hugo server. End to end, roughly 85 seconds, and almost all of it is Playwright.

Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see

A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't "does the right session get evicted", it is "do the four survivors stay on the keys they were already on." Building ClaudeDeck · Part 3 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Two bits of context for anyone new to the stack: Stream Deck is Elgato’s USB grid of programmable LCD keys, and a “session” here is a single Claude Code conversation: claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for “least-recently used,” the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)