Current State of Analytics in the WeBuddhist App

1. Why now

The app is the product. Every roadmap conversation we have (“should we invest in Connect or in the reader?”, “is the onboarding event plan working?”, “do people actually use timers?”) is currently settled by opinion, App Store reviews and the handful of backend numbers the Me tab shows. We have a working analytics setup that costs nothing at our volume and we use about 10% of it.

The goal of this post is to get us to a shared tracking plan and a tool decision, so the next few sprints can instrument the app deliberately instead of one event at a time.


2. Where we are today: an honest audit

2.1 What is wired

Piece State Where
PostHog Flutter SDK posthog_flutter ^5.24.0 in pubspec (5.27 is current, adds structured logs) pubspec.yaml
Abstraction AnalyticsService interface with a PostHog implementation and a no-op fallback, exposed through analyticsServiceProvider lib/core/analytics/
Per-flavor projects Separate POSTHOG_API_KEY per .env.dev / .env.staging / .env.prod, POSTHOG_ENABLED kill switch lib/env.dart, README.md
Host Defaults to https://us.i.posthog.com (US region) lib/env.dart
Identity identify(userId) on login and session restore, reset() on logout, is_guest super property for guests auth_notifier.dart
Super properties environment, app_flavor, platform, app_version, build_number posthog_analytics_service.dart
Screen views PosthogObserver on the GoRouter, so every named route (home, reader, mala, group-chat, …) becomes a $screen event app_router.dart:101
Lifecycle SDK default captureApplicationLifecycleEvents = true: Application Installed / Updated / Opened / Backgrounded SDK default
Person profiles identifiedOnly, so anonymous guests do not create billable person profiles config
PII guard beforeSend blanks email, id_token, access_token if they ever appear in properties config
Native auto-init Correctly disabled on both platforms (com.posthog.posthog.AUTO_INIT = false) AndroidManifest.xml, Info.plist
Firebase Crashlytics Wired for fatal Flutter errors main.dart
Airbridge Attribution, install tracking and deep links (join.webuddhist.com/get-app) docs/implementation/AIRBRIDGE_SETUP.md

2.2 The 12 custom events we fire today

Event Properties Fired from
auth_login_succeeded method auth_notifier.dart
auth_login_failed method, reason auth_notifier.dart
auth_guest_started auth_notifier.dart
onboarding_completed onboarding_notifier.dart
plan_enrolled plan_id, plan_name (onboarding promo only) event_enrollment_service.dart
plan_viewed plan_id, plan_name, total_days plan_details.dart
plan_day_completed plan_id, plan_name, day_number, total_days, completed_days plan_details.dart
routine_saved block_count, item_count routine_provider.dart
mala_screen_opened accumulatorId mala_screen.dart
mala_round_completed accumulatorId, rounds mala_counter_notifier.dart
mala_mantra_switched from, to mala_screen.dart
mala_synced accumulatorId/groupAccumulatorId, total, reset, group mala_sync_manager.dart (4 sites)

2.3 What is wrong or missing

  1. Session replay is on but almost certainly not recording. config.sessionReplay = true is set in release builds, but PostHog’s Flutter replay needs the app wrapped in PostHogWidget (docs). main.dart builds MaterialApp.router directly with no wrapper. Nobody has noticed because nobody looks at the replay tab. Either wire it properly or turn it off.
  2. maskAllImages = false. When replay does start working, screenshots will include bead artwork, user avatars, group banners and any image a user uploads to a post. The SDK default is true for a reason. Flip it back until we have a per-widget masking plan.
  3. plan_enrolled only fires for the onboarding promo plan. Normal enrolment from Browse Plans, series enrolment, and group-driven enrolment are invisible.
  4. mala_synced is an operations event, not a product event. It fires on every debounced flush (5 s idle after a tap, every round, on reconnect, on lifecycle). A user doing one mala of 108 beads will generate a handful of these; a heavy user will generate dozens per day. It answers “did sync work?”, which belongs in logs (PostHog 5.27 has a logs API) or Crashlytics breadcrumbs, not in the event stream we build funnels on.
  5. Mala properties use accumulatorId (camelCase) while everything else is snake_case. Small, but it breaks the [object]_[verb] and snake_case convention that analytics_events.dart itself documents. Fix before the event count grows.
  6. No tests. test/ has nothing for lib/core/analytics/. The abstraction makes this easy: inject a fake AnalyticsService and assert calls.
  7. No consent gate. Analytics starts in main() before onboarding. The privacy policy screen is a WebView to the website, so I could not verify what we disclose. See section 9.
  8. firebase_analytics is a dependency but is never imported in Dart. The native Google Analytics SDK is still initialised via google-services.json / GoogleService-Info.plist, which means it is quietly collecting first_open, session_start, app_update and device data into a Firebase project nobody reads. Either use it deliberately or remove it (it also has to be disclosed in App Store / Play data-safety forms).
  9. Coverage. Of the 20 feature modules, five have any event. The reader, timer, recitations, Connect, group chat, AI, sharing, notifications, calendar, poems and settings have zero.

3. The app on one page

For readers outside engineering, here is the surface we are instrumenting. Bottom navigation has four tabs: Home, Practice, Connect, Me. Learn and Explore tabs exist as placeholders and are hidden.

