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
- Session replay is on but almost certainly not recording.
config.sessionReplay = trueis set in release builds, but PostHog’s Flutter replay needs the app wrapped inPostHogWidget(docs).main.dartbuildsMaterialApp.routerdirectly with no wrapper. Nobody has noticed because nobody looks at the replay tab. Either wire it properly or turn it off. 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 istruefor a reason. Flip it back until we have a per-widget masking plan.plan_enrolledonly fires for the onboarding promo plan. Normal enrolment from Browse Plans, series enrolment, and group-driven enrolment are invisible.mala_syncedis 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.- Mala properties use
accumulatorId(camelCase) while everything else is snake_case. Small, but it breaks the[object]_[verb]and snake_case convention thatanalytics_events.dartitself documents. Fix before the event count grows. - No tests.
test/has nothing forlib/core/analytics/. The abstraction makes this easy: inject a fakeAnalyticsServiceand assert calls. - 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. firebase_analyticsis a dependency but is never imported in Dart. The native Google Analytics SDK is still initialised viagoogle-services.json/GoogleService-Info.plist, which means it is quietly collectingfirst_open,session_start,app_updateand 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).- 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(), neverPosthog()directly. - Priority: P0 = needed for the first dashboard set (activation + core loop), P1 = next sprint, P2 = when a question needs it.
- Status:
exists,
exists but change it,
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 | |
$screen |
every named GoRoute | $screen_name |
screen flow, most visited screens | P0 | |
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 | |
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 | |
local_notification_opened |
NotificationService tap handler |
type (routine, recitation, mala, timer), minutes_after_scheduled |
are reminders acted on | P0 | |
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 | |
offline_mode_entered |
ConnectivityService transitions to offline while app is foreground |
screen |
how common offline use is (matters for local-first investment) | P1 |
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 | ||
onboarding_step_viewed |
page changes | step (language, welcome, tradition, how_it_works, events, finish), step_index |
step-level drop-off | P0 | |
onboarding_language_selected |
ui_language |
language mix of new users | P0 | ||
onboarding_tradition_selected |
tradition_count only (see privacy note) |
do people pick one, many, none | P1 | ||
onboarding_event_plan_selected |
plan_id, plan_name |
uptake of the promo plan | P0 | ||
onboarding_completed |
add duration_ms, traditions_count, event_plan_selected (bool) |
completion rate, time to complete | P0 | ||
onboarding_abandoned |
app backgrounded for > 5 min mid-flow, or relaunch resumes mid-flow | last_step |
where people leave | P1 |
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 | |
auth_login_succeeded |
add source, is_new_user (first login vs returning) |
conversion, provider mix | P0 | ||
auth_login_failed |
add source |
which provider fails (Apple vs SMS), why | P0 | ||
auth_login_cancelled |
user dismisses Auth0 web flow | method, source |
abandoned logins | P1 | |
auth_guest_started |
add source (onboarding, login_page) |
guest volume | P0 | ||
login_prompt_shown |
LoginDrawer opened |
feature (mala, routine_edit, plan_enroll, recitation_save, chat, post_like, …) |
which gated feature triggers login attempts | P0 | |
login_prompt_dismissed |
drawer closed without login | feature |
which prompts annoy rather than convert | P0 | |
auth_logout |
local_only (bool) |
P1 | |||
account_delete_started / account_deleted |
reason if we add a reason picker |
churn signal, store compliance | P1 |
The pair login_prompt_shown → auth_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 | |
verse_of_day_viewed |
card visible ≥ 1 s | verse_id, language |
is the verse noticed | P1 | |
verse_shared |
share sheet action | verse_id, format (image, text), channel if share_plus returns it |
most shared surface? | P0 | |
action_of_day_tapped |
action_kind |
P2 | |||
home_shortcut_tapped |
shortcuts row | target (mala, timer, reader, calendar…) |
which shortcuts earn their place | P1 | |
stats_card_tapped |
practice stats card → Me | P2 | |||
series_viewed |
series detail | series_id, series_name, source (home, group, deep_link) |
P0 | ||
series_enrolled / series_unenrolled |
seriesEnrollmentProvider |
series_id, series_name, group_id if via group |
series funnel | P0 | |
calendar_banner_tapped / calendar_month_changed |
lunar_day, direction |
calendar usage | P2 | ||
poem_viewed |
page settles in poems viewer | poem_id, index, source |
poem reach | P1 | |
poem_shared |
poem_id |
P1 | |||
tag_search_performed |
tag overlay | tag, result_count |
P2 | ||
notification_permission_prompted / _granted / _denied |
first home load prompt | os |
permission rate, later notification reach | P0 | |
video_played |
YouTube embed | video_id, surface (home, reader_segment) |
P2 | ||
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 |
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 | |||
practice_section_expanded |
“see all” | section |
which explore sections matter | P1 | |
routine_edit_started |
is_first_routine |
P0 | |||
routine_block_added / routine_block_removed |
time_of_day (bucketed hour), block_count |
when people schedule practice | P1 | ||
routine_item_added / routine_item_removed |
item_type (plan, series, recitation, mala, timer, group_practice), item_id |
what goes in a routine | P0 | ||
routine_saved |
add session_types (list), reminder_enabled, earliest_block_hour |
routine composition | P0 | ||
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 | |
routine_item_completed |
where completion exists (recitation collections, plan days) | item_type, item_id |
routine follow-through | P0 | |
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 | |
bookmarks_viewed |
count |
P2 | |||
collection_created / collection_edited / collection_deleted |
recitation collections | chant_count |
P1 | ||
collection_chant_added / _removed |
collection_id, text_id |
P2 | |||
practice_search_performed |
plans / recitations / accumulations search screens | scope, query_length, result_count, language |
do searches succeed | P1 | |
practice_search_result_opened |
scope, position, target_id |
search → open conversion | P1 |
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 | ||
plan_viewed |
add source (browse, home_featured, search, deep_link, onboarding, routine), is_enrolled |
discovery paths | P0 | ||
plan_enrolled |
every enrolment path, not only onboarding | plan_id, plan_name, total_days, source |
enrol rate by source | P0 | |
plan_unenrolled |
plan_id, days_completed, days_since_enrolled |
when people give up | P0 | ||
plan_day_viewed |
day carousel settles | plan_id, day_number, is_today, is_missed |
day-level engagement | P0 | |
plan_task_completed / plan_task_uncompleted |
checkbox toggle | plan_id, day_number, task_id, task_type |
task friction | P0 | |
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 | |
plan_day_completed |
keep; add is_on_time (completed on the scheduled day) |
on-track rate | P0 | ||
plan_completed |
last day completed | plan_id, total_days, days_elapsed |
finish rate | P0 | |
plan_audio_played / plan_audio_completed |
audio segment | plan_id, subtask_id, duration_s, pct_listened |
audio value | P1 | |
plan_shared |
plan_id, day_number |
P1 | |||
author_viewed |
author_id |
P2 |
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 | |
reader_page_loaded |
pagination fetch completes | text_id, page_number, segment_count, load_ms |
reading depth, API latency | P0 | |
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 | |
reader_segment_selected |
tap on a segment | text_id, segment_number |
P2 | ||
reader_action_tapped |
segment action bar | action (copy, bookmark, commentary, version, share, video, image_export), text_id |
which actions matter | P0 | |
reader_commentary_opened / reader_translation_opened |
panel opens | text_id, segment_number, commentary_count |
P1 | ||
reader_version_changed / reader_language_changed / reader_script_changed |
picker sheets | text_id, slot (primary, secondary), from, to |
multilingual usage | P1 | |
reader_layout_changed |
dual-slot toggle | from, to |
interlinear vs split adoption | P1 | |
reader_font_size_changed |
from, to |
accessibility signal | P2 | ||
reader_search_performed |
in-text search | text_id, query_length, result_count |
P1 | ||
reader_swipe_navigated |
plan/routine multi-item swipe | direction, from_item_type, to_item_type |
P1 | ||
group_chant_mode_entered |
reader opened with group context | group_id, accumulator_id |
P1 | ||
segment_shared / segment_image_exported |
text_id, segment_number, format |
P1 | |||
library_search_performed |
texts search | search_type (title, author, segment, multilingual), query_length, result_count, language |
P1 | ||
library_result_opened |
search_type, position, text_id |
search → read conversion | P1 | ||
toc_opened / toc_entry_tapped |
table of contents | text_id, depth |
P2 | ||
collection_browsed |
texts collections | collection_id |
P2 |
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 | |||
recitation_opened |
card → reader | text_id, source (mine, all, search, routine, collection) |
most recited texts | P0 | |
recitation_saved / recitation_unsaved |
RecitationSaveController |
text_id, source |
P0 | ||
recitation_reordered |
saved_count |
P2 | |||
recitation_display_toggled |
secondary/tertiary segment switch | segment (translation, transliteration, adaptation), enabled |
which layers people want | P1 | |
recitation_search_performed |
query_length, result_count |
P1 |
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 accumulatorId → preset_id; add mantra_name, mode (personal, group), group_id, source (practice, routine, home_shortcut, group, deep_link) |
entry paths | P0 | ||
mala_session_started |
first tap after screen open or after ≥ 5 min idle | preset_id, mode, group_id, starting_total |
sessions per user | P0 | |
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 | |
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 | |
mala_mantra_switched |
rename from/to → from_preset_id/to_preset_id; add via (carousel, catalogue) |
mantra popularity | P1 | ||
mala_mode_changed |
personal ↔ group | from, to, group_id |
group accumulation adoption | P0 | |
mala_group_selected |
group sheet | group_id, accumulator_id |
P1 | ||
mala_offline_rounds_added |
“add rounds” dialog | preset_id, rounds |
offline practice volume | P0 | |
mala_reset |
preset_id, total_before, mode |
P1 | |||
mala_feedback_toggled |
setting (sound, vibration), enabled |
P2 | |||
mala_bookmarked |
preset_id |
P2 | |||
mala_synced |
demote: keep only failures as mala_sync_failed (reason, pending_delta), move success to logs |
sync health without event spam | P1 | ||
group_accumulator_viewed |
group accumulator screen | group_id, accumulator_id, source |
P1 |
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 | |||
timer_started |
countdown finishes, main timer starts | preset_id, duration_s, source (practice, routine, home_shortcut, push) |
timer adoption | P0 | |
timer_paused / timer_resumed |
preset_id, remaining_s |
P1 | |||
timer_completed |
bell rings | preset_id, duration_s, was_backgrounded, pause_count |
completion rate | P0 | |
timer_discarded |
user finishes early | preset_id, elapsed_s, pct_complete |
when people quit | P0 | |
timer_stop_reported / timer_stop_queued |
backend report or offline queue | elapsed_s, queued (bool) |
reliability | P2 |
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 | |
feed_item_opened |
item_type (post, event, practice), item_id, group_id, position |
feed relevance | P1 | ||
group_viewed |
group_id, group_type (community, page), source (feed, discover, search, deep_link, home_events, mala) |
P0 | |||
group_followed / group_unfollowed |
groupFollowProvider |
group_id, group_type, source |
community growth | P0 | |
group_searched |
query_length, result_count |
P2 | |||
group_tab_viewed |
posts / events / practices / members | group_id, tab |
P1 | ||
event_viewed |
event_id, group_id, is_live, source |
P0 | |||
event_joined / event_left |
event_id, group_id |
attendance funnel | P0 | ||
event_live_opened / event_live_ended |
embedded livestream | event_id, duration_s |
livestream value | P1 | |
post_viewed |
detail screen | post_id, group_id, has_media |
P1 | ||
post_liked / post_unliked |
post_id, group_id, surface (feed, detail) |
P1 | |||
post_comment_added |
post_id, group_id, comment_length |
P1 | |||
post_composer_opened / post_created / post_create_failed |
group_id, media_count, text_length, reason |
creator funnel | P1 | ||
group_practice_viewed |
practice_id, group_id |
P1 | |||
recitation_collection_opened |
group or personal | collection_id, group_id, item_count, source |
P0 | ||
recitation_collection_item_completed |
checklist tick | collection_id, text_id, completed_count, item_count |
daily chant follow-through | P0 | |
recitation_collection_completed |
all items ticked | collection_id, group_id |
P0 | ||
group_shared |
group_id, method |
P1 |
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 | |||
chat_room_opened |
group_id, room_id, unread_count, source |
chat reach | P0 | ||
chat_message_sent |
success | group_id, is_reply, length_bucket (short/medium/long), has_link, has_emoji_only |
chat activity | P0 | |
chat_message_send_failed |
group_id, reason |
P1 | |||
chat_reaction_added / chat_reaction_removed |
group_id, emoji_category (not the emoji itself if we want to be strict) |
P2 | |||
chat_message_deleted |
group_id, is_own |
moderation health | P1 | ||
chat_message_reported |
group_id, reason |
safety signal | P0 | ||
chat_reconnected |
WebSocket recovers | attempts, downtime_s |
reliability | P2 |
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 | |||
streak_shared |
streak_current, format |
growth loop | P0 | ||
stats_sheet_opened |
sheet (mantra_breakdown, practice_days, series_history) |
P2 | |||
profile_edited |
save | fields_changed (list of field names), avatar_changed |
P1 | ||
content_language_changed |
from, to, source (settings, onboarding, reader) |
multilingual demand | P0 | ||
ui_language_changed |
from, to |
P1 | |||
theme_changed |
to (light, dark, system) |
P2 | |||
notification_setting_changed |
setting (master, routine, recitation, practice, timer), enabled |
reminder opt-out | P0 | ||
os_notification_status_viewed |
settings screen shows blocked state | permission, exact_alarm, battery_optimized |
why reminders fail | P1 | |
legal_viewed / about_viewed |
page |
P2 |
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 | |
push_token_registered / push_token_register_failed |
reason |
push reach | P1 | ||
push_notification_received_foreground |
session_type |
P2 |
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
beforeSendhook already blanks email and tokens; extend it tophone,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:
- Activation funnel:
Application Installed→onboarding_completed→ (auth_login_succeededorauth_guest_started) → first core action (mala_round_completedORreader_page_loadedORtimer_startedORplan_task_completed) within 24 h. Broken down byui_languageandonboarding_event_plan_selected. - Guest conversion:
login_prompt_shown→auth_login_succeeded, broken down byfeature. Expect this to reorder our gating decisions. - Core loop retention: weekly retention where “active” means any core action, not just an app open.
- 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.
- Reading depth:
pct_reacheddistribution per text, top texts by sessions, version/language mix, action-bar usage. - Plan health: enrol → day 1 → day 7 → finish, per plan. Unenrol timing.
- Routine effectiveness: practice days per week for users with vs without a routine (
has_routine). - Notifications: opens by type; permission grant rate; reminder opt-out rate.
- Community: followed groups per user, event join rate, recitation-collection completion, chat messages per active room, reports.
- Share loop:
content_sharedby surface vs Airbridge attributed installs. - AI: search → open conversion, chat latency, failure reasons, rate-limit hits.
- 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.
- Flutter SDK maturity and feature parity
- Event analytics depth (funnels, retention, cohorts, paths, breakdowns)
- Mobile session replay quality and masking
- Extras we would actually use (feature flags, surveys, group analytics, logs)
- Cost at 10k / 50k / 200k MAU
- Privacy posture: EU region, DPA, who can see the data, deletion, retention
- Data ownership and export (warehouse, API, self-host)
- Migration cost from where we are
- 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,
PostHogMaskWidgetfor 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_syncedrisks). 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_viewis not automatic; you have to addFirebaseAnalyticsObserver, 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
- Pseudonymous identity. The analytics user id is our backend UUID, never email or phone.
beforeSendblanks email and tokens; extend it to phone, name, username. - 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.
- Guests are anonymous.
personProfiles = identifiedOnlymeans guests never get a person profile; keep it that way. - Replay masking. Text masked (default), images masked (set
maskAllImages = true), andPostHogMaskWidgetaround 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. - 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.
- 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
AnalyticsServicebecomes the no-op implementation at runtime (we already have that class) andPosthog().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. - 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.
- 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. - 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.routerinPostHogWidget; keepPosthogObserver. Verify a recording appears in the dev project. - Set
maskAllImages = true; addPostHogMaskWidgetto avatar, chat, post, profile and AI chat widgets. - Bump
posthog_flutterto 5.27 for logs and flag parity. - Rename mala properties to snake_case; demote
mala_syncedto a log line plus amala_sync_failedevent. - Add
AnalyticsPropertiesconstants 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
AnalyticsServicefor widget tests. - Write
docs/analytics/TRACKING_PLAN.mdas 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
- Consent model: default-on with opt-out, or explicit opt-in at onboarding? (Affects data volume and Article 9 exposure.)
- Firebase Analytics: remove, or keep as a marketing sink for Google Ads and Play audiences?
- Clarity pilot: is there a specific UX study from design team that justifies it?
- 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.