I run several sites under one company, all written in Go. I wanted one analytics package I could drop into every one of them, with the data in my own database and no new monthly bill.
So I built snout, an MIT-licensed Go package in the spirit of GoatCounter. It's live on pigfox.com right now, running next to Google Analytics so I can compare the two.
This post covers how it works, what it costs, and the setup problems I ran into, so you can skip them.
What it does
- A small JavaScript snippet, served first-party, sends a beacon on each page view: path, referrer, UTM parameters and screen size.
- A second beacon fires when the tab is hidden, which gives engaged time per page.
- A Go
http.Handlercollects the beacons and writes them to Postgres in batches. - SQL views turn raw events into the numbers you'd look for in GA: users, sessions, views, bounce rate, engaged time, top pages, sources and campaigns.
- A Grafana dashboard JSON file sits in the repo, ready to import.
How it tracks without cookies
There are no cookies, no localStorage, and no persistent visitor ID.
Each visitor gets a hash:
SHA-256(daily salt + site + IP address + User-Agent)
The salt is random and rotates every day at midnight UTC, and the previous day's salt is deleted. The raw IP address is used to build the hash and is never stored.
That gives you:
- Unique visitors per day. The same person on the same device produces the same hash all day.
- Sessions. A visit ends after 30 minutes of inactivity.
- Bounce rate, entry and exit pages, paths and time on page, all within a single visit.
What you give up is recognizing the same person across days. Once the salt rotates, yesterday's visitor is a new hash. For my sites that's a fair trade.
Why Postgres instead of ClickHouse
I started out wanting ClickHouse, since it's built for event data. But ClickHouse Cloud runs on AWS, GCP or Azure, and its default setup is sized for traffic I don't have.
My sites already share a small managed Postgres cluster on DigitalOcean. At my traffic level Postgres handles event data fine, and it adds nothing to the bill. Events go into their own snout schema inside the site's existing database.
The cluster is small and its connection limit is shared by several apps, so snout:
- uses the app's existing connection pool instead of opening its own,
- buffers events in memory and writes them in batches,
- drops events rather than blocking a request if the buffer ever fills,
- prunes raw events past a retention window.
Why Grafana Cloud
Grafana's free cloud tier connects directly to Postgres. There's no extra server to run, and the dashboard is just SQL against the snout views.
The setup problems (so you can skip them)
The package was the easy part. Connecting Grafana Cloud to a locked-down managed database took longer. Here's what I hit.
1. Use a read-only role. Grafana runs whatever SQL a panel contains, so give it its own role with read access to the analytics schema only.
2. CONNECT is a separate grant, and the app user usually can't give it. My Grafana role had read access to the tables but still failed with User does not have CONNECT privilege. Granting CONNECT on the database needs the owner or admin role:
GRANT CONNECT ON DATABASE appdb TO grafana_user;
GRANT USAGE ON SCHEMA snout TO grafana_user;
GRANT SELECT ON ALL TABLES IN SCHEMA snout TO grafana_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA snout GRANT SELECT ON TABLES TO grafana_user;
3. Don't allowlist Grafana's global IP list. The legacy list covers every Grafana region, about 250 addresses, and it's deprecated. Use the regional endpoint with the exact region from your stack's Grafana Details page:
curl -s https://allowlists.prod-us-west-0.grafana.net/v1/grafana
Mine returned two addresses. Be careful with the short-slug form (allowlists.us...), which returned a different region's list for me.
4. A saved password can't be edited in place. When the test said password authentication failed, the fix was clicking Reset on the password field and pasting it again.
5. Keep Grafana's connection pool small. The Postgres data source defaults to 100 max open connections. On a small managed plan, set it to 2.
6. A spinning query means a network block. If a query spins with no error, Grafana can't reach the database at all. Check the allowlist before anything else.
The repo README has the full setup and a troubleshooting table.
What's next
Data starts on the day you install it; there's no backfill. I'll let it run alongside GA for a few weeks and then compare the numbers. I expect snout to count somewhat more, since ad blockers stop GA but not a first-party script.
After that, it goes into the rest of my sites.
The code is on GitHub: github.com/pigfox/snout. Issues and PRs are welcome.
Top comments (0)