Area What the user does Guest access
Onboarding Language → welcome → tradition selection → how it works → optional event plan (currently “Daily Tipitaka · ITCC Bodhgaya Dec 2026”) n/a
Auth Google, Apple, phone/SMS via Auth0; “continue as guest”; profile edit; account deletion Guest mode
Home Verse of the day (share sheet), action of the day, practice stats and streak card, featured plans/series, group events, calendar banner, poems section, shortcuts row, tag search, YouTube embeds, share-the-app prompt Yes
Practice tab Daily routine builder (time blocks with plans, series, recitations, mala, timers, group practice), explore sections for plans/chants/accumulations/timers, bookmarks, “see all” and search screens, recitation collections Browse only; routine editing gated
Plans Browse, enrol, day carousel, task and subtask completion, text/image subtasks that open the reader, audio segments, author pages, share, missed-day badges Preview only; enrol gated
Reader Paginated text, dual-slot (interlinear / split) with version, language and script pickers, in-text search, commentary and translation panels, font size, copy / bookmark / share / video actions, plan and routine swipe navigation, group chant mode Yes
Texts library Browse, search (title, author, segment), table of contents, version selection, collections, image export of a segment Yes
Recitations My Recitations vs All, multi-language display, save/unsave, reorder, search, open in reader Browse only; save gated
Mala Bead-arc tap counter, 108 beads per round, mantra carousel, personal vs group accumulation, add offline rounds, reset, sound/vibration, background sync Login-gated
Timer Preset timers, 5 s countdown, pause/resume, lock-screen notification and iOS Live Activity, completion bell, session reported to backend, offline stop queue Login-gated
Calendar Tibetan lunar calendar, moon phases, today summary Yes
Poems Story-style swipeable poem viewer, share Yes
Connect Unified feed, group events (with attendance and embedded livestream), posts (like, comment, compose with media), group practices, discover / my groups, follow/unfollow Read only; actions gated
Group profile Follow/join, events, practices, members, posts, group accumulators (collective mala), recitation collections with completion tracking, share, deep links Partial
Group chat Rooms per group, send/reply/quote, reactions, delete, report, link previews, live WebSocket client Login-gated
AI (Archive-not used) Search across the text library (All / Authors / Contents / Titles tabs); streaming chat with citations and thread history; 10 requests/min rate limit Search only
Me Profile header, streak card with share sheet, practice stats (timer minutes, accumulations, practice days), mantra breakdown sheet, series completion history Login prompts
Settings Content language (en, bo, zh, hi, mn, ne), theme, notification toggles and OS permission status, legal, about, logout, delete account Yes
Notifications Local scheduling for routine, recitation, mala and timer reminders; FCM push for plan, series and verse of the day with deep links Local only
Sharing / growth Verse, streak, plan, poem, segment, group, timer and app share via share_plus; QR code sheet; Airbridge tracking link Yes

The backend already computes and serves: streak (current, highest, week), total timer seconds, total accumulated beads, total practice days, and per-mantra counts. Client analytics should complement those, not duplicate them.


4. The questions we want answered

Instrumentation without questions produces noise. These are the questions I heard in planning discussions over the last months, and they drive the event map in the next section.

Activation and retention

  • Of the people who install, how many finish onboarding, and where do they drop?
  • How many guests convert to an account, and which feature prompt converts them? (The login drawer is referenced from 28 files across the features. We don’t know which of those prompts converts.)
  • What is our day 1 / day 7 / day 30 retention, and does it differ by tradition, content language or entry plan?
  • What does a retained user do in week one that a churned user does not?

Core practice loop

  • How many malas does a user do, per session, per day, per week? How long is a mala session? Personal vs group?
  • How deep do people read? Do they finish a text, or stop at page one? Which versions and languages get used?
  • Timer: started vs completed vs abandoned; typical duration.
  • Plans: enrol → first day complete → day N → finished. Where do people stall?
  • Does the daily routine drive practice? Do users with a routine practise more days than users without?

Content and discovery

  • Which texts, recitations, plans, mantras and authors are most opened? Which are never opened?
  • Do search and AI search lead to a reader open, or to nothing?
  • Is the Tibetan calendar and verse of the day noticed or ignored?

Community

  • Which groups drive practice (group accumulators, recitation collections)? Does joining a group change retention?
  • Are events attended? Does the livestream embed get used?
  • Chat: is it alive? Reports and deletions as a health signal.

Growth and notifications

  • Which share surface produces installs (Airbridge already attributes installs; we need the client-side “share tapped” event to compute the ratio)?
  • Do notifications bring users back? Which type?

Reliability (product-facing)

  • How often do users hit offline mode, failed syncs, forced updates, login failures?

5. The tracking plan: events by feature

Conventions, all already established in lib/core/analytics/analytics_events.dart:

  • Event names: object_verb, past tense, snake_case (reader_opened, timer_completed).
  • Property names: snake_case. IDs are backend UUIDs, never names of people. Content names (plan title, text title) are fine.
  • Never send free text a user typed (chat body, search query, AI prompt, comments, bio). Send lengths and counts.
  • Every event goes through AnalyticsService.track(), never Posthog() directly.
  • Priority: P0 = needed for the first dashboard set (activation + core loop), P1 = next sprint, P2 = when a question needs it.
  • Status: :white_check_mark: exists, :wrench: exists but change it, :new_button: new.

5.1 Lifecycle, entry points, deep links

Event Fires when Properties Answers Pri Status
Application Installed / Opened / Backgrounded / Updated SDK automatic version, build sessions, DAU/WAU/MAU, upgrade lag P0 :white_check_mark:
$screen every named GoRoute $screen_name screen flow, most visited screens P0 :white_check_mark:
deep_link_opened DeepLinkRouter.route() source (airbridge / app_links / push / local_notification), route_kind (series, poem, reader, group, plan…), target_id which shared links bring people in P0 :new_button:
push_notification_opened PushMessageNavigator session_type (PLAN, SERIES, TIMER, VERSE_OF_DAY…), source_id, app_state (foreground / background / terminated) do pushes bring people back P0 :new_button:
local_notification_opened NotificationService tap handler type (routine, recitation, mala, timer), minutes_after_scheduled are reminders acted on P0 :new_button:
force_update_shown / update_banner_tapped force_update_gate.dart, update_banner.dart current_version, required_version how many users are stuck on old versions P2 :new_button:
offline_mode_entered ConnectivityService transitions to offline while app is foreground screen how common offline use is (matters for local-first investment) P1 :new_button:

5.2 Onboarding

Hook: onboarding_notifier.dart, onboarding_wrapper.dart, event_enrollment_service.dart.

Event Fires when Properties Answers Pri Status
onboarding_started first page shown funnel top P0 :new_button:
onboarding_step_viewed page changes step (language, welcome, tradition, how_it_works, events, finish), step_index step-level drop-off P0 :new_button:
onboarding_language_selected ui_language language mix of new users P0 :new_button:
onboarding_tradition_selected tradition_count only (see privacy note) do people pick one, many, none P1 :new_button:
onboarding_event_plan_selected plan_id, plan_name uptake of the promo plan P0 :new_button:
onboarding_completed add duration_ms, traditions_count, event_plan_selected (bool) completion rate, time to complete P0 :wrench:
onboarding_abandoned app backgrounded for > 5 min mid-flow, or relaunch resumes mid-flow last_step where people leave P1 :new_button:

5.3 Auth and guest conversion

Hook: auth_notifier.dart, login_drawer.dart, login_page.dart, delete_account_screen.dart.

