I Built My Own Analytics. Three Invisible Bugs Said Otherwise.

This week I added analytics to the site builder I've been working on. Not "drop in a Google Analytics snippet" analytics. The real thing: first-party pageview capture, owned data, a dashboard built into the app. By the end of the day every check was green and the feature was completely broken in three separate ways. I want to write down how that happened, because the gap between "the code passes" and "the code works" turned out to be the whole story.

Why build it at all

The builder is a multi-site product. One account can publish a dozen child sites, some on custom domains. The thing I actually wanted was a single view: total traffic across everything I've published, and the ability to drill into one site. A third-party tool gives you that per site, but never rolls up across all of them without manual setup on each one, and it parks your data on someone else's servers.

So the decision was first-party capture. A small client beacon fires on every published page, posts to an ingestion endpoint, and writes one row to a page_views table. Because the beacon already knows which site it's on, every row is auto-segmented by site_id. The cross-site rollup falls out of that for free.

The part I cared most about getting right was privacy. No cookies, no raw IP stored, ever. Instead each visit gets a hash:

const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const visitorHash = createHash("sha256")
.update(`${ip}${userAgent}${today}${siteId}${salt}`)
.digest("hex");

The date is baked into the hash, so it rotates every 24 hours. You can count distinct visitors within a day, but you can't track anyone across days, and there's no IP sitting in a column waiting to leak. That felt like the right default.

I wrote the table, the migration, the ingestion route, the query layer, the charts. npm run type-check: clean. npm run lint: zero errors. On paper, done.

The part where it didn't work

Here's the thing about a fire-and-forget beacon: when it fails, nothing tells you. There's no error toast, no failed request you'd notice, no exception in your face. The page loads fine. The dashboard just stays empty. Forever. It is the single easiest kind of feature to ship broken, because broken and not-yet-any-traffic look identical.

So I ran it for real. Three bugs came out, one at a time, and not one of them was the kind a type checker can see.

Bug one: the custom-domain rewrite ate the endpoint. The app rewrites custom domains to an internal route (yoursite.com becomes /sites/{slug} under the hood). That rewrite was greedy. A beacon posting to /api/track from a custom domain got rewritten to /sites/{slug}/api/track, which doesn't exist. The fix was one clause, telling the middleware to leave API paths alone:

if (isCustomDomain && !pathname.startsWith("/api/")) {
// rewrite to the internal site route
}

Bug two: auth quietly redirected the beacon to the login page. This is the one that would have haunted me. The Supabase session middleware guards routes, and anything not on the public list gets a 307 redirect to /auth/login. My ingestion endpoint wasn't on the list. So every single beacon, from every visitor, got bounced to a login page instead of recording. No pageview would ever have landed. And critically: my earlier test had called the route handler directly, which skips middleware entirely, so it passed. The bug only existed in the path a real browser takes.

Bug three: the production build fell over on a line that ran fine in dev. I'd written const NO_CONTENT = new NextResponse(null, { status: 204 }) at the top of the file, module scope, to reuse the empty response. Dev was happy. Then the Vercel build failed during its "collecting page data" step, because constructing a response at import time blows up that phase. type-check and lint both sail right past it. Only a full next build catches it. The fix was to build the response inside a function instead of at the top of the module:

function noContent(): NextResponse {
return new NextResponse(null, { status: 204 });
}

What I actually took from it

None of these three are interesting bugs. They're boring, the kind you fix in one line each. What's interesting is that all three were invisible to every automated check I had, and all three would have shipped a feature that looks completely finished and does nothing.

Static analysis tells you the code is internally consistent. It does not tell you the code works. Those are different claims, and I think it's easy to let a wall of green checkmarks blur the line. Type-check passing means your types line up. It says nothing about whether a request survives your middleware stack, whether a route exists after a rewrite, or whether the build server can collect your page data.

The fix for all of it was the same move: run the real thing through the real path. Start the dev server, fire an actual HTTP request through the actual middleware, watch a row land in the actual database. The first time I did that, I caught a 307 to a login page that no unit test was ever going to surface, because the unit test was politely skipping the exact layer that was broken.

I shipped the analytics. Cookieless, owned, rolling up across every site, working. But the lesson I'm keeping isn't about analytics. It's that a fire-and-forget feature with no error surface is exactly the kind you have to watch work with your own eyes, because it will never, ever tell you when it doesn't.