How We Made Watch Page Analytics Provider-Agnostic
We wanted analytics on our shared video watch pages. Who’s watching, when, from where. We added Umami support in the morning and ripped it out by the afternoon — not because it didn’t work, but because we’d hardcoded ourselves into a corner.
The first version: UMAMI_WEBSITE_ID
The initial implementation was a single environment variable:
UMAMI_WEBSITE_ID=9abee88c-b595-41ea-b4cf-659c01e57101
The Go template rendered a hardcoded Umami script tag:
{{if .UmamiWebsiteID}}
<script defer src="/script.js"
data-website-id="{{.UmamiWebsiteID}}"
nonce="{{.Nonce}}"></script>
{{end}}
This worked. But it only worked for Umami. If a self-hoster wanted to use Plausible, Matomo, Simple Analytics, or Fathom, they were out of luck. We’d coupled the app to a specific provider for no good reason.
The fix: accept any <script> tag
We replaced the Umami-specific variable with a generic one:
ANALYTICS_SCRIPT='<script defer src="/script.js" data-website-id="xxx"></script>'
The value is a complete <script> tag. Whatever your analytics provider gives you to paste into your HTML, you paste it here. Umami, Plausible, Matomo — they all work the same way: a script tag with a src and some data attributes.
The Go code reads the env var and renders it verbatim in the watch page:
type watchPageData struct {
// ...
AnalyticsScript template.HTML
}
Using template.HTML instead of string tells Go’s template engine to render the value as raw HTML, not escape it. This is safe because the value comes from a server environment variable — it’s set by the operator, not by end users.
The CSP nonce problem
There’s a catch. SendRec uses Content Security Policy with nonces. Every <script> tag needs a nonce attribute that matches the CSP header for that request, or the browser blocks it.
The operator’s script tag won’t have a nonce — they don’t know what nonce the server will generate for each request. So we inject it:
func injectScriptNonce(scriptTag, nonce string) template.HTML {
if scriptTag == "" {
return ""
}
injected := strings.Replace(scriptTag,
"<script",
`<script nonce="`+nonce+`"`,
1)
return template.HTML(injected)
}
Simple string replacement. Find <script, insert nonce="..." after it. The 1 limits it to the first occurrence — if someone’s analytics snippet somehow has multiple script tags, only the first gets the nonce (the second would need to be loaded by the first).
Each request generates a fresh nonce, so the injected value is always current:
AnalyticsScript: injectScriptNonce(h.analyticsScript, nonce),
What different providers look like
The same env var works for all of these:
Umami (self-hosted, proxied through your domain):
ANALYTICS_SCRIPT='<script defer src="/script.js" data-website-id="your-id"></script>'
Plausible (cloud or self-hosted):
ANALYTICS_SCRIPT='<script defer data-domain="videos.example.com" src="https://plausible.io/js/script.js"></script>'
Matomo:
ANALYTICS_SCRIPT='<script src="https://analytics.example.com/matomo.js"></script>'
No code changes, no rebuilds, no new env vars per provider. One variable, any provider.
The deploy script that broke
This feature shipped with a bug that broke both staging and production deploys. The .env file on our server had:
ANALYTICS_SCRIPT=<script defer src="/script.js" data-website-id="xxx"></script>
Notice the missing quotes. Our deploy script sources the .env file with source .env, and bash interpreted <script as a redirect operator. The error:
/opt/sendrec/.env: line 19: syntax error near unexpected token `<'
The fix is single quotes:
ANALYTICS_SCRIPT='<script defer src="/script.js" data-website-id="xxx"></script>'
Single quotes prevent bash from interpreting the contents. The value gets loaded verbatim, angle brackets and all.
The same quoting matters in docker run -e arguments. We switched from double quotes to single quotes there too:
docker run -e 'ANALYTICS_SCRIPT=<script ...></script>' ...
A reminder that HTML in shell variables is a quoting minefield, and that even a straightforward change deserves a deploy test.
No analytics by default
When ANALYTICS_SCRIPT is empty or unset, nothing renders. No tracking pixel, no script tag, no network request. Self-hosters get zero analytics tracking out of the box.
This is a deliberate choice. Analytics should be opt-in, not opt-out. If you want tracking on your watch pages, set the variable. If you don’t, your viewers’ browsers stay quiet.
The full change
The diff is small — six files, 139 additions, 11 deletions:
video.go:analyticsScriptfield replacesumamiWebsiteIDwatch_page.go:injectScriptNonce()helper,template.HTMLrenderingserver.goandmain.go: wiring the new env var throughwatch_page_test.go: three new tests (nonce injection, script rendered, no script when empty)SELF-HOSTING.md: updated docs
No migrations, no database changes, no frontend changes. The analytics script only affects the server-rendered watch page.
Try it
SendRec is open source (AGPL-3.0) and self-hostable. Set ANALYTICS_SCRIPT to your provider’s snippet and every shared video page gets tracking — with the CSP nonce handled for you. The implementation is in watch_page.go.