Event Fires when Properties Answers Pri Status
auth_login_started user taps a provider button method (google, apple, sms), source (login_page, login_drawer:) funnel top for login P0 :new_button:
auth_login_succeeded add source, is_new_user (first login vs returning) conversion, provider mix P0 :wrench:
auth_login_failed add source which provider fails (Apple vs SMS), why P0 :white_check_mark:
auth_login_cancelled user dismisses Auth0 web flow method, source abandoned logins P1 :new_button:
auth_guest_started add source (onboarding, login_page) guest volume P0 :white_check_mark:
login_prompt_shown LoginDrawer opened feature (mala, routine_edit, plan_enroll, recitation_save, chat, post_like, …) which gated feature triggers login attempts P0 :new_button:
login_prompt_dismissed drawer closed without login feature which prompts annoy rather than convert P0 :new_button:
auth_logout local_only (bool) P1 :new_button:
account_delete_started / account_deleted reason if we add a reason picker churn signal, store compliance P1 :new_button:

The pair login_prompt_shownauth_login_succeeded (joined on source/feature) is the single most valuable funnel we do not have.

5.4 Home

Hook: home_screen.dart and the widgets under home/presentation/widgets/.

Event Fires when Properties Answers Pri Status
home_viewed tab shown (not every rebuild) has_routine, streak_current, is_guest baseline P0 :new_button:
verse_of_day_viewed card visible ≥ 1 s verse_id, language is the verse noticed P1 :new_button:
verse_shared share sheet action verse_id, format (image, text), channel if share_plus returns it most shared surface? P0 :new_button:
action_of_day_tapped action_kind P2 :new_button:
home_shortcut_tapped shortcuts row target (mala, timer, reader, calendar…) which shortcuts earn their place P1 :new_button:
stats_card_tapped practice stats card → Me P2 :new_button:
series_viewed series detail series_id, series_name, source (home, group, deep_link) P0 :new_button:
series_enrolled / series_unenrolled seriesEnrollmentProvider series_id, series_name, group_id if via group series funnel P0 :new_button:
calendar_banner_tapped / calendar_month_changed lunar_day, direction calendar usage P2 :new_button:
poem_viewed page settles in poems viewer poem_id, index, source poem reach P1 :new_button:
poem_shared poem_id P1 :new_button:
tag_search_performed tag overlay tag, result_count P2 :new_button:
notification_permission_prompted / _granted / _denied first home load prompt os permission rate, later notification reach P0 :new_button:
video_played YouTube embed video_id, surface (home, reader_segment) P2 :new_button:
app_share_prompt_shown / app_shared home_share_prompt.dart, app_share_service.dart surface, method (link, qr) referral loop; pair with Airbridge installs P0 :new_button:

5.5 Practice tab and daily routine

Hook: practice_explore_screen.dart, routine_provider.dart, edit_routine_screen.dart, select_session_screen.dart, bookmark_controller.dart, collection screens.

Event Fires when Properties Answers Pri Status
practice_tab_viewed section_counts (plans, chants, accumulations, timers) P1 :new_button:
practice_section_expanded “see all” section which explore sections matter P1 :new_button:
routine_edit_started is_first_routine P0 :new_button:
routine_block_added / routine_block_removed time_of_day (bucketed hour), block_count when people schedule practice P1 :new_button:
routine_item_added / routine_item_removed item_type (plan, series, recitation, mala, timer, group_practice), item_id what goes in a routine P0 :new_button:
routine_saved add session_types (list), reminder_enabled, earliest_block_hour routine composition P0 :wrench:
routine_item_opened user starts an item from the routine card item_type, item_id, block_index, minutes_from_block_time does the routine actually get followed P0 :new_button:
routine_item_completed where completion exists (recitation collections, plan days) item_type, item_id routine follow-through P0 :new_button:
bookmark_added / bookmark_removed BookmarkController.toggle bookmark_type (text, verse, timer, mala, series, recitation_collection, group_accumulator, group_recitation_collection), target_id which content is saved P1 :new_button:
bookmarks_viewed count P2 :new_button:
collection_created / collection_edited / collection_deleted recitation collections chant_count P1 :new_button:
collection_chant_added / _removed collection_id, text_id P2 :new_button:
practice_search_performed plans / recitations / accumulations search screens scope, query_length, result_count, language do searches succeed P1 :new_button:
practice_search_result_opened scope, position, target_id search → open conversion P1 :new_button:

5.6 Plans

Hook: plan_details.dart, plan_navigator.dart, plans_screen.dart, plan_info.dart, plan_share_service.dart, author_detail_screen.dart, PlanStoryPresenter.

Event Fires when Properties Answers Pri Status
plans_browsed Browse tab shown tab (my_plans, browse), my_plan_count P1 :new_button:
plan_viewed add source (browse, home_featured, search, deep_link, onboarding, routine), is_enrolled discovery paths P0 :wrench:
plan_enrolled every enrolment path, not only onboarding plan_id, plan_name, total_days, source enrol rate by source P0 :wrench:
plan_unenrolled plan_id, days_completed, days_since_enrolled when people give up P0 :new_button:
plan_day_viewed day carousel settles plan_id, day_number, is_today, is_missed day-level engagement P0 :new_button:
plan_task_completed / plan_task_uncompleted checkbox toggle plan_id, day_number, task_id, task_type task friction P0 :new_button:
plan_subtask_viewed PlanStoryPresenter (already debounced 300 ms) plan_id, day_number, subtask_id, content_type (TEXT, IMAGE, SOURCE_REFERENCE, AUDIO) which content types get consumed P1 :new_button:
plan_day_completed keep; add is_on_time (completed on the scheduled day) on-track rate P0 :white_check_mark:
plan_completed last day completed plan_id, total_days, days_elapsed finish rate P0 :new_button:
plan_audio_played / plan_audio_completed audio segment plan_id, subtask_id, duration_s, pct_listened audio value P1 :new_button:
plan_shared plan_id, day_number P1 :new_button:
author_viewed author_id P2 :new_button:

5.7 Reader and texts library

Hook: reader_screen.dart, reader_notifier.dart, segement_action_bar.dart, reader_settings_screen.dart, picker sheets, reader_search_delegate.dart, chapters_screen.dart, version_selection_screen.dart, texts search delegates.

This is the feature where “time in app” is earned and we know nothing about it.

