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) was retired on 2026-07-08 once the unified property was validated: its on-page tag and the GTM-54VFZK6 container were removed from www, and its BigQuery export was unlinked. Its existing analytics_293444096 dataset is kept read-only to preserve pre-cutover 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 configures the single unified property:

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.

Until 2026-07-08 this partial also dual-tagged the legacy G-EXMEP1FPRT property and loaded the GTM-54VFZK6 container (whose only two tags both fed that legacy property). Both were removed once BigQuery parity confirmed the unified property captured every event at correct counts — the legacy setup was double-counting page_view 2× (on-page gtag and a GTM tag firing to the same property) and never tracked purchase. user_id on the unified web stream was verified populating (real numeric member_ids; ~100% on purchase/begin_checkout).

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:

  • Session stitching (important). The GA client_id + session_id are captured client-side in www/src/assets/scripts/app/account/fees/view/view.payment.jsgtag('get', 'G-CMSTFXVKN6', 'client_id' | 'session_id') — and passed to the api as query params when the PaymentIntent is created. intent.service.js (resolveGaIds) reads them and stores them in the Stripe PaymentIntent metadata alongside role_id. They are not read from the _ga cookie server-side: the www→api call is a server-to-server proxy hop that strips those cookies, so cookie-only capture always fell back to a synthetic client_id (<member_id>.0) and the purchase never joined the user’s real web session. The cookie parse remains only as a fallback for direct (non-proxied) callers.
  • api/src/features/account/fees/service/webhook.service.js (handleSuccessfulPayment) sends the GA4 purchase via the Measurement Protocol (api/src/shared/utils/analytics/ga4.js), reusing that metadata so the event lands in the same web session.
  • Fully-comped memberships (a 100%-off coupon) cancel the PaymentIntent and mark the fee paid without a webhook — so coupon.service.js (sendFullDiscountPurchaseAnalytics) emits the same purchase with value: 0 (reusing the metadata client_id/session_id) so those conversions still count in the funnel without adding revenue.
  • 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.
  • role_id type differs by platform. GA4 coerces the numeric strings web/api send (gtag + Measurement Protocol) to int_value, while the app’s Firebase SDK preserves string_value; it also arrives as a user property (web) or an event param (app book_now, api purchase). It can’t be forced consistent at emit, so the BigQuery v_events view coalesces all four shapes to a STRING role_id — group/join on that, never the raw param.
  • History does not merge. Reports spanning 2026-07-01 must UNION the old + new BigQuery datasets.
  • Legacy web page_view is inflated ~2×. The retired analytics_293444096 dataset double-counted page_view (on-page gtag and a GTM tag both fired to G-EXMEP1FPRT). When UNION-ing pre-cutover history, treat its page_view counts as unreliable — sessions, users, and conversions are fine. The unified dataset counts correctly.
  • 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