Files
halobestie-clone/requirement/analytics-funnel-plan.md
Ramadhan Sjamsani 7e218decae docs(analytics): add funnel plan + live events reference
- analytics-funnel-plan.md: design rationale, hybrid client/server stitching,
  identity model, GA4 setup
- analytics-events-reference.md: live event dictionary + two Mermaid flow
  diagrams (funnel event flow + route/sheet navigation map)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00

15 KiB

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: <payment_request_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_startedpayment_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 wrappercore/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_names (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 serviceservices/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.confirmedpayment_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_startedpayment_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.confirmedpayment_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?