Event Fires when Properties Answers Pri Status
reader_opened ReaderScreen init text_id, text_title, source (plan, routine, recitation, search, ai_search, deep_link, collection, group_chant, library, bookmark), language, version_id, script, layout (single, interlinear, split), entry_segment what brings people to a text; language/version mix P0 :new_button:
reader_page_loaded pagination fetch completes text_id, page_number, segment_count, load_ms reading depth, API latency P0 :new_button:
reader_session_ended dispose or background > 30 s text_id, duration_s, pages_loaded, max_segment_number, segments_total if known, pct_reached how much of a text gets read P0 :new_button:
reader_segment_selected tap on a segment text_id, segment_number P2 :new_button:
reader_action_tapped segment action bar action (copy, bookmark, commentary, version, share, video, image_export), text_id which actions matter P0 :new_button:
reader_commentary_opened / reader_translation_opened panel opens text_id, segment_number, commentary_count P1 :new_button:
reader_version_changed / reader_language_changed / reader_script_changed picker sheets text_id, slot (primary, secondary), from, to multilingual usage P1 :new_button:
reader_layout_changed dual-slot toggle from, to interlinear vs split adoption P1 :new_button:
reader_font_size_changed from, to accessibility signal P2 :new_button:
reader_search_performed in-text search text_id, query_length, result_count P1 :new_button:
reader_swipe_navigated plan/routine multi-item swipe direction, from_item_type, to_item_type P1 :new_button:
group_chant_mode_entered reader opened with group context group_id, accumulator_id P1 :new_button:
segment_shared / segment_image_exported text_id, segment_number, format P1 :new_button:
library_search_performed texts search search_type (title, author, segment, multilingual), query_length, result_count, language P1 :new_button:
library_result_opened search_type, position, text_id search → read conversion P1 :new_button:
toc_opened / toc_entry_tapped table of contents text_id, depth P2 :new_button:
collection_browsed texts collections collection_id P2 :new_button:

5.8 Recitations

Hook: recitations_screen.dart, recitation_save_controller.dart, recitation_search_provider.dart, recitation_card.dart.

Event Fires when Properties Answers Pri Status
recitations_viewed tab (mine, all), saved_count P1 :new_button:
recitation_opened card → reader text_id, source (mine, all, search, routine, collection) most recited texts P0 :new_button:
recitation_saved / recitation_unsaved RecitationSaveController text_id, source P0 :new_button:
recitation_reordered saved_count P2 :new_button:
recitation_display_toggled secondary/tertiary segment switch segment (translation, transliteration, adaptation), enabled which layers people want P1 :new_button:
recitation_search_performed query_length, result_count P1 :new_button:

5.9 Mala

Hook: mala_screen.dart, mala_counter_notifier.dart, mala_sync_manager.dart, mala_accumulation_selection_provider.dart, group accumulation sheet.

The question “how many malas did one user do” is answered by mala_round_completed counted per user in PostHog, and by a session summary event so we can get distributions (rounds per session, seconds per round) without one event per tap.

Event Fires when Properties Answers Pri Status
mala_screen_opened rename accumulatorIdpreset_id; add mantra_name, mode (personal, group), group_id, source (practice, routine, home_shortcut, group, deep_link) entry paths P0 :wrench:
mala_session_started first tap after screen open or after ≥ 5 min idle preset_id, mode, group_id, starting_total sessions per user P0 :new_button:
mala_round_completed every 108 beads preset_id, mantra_name, rounds (session), mode, group_id, seconds_since_last_round malas per user / day / week; pace P0 :wrench:
mala_session_ended screen disposed, backgrounded, or 5 min idle preset_id, mode, group_id, duration_s, beads_counted, rounds_completed, input (tap, swipe, mixed) session length, partial rounds P0 :new_button:
mala_mantra_switched rename from/tofrom_preset_id/to_preset_id; add via (carousel, catalogue) mantra popularity P1 :wrench:
mala_mode_changed personal ↔ group from, to, group_id group accumulation adoption P0 :new_button:
mala_group_selected group sheet group_id, accumulator_id P1 :new_button:
mala_offline_rounds_added “add rounds” dialog preset_id, rounds offline practice volume P0 :new_button:
mala_reset preset_id, total_before, mode P1 :new_button:
mala_feedback_toggled setting (sound, vibration), enabled P2 :new_button:
mala_bookmarked preset_id P2 :new_button:
mala_synced demote: keep only failures as mala_sync_failed (reason, pending_delta), move success to logs sync health without event spam P1 :wrench:
group_accumulator_viewed group accumulator screen group_id, accumulator_id, source P1 :new_button:

5.10 Timer

Hook: preset_timers_screen.dart, active_timer_screen.dart, timers_local_datasource.dart queue.

Event Fires when Properties Answers Pri Status
timer_presets_viewed preset_count P1 :new_button:
timer_started countdown finishes, main timer starts preset_id, duration_s, source (practice, routine, home_shortcut, push) timer adoption P0 :new_button:
timer_paused / timer_resumed preset_id, remaining_s P1 :new_button:
timer_completed bell rings preset_id, duration_s, was_backgrounded, pause_count completion rate P0 :new_button:
timer_discarded user finishes early preset_id, elapsed_s, pct_complete when people quit P0 :new_button:
timer_stop_reported / timer_stop_queued backend report or offline queue elapsed_s, queued (bool) reliability P2 :new_button:

5.11 Connect, groups, events, posts

Hook: connect_screen.dart, tab providers, group_profile_screen.dart, groupFollowProvider, group_event_detail_screen.dart, connect_post_like_actions.dart, comments providers, group_post_composer_screen.dart, group_recitation_collection_screen.dart.

Event Fires when Properties Answers Pri Status
connect_tab_viewed sub-tab becomes active (lazy load already exists) sub_tab (feed, events, posts, practices, groups), followed_group_count which sub-tabs matter P0 :new_button:
feed_item_opened item_type (post, event, practice), item_id, group_id, position feed relevance P1 :new_button:
group_viewed group_id, group_type (community, page), source (feed, discover, search, deep_link, home_events, mala) P0 :new_button:
group_followed / group_unfollowed groupFollowProvider group_id, group_type, source community growth P0 :new_button:
group_searched query_length, result_count P2 :new_button:
group_tab_viewed posts / events / practices / members group_id, tab P1 :new_button:
event_viewed event_id, group_id, is_live, source P0 :new_button:
event_joined / event_left event_id, group_id attendance funnel P0 :new_button:
event_live_opened / event_live_ended embedded livestream event_id, duration_s livestream value P1 :new_button:
post_viewed detail screen post_id, group_id, has_media P1 :new_button:
post_liked / post_unliked post_id, group_id, surface (feed, detail) P1 :new_button:
post_comment_added post_id, group_id, comment_length P1 :new_button:
post_composer_opened / post_created / post_create_failed group_id, media_count, text_length, reason creator funnel P1 :new_button:
group_practice_viewed practice_id, group_id P1 :new_button:
recitation_collection_opened group or personal collection_id, group_id, item_count, source P0 :new_button:
recitation_collection_item_completed checklist tick collection_id, text_id, completed_count, item_count daily chant follow-through P0 :new_button:
recitation_collection_completed all items ticked collection_id, group_id P0 :new_button:
group_shared group_id, method P1 :new_button:

5.12 Group chat

Hook: group_chat_screen.dart, chats_screen.dart, group_chat_repository_impl.dart, chat_reconnect_backoff.dart.

Event Fires when Properties Answers Pri Status
chats_list_viewed room_count, unread_rooms P1 :new_button:
chat_room_opened group_id, room_id, unread_count, source chat reach P0 :new_button:
chat_message_sent success group_id, is_reply, length_bucket (short/medium/long), has_link, has_emoji_only chat activity P0 :new_button:
chat_message_send_failed group_id, reason P1 :new_button:
chat_reaction_added / chat_reaction_removed group_id, emoji_category (not the emoji itself if we want to be strict) P2 :new_button:
chat_message_deleted group_id, is_own moderation health P1 :new_button:
chat_message_reported group_id, reason safety signal P0 :new_button:
chat_reconnected WebSocket recovers attempts, downtime_s reliability P2 :new_button:

5.13 Me, profile, settings

Hook: me_screen.dart, more_screen.dart, edit_profile_screen.dart, streak_share_sheet.dart, notification_settings_screen.dart, theme_notifier.dart, locale_notifier.dart, contentLanguageProvider.

Event Fires when Properties Answers Pri Status
me_viewed is_guest, streak_current P1 :new_button:
streak_shared streak_current, format growth loop P0 :new_button:
stats_sheet_opened sheet (mantra_breakdown, practice_days, series_history) P2 :new_button:
profile_edited save fields_changed (list of field names), avatar_changed P1 :new_button:
content_language_changed from, to, source (settings, onboarding, reader) multilingual demand P0 :new_button:
ui_language_changed from, to P1 :new_button:
theme_changed to (light, dark, system) P2 :new_button:
notification_setting_changed setting (master, routine, recitation, practice, timer), enabled reminder opt-out P0 :new_button:
os_notification_status_viewed settings screen shows blocked state permission, exact_alarm, battery_optimized why reminders fail P1 :new_button:
legal_viewed / about_viewed page P2 :new_button:

5.14 Notifications (scheduling side)

Hook: notification_sync_engine.dart, push_notification_service.dart.

Event Fires when Properties Answers Pri Status
reminders_reconciled sync engine run trigger (SyncTrigger enum), scheduled_count, cancelled_count how many reminders exist per user P2 :new_button:
push_token_registered / push_token_register_failed reason push reach P1 :new_button:
push_notification_received_foreground session_type P2 :new_button:

Pair with 5.1’s push_notification_opened / local_notification_opened to get open rates by type.

5.15 Sharing (cross-cutting)

Every share in the app already funnels through share_plus. Rather than sprinkle events, add one helper trackShare(surface, targetId, format) next to the share helper in lib/shared/utils/helper_functions.dart and emit a single content_shared event with surface ∈ {verse, streak, plan, plan_day, poem, segment, group, event, timer, app}. The per-surface events in earlier sections are the same event with different surface values; I listed them separately only to show where they hook.

5.16 Reliability events (product-visible failures only)

Event Properties Pri
api_request_failed (sampled, from retry_interceptor after final retry) endpoint_group (plans, texts, mala, chat…), status_code, is_offline P1
token_refresh_failed reason P1
sync_queue_flushed (mala, timer) queue (mala, timer), items, success P2

Crashes stay in Crashlytics. PostHog error tracking (100K exceptions/month free) is an option if we want stack traces next to the session that produced them, but Crashlytics is already wired and works.

5.17 What we deliberately do not track

  • Chat message bodies, comment text, post text, bio, search queries, AI prompts and responses.
  • Phone numbers, emails, names, avatars (the beforeSend hook already blanks email and tokens; extend it to phone, name, username).
  • Individual bead taps (108 events per round is waste; the round and session events carry the totals).
  • Per-user tradition selection as an event property (see section 9). Aggregate counts only, and a consented person property at most.

6. User and group properties

Person properties (set via identify() and updated on change). These make every dashboard segmentable.

Property Source Note
is_guest exists
content_language, ui_language locale providers
theme theme notifier
has_routine, routine_item_count, routine_session_types routine provider
enrolled_plan_count, followed_group_count, saved_recitation_count respective providers update on change
notification_master_enabled, os_notifications_granted notification provider
first_app_version ($set_once), app_version ($set) package info
signup_method ($set_once) auth
traditions_count onboarding count only, not which ones. See 9.
streak_current, total_practice_days backend stats when the Me tab loads optional; backend is the source of truth

Group analytics. PostHog supports “groups” as a first-class entity. Attaching group_id to group events and calling Posthog().group('sangha', groupId, {name, type, member_count}) gives us per-group dashboards (active members, malas accumulated, events attended) without any backend work. This is a distinct advantage of PostHog for a community product; Clarity has no equivalent and Mixpanel/Amplitude charge for it as an add-on or higher plan.


7. Dashboards this unlocks

Once P0 events land, these are the dashboards I would build first, in PostHog, in this order:

  1. Activation funnel: Application Installedonboarding_completed → (auth_login_succeeded or auth_guest_started) → first core action (mala_round_completed OR reader_page_loaded OR timer_started OR plan_task_completed) within 24 h. Broken down by ui_language and onboarding_event_plan_selected.
  2. Guest conversion: login_prompt_shownauth_login_succeeded, broken down by feature. Expect this to reorder our gating decisions.
  3. Core loop retention: weekly retention where “active” means any core action, not just an app open.
  4. Mala volume: rounds per user per week (distribution, not average), sessions per day, personal vs group, top mantras. Users with ≥ 1 round/day cohort vs retention.
  5. Reading depth: pct_reached distribution per text, top texts by sessions, version/language mix, action-bar usage.
  6. Plan health: enrol → day 1 → day 7 → finish, per plan. Unenrol timing.
  7. Routine effectiveness: practice days per week for users with vs without a routine (has_routine).
  8. Notifications: opens by type; permission grant rate; reminder opt-out rate.
  9. Community: followed groups per user, event join rate, recitation-collection completion, chat messages per active room, reports.
  10. Share loop: content_shared by surface vs Airbridge attributed installs.
  11. AI: search → open conversion, chat latency, failure reasons, rate-limit hits.
  12. Reliability: offline entries, failed syncs, login failures by provider, forced updates.

8. Tool comparison

8.1 What we are choosing between

Tool What it is Why it is on the list
PostHog (current) All-in-one: product analytics, session replay, feature flags, experiments, surveys, error tracking, logs, data warehouse. Open source core, cloud US/EU. Already integrated.
Microsoft Clarity Free behaviour analytics: session replay, heatmaps, rage/dead taps, filters. Mobile SDKs incl. Flutter. Suggested by Kunsang; free.
Firebase Analytics (GA4) Free event analytics from Google, tied to Firebase/Google Ads/BigQuery. Already in pubspec.yaml, unused.
Mixpanel Product analytics leader: funnels, retention, cohorts, flows; session replay (Flutter beta). Common alternative, strong analysis UX.
Amplitude Product analytics leader with experimentation and CDP features; session replay (Flutter alpha). Common alternative, strong free tier.
Also looked at UXCam (mobile replay specialist, quote-only pricing), Smartlook (being shut down by Cisco), Aptabase and Countly (open-source, privacy-first) Covered briefly in 8.8.

8.2 Evaluation criteria

Weighted for our situation: small team, Flutter only, community and practice product with sensitive data, unknown but growing volume, no analytics headcount.

  1. Flutter SDK maturity and feature parity
  2. Event analytics depth (funnels, retention, cohorts, paths, breakdowns)
  3. Mobile session replay quality and masking
  4. Extras we would actually use (feature flags, surveys, group analytics, logs)
  5. Cost at 10k / 50k / 200k MAU
  6. Privacy posture: EU region, DPA, who can see the data, deletion, retention
  7. Data ownership and export (warehouse, API, self-host)
  8. Migration cost from where we are
  9. Vendor longevity and pricing stability

8.3 PostHog (keep)

What we get, free, every month (pricing): 1M events, 5K web session recordings (mobile recordings are metered separately at roughly 2× the web rate; an August 2026 teardown lists 2,500 free mobile recordings then $0.01 each (Flexprice)), 1M feature-flag requests, 1,500 survey responses, 100K exceptions. Paid events start at $0.00005 per event in the 1–2M tier and fall with volume. Free tier keeps events for 1 year; paid tiers 7 years. Replays 30 days on free, 90 days on pay-as-you-go (retention docs).

Pros

  • Zero migration. The abstraction, per-flavor projects, identify/reset, super properties and route observer are done.
  • Event analytics is complete: funnels, retention, lifecycle, stickiness, paths, cohorts, SQL over your own events, dashboards, alerts.
  • Feature flags and experiments are included with 1M free requests. We have exactly one hard-coded flag (kComingSoonMode); remote flags would let us roll out Connect/AI changes by cohort without a store release.
  • In-app surveys ship in the same SDK (already enabled by default in our config). An NPS or “why did you stop this plan?” survey costs one dashboard click.
  • Group analytics for sanghas (section 6).
  • Mobile session replay exists for Flutter (screenshot mode, throttle 1 s, text and image masking, PostHogMaskWidget for per-widget masking).
  • Structured logs from 5.27 give us a home for the sync/ops noise we currently push as events.
  • EU cloud in Frankfurt with IP capture disabled by default; DPA available (GDPR page).
  • Open-source core. Self-hosting exists as an escape hatch (unsupported, fine up to ~300k events/month on a 4 vCPU / 16 GB box; self-host docs).
  • Data warehouse and export (S3, BigQuery, Postgres) if we ever join client events with backend tables.

Cons

  • Flutter replay is screenshot-based: heavier than wireframe capture, and anything not masked is literally in the picture. Minimum 10 s of interaction before a recording exists.
  • Mobile replay is priced at 2× web and retained only 30 days on free.
  • The UI is broad and takes a week to feel at home in. Mixpanel and Amplitude are more polished for pure analysis.
  • Usage-based billing can surprise you if an event is fired in a loop (which is exactly what mala_synced risks). Set billing limits on day one.
  • Our project is in the US region. Moving to EU means a new project and losing history (there is little to lose today).

Cost model (list prices, event volume assumed at 150 events per active user per month; verify before commitment):

Scale Events/month PostHog events PostHog mobile replay (10% of sessions) Notes
10k MAU 1.5M ≈ $25 ≈ $0–20 first 1M free
50k MAU 7.5M ≈ $240–325 ≈ $100–200 tiers drop the per-event price
200k MAU 30M ≈ $900–1,000 ≈ $500+ or sample harder still under one engineer-day per month

8.4 Microsoft Clarity (free qualitative add-on, not a replacement)

Clarity’s Flutter SDK is current (clarity_flutter 1.10.0, published early September 2026, docs updated May 2026) and installation is a few lines: wrap the app in ClarityWidget or call Clarity.initialize, mask with ClarityMask/ClarityUnmask, identify with setCustomUserId, tag sessions with setCustomTag, send name-only custom events with sendCustomEvent, set screen names from a RouteObserver, and pass consent via Clarity.consent() (Flutter SDK docs, pub.dev).

Pros

  • Free, no session caps, no credit card. Microsoft’s stated model is that Clarity is free and data lives in Azure.
  • Session replay and heatmaps (tap maps, scroll) with rage-tap and dead-tap detection, filterable by 25+ dimensions (country, OS, device, tags).
  • Session reconstruction rather than screenshots: lower performance cost, and masking is by widget.
  • Live sessions view; integrations that push the Clarity session URL into Mixpanel, Amplitude, Firebase or Sentry, so a crash or an event can link to the replay.
  • Genuinely good for “why is this screen confusing?” questions: watching ten people struggle with the routine editor is worth more than a funnel for UX fixes.

