Skip to main content
  1. Posts/

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

··862 words·5 mins·
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 diagrams rendered on refresh and vanished on click. The head never loaded.

··1010 words·5 mins
A reader clicking from my homepage to a post with a diagram got a block of raw mermaid source. The same reader pasting that post's URL directly got a rendered diagram. Same page, same build, same browser. The difference was the click, and the bug had been live on this site for months across every diagram, every math formula, and every chart, because I had only ever tested pages by loading them directly. 🧪 Tested with Blowfish 2.10x · htmx 2.0.10 Two features, both reasonable, one collision # This site has htmx’s hx-boost on the body: internal navigation swaps page content in place instead of doing full page loads, which keeps transitions smooth. Separately, the Blowfish theme is smart about heavy libraries: mermaid, KaTeX, and Chart.js bundles are only included on pages that use them, injected into the <head> of exactly those pages.

My blog publishes one post a day. I haven't touched the deploy in weeks.

··1104 words·6 mins
In July I went three weeks without opening my blog repo. During those weeks it published two posts, on schedule, each one confirmed live by an automated check, and the only reason I know all this is a green history in the Actions tab. The system's single notification channel is a failure email, and it has never fired. This post is the full pipeline, including the parts that only exist because something went wrong on the way here. 🧪 Tested with Hugo 0.164.0 · Cloudflare Pages The one Hugo fact everything hangs on # Hugo skips content dated in the future unless you pass --buildFuture (docs). That single default turns the date field into a release valve. Merge a post dated next Tuesday and production simply does not contain it: not in the sitemap, not in RSS, not at its URL. It sits in main, invisible, until a build happens after its date.

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

··669 words·4 mins
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: