Track storefront activity through Umami, Google Analytics, or both — without leaking personal information.
Each tenant can connect a Umami website ID and/or a Google Analytics 4 measurement ID. Set these on the tenant record (Platform Admin → Tenants → edit). Scripts only load in production when the corresponding ID is present.
Beyond automatic page views, the platform fires a typed set of business events from the customer-facing site, the admin app, and the instructor app:
booking_completed, checkout_completed, signup_completed, login_completedcourse_view, schedule_view, product_view, cart_add, checkout_started, booking_started, waitlist_joinsearch, filter_applied, discount_applied, discount_code_failed, contact_submitpayment_failed, booking_blocked_prereq, out_of_stockenrollment_cancelled (with actor set to customer or admin)admin:course_created, admin:invoice_sent, admin:order_refunded, instructor:session_marked_completeEvents fired inside the admin or instructor surfaces — or with an admin: / instructor: namespace — are tagged with is_internal: true. Funnel dashboards should filter on this tag so staff dogfooding doesn't pollute customer conversion data.
Conversion events that don't have a reliable client (webhooks, redirect-driven OAuth flows, background jobs) are emitted from the server via Umami's send API and the Google Analytics 4 Measurement Protocol. The Square payment.completed webhook, for example, re-asserts checkout_completed so checkouts aren't lost when the customer tab is closed or ad-block strips the client beacon. Each event is delivered by exactly one side to avoid double counting. Configure with the UMAMI_HOST and GA_API_SECRET environment variables.
When a user is signed in, their internal user ID is passed to both backends as a stable identifier for cross-device attribution and conversion reporting. No emails, names, or other personal details are sent. The identifier is cleared on logout.
On sign-in, the platform forwards a coarse, bucketed profile to Umami as session data so cohort and funnel reports can segment by audience: role, cert_level (highest verified tier), account_age_months (banded), customer_type (new / returning / vip / lapsed), order_count (banded), course_completions (banded), locale, and tenant_slug. Every field is intentionally coarsened to a bucket so no single user can be re-identified from a value. The computation lives in src/lib/analytics-profile.ts and the dispatch is in src/components/analytics-identity.tsx.
Outbound transactional emails route their primary CTA through /api/email/click/[token]. The token is an HMAC-SHA256 signed payload (EMAIL_LINK_SECRET env) carrying the destination URL, template name, tenant slug, and an optional resource id, with a 90-day expiry. The route verifies the signature, fires email_link_clicked via trackServer(), and 302s to the destination. Authentication flows (email verification, password reset) are intentionally not wrapped. Disable globally by leaving EMAIL_LINK_SECRET unset.
Conversion events (checkout_completed, booking_completed) include a revenue field alongside currency so Umami's Revenue report populates automatically. The Umami tracker is loaded with data-performance="true" so Core Web Vitals (LCP, INP, CLS, FCP, TTFB) feed the Performance tab. Session replays are captured by Umami's recorder.js when the tenant has Umami configured; sample rate, mask level, and excluded selectors are managed per-website inside the Umami dashboard.
Event payloads contain only non-identifying details — internal resource IDs, amounts, currency, item counts, route group, and locale. We never send emails or IP-derived data. Search queries are lowercased, trimmed, and limited to 100 characters. Session replays mask form inputs by default and exclude the Square payment iframe, password fields, and other sensitive inputs; replays are retained for 30 days.
Conversion events map to GA4's recommended event names so Google's built-in Monetization, Acquisition, and Conversions reports populate automatically: checkout_completed and booking_completed become purchase; signup_completed becomes sign_up; search stays as search with search_term. Other events fire under their original snake-cased names.