- 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>
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
- Measure the activation funnel (acquisition → first paid chat) and the repeat/retention funnel (returning user → curhat lagi → paid chat) in one consistent event taxonomy.
- Attribute drop-off to specific screens/steps so product can act on it.
- Keep authoritative money/session events server-side so they are never lost when the app is backgrounded or killed mid-payment.
- 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_idto attribute a server event to a user's stream —user_idalone will record the event but standard funnel/realtime reports won't stitch it to the device's session.user_idgives cross-device continuity (anon→verified).payment_request_idis the precise attempt-level join used in Explorations/BigQuery to tie onepayment_startedto itspayment_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_started↔payment_confirmedonpayment_request_idin 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 viaanonymous_customer_id). Set on app start once auth resolves, and re-set after identity upgrade so the verified session continues the sameuser_id.- User properties (low-cardinality, no PII):
user_type=anonymous|verifiedis_returning= whether the customer has ≥1 prior confirmed sessionacquisition_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)
- Add deps to
client_app/pubspec.yaml:firebase_analytics. Re-runflutterfire configureif needed (firebase_core already present; messaging configured). - Init in bootstrap after
Firebase.initializeApp(): enable collection, set default user properties. - Analytics service wrapper —
core/analytics/analytics.dart:- Thin façade over
FirebaseAnalytics.instancewith typed methods (logCurhatStart,logPaymentStarted, …) so event names/params are centralized and not stringly-typed at call sites. - Exposes
appInstanceId()andsessionId()helpers for the payment-create call. - Riverpod provider
analyticsProviderfor injection.
- Thin façade over
- Auto screen_view — add
FirebaseAnalyticsObservertoGoRouter(observers: [...]). Map routes → cleanscreen_names (avoid leaking path params like:sessionId). - user_id wiring — in the auth notifier listener, call
setUserId+ updateuser_type/is_returninguser properties whenever auth state resolves/upgrades. - Instrument the funnel call sites per §5 (CTAs, OTP submit, payment screens, pairing screens). Fire
payment_startedonly after the POST returns apayment_request_id.
Pitfall guard (per client_app/CLAUDE.md): analytics calls inside widget teardown go in
deactivate(), notdispose()— but prefer firing on the user action, not on screen disposal.
7. Backend implementation (Measurement Protocol)
- 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. - Capture identifiers: extend POST
/api/client/payment-requeststo acceptanalytics:{app_instance_id, ga_session_id}and persist intoproduct_metadata(already JSONB, already "for analytics"). - 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_msecandsession_idfor app-stream session attribution.
- 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
- 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)
- Create/confirm the Firebase Analytics → GA4 property for client_app (Android + iOS app streams).
- Register custom dimensions:
funnel,is_repeat,method,user_type,payment_request_id,product_type,end_reason. - Build two Funnel Explorations:
- Activation: steps =
curhat_start → auth_complete → payment_started → payment_confirmed → pairing_matched → chat_session_start, filterfunnel=activation. - Repeat: steps =
curhat_repeat_start → payment_started → payment_confirmed → chat_session_start, filteris_repeat=true.
- Activation: steps =
- Mark conversions/key events:
payment_confirmed,chat_session_start. - (Optional, recommended) Enable BigQuery export for attempt-level joins (
payment_started↔payment_confirmedonpayment_request_id) that GA's UI can't express precisely. - 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_idis 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 AnalyticsServicetyped wrapper + Riverpod providerFirebaseAnalyticsObserveron GoRouter + screen_name mapsetUserId+ user properties in auth listener
Phase 2 — Client funnel events
- Activation events (curhat_start … pairing_matched)
- Repeat events (curhat_repeat_start, bestie_reselect)
payment_startedafter POST returns id; captureapp_instance_id+ga_session_id, send on payment-create
Phase 3 — Backend server events (hybrid)
- Persist
analyticsidentifiers intoproduct_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
- Confirm GA4 property already exists for client_app (or do we create fresh)? Re separate prod/dev Firebase projects — see existing
firebase_env_strategynote. - Do we want BigQuery export from day one (enables exact attempt-level payment analytics)?
- Retention window + any consent-banner requirement for the mental-health context?