Analytics
How we measure marketing and product behaviour across the website and the mobile app. Everything flows into one Google Analytics 4 (GA4) property and a BigQuery export, so a single query can follow a member from web visit to app usage to paid booking.
Context
We used to run two GA4 properties — one for the website, one for the app — which meant every report had to be stitched together by hand and the same person counted twice. As of the Q3 2026 cutover (2026-07-01) these are consolidated into one property with three data streams, and the event-level data is exported to BigQuery as the reporting substrate (and the future home of the assistant’s analytics tool).
This page is infra/ because analytics is genuinely cross-cutting: it spans www, api, the mobile app, and the reporting/assistant layer — it isn’t owned by any one component.
Architecture
┌─────────────────────────── GA4 property: myiba-62a1b ───────────────────────────┐
www (gtag) ───▶│ Web stream (G-CMSTFXVKN6) │
api (MP) ───▶│ Web stream (server-side purchase, Measurement Protocol) │──▶ BigQuery
iOS app ───▶│ iOS stream (Firebase Analytics) │ (tunnelflight-analytics
Android ───▶│ Android stream (Firebase Analytics) │ .analytics_528361126)
└─────────────────────────────────────────────────────────────────────────────────┘- GA4 property
myiba-62a1b(accountTunnel Flight, property id528361126). The mobile app always reported here via Firebase; the website’s web stream (G-CMSTFXVKN6) was added at cutover. - BigQuery export lands in the user-owned
tunnelflight-analyticsGCP project (the Firebase project is client-owned, so it can’t host the export), datasetanalytics_528361126, Daily. Starter views live in the feature spec (specs/003-analytics-consolidation/sql/). - The old standalone web property (
G-EXMEP1FPRT) is kept read-only and also linked to BigQuery to preserve its history — GA4 cannot merge two properties’ past data, so continuity across the cutover is a BigQueryUNION.
How it works
Web tagging (www)
www/src/views/_partials/google.ejs loads gtag and, during the transition, dual-tags both the legacy property and the unified one:
gtag('config', 'G-EXMEP1FPRT'); // legacy web property — remove after validation
gtag('config', 'G-CMSTFXVKN6', { user_id: <%- ... member_id ... %> }); // unified propertyFor logged-in members it sets User-ID = member_id plus non-PII user properties (role_id, preferred_language) before config, so they apply to the first page_view. All values are emitted via JSON.stringify + <-escaping so nothing breaks out of the inline script.
Server-side purchase (api)
Revenue is sent server-side from the Stripe webhook, not the browser — accurate, ad-blocker-proof, and impossible to double-count:
api/src/features/account/fees/service/intent.service.jscaptures the GAclient_id(_gacookie) andsession_id(_ga_<stream>cookie) plusrole_idinto the Stripe PaymentIntent metadata at creation.api/src/features/account/fees/service/webhook.service.js(handleSuccessfulPayment) sends a GA4purchaseevent via the Measurement Protocol usingapi/src/shared/utils/analytics/ga4.js.- The helper is a safe no-op until
GA4_MEASUREMENT_ID+GA4_MP_API_SECRETare set (Infisical →api→ Production), and never throws into the payment flow.
Mobile (Firebase)
The app’s taxonomy is the source of truth — see the mobile repo’s src/services/analyticsEvents.ts and docs/FIREBASE_ANALYTICS.md. Web and api event names mirror it.
Event taxonomy
| Event | Platform | Transport | Key params |
|---|---|---|---|
login | web, app | gtag / Firebase | method |
sign_up | web, app | gtag / Firebase | — |
begin_checkout | web | gtag | value, currency |
purchase | api (server) | Measurement Protocol | transaction_id, value, currency, role_id, user_id, coupon?, session_id? |
book_now | app | Firebase | role_id, source |
logbook_entry_created / _updated | app | Firebase | entry_type, category? |
profile_updated · support_ticket_created · notification_opened | app | Firebase | (per event) |
Cross-platform funnel: book_now (app) → begin_checkout (web) → purchase (api), stitched by User-ID (member_id).
Custom dimensions (registered on the property)
User-scoped: role_id, preferred_language. Event-scoped: method, entry_type, category, enquiry_type, source, notification_type. (BigQuery captures every parameter regardless of registration — these matter only for the GA4 UI / Data API.)
Privacy posture
- First-party product analytics only. Google Signals is OFF and no ads/remarketing is configured — this is what keeps the app’s no-ATT-prompt stance valid. Turning Signals on would re-open App Tracking Transparency + store privacy labels.
- User-ID is the internal
member_id(not PII). No emails/names are sent. - Redact data is enabled on the web stream (email + URL query params) as a safety net for the member portal.
Gotchas
purchaseis server-side only. Never fire it from the browser — that double-counts revenue. The Stripe webhook is the canonical “paid” moment, andvalidatePayment()makes it idempotent against Stripe’s webhook retries.- MP needs its own secret.
GA4_MP_API_SECRETmust be created on theG-CMSTFXVKN6web stream specifically, andGA4_MEASUREMENT_IDmust equalG-CMSTFXVKN6— a secret from the wrong stream silently misroutes the event. discount_groupis intentionally not sent —role_id(pricing tier) + Stripecouponcover it.regionis not a custom param — GA4’s built-in geo (geo.country/geo.region) covers it; a business-region grouping is a BigQueryCASEongeo.country.- History does not merge. Reports spanning 2026-07-01 must
UNIONthe old + new BigQuery datasets. - Remove the legacy tag (
G-EXMEP1FPRTconfig ingoogle.ejs) once the unified property is validated.
Related
- Tunnel bookings — the booking the funnel measures.
- Fees & payments — the Stripe flow
purchasehooks into. - Mobile app analytics —
docs/FIREBASE_ANALYTICS.mdin the mobile repo (source-of-truth event taxonomy). - Implementation:
specs/003-analytics-consolidation/(spec, plan, event contract, cutover runbook, BigQuery views).