Cons (these are the deciding ones)

  • Not an event analytics tool on Flutter. Custom events are a bare string with no properties. There is no way to say mala_round_completed{preset_id, rounds}. Custom tags attach key/value to a session for filtering, not to an event. Smart Events, Funnels and Components are listed as unsupported in the Flutter SDK README, and the server-side data capture rules (network limits, low-end device exclusion, allowed screens) are explicitly “not supported in the Flutter SDK” (data capture rules). “How many malas did one user do” cannot be computed in Clarity.
  • No offline upload on Flutter. Only sessions captured while online are sent (SDK docs, FAQ). Our app is deliberately local-first; mala and timer sessions on a train or in a monastery with poor signal would vanish.
  • 30-day retention. Recordings are kept 30 days; favourites and a random sample up to 9 months (FAQ). Year-over-year questions are impossible.
  • Privacy posture. Clarity positions itself as a GDPR data controller (not merely our processor), “Microsoft/Clarity has access to the data”, data is stored in Azure with no region choice, and per-user deletion is not supported: “You need to delete the entire project to delete user’s data” (FAQ). Clarity’s opt-out is routed through the Digital Advertising Alliance ad opt-out. For a religious-practice app, handing unmasked screen recordings to an advertising-adjacent controller is a decision that needs leadership and possibly legal sign-off, not a pub add.
  • Native views and web views are not captured (our privacy policy, legal pages and YouTube embeds would show as blank), fonts render differently (Tibetan script in BabelStone will not look right in replays), and sessions take up to two hours to appear.
  • Two SDKs in the app means two identify calls, two masking systems, two privacy disclosures in the store listings.

Verdict. Clarity would be a reasonable second tool once masking is sorted and only if we want heatmaps and unlimited replays for free. It cannot be the primary tool because it cannot answer the questions in section 4.

8.5 Firebase Analytics / Google Analytics 4 (decide: use minimally or remove)

We already pay the SDK-size and disclosure cost of having it in the bundle.

Pros

  • Free at any volume; retention up to 14 months in the UI, unlimited if exported to BigQuery (export requires the Blaze plan and is capped at 1M events/day on the standard export).
  • The audience it builds can feed Google Ads and Play Store install campaigns, and Airbridge integrates with Firebase.
  • Auto-collected lifecycle events with zero code.

Cons

  • Limited model: 500 distinct event types, 25 parameters per event, 40-char names, 100-char string values, 25 user properties. Our plan fits, but only just, and it is designed around e-commerce.
  • Funnels and retention are “explorations” that are slower and less flexible than PostHog/Mixpanel/Amplitude; no session replay, no flags, no surveys, no group analytics.
  • Flutter screen_view is not automatic; you have to add FirebaseAnalyticsObserver, so today it records opens and nothing else.
  • Data lives with Google under Google’s terms; GA4 and Firebase Analytics are the SDKs regulators in the EU have most often challenged.

Verdict. Either wire it as a thin secondary sink (lifecycle + 10 conversion events) purely for Google Ads and store audiences, or remove the dependency and the corresponding data-safety disclosure. Do not run it as a second full tracking plan.

8.6 Mixpanel

Pros

  • Best-in-class analysis UI: funnels, retention, flows, cohorts, impact reports. Non-engineers find it easier than PostHog.
  • Free plan: 1M events/month, 10K session replays/month, unlimited seats (pricing).
  • Session replay for Flutter exists (official SDK), EU data residency, Clarity integration for replay linking.

Cons

  • Flutter replay is beta, replays retained 30 days by default (7–360 days only on Enterprise).
  • Paid events cost about $0.28 per 1,000 above the free tier (third-party pricing analyses, e.g. OpenPanel, usercall). At 7.5M events that is ~$1,800/month before discounts, roughly 6× PostHog for the same data. Mixpanel changed its pricing model in early 2026 and several sources disagree on the free allowance (1M on the official page vs 20M in some write-ups); treat the free tier as unstable.
  • No feature flags, no surveys, no logs; group analytics is an add-on.
  • Migration: new SDK, re-implement identify/super properties/route observer, lose current history.

8.7 Amplitude

Pros

  • The most generous free tier of the paid tools: 2M events/month, 10K session replays, 1-year retention, unlimited seats, unlimited feature flags (pricing).
  • Strong behavioural cohorts, experimentation, and a mature Flutter analytics SDK (v4).

Cons

  • Flutter session replay SDK is alpha (“APIs may change and there will be breaking changes” per its README); community reports of black-screen recordings.
  • EU data centre only on Growth/Enterprise plans, which are sales-led. Plus plan is annual-billed and MTU/event based; larger volumes are quote-only, so budgeting past the free tier is opaque.
  • Same migration cost as Mixpanel.
  • Historically the most enterprise-priced of the group once you cross the free tier.

8.8 Others we looked at

  • UXCam: purpose-built mobile replay with heatmaps, funnels and crash logs; Flutter supported. Pricing is quote-only (third parties cite a free plan of ~3,000 sessions/month and a median contract around $21k/year). Overkill for us.
  • Smartlook: Cisco announced end-of-sale 31 May 2026, end of renewals 31 August 2026, decommission 30 September 2027 (Cisco EoL notice). Do not adopt.
  • Aptabase: open-source, privacy-first, no user identifiers by design, Flutter SDK, free up to 1M events, paid from ~$14/month. Appealing for a religious app precisely because it cannot profile individuals, but for the same reason it cannot answer per-user questions (malas per user, guest conversion, retention by cohort). Worth remembering if we ever decide that per-user analytics is not acceptable.
  • Countly: open-source, self-hostable, Flutter SDK. Community edition is free to run yourself; hosted Flex starts around $80/month for 2k MAU. A self-hosted PostHog gives us the same ownership with a much better product.

8.9 Scorecard

Scores 1–5, weighted by the criteria in 8.2.

Criterion (weight) PostHog Clarity Firebase Mixpanel Amplitude
Flutter SDK maturity (3) 4 3 4 4 (replay beta) 3 (replay alpha)
Event analytics depth (3) 5 1 3 5 5
Mobile replay + masking (2) 3 4 0 3 2
Useful extras: flags, surveys, groups, logs (2) 5 1 1 2 3
Cost at our scale (2) 4 5 5 2 3
Privacy posture (3) 4 2 2 4 3
Data ownership / export (1) 5 1 3 3 3
Migration cost (2) 5 3 (additive) 4 (additive) 1 1
Longevity / pricing stability (1) 4 4 5 3 3
Weighted total (out of 95) 82 48 51 60 58

8.10 Decision

  • Primary: PostHog. Fix the replay wiring, tighten masking, decide on region, set a billing limit, and instrument the plan in section 5.
  • Optional later: Clarity as a free qualitative layer, only after a privacy review, with strict masking mode, only on the screens we are redesigning, and switched off again when the study is done. Its consent API must be wired to the same toggle as PostHog.
  • Firebase Analytics: remove, or restrict to a thin marketing sink. Decide in the same PR as the consent work.
  • Airbridge stays for attribution and deep links; it is a different job.
  • Not now: Mixpanel, Amplitude, UXCam. Re-evaluate only if the team grows a dedicated analyst who needs Mixpanel-grade analysis UX, or if PostHog’s mobile replay proves unusable.

9. Privacy and compliance

