Vibe-coding an analytics system for 8 products in a month
Eight products means eight funnels, eight revenue streams, eight things that can quietly break. And there's just one of me.
So analytics either becomes shared infrastructure or it doesn't exist. I built it once, as a system, and every product inherits it. Eight products today, architected so the ninth costs a clone and a strip, not a rebuild.
This post is how it works, at three altitudes: the portfolio, one product, and the loop that turns numbers into build and kill decisions.
It follows the Claude skills post and the cost post. Same sprint, different layer.
Altitude 1: the portfolio map
Each product is its own business on paper. But they have separate databases, Posthog projects, Stripe accounts, separate keys, etc. Nothing shared that could leak between them.
But they all run the same platform code, and they all report up to the same place: HQ, a single mission-control dashboard.

The design constraint that makes HQ workable for one person: it holds no live state.
Each product exposes a read-only metrics endpoint conforming to one shared, versioned contract, plus a public health endpoint. HQ fans out to all eight on every view, and again every five minutes, and rolls up revenue, growth, engagement, expenses, and health. Nothing to keep in sync, because the live numbers are recomputed every time. The only thing it persists is one aggregated snapshot per product per day, so the trend lines have a history to draw.
One standing rule keeps it correct: any change that touches billing, pricing, admin metrics, health, or account tables must update that product's metrics endpoint in the same change. And a broader version of the same discipline covers everything else: every feature change updates its product admin, HQ, the analytics events, and its tests in the same session, or it isn't done.

Those rules live in the shared agent instructions, so the agent enforces them on every product, every session. That's what stops the system rotting the day I stop touching a given product.
Altitude 2: one product, two layers
Inside each product, analytics runs on two layers on purpose.

Layer 1 is PostHog, for breadth. Pageviews, funnels, the journey from anonymous visitor to signed-up account. A session starts anonymous at the first pageview, gets identified on signup, and gets aliased so the pre-signup events join the same person. PostHog is proxied through our own domain, so the analytics survives ad blockers: it's first-party traffic, not a third-party script something can drop.
Event names live in exactly one file per product, lib/events.ts, never as string literals. A unit test walks the code and fails CI if a declared event fires nowhere, or a fired event isn't declared. Event names cannot drift, because drifting fails the build.
The gotcha that bit me more than once: identity threading.
Server-side events from webhooks and API routes defaulted to an anonymous person, so a money funnel like "uploaded, checkout, paid" fragmented across anonymous IDs and resolved to what looked like three different people, none of them real. The fix is boring and absolute. Server events carry the same identity as the client, always. It's a rule with a check now, so it can't ship broken twice.
Layer 2 is our own Postgres events table, for trust. Server-side events flow through one shared helper that writes to PostHog and our own table in a single call. Client-side events go to PostHog only. That split is deliberate: the owned table holds the events we compute money and admin funnels from, the ones I refuse to trust a third party for, and those are all server-authored. The in-product admin dashboards read from data we own, not from a third-party UI.
Money has extra rules. Figures are authored by deterministic code, never by a model. Stored as integer cents. Null means "not applicable," never a fake zero. Paid status flips one way only: through the Stripe webhook. Stripe is the source of truth, and two cron sweeps reconcile our table against it, never the other way. One runs every ten minutes and catches paid buyers the webhook missed, because a paying customer with no product is an outage. One runs daily and catches refunds, because a refunded user keeping access for a few hours is a leak, not an outage. The webhook is the fast path. The sweeps are the net under it. The number I look at cannot silently be wrong.
There's one more rule that makes the small numbers trustworthy. Every row the agent creates while building or testing uses a reserved, undeliverable identity: an @example.com address, a +clerk_test signup. Those are excluded from every metric with zero false positives, while my own real accounts stay counted. So when a dashboard says a product has paying users, it means humans, not humans plus the test harness. Clean data is a feature, not an afterthought.
What actually gets measured
Here is everything the system tracks, and why each thing earns its place.
- Growth. Visitors, new accounts, signups by day, week, and month. The one lifecycle event that anchors it is
account_created, described in the code as the top of the funnel. It answers one question: is anything arriving at all. - Engagement, split two ways. Raw activity is easy to fool yourself with, so it gets divided on purpose. Every product's metrics contract reports a primary action, the core thing the product is for, a lookup, a decoded document, an obligation record, separately from secondary value actions like a saved panel, a subscribe, a second document. A burst of people poking around settings is real, but it isn't the product working, and the split keeps me from confusing the two. One level up, events roll into four funnel groups, engagement, lead, signup, and sale, so I can see where the portfolio thins out from visitor to paying.
- Named versus anonymous. Most activity starts anonymous and gets stitched to a person only at signup. So HQ shows engagement two ways. One chart counts all actions, named and anonymous, stacked by product, for volume. A separate "recent engagement" feed shows the named-only view: who did what, by email. "Forty actions" and "forty people who told me who they are" are different businesses, and I want both visible without one hiding inside the other.
- Revenue. MRR, one-time purchases, paying customers. The rules here are the strictest in the codebase, and they're the ones from the money paragraph above.
- Expenses, as a first-class citizen. This is the part most analytics skips, and skipping it is how people talk themselves into believing a money-losing thing is working. Costs roll up per vendor into eight categories, from shared-platform down to a parked bucket for domains I bought for products I haven't built yet, so those bets don't quietly contaminate the economics of the products that are actually live. Revenue without cost is theater. I want both on one screen.
- I also track signup and revenue but have no activation metric, no formal "did this new account reach a first real action" number. And it's not an oversight. Activation only starts to matter once acquisition does, and my honest constraint isn't that people sign up and bounce, it's that not enough people arrive yet.
Altitude 3: the loop
Every product ships its own /admin, gated by an allowlist: funnels, review queues, feedback triage, revenue, health. HQ rolls all of that up.
Then the numbers come to me.
A daily email digest covers revenue, visitors, engagement, and costs, broken down by day, week, and month. And an hourly visitors-and-accounts pulse runs from 10am to 9pm eastern, so I can feel the whole portfolio from my phone. Both are deterministic end to end. Zero model tokens. The reporting layer costs approximately nothing to run.
The dashboards exist to answer four questions:
- Which products convert, and which just get visitors.
- Where funnels leak. An onboarding step nobody finishes is a to-do list, not a mystery.
- Which paid features have literally zero usage. That's a kill-or-rework signal, not a polish signal.
- Whether the real constraint is the product or distribution.
Vibe-coded, reliably
All of this was built conversationally with Claude Code, with guardrails:
- The event-coverage test that fails CI on drift.
- Cron auth that fails closed.
- A health check that makes production prove which database it's actually connected to.
One more discipline that keeps shipping safe: metrics endpoints degrade to inert when a key is missing. A half-configured product ships fine, it just reports less until it's wired.
Why this is a system, not eight apps
The numbers themselves are still small. But they're real and fully instrumented: every visitor and user is visible on one screen, attributed to one identity, computed from data I own, with the test harness filtered out.
And the whole thing transfers. Product #9 inherits the events registry pattern, the owned table, the metrics contract, the digest, and every check that keeps them honest. That's the actual asset here. Not eight apps with eight analytics setups. One analytics system, eight products on it, and room for many more.