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) was retired on 2026-07-08 once the unified property was validated: its on-page tag and theGTM-54VFZK6container were removed fromwww, and its BigQuery export was unlinked. Its existinganalytics_293444096dataset is kept read-only to preserve pre-cutover 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 configures the single unified property:
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.
Until 2026-07-08 this partial also dual-tagged the legacy
G-EXMEP1FPRTproperty and loaded theGTM-54VFZK6container (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-countingpage_view2× (on-page gtag and a GTM tag firing to the same property) and never trackedpurchase.user_idon the unified web stream was verified populating (real numericmember_ids; ~100% onpurchase/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_idare captured client-side inwww/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 alongsiderole_id. They are not read from the_gacookie 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 syntheticclient_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 GA4purchasevia 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 samepurchasewithvalue: 0(reusing the metadataclient_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_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.role_idtype differs by platform. GA4 coerces the numeric strings web/api send (gtag + Measurement Protocol) toint_value, while the app’s Firebase SDK preservesstring_value; it also arrives as a user property (web) or an event param (appbook_now, apipurchase). It can’t be forced consistent at emit, so the BigQueryv_eventsview coalesces all four shapes to a STRINGrole_id— group/join on that, never the raw param.- History does not merge. Reports spanning 2026-07-01 must
UNIONthe old + new BigQuery datasets. - Legacy web
page_viewis inflated ~2×. The retiredanalytics_293444096dataset double-countedpage_view(on-page gtag and a GTM tag both fired toG-EXMEP1FPRT). WhenUNION-ing pre-cutover history, treat itspage_viewcounts as unreliable — sessions, users, and conversions are fine. The unified dataset counts correctly.
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).