This section is not legal advice. It is the engineering reading of the constraints, so that the tracking plan is safe by construction and leadership can make an informed consent decision.

What makes our data sensitive. The fact that someone uses a Buddhist practice app, the tradition they selected in onboarding, which mantras they recite and how often, and which sangha groups they follow all “reveal religious or philosophical beliefs”. Under GDPR that is Article 9 special-category data, which is prohibited to process unless a specific condition applies, in practice explicit consent for us (Art. 9 GDPR, ICO guidance). Explicit consent is a clear affirmative statement, not a pre-ticked box or a cookie banner. India’s DPDP Act 2023 does not have a special-category concept but is consent-first for everything: free, specific, informed, withdrawable as easily as it was given, with purpose limitation and a processing agreement with every SDK vendor (DPDP Act text, mobile-app guidance). Our locales (Tibetan, Hindi, Nepali, Mongolian, Traditional Chinese, English) tell us where our users are: India, Nepal, Bhutan, Taiwan, Mongolia and the diaspora in the EU and North America. We are in scope for both regimes.

How the tracking plan is designed to be defensible

  1. Pseudonymous identity. The analytics user id is our backend UUID, never email or phone. beforeSend blanks email and tokens; extend it to phone, name, username.
  2. Minimisation. No free text, no message bodies, no query strings, no bead-level taps. Tradition is tracked as a count, not a list. Mantra preset IDs and names are product identifiers we need for “which mantras are popular”; they are the most sensitive thing in the plan and should be listed explicitly in the consent copy.
  3. Guests are anonymous. personProfiles = identifiedOnly means guests never get a person profile; keep it that way.
  4. Replay masking. Text masked (default), images masked (set maskAllImages = true), and PostHogMaskWidget around avatars, chat, posts, profile edit and the AI chat. Screenshots of a mantra counter are still a religious signal; consider excluding the mala and reader screens from replay entirely and relying on events there.
  5. Region. Move the prod project to PostHog EU (Frankfurt) before the volume matters. It disables IP capture by default and simplifies the DPA story for EU users; it does not by itself solve DPDP, which cares about consent more than location.
  6. Consent surface. Add an “Improve the app by sharing anonymous usage data” step at the end of onboarding and a toggle in Settings. Off means AnalyticsService becomes the no-op implementation at runtime (we already have that class) and Posthog().optOut() is called. Default-on with easy opt-out is common; default-off is safer for Article 9. This is the decision leadership needs to make.
  7. Disclosure. Update the privacy policy and the Apple privacy nutrition label / Google Data safety form to list PostHog (and Firebase if kept, and Clarity if added), the categories collected, and retention. Sign PostHog’s DPA.
  8. Retention and deletion. PostHog can delete a person on request; wire account deletion (delete_account_screen.dart) to also call the PostHog person-deletion API from the backend. Clarity cannot delete one user, which is another reason it fails as a primary tool.
  9. Tests. A unit test that asserts no event property key is in a deny-list (email, phone, name, body, query, message) is cheap and prevents regressions.

10. Recommendation and rollout plan

Phase 0: hygiene (one sprint, mostly one dev from App Team)

  • Wrap MaterialApp.router in PostHogWidget; keep PosthogObserver. Verify a recording appears in the dev project.
  • Set maskAllImages = true; add PostHogMaskWidget to avatar, chat, post, profile and AI chat widgets.
  • Bump posthog_flutter to 5.27 for logs and flag parity.
  • Rename mala properties to snake_case; demote mala_synced to a log line plus a mala_sync_failed event.
  • Add AnalyticsProperties constants for every property in this plan, so names cannot drift.
  • Set a PostHog billing limit on all three projects.
  • Decide region and consent model (section 9). Implement the Settings toggle and the runtime no-op switch.
  • Add the deny-list unit test and a fake AnalyticsService for widget tests.
  • Write docs/analytics/TRACKING_PLAN.md as the living version of section 5, and add “Analytics events added/updated?” to the PR template.

Phase 1: activation and core loop (P0 events, two sprints)

Onboarding steps, login_prompt_shown/dismissed, auth_login_started, all plan_enrolled paths, plan_day_viewed, plan_task_completed, plan_completed, mala session start/end and property fixes, mala_offline_rounds_added, mala_mode_changed, reader_opened, reader_page_loaded, reader_session_ended, reader_action_tapped, timer_started/completed/discarded, routine_item_opened, notification_permission_*, push_notification_opened, local_notification_opened, deep_link_opened, content_shared.

Build dashboards 1–6 from section 7. Review them in the first product meeting after two weeks of data.

Phase 2: community, AI, settings (P1, two sprints)

Connect and group events, recitation collections, chat, AI search and chat, settings changes, person properties, PostHog groups for groups. Dashboards 7–11.

Phase 3: act on it (ongoing)

  • Turn on one remote feature flag (candidate: the Connect posts tab) to prove the flag path.
  • Ship one survey (“What stopped you from continuing this plan?”) triggered by plan_unenrolled.
  • Quarterly review of the tracking plan; delete events nobody has queried in 90 days (PostHog shows this).
  • Revisit Clarity if the UX team wants heatmaps for a redesign.

How adding an event looks in code

Everything goes through the existing abstraction, so a new event is three lines at the call site and one constant:

// lib/core/analytics/analytics_events.dart
static const String timerCompleted = 'timer_completed';

// lib/features/timer/presentation/screens/active_timer_screen.dart
void _completeSession({required bool playBell}) {
  ...
  unawaited(ref.read(analyticsServiceProvider).track(
    AnalyticsEvents.timerCompleted,
    properties: {
      AnalyticsProperties.presetId: widget.timer.id,
      AnalyticsProperties.durationSeconds: widget.timer.durationSeconds,
      AnalyticsProperties.wasBackgrounded: _wasBackgrounded,
      AnalyticsProperties.pauseCount: _pauseCount,
    },
  ));
}

A composite AnalyticsService that fans out to more than one sink (PostHog plus Firebase, or PostHog plus Clarity’s name-only events) is a 30-line class if we ever want it, and no call site changes.


11. Open questions for the team

  1. Consent model: default-on with opt-out, or explicit opt-in at onboarding? (Affects data volume and Article 9 exposure.)
  2. Firebase Analytics: remove, or keep as a marketing sink for Google Ads and Play audiences?
  3. Clarity pilot: is there a specific UX study from design team that justifies it?
  4. Backend join: do we want server-side events (streak computed, sync accepted) sent to PostHog from the API so client and server views line up? Cheap once the client plan is stable.