Skip to Content
InfraAnalytics

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 (account Tunnel Flight, property id 528361126). 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-analytics GCP project (the Firebase project is client-owned, so it can’t host the export), dataset analytics_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 BigQuery UNION.

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 property

For 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.js captures the GA client_id (_ga cookie) and session_id (_ga_<stream> cookie) plus role_id into the Stripe PaymentIntent metadata at creation.
  • api/src/features/account/fees/service/webhook.service.js (handleSuccessfulPayment) sends a GA4 purchase event via the Measurement Protocol using api/src/shared/utils/analytics/ga4.js.
  • The helper is a safe no-op until GA4_MEASUREMENT_ID + GA4_MP_API_SECRET are 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

EventPlatformTransportKey params
loginweb, appgtag / Firebasemethod
sign_upweb, appgtag / Firebase
begin_checkoutwebgtagvalue, currency
purchaseapi (server)Measurement Protocoltransaction_id, value, currency, role_id, user_id, coupon?, session_id?
book_nowappFirebaserole_id, source
logbook_entry_created / _updatedappFirebaseentry_type, category?
profile_updated · support_ticket_created · notification_openedappFirebase(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

  • purchase is server-side only. Never fire it from the browser — that double-counts revenue. The Stripe webhook is the canonical “paid” moment, and validatePayment() makes it idempotent against Stripe’s webhook retries.
  • MP needs its own secret. GA4_MP_API_SECRET must be created on the G-CMSTFXVKN6 web stream specifically, and GA4_MEASUREMENT_ID must equal G-CMSTFXVKN6 — a secret from the wrong stream silently misroutes the event.
  • discount_group is intentionally not sentrole_id (pricing tier) + Stripe coupon cover it. region is not a custom param — GA4’s built-in geo (geo.country/geo.region) covers it; a business-region grouping is a BigQuery CASE on geo.country.
  • History does not merge. Reports spanning 2026-07-01 must UNION the old + new BigQuery datasets.
  • Remove the legacy tag (G-EXMEP1FPRT config in google.ejs) once the unified property is validated.
  • Tunnel bookings — the booking the funnel measures.
  • Fees & payments — the Stripe flow purchase hooks into.
  • Mobile app analytics — docs/FIREBASE_ANALYTICS.md in the mobile repo (source-of-truth event taxonomy).
  • Implementation: specs/003-analytics-consolidation/ (spec, plan, event contract, cutover runbook, BigQuery views).
Last updated on