Product analytics
Product analytics
Funnel events (signed up → started checkout → paid → canceled) and page views, sent to PostHog, Umami, both, or neither, all keyed by the user's id so one person is one timeline.
Two halves:
- Server: the funnel events. They come from the server because the steps that matter (a Stripe webhook confirming payment, a cancellation) never happen in a browser.
- Browser: page views, plus identifying the signed-in user so those page views join the server events. Loads only after analytics consent. Clarity (session recordings) rides the same mechanism.
Setup
Set the env for each provider you want. Unset means off, no code change.
# PostHog
POSTHOG_API_KEY=phc_... # project API key
POSTHOG_HOST=https://us.i.posthog.com # default; eu.i.posthog.com or self-hosted
# Umami
UMAMI_WEBSITE_ID=... # same value as VITE_UMAMI_WEBSITE_ID
UMAMI_HOST=https://cloud.umami.is # default; or your self-hosted Umami
# Browser (build-time, VITE_*)
VITE_POSTHOG_KEY=phc_... # same value as POSTHOG_API_KEY
VITE_POSTHOG_HOST=https://us.i.posthog.com
VITE_UMAMI_WEBSITE_ID=...
VITE_UMAMI_SRC=https://cloud.umami.is/script.js
VITE_CLARITY_PROJECT_ID=...
The server and browser keys are separate on purpose: each half turns on on its own, so enabling page views never silently starts per-user server events, or the other way round. Set both for the joined timeline.
Events
| Event | Fired from | Properties |
|---|---|---|
user.signed_up | onUserCreated (src/server/auth-hooks.ts) | |
checkout.started | createCheckoutSessionFn, createOneTimeCheckoutFn | kind, priceId |
billing_portal.opened | createBillingPortalFn | |
subscription.activated | Stripe checkout.session.completed | planId, amount, currency |
purchase.completed | Stripe checkout.session.completed (one-time) | amount, currency |
credits.purchased | Stripe checkout.session.completed (credits) | amount |
subscription.canceled | Stripe customer.subscription.deleted | |
billing.refunded | Stripe charge.refunded | kind, amount |
account.deleted | account deletion |
Every event's distinct id is the internal user id. Never email or name: the vendor gets a pseudonymous id only, the same rule as the application logs. That makes one user's events one timeline in PostHog, and one session per user in Umami, so funnels and retention work across signup and a payment that arrives later by webhook.
How it works
Strategy pattern, in src/server/analytics/:
providers.server.ts: theAnalyticsProvidertype (name,capture) and one factory per vendor. Plainfetchwith a 3s timeout, no SDKs: each vendor takes one JSON POST.analytics.server.ts:providersFromEnv()builds the list of configured providers;track()sends an event to all of them withPromise.allSettled, so one vendor being down never affects the other, and never throws (failures are logged at warn).src/server/event-handlers.ts: oneon()per domain event callstrackInBackground(). It doesn't await the send: handlers run inside the Stripe webhook'sawait emit(), and a slow vendor must not hold up the webhook response. An event in flight is lost if the process dies; fine for analytics.
Replays: Stripe redelivers webhooks. Each billing event above is emitted
behind the handler's existing replay gate, and subscription.canceled adds
one (a redelivery finds the row already canceled and skips the emit). See
Webhook replay.
Adding a provider
Write a factory returning AnalyticsProvider in providers.server.ts, add
its env check to providersFromEnv(), and add the env to .env.example.
Nothing else changes.
Adding an event
Add it to DomainEventMap (src/server/events.ts), await emit() it where
it happens, and add an on() calling trackInBackground() in
event-handlers.ts. If the emit is in a webhook handler, gate it on replay.
Browser
src/lib/analytics/, the same strategy pattern as the server:
providers.ts:ClientAnalyticsProvider(load,identify,reset) and one factory each for PostHog, Umami and Clarity.clientProvidersFromEnv()picks the configured ones.index.ts:loadAnalytics(),identifyAnalyticsUser(id),resetAnalyticsUser(). Consent and sign-in come in either order (a returning user is signed in before clicking Accept), so it remembers the user and whichever comes second links them.
Who calls what:
| Call | Where |
|---|---|
loadAnalytics() | CookieConsentBanner, on consent (or on mount when consent gating is off) |
identifyAnalyticsUser(user.id) | src/routes/_app.tsx, the authenticated shell |
resetAnalyticsUser() | sign-out (nav-user.tsx) and account deletion (account-page.tsx) |
Switching accounts (multi-session) resets before identifying the new user; otherwise PostHog merges the two into one person.
PostHog in the browser:
posthog-js, imported only whenloadAnalytics()runs, so it's not in the main bundle and never runs before consent. Theposthog-js/no-externalbuild never injects scripts from PostHog's CDN, so the CSP needsconnect-srchosts only (src/server/csp.ts; add yours if self-hosted).capture_pageview: 'history_change', so TanStack Router navigations count.autocapture: false: autocapture records element text, which on authenticated pages can be user data. Turn it on inproviders.tsif you want it.- Session replay and surveys are off; Clarity does recordings.
persistence: 'cookie', so rejecting consent clears it (ph_*).- The first page view before sign-in is anonymous;
$identifymerges it into the user.
Umami in the browser: the script gets data-distinct-id when the user is
known at load, so the first page view is attributed; umami.identify()
covers sign-in after load. Its session links to the server events' session
through the distinct id.
Testing it in a headless browser
PostHog drops events from headless Chrome as bots (navigator.webdriver and
the HeadlessChrome brand in navigator.userAgentData). To see real
traffic in Playwright, launch with
--disable-blink-features=AutomationControlled, set a normal Chrome
userAgent, and override navigator.userAgentData in an init script.
Bodies to /e/ are gzip.
Umami specifics
- Umami drops requests whose User-Agent its
isbot()check flags, and that includes Bun's default UA,node, and any UA containing "server". The provider sends a fixed Chrome-shaped UA ending inWarpkit/1.0. - Events come from the server's IP, so Umami's country/device columns show the server, not the user.
Privacy
Server events carry no cookies, no device data and no PII: a user id, an
event name, and the properties in the table. The browser half sets cookies
(ph_*, umami.*) and only loads after analytics consent. A pseudonymous id
is still personal data under GDPR, so list your provider in your privacy
policy. The cookie banner gates the browser half only. To gate server events
on consent too, leave the server env unset or check the user's consent in the
on() handlers.