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

EventFired fromProperties
user.signed_uponUserCreated (src/server/auth-hooks.ts)
checkout.startedcreateCheckoutSessionFn, createOneTimeCheckoutFnkind, priceId
billing_portal.openedcreateBillingPortalFn
subscription.activatedStripe checkout.session.completedplanId, amount, currency
purchase.completedStripe checkout.session.completed (one-time)amount, currency
credits.purchasedStripe checkout.session.completed (credits)amount
subscription.canceledStripe customer.subscription.deleted
billing.refundedStripe charge.refundedkind, amount
account.deletedaccount 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: the AnalyticsProvider type (name, capture) and one factory per vendor. Plain fetch with 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 with Promise.allSettled, so one vendor being down never affects the other, and never throws (failures are logged at warn).
  • src/server/event-handlers.ts: one on() per domain event calls trackInBackground(). It doesn't await the send: handlers run inside the Stripe webhook's await 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:

CallWhere
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 when loadAnalytics() runs, so it's not in the main bundle and never runs before consent. The posthog-js/no-external build never injects scripts from PostHog's CDN, so the CSP needs connect-src hosts 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 in providers.ts if 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; $identify merges 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 in Warpkit/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.