# Funnel Analytics Plan — Firebase Analytics (GA4) > Status: PLAN / draft for review. Scope decisions (2026-06-02): > **client_app only** · **hybrid client+server events** · **user_id = customer UUID, no PII** · **full-lifecycle taxonomy** (activation + repeat/retention). --- ## 1. Objectives 1. Measure the **activation funnel** (acquisition → first paid chat) and the **repeat/retention funnel** (returning user → curhat lagi → paid chat) in one consistent event taxonomy. 2. Attribute drop-off to specific screens/steps so product can act on it. 3. Keep authoritative money/session events **server-side** so they are never lost when the app is backgrounded or killed mid-payment. 4. **No PII** ever leaves the device into GA4 — no phone number, display name, or chat content. Identity is an opaque customer UUID only. --- ## 2. The two funnels (full lifecycle map) Screen/route references are from `client_app/lib/router.dart`. ### Funnel A — Activation (first paid chat) | # | Step (GA4 funnel step) | Event | Where | |---|---|---|---| | 1 | App open | `app_open` (auto) | Firebase auto | | 2 | Home viewed | `screen_view{home}` | `/home` | | 3 | Start curhat tapped | `curhat_start` | Home CTA "Aku Mau Curhat" | | 4 | Auth started | `auth_start` | `/auth/display-name` / register | | 5 | OTP submitted | `auth_otp_submit` | `/auth/otp` | | 6 | Identified (verified or anon) | `auth_complete` | post-OTP / loginAnonymous | | 7 | USP/onboarding seen | `onboarding_usp_view` | `/onboarding/*/usp` | | 8 | Payment entry | `payment_view` | `/payment/entry` | | 9 | Payment channel chosen | `payment_method_select` | `/payment/method` (channel picker; the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick`) | | 10 | Payment created (invoice) | `payment_started` ⭐ | POST `/api/client/payment-requests` | | 11 | **Payment confirmed** | `payment_confirmed` ⭐ **SERVER** | webhook → `payment_request.confirmed` | | 12 | Paired with mitra | `pairing_matched` | `/chat/found` | | 13 | Chat started | `chat_session_start` ⭐ **SERVER** | session.service start | | 14 | Chat ended | `chat_session_end` **SERVER** | session end / timer | ### Funnel B — Repeat / retention | # | Step | Event | Where | |---|---|---|---| | 1 | Returning home | `screen_view{home}` | `/home` (has history) | | 2 | "Curhat lagi" | `curhat_repeat_start` | BestieChoiceSheet | | 3 | Picked known bestie | `bestie_reselect` | `/bestie/history` | | 4 | Payment created | `payment_started` ⭐ (`is_repeat=true`) | targeted payment | | 5 | Payment confirmed | `payment_confirmed` ⭐ **SERVER** | webhook | | 6 | Targeted pairing | `pairing_targeted_*` | `/chat/waiting-targeted` | | 7 | Chat started | `chat_session_start` ⭐ **SERVER** | session start | ⭐ = the events that must join cleanly across client→server (see §3). Funnel A and B share the same `payment_started` / `payment_confirmed` / `chat_session_start` events — they are distinguished by the **`funnel` / `is_repeat` event params**, not by separate event names. This keeps GA4 reports simple and lets one funnel exploration filter by param. --- ## 3. ⭐ The hybrid join problem — "which payment event relates to which funnel?" This is the central design question. Answer: **three identifiers travel with every payment, so a server-fired `payment_confirmed` lands on the exact same user, session, and attempt as the client-fired `payment_started`.** ### 3.1 The three join keys | Key | Joins at level | Who sets it | How it flows | |---|---|---|---| | `app_instance_id` | **device/app-instance** (required by GA4 MP for app streams) | Firebase SDK on device | client reads `FirebaseAnalytics.instance.appInstanceId`, sends it on payment-create, backend stores in `product_metadata`, replays it in the MP call | | `user_id` | **user** (cross-device, cross-session) | our app | customer UUID set on both client `setUserId()` and server MP payload | | `payment_request_id` | **attempt** (this specific purchase) | backend | returned by POST `/payment-requests`; client puts it on `payment_started`, backend puts the same value on `payment_confirmed` | > **Why all three:** GA4's Measurement Protocol for app streams *requires* `app_instance_id` to attribute a server event to a user's stream — `user_id` alone will record the event but standard funnel/realtime reports won't stitch it to the device's session. `user_id` gives cross-device continuity (anon→verified). `payment_request_id` is the precise attempt-level join used in Explorations/BigQuery to tie one `payment_started` to its `payment_confirmed` (compute exact payment success rate & latency). ### 3.2 The flow, concretely ``` CLIENT payment_method_screen.dart appInstanceId = await FirebaseAnalytics.instance.appInstanceId POST /api/client/payment-requests body: { ...draft, analytics: { app_instance_id, ga_session_id } } ← { id: , invoice_url } analytics.log('payment_started', { payment_request_id, amount, currency:'IDR', method, funnel:'activation'|'repeat', is_repeat, product_type }) BACKEND payment.service.createPaymentRequest() store analytics.app_instance_id + ga_session_id into product_metadata BACKEND payment.service.confirmPayment() (fired from Xendit webhook) emits 'payment_request.confirmed' → analytics subscriber → GA4 Measurement Protocol POST: app_instance_id = product_metadata.app_instance_id ← stitches device/session user_id = customer_id ← stitches user events: [{ name:'payment_confirmed', params:{ payment_request_id, amount, currency, method, funnel, is_repeat, session_id: ga_session_id, engagement_time_msec: 1 }}] ``` `session_id` + `engagement_time_msec` in the MP params are what make GA4 attribute the server event to the **same session** as the client funnel (needed only for *session-scoped* funnel explorations; user-scoped funnels already work via `app_instance_id`+`user_id`). We capture `ga_session_id` client-side (`getSessionId()`) at payment-create and replay it. ### 3.3 Net result - **User-scoped funnel** (default): works via `app_instance_id` + `user_id`. - **Session-scoped funnel**: works because we replay `ga_session_id`. - **Exact attempt analysis** (success rate, time-to-pay): join `payment_started`↔`payment_confirmed` on `payment_request_id` in BigQuery/Explore. The same pattern covers `chat_session_start` / `chat_session_end` (server-authoritative) — keyed by `session_id` + `app_instance_id` + `user_id`. --- ## 4. Identity & user properties - `setUserId(customerId)` — the customer UUID (same row across anon→verified via `anonymous_customer_id`). Set on app start once auth resolves, and re-set after identity upgrade so the verified session continues the same `user_id`. - **User properties** (low-cardinality, no PII): - `user_type` = `anonymous` | `verified` - `is_returning` = whether the customer has ≥1 prior confirmed session - `acquisition_channel` (if/when known) - **Never** set: phone, display name, email, chat text, mitra identity. - Set GA4 data retention + IP anonymization per the mental-health sensitivity; document in privacy section §9. --- ## 5. Master event taxonomy Naming: `snake_case`, object_action where natural, ≤40 chars. Reserved Firebase events (`app_open`, `screen_view`, `first_open`, `session_start`) are auto-collected — do not redefine. | Event | Client/Server | Key params | Notes | |---|---|---|---| | `screen_view` | client (auto via observer) | `screen_name` | go_router observer, §6 | | `curhat_start` | client | `funnel='activation'`, `entry_point` | Home primary CTA | | `curhat_repeat_start` | client | `funnel='repeat'` | returning CTA | | `bestie_reselect` | client | `mitra_ref` (opaque) | `/bestie/history` | | `auth_start` | client | `method` (phone/google/apple) | | | `auth_otp_submit` | client | — | | | `auth_complete` | client | `user_type` | | | `onboarding_usp_view` | client | `verified` | | | `payment_view` | client | `funnel`, `is_repeat` | `/payment/entry` | | `payment_method_select` | client | `method` | | | `payment_started` ⭐ | client | `payment_request_id`, `amount`, `currency`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | fired right after POST returns id | | `payment_confirmed` ⭐ | **server (MP)** | same as above + `session_id`, `engagement_time_msec` | from webhook | | `payment_failed` | **server (MP)** | `payment_request_id`, `reason` | expiry/failure | | `pairing_matched` | client | `funnel` | `/chat/found` | | `pairing_no_bestie` | client | `funnel` | `/chat/no-bestie` | | `chat_session_start` ⭐ | **server (MP)** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | authoritative | | `chat_session_end` | **server (MP)** | `session_id`, `end_reason`, `messages_count` | authoritative | | `chat_extension_requested` | client | `session_id` | optional | | `app_open` / `session_start` / `first_open` | auto | — | Firebase default | > Keep custom params to those used in funnels/segments. Register the high-value ones as **custom dimensions** in GA4 (`funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`) so they're queryable. --- ## 6. Client implementation (Flutter) 1. **Add deps** to `client_app/pubspec.yaml`: `firebase_analytics`. Re-run `flutterfire configure` if needed (firebase_core already present; messaging configured). 2. **Init** in bootstrap after `Firebase.initializeApp()`: enable collection, set default user properties. 3. **Analytics service wrapper** — `core/analytics/analytics.dart`: - Thin façade over `FirebaseAnalytics.instance` with typed methods (`logCurhatStart`, `logPaymentStarted`, …) so event names/params are centralized and not stringly-typed at call sites. - Exposes `appInstanceId()` and `sessionId()` helpers for the payment-create call. - Riverpod provider `analyticsProvider` for injection. 4. **Auto screen_view** — add `FirebaseAnalyticsObserver` to `GoRouter(observers: [...])`. Map routes → clean `screen_name`s (avoid leaking path params like `:sessionId`). 5. **user_id wiring** — in the auth notifier listener, call `setUserId` + update `user_type`/`is_returning` user properties whenever auth state resolves/upgrades. 6. **Instrument the funnel call sites** per §5 (CTAs, OTP submit, payment screens, pairing screens). Fire `payment_started` only after the POST returns a `payment_request_id`. > Pitfall guard (per client_app/CLAUDE.md): analytics calls inside widget teardown go in `deactivate()`, not `dispose()` — but prefer firing on the user action, not on screen disposal. --- ## 7. Backend implementation (Measurement Protocol) 1. **Config/env**: `GA4_API_SECRET`, `GA4_FIREBASE_APP_ID`, `GA4_MP_ENABLED` (default off, opt-in like Xendit/Fazpass flags). Endpoint: `https://www.google-analytics.com/mp/collect`. 2. **Capture identifiers**: extend POST `/api/client/payment-requests` to accept `analytics:{app_instance_id, ga_session_id}` and persist into `product_metadata` (already JSONB, already "for analytics"). 3. **Analytics emitter service** — `services/analytics-mp.service.js`: - `sendEvent({ appInstanceId, userId, name, params })` → builds MP payload, POSTs, logs failures non-fatally (analytics must never break payment). - Always include `engagement_time_msec` and `session_id` for app-stream session attribution. 4. **Subscribe to existing internal events** (no new webhook plumbing needed): - `payment_request.confirmed` → `payment_confirmed` - payment expiry/failure → `payment_failed` - session start/end (session.service) → `chat_session_start` / `chat_session_end` 5. **Validation**: use GA4 MP **debug endpoint** (`/debug/mp/collect`) in dev to assert payloads before enabling. > Reliability: MP sends are fire-and-forget with a short timeout + retry-once; wrap in try/catch so a GA outage never affects the money path. Consider a lightweight outbox if we later need delivery guarantees. --- ## 8. GA4 configuration (console) 1. Create/confirm the **Firebase Analytics → GA4 property** for client_app (Android + iOS app streams). 2. Register **custom dimensions**: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`. 3. Build **two Funnel Explorations**: - *Activation*: steps = `curhat_start → auth_complete → payment_started → payment_confirmed → pairing_matched → chat_session_start`, filter `funnel=activation`. - *Repeat*: steps = `curhat_repeat_start → payment_started → payment_confirmed → chat_session_start`, filter `is_repeat=true`. 4. Mark **conversions/key events**: `payment_confirmed`, `chat_session_start`. 5. (Optional, recommended) Enable **BigQuery export** for attempt-level joins (`payment_started`↔`payment_confirmed` on `payment_request_id`) that GA's UI can't express precisely. 6. Use **DebugView** with a debug build to validate the full funnel end-to-end before release. --- ## 9. Privacy & governance - **No PII** in events or user properties — enforce via the typed wrapper (no free-form string params at call sites). - `user_id` is an opaque UUID; document the mapping policy and retention. - Respect OS-level analytics/ads consent; gate collection behind app config so it can be disabled. - Add a one-page **event dictionary** (this §5 table) to `requirement/` and keep it the single source of truth; any new event gets added here first (governance). - Set GA4 data retention to the minimum that supports the funnels; enable IP anonymization. --- ## 10. Implementation phases / checklist **Phase 1 — Client foundation** - [ ] Add `firebase_analytics`; init + collection toggle - [ ] `AnalyticsService` typed wrapper + Riverpod provider - [ ] `FirebaseAnalyticsObserver` on GoRouter + screen_name map - [ ] `setUserId` + user properties in auth listener **Phase 2 — Client funnel events** - [ ] Activation events (curhat_start … pairing_matched) - [ ] Repeat events (curhat_repeat_start, bestie_reselect) - [ ] `payment_started` after POST returns id; capture `app_instance_id` + `ga_session_id`, send on payment-create **Phase 3 — Backend server events (hybrid)** - [ ] Persist `analytics` identifiers into `product_metadata` - [ ] `analytics-mp.service.js` + env flags - [ ] Subscribe to `payment_request.confirmed` → `payment_confirmed` - [ ] Subscribe to session start/end → `chat_session_*` - [ ] Validate via MP debug endpoint **Phase 4 — GA4 config + validation** - [ ] Custom dimensions, conversions - [ ] Two funnel explorations - [ ] DebugView end-to-end pass on a real device - [ ] (Optional) BigQuery export --- ## 11. Open questions for product 1. Confirm GA4 property already exists for client_app (or do we create fresh)? Re separate prod/dev Firebase projects — see existing `firebase_env_strategy` note. 2. Do we want BigQuery export from day one (enables exact attempt-level payment analytics)? 3. Retention window + any consent-banner requirement for the mental-health context?