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.
  • The API reads the property through the GA4 Data API, for the admin dashboard and the weekly review email. It authenticates as the google-data@hybrid-poetry-372104 service account, which has Viewer on the property. The id is the GA4_PROPERTY_ID constant in api/src/config/analytics.js. Until 2026-11-11 the dashboard read the GA_PROPERTY_ID env var instead; locally that pointed at an old property, and the service account had no access to the unified one. Web figures in the unified property start at the cutover, so dashboard ranges before 2026-07-08 show mobile only.

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.js — gtag('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 sent — role_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