diff --git a/requirement/analytics-events-reference.md b/requirement/analytics-events-reference.md new file mode 100644 index 0000000..469be39 --- /dev/null +++ b/requirement/analytics-events-reference.md @@ -0,0 +1,191 @@ +# Analytics Events Reference — client_app (GA4 / Firebase Analytics) + +> Companion to `requirement/analytics-funnel-plan.md` (the design). This is the +> **single source of truth for what is actually instrumented** in client_app as +> of 2026-06-02. Any new event must be added to this table *before* it is coded +> (governance, plan §9). +> +> Scope: **client_app only** · `user_id` = customer UUID · **no PII** · client +> events live now; server (Measurement Protocol) events are deferred. + +--- + +## 1. Identity & user properties + +| Key | Set where | Values | Notes | +|---|---|---|---| +| `user_id` | auth listener (main.dart) on resolve/upgrade | customer UUID | opaque; same row across anon→verified. Never phone/name. | +| `user_type` (user property) | same | `anonymous` \| `verified` | | +| `is_returning` (user property) | available via `setIsReturning` | `true` \| `false` | wire when "has ≥1 prior session" signal is read | + +--- + +## 2. Event dictionary (custom events) + +Type: **C** = fired client-side now · **S** = server-side (Measurement Protocol), **deferred** · **auto** = Firebase auto-collected. + +| Event | Type | Params | Trigger / location | +|---|---|---|---| +| `app_open` / `first_open` / `session_start` | auto | — | Firebase default | +| `screen_view` | auto (C) | `screen_name` | GoRouter observer — **page routes only** (see §4) | +| `curhat_start` | C | `funnel=activation`, `entry_point=home_primary` | Home "Aku Mau Curhat" CTA | +| `curhat_repeat_start` | C | `funnel=repeat` | Home "Aku Mau Curhat" (returning) / returning path | +| `bestie_choice_view` | C | — | `bestie_choice_sheet` shown (returning user with history) | +| `bestie_choice_select` | C | `choice=known_bestie\|new_bestie` | bestie-choice sheet card tap | +| `bestie_reselect` | C | `funnel=repeat`, `mitra_ref` (hashed) | `/bestie/history` row tap (targeted) | +| `verif_choice_view` | C | — | `verif_choice_sheet` shown (post anon-login) | +| `verif_choice_select` | C | `choice=verified\|anonymous` | verif-choice sheet decision (not on dismiss) | +| `auth_start` | C | `method=phone` | register screen "kirim kode" | +| `auth_otp_submit` | C | — | OTP screen submit | +| `auth_complete` | C | `user_type` | OTP verified resolve (verified) · display-name anon resolve (anonymous) | +| `onboarding_usp_view` | C | `verified` | USP screen initState | +| `payment_view` | C | `funnel`, `is_repeat` | `/payment/entry` initState | +| `payment_method_select` | C | `method` | payment-**channel** selection on `/payment/method` (once per change) — note: the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick` | +| `payment_started` ⭐ | C | `payment_request_id`, `amount`, `currency=IDR`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | `payment_method_screen._onPay`, **after** POST returns id | +| `pairing_matched` | C | `funnel` | `/chat/found` initState | +| `pairing_no_bestie` | C | `funnel` | `/chat/no-bestie` initState | +| `extension_offer_view` | C | `session_id` | `pricing_bottom_sheet` shown for extension (chat) | +| `chat_extension_requested` | C | `session_id` | user confirms extension (`PricingBottomSheet._onConfirm`) | +| `payment_confirmed` ⭐ | **S — deferred** | mirrors `payment_started` + `session_id`, `engagement_time_msec` | webhook → `payment_request.confirmed` | +| `payment_failed` | **S — deferred** | `payment_request_id`, `reason` | expiry/failure | +| `chat_session_start` ⭐ | **S — deferred** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | session.service start | +| `chat_session_end` | **S — deferred** | `session_id`, `end_reason`, `messages_count` | session end / timer | + +`funnel`/`is_repeat` are derived from `paymentDraftNotifierProvider.targetedMitraId != null` (targeted mitra ⇒ repeat funnel). + +### ⭐ Stitching keys carried on payment +Sent on `POST /api/client/payment-requests` body as `analytics:{ app_instance_id, ga_session_id }` (backend currently ignores). They let the deferred server `payment_confirmed` join the client funnel: `app_instance_id` (device/session), `user_id` (user), `payment_request_id` (exact attempt). Full rationale: plan §3. + +--- + +## 3. Screen views tracked (page routes) + +Auto `screen_view` fires for every GoRoute, mapped to a stable `screen_name` (path params stripped): + +`splash · auth_display_name · auth_register · auth_otp · auth_set_name · auth_force_register · onboarding_usp_verified · onboarding_usp_anon · onboarding_notif_gate · home · profile · payment_entry · payment_discount_paywall · curhat_mode_pick · payment_duration_pick · payment_method · payment_waiting · payment_expired · chat_searching · chat_found · chat_no_bestie · chat_waiting_targeted · chat_session · chat_thank_you · chat_tab_aktif · chat_tab_pembayaran · chat_tab_selesai · chat_transcript · bestie_history` + +--- + +## 4. Bottom sheets & modals + +Sheets/dialogs (`showModalBottomSheet` / `showDialog`) push routes with a **null `RouteSettings.name`**, so the `FirebaseAnalyticsObserver` skips them — they get **no auto `screen_view`**. Funnel-relevant sheets are instead instrumented with explicit `*_view` / `*_select` events (logged from the sheet's show/onTap). Each tracked sheet fires a `view` when shown and a `select` when the user acts; the **gap between them = abandonment**. + +| Sheet / dialog | Funnel relevance | Tracking | +|---|---|---| +| `verif_choice_sheet` (verify vs anonymous) | **high** | ✅ `verif_choice_view` + `verif_choice_select{choice}` | +| `bestie_choice_sheet` (new vs known bestie fork) | **high** | ✅ `bestie_choice_view` + `bestie_choice_select{choice}` | +| `pricing_bottom_sheet` (extension upsell in chat) | **medium** (monetization) | ✅ `extension_offer_view` + `chat_extension_requested` | +| `topic_selection_bottom_sheet` (pre-chat topic pick) | — | ⬜ **dead code** — `.show()` never called; track only once wired into a flow | +| `tanya_admin_sheet` (support) | low | ⬜ not tracked (negligible funnel value) | +| `bestie_unavailable_dialog` | low | ⬜ not tracked | +| `closing_message_sheet` (goodbye) | low | ⬜ not tracked | + +**Why not the rest:** the `verif_choice` and `bestie_choice` *outcomes* are also inferable from downstream events (`auth_start` vs anon `payment_view`; `bestie_history` view vs direct `payment_view`) — the explicit events add the **abandonment** signal you can't otherwise see, plus one-step branch clarity. The extension pair is pure net-new (no other event covers extension take-rate). The low-tier sheets are support/edge surfaces and intentionally left untracked to avoid noise. + +--- + +## 5. Visual flows + +Two views of the same instrumentation: +- **5.1 Funnel event flow** — the abstract conversion funnel (what GA4 reports on). +- **5.2 Screen navigation map** — the real route/screen/sheet flow with each event pinned to where it fires (what you'll see live in DebugView). + +### 5.1 Funnel event flow + +```mermaid +flowchart TD + classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47; + classDef srv fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A,stroke-dasharray:4 3; + classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500; + + AO([app_open / session_start]):::evt --> HOME[screen_view: home] + + %% ---- Activation funnel ---- + HOME --> CS{{bestie_choice_sheet — not viewed}}:::sheet + CS -->|new bestie| CSTART([curhat_start
funnel=activation]):::evt + CSTART --> AUTH[screen_view: auth_register] + AUTH --> ASTART([auth_start method=phone]):::evt + ASTART --> OTP[screen_view: auth_otp] + OTP --> AOTP([auth_otp_submit]):::evt + AOTP --> ACOMP([auth_complete user_type]):::evt + ACOMP --> USP([onboarding_usp_view]):::evt + USP --> PV([payment_view funnel,is_repeat]):::evt + PV --> PMS([payment_method_select method]):::evt + PMS --> PSTART([payment_started ⭐
+ app_instance_id, ga_session_id sent on POST]):::evt + PSTART --> PCONF([payment_confirmed ⭐
SERVER — deferred]):::srv + PCONF --> PM{pairing} + PM -->|matched| PMATCH([pairing_matched]):::evt + PM -->|none| PNB([pairing_no_bestie]):::evt + PMATCH --> CSS([chat_session_start ⭐
SERVER — deferred]):::srv + CSS --> CSE([chat_session_end
SERVER — deferred]):::srv + + %% ---- Repeat funnel ---- + HOME --> RSTART([curhat_repeat_start
funnel=repeat]):::evt + RSTART --> BHIST[screen_view: bestie_history] + BHIST --> BRESEL([bestie_reselect funnel=repeat]):::evt + BRESEL --> PV2([payment_view is_repeat=true]):::evt + PV2 --> PMS +``` + +Legend — pink = client event (live) · blue dashed = server event (deferred) · yellow = bottom sheet (not auto-tracked). + +### 5.2 Screen navigation map (routes + sheets + events) + +Real GoRouter routes (blue `screen_view` nodes), bottom sheets (yellow, **no** `screen_view`), and the exact event each transition fires (pink = live, dashed = deferred server). Both home CTAs read **"Aku Mau Curhat"**; the path taken depends on auth state, not the label. + +```mermaid +flowchart TD + classDef screen fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A; + classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47; + classDef srv fill:#EFE3FF,stroke:#7B3BE0,color:#2E1B5A,stroke-dasharray:4 3; + classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500; + + SPL[/splash/]:::screen --> HOME[/home/]:::screen + + %% ===== FRESH USER (activation) ===== + HOME -->|tap CTA · curhat_start| DN[/auth/display-name/]:::screen + DN -.loginAnonymous.-> VCS{{verif_choice_sheet
verif_choice_view}}:::sheet + VCS -->|verify · verif_choice_select| REG[/auth/register/]:::screen + VCS -->|lanjut tanpa verif · verif_choice_select| PE + REG -->|auth_start| OTP[/auth/otp/]:::screen + OTP -->|auth_otp_submit → auth_complete| UVU[/onboarding/verif/usp/]:::screen + UVU -->|onboarding_usp_view| PE + + %% ===== RETURNING USER (repeat) ===== + HOME -->|tap CTA · curhat_repeat_start| BCS{{bestie_choice_sheet
bestie_choice_view}}:::sheet + BCS -->|new bestie · bestie_choice_select| PE + BCS -->|known bestie · bestie_choice_select| BHL[/bestie/history/]:::screen + BHL -->|tap row · bestie_reselect| PE + + %% ===== SHARED PAYMENT SHELL ===== + %% NOTE: /payment/method-pick is the chat-vs-call MODE picker (curhat_mode_pick), + %% NOT the channel picker. The channel picker is /payment/method (payment_method), + %% where payment_method_select fires. + PE[/payment/entry/
payment_view/]:::screen --> MODE[/payment/method-pick/
curhat_mode_pick/]:::screen + MODE -->|chat / call| DUR[/payment/duration-pick/
payment_duration_pick/]:::screen + DUR --> PMETH[/payment/method/
payment_method + payment_method_select/]:::screen + PMETH -->|tap bayar · payment_started ⭐
+ app_instance_id & ga_session_id on POST| WP[/payment/waiting/:id/]:::screen + WP -.->|payment_confirmed ⭐ SERVER deferred| PCONF([backend webhook]):::srv + + %% ===== PAIRING + CHAT ===== + PCONF --> SRCH[/chat/searching/]:::screen + SRCH -->|matched · pairing_matched| FOUND[/chat/found/]:::screen + SRCH -->|none · pairing_no_bestie| NOB[/chat/no-bestie/]:::screen + FOUND --> SESS[/chat/session/:id/]:::screen + SESS -.->|chat_session_start / _end ⭐ SERVER deferred| SSRV([session.service]):::srv + SESS -->|tap perpanjang| EXT{{pricing_bottom_sheet
extension_offer_view}}:::sheet + EXT -->|confirm · chat_extension_requested| SESS +``` + +Legend — blue = page route (auto `screen_view`) · pink label = client event fired on that transition · yellow = bottom sheet (no `screen_view`) · purple dashed = deferred server event. + +> The three funnel-relevant sheets — **verify-vs-anonymous** (`verif_choice_sheet`), **new-vs-known-bestie** (`bestie_choice_sheet`), and the **extension upsell** (`pricing_bottom_sheet`) — each fire a `*_view` on show and a `*_select` / `chat_extension_requested` on action, so both the branch taken and sheet abandonment are measurable. See §4 for which sheets are intentionally left untracked. + +--- + +## 6. GA4 setup checklist (console) + +- Register custom dimensions: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`. +- Mark key events / conversions: `payment_confirmed`, `chat_session_start` (once server phase lands; until then `payment_started` is the furthest reliable client conversion). +- Build two Funnel Explorations (activation / repeat) filtered by `funnel` / `is_repeat`. +- Validate end-to-end in **DebugView** with a debug build before release. diff --git a/requirement/analytics-funnel-plan.md b/requirement/analytics-funnel-plan.md new file mode 100644 index 0000000..e9706c3 --- /dev/null +++ b/requirement/analytics-funnel-plan.md @@ -0,0 +1,235 @@ +# 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?