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. - 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-372104service account, which has Viewer on the property. The id is theGA4_PROPERTY_IDconstant inapi/src/config/analytics.js. Until 2026-11-11 the dashboard read theGA_PROPERTY_IDenv 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 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).