Bonds Analytics Catalog
Comprehensive reference for all Mixpanel tracking in the Bonds app. Module: src/utils/analytics.js.
For product: This document lists every event, property, user attribute, and funnel tracked in the app. Use the table of contents to navigate.
For developers: When adding or changing events, update this document in the same PR. See the Developer Checklist at the bottom.
Table of Contents
- Conventions
- Identity & Properties
- Screens
- Domain Events
- Element Clicked Reference
- Funnels
- Recommended Mixpanel Reports
- Developer Checklist
1. Conventions
Measuring: always filter to US/CA
Any real-user analysis (funnels, retention, drop-off claims) MUST be filtered to
mp_country_code = US or CA. The team runs heavy QA testing against production,
concentrated in India/Israel, which pollutes global numbers badly enough to
invert conclusions (e.g. a "~40% forced-logout within 1 hour" cluster that was
entirely India QA traffic). Mixpanel's single-value string filters can't express
"in [US, CA]" — either run US and CA separately, or break down by
mp_country_code and read the US + CA rows. Production project id: 3256863.
No tester-exclusion cohort exists yet; this filter is the stand-in.
Naming Rules
| What | Convention | Example |
|---|---|---|
| Event names | Title Case, Object-Action, past tense | Simulator Session Completed |
| Property names | snake_case | chapter_index, skill_level |
| Screen names | lowercase, underscore-separated | learn_video, simulator_results |
When to Use Which Event Type
| Pattern | When to use | Example |
|---|---|---|
| Domain event (own name) | Funnel steps, business-critical actions | Onboarding Completed, Daily Question Voted |
Screen Viewed |
A distinct screen becomes visible | Screen Viewed { screen: 'journey' } |
Element Clicked |
UI interaction (button, tab, card, toggle) | Element Clicked { screen: 'profile', element: 'invite_partner' } |
Property Value Enums
| Property | Possible values |
|---|---|
platform |
ios, android, web |
user_type |
anonymous, authenticated |
step_type |
learn, practice, insight, act |
mode |
freeform, journey |
input_mode (simulator) |
voice, text |
method (auth) |
google, apple, email |
element_type |
button, tab, banner, card, toggle, link |
delivery (report) |
clipboard, slack |
2. Identity & Properties
2a. Identity Lifecycle
┌─────────────────┐ identifyUser() ┌──────────────────┐
│ Anonymous │ ─────────────────────→ │ Authenticated │
│ (random ID) │ │ (Bubble user ID) │
│ user_type: │ │ user_type: │
│ 'anonymous' │ resetIdentity() │ 'authenticated' │
│ │ ←───────────────────── │ │
└─────────────────┘ └──────────────────┘
| Stage | Trigger | What happens |
|---|---|---|
| App opens, no auth | initAnalytics() |
Anonymous distinct_id assigned, super props registered |
| User authenticates | identifyUser(userId, traits) via UserContext |
identify(userId) merges anonymous→identified, people props set |
| User logs out | resetIdentity() |
reset() clears identity, back to anonymous with fresh ID |
2b. Super Properties
Auto-attached to every event. Set by initAnalytics(), updated by identifyUser().
| Property | Type | Example | Description |
|---|---|---|---|
app_version |
string | v1.0.0-alpha-bc22c80df36e |
Build version with content hash |
platform |
string | ios |
Detected from window.natively (ios / android / web) |
user_type |
string | authenticated |
anonymous until first identifyUser(), then authenticated |
2c. People Properties (User Profile)
Stored on the Mixpanel user profile. Visible in the People section.
| Property | Type | Example | Set by | When updated |
|---|---|---|---|---|
$name |
string | "Sarah" |
identifyUser() |
First auth only |
$email |
string | "sarah@example.com" |
identifyUser() |
First auth only |
coins |
number | 150 |
identifyUser() + setUserProperties() |
Every user-state update (updateUser — fed by /me applies and the legacy setUserData push) |
partner_connected |
boolean | true |
identifyUser() + setUserProperties() |
Every user-state update (updateUser) |
partner_name_collected |
boolean | true |
setUserProperties() |
Every user-state update where partnerName is present |
partner_gender |
string | "female" |
setUserProperties() |
Every user-state update where partnerGender is present |
onboarding_complete |
boolean | true |
identifyUser() |
First auth only |
signup_date |
string (ISO) | "2026-01-15T10:30:00Z" |
set_once |
First auth ever (never overwritten) |
initial_source |
string | "Ross2" |
set_once via identifyUser() |
First auth ever (never overwritten) |
initial_utm_source |
string | "Facebook" |
set_once via identifyUser() |
First auth ever (never overwritten) |
initial_utm_medium |
string | "Paid" |
set_once via identifyUser() |
First auth ever (never overwritten) |
initial_utm_campaign |
string | "Default_Campaign" |
set_once via identifyUser() |
First auth ever (never overwritten) |
initial_through |
string | "partner-x" |
set_once via identifyUser() |
First auth ever (never overwritten) |
last_source |
string | "Ross2" |
set via identifyUser() / captureAttribution() |
Every new campaign click |
last_utm_source |
string | "Facebook" |
set via identifyUser() / captureAttribution() |
Every new campaign click |
last_utm_medium |
string | "Paid" |
set via identifyUser() / captureAttribution() |
Every new campaign click |
last_utm_campaign |
string | "Default_Campaign" |
set via identifyUser() / captureAttribution() |
Every new campaign click |
last_through |
string | "partner-x" |
set via identifyUser() / captureAttribution() |
Every new campaign click |
is_subscribed |
boolean | true |
setUserProperties() via EntitlementContext |
Every entitlement refresh (auth, foreground, post-purchase, post-restore) |
subscription_type |
string | null | "quarterly" |
setUserProperties() via EntitlementContext |
Every entitlement refresh. Resolved from the active entitlement's store product id → RC package id (offerings endpoint) → key of APP_CONFIG.purchases.packages (yearly/quarterly/monthly). null when not subscribed, the offerings map is unavailable, or the package isn't in config |
payment_variant |
string | "B" |
setUserProperties() via <PaymentVariantSeeder> |
When Bubble reports the variant, or on a fresh Statsig seed |
push_enabled |
boolean | true |
setUserProperties() via notifications.js |
Authoritative write (true or false) on the OS permission-dialog callback in requestPermission. initNotifications re-asserts it on cold start but only ever upgrades to true — an early-mount getPermissionStatus read is untrustworthy (bridge not ready, same timing class as BNS-1025) and writing a false there would clobber a real grant. Consequence: a Settings-level revoke is not reflected (the property can stay true until a definitive deny path runs); in-session/foreground changes are not tracked. |
Cohort tag: the A/B/C payments cohort's source of truth is the Bubble User field
payment_variant(Statsig only seeds it once — seedocs/statsig-experiments.md). It's written to the Mixpanel user profile as thepayment_variantPeople property (the effective value incl. admin overrides, set by<PaymentVariantSeeder>) — use this for all cohort filtering and breakdowns. It's a People (not super) property because the cohort is sticky and server-owned: a profile property survivesreset(), is queryable on the user level, and is still segmentable in event reports, with no need for an at-event snapshot. The raw Statsig bucket is not mirrored to Mixpanel asexperiment_payments(kept to one property to avoid confusion); Statsig retains the exposure for experiment analysis on its side.
3. Screens
All values used with Screen Viewed. Fired via screen(name) helper which calls track('Screen Viewed', { screen: name }).
| Screen | When shown | Source | Notes |
|---|---|---|---|
welcome |
App loads without active session | App.jsx | |
signin |
User taps "Sign In" | App.jsx | |
signup |
After onboarding insight, before account creation | App.jsx | |
onboarding_intro |
One-time intro video before questions | App.jsx | |
onboarding |
User starts or resumes onboarding | App.jsx | |
onboarding_insight |
During insight generation/playback | App.jsx | |
journey |
Journey tab selected | MainTabs.jsx | |
dr-leo |
Dr Leo tab selected (formerly simulator) |
MainTabs.jsx | Tab id renamed. Deep links: /dr-leo (choice), /dr-leo/ask, /dr-leo/simulator; legacy /simulator opens the simulator directly |
dr_leo_choice |
Dr Leo "How can I help?" choice screen | DrLeoChoiceScreen.jsx | |
ask_landing |
Ask Anything pre-session intro | AskLanding.jsx | |
ask_session |
Ask Dr Leo realtime Q&A session (text/voice) | AskSession.jsx | |
ask_answer |
Ask Dr Leo answer + CTAs | AskAnswer.jsx | |
ask_act |
Ask act proposal ("Act upon it" from the answer) | AskActFlow.jsx | |
act_acceptance |
Act acceptance screen ("This is where change happens") + moment drawer | AskActFlow.jsx | Ask acts only for now; the journey act reaches the same screen via ActStep, which does not report it as a screen |
act_check_in |
Act check-in ("How did it go?") reached from a check-up push or the app-open resurface | AskActFlow.jsx | Head of its own funnel — the resurface/deep-link entries never pass through ask_act |
fun |
Fun Zone tab selected | MainTabs.jsx | |
profile |
Profile tab selected | MainTabs.jsx | |
step_review |
User opens a completed step for review | StepReview.jsx | Extra props: step_type, chapter |
ftue_walkthrough |
First-time user walkthrough starts | MainTabs.jsx | |
daily_question |
Daily question overlay opened | DailyQuestion.jsx | |
push_prompt |
Push notification permission dialog shown | MainTabs.jsx | |
name_prompt |
Name entry dialog after signup | MainTabs.jsx | |
partner_name_prompt |
Partner name entry dialog (after user name dialog) | MainTabs.jsx | |
account_info |
Account info / danger zone screen | MainTabs.jsx | |
share_viewer |
Shared content viewer (external link) | ShareViewer.jsx |
4. Domain Events
App Lifecycle
App Opened
Fires once on app launch after analytics initialization.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: App.jsx
App Backgrounded
Fires when the app moves to the background (Natively lifecycle hook).
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: App.jsx
App Foregrounded
Fires when the app returns to the foreground (Natively lifecycle hook).
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: App.jsx
Logged Out
Fires when user logs out. Identity is reset after this event.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: App.jsx
Session Force Logout
Fires when Bubble force-logs the user out via setLoginState(false) while they were in an authenticated phase (no React-initiated logout pending). Most likely cause is the WebView losing its session cookie (_u2main) across a process restart, which triggers Bubble's "User is logged out" event server-side. Tracked into Mixpanel so we can monitor frequency and surface in retention/funnels alongside other auth events.
| Property | Type | Example | Required |
|---|---|---|---|
phase |
string | "main" |
yes |
source |
string | "bubble" |
yes |
Source: App.jsx (in setLoginState(false) handler when source === 'bubble')
Since 2026-07 (conservative session validation): also fires on the cold-start path, but ONLY after /me (get_user) returned a confirmed-anonymous answer twice — source: "cold_start_me_anonymous", reason: "me_anonymous_confirmed". The old snapshot-mismatch trigger (source: "cold_start_uid_mismatch") was removed; watch this event's volume as the "did we regress auth" tripwire (Sentry alert rule).
Session Validation Outcome
Fires once per cold-start session validation (authenticated session flags present). Replaces the old silent snapshot-mismatch logic with an auditable decision. Spec: docs/superpowers/specs/2026-06-29-conservative-session-validation-design.md.
| Property | Type | Example | Required |
|---|---|---|---|
reason |
string | "proceed" | "account_switch" | "adopt" | "genuine_logout" | "stay_put" | "cross_env" | "fresh" | "env_switch_pending_hold" |
yes |
tier |
string | "production" |
yes (except cross_env via autoReturnHome and env_switch_pending_hold) |
version_key |
string | "(production)" | "/version-73fh1" |
yes (except cross_env and env_switch_pending_hold) |
me_status |
string | "authenticated" | "anonymous" | "indeterminate" | "skipped" |
yes (except cross_env and env_switch_pending_hold) |
resampled |
boolean | false |
yes (except cross_env and env_switch_pending_hold) |
source |
string | "cold_start" | "set_login_state" | "env_switch_prompt" |
cross_env and env_switch_pending_hold only |
has_home |
boolean | true |
cross_env only |
env_switch_pending_hold (2026-07-07): a foreign-version setLoginState(true) arrived while the staging env-switch prompt was awaiting the user's choice — the event is held instead of bouncing (the bounce used to race the prompt); the prompt's own resolution follows as Element Clicked { switch_here | go_home }.
cross_env = landing on any non-HOME version (universal HOME rule — link or bare URL, any tier) → auto-return home, no logout, no wipe. adopt = unanchored version where Bubble confirms a session (migration heal). fresh = quiet welcome, nothing lost. Production should see ~zero; Sentry alert rule on sustained volume (a mis-built push/link reached real users).
Source: App.jsx (checkSession decision flow; autoReturnHome)
Env Switch Prompt Shown
Staging-only QA affordance: shown when a device lands on a foreign staging version whose home is also staging, in place of the silent foreign-landing bounce (design 2026-07-06). Production-home users never see it. Resolves into exactly one Element Clicked { element: 'switch_here' | 'go_home' }; go_home additionally emits Session Validation Outcome { reason: 'cross_env', source: 'env_switch_prompt' }.
| Property | Type | Example | Always? |
|---|---|---|---|
from_version |
string | "/version-aaa" |
yes (staging home key) |
to_version |
string | "/version-73fh1" |
yes (landed staging key) |
Deep Link Rejected (Wrong Environment)
Fires when a deeplink's tier differs from the user's active environment — the route is dropped (never honored) and the generic "This link couldn't be opened" notice is shown.
| Property | Type | Example | Required |
|---|---|---|---|
path |
string | "/version-live/" |
yes |
via |
string | "cold_start" | "handleDeepLink" | "handleDeepLink_version_mismatch" |
yes |
Source: index.jsx (cold-start parse + handleDeepLink guard)
Deep Link Opened
Fires when a deep link is processed. Attribution params (if present in the URL) are spread into the event properties.
| Property | Type | Example | Required |
|---|---|---|---|
tab |
string | null | "journey" |
no |
view |
string | null | "chapter_1" |
no |
step_status |
string | undefined | "completed" |
no |
source |
string | "Ross2" |
no (from attribution) |
utm_source |
string | "Facebook" |
no (from attribution) |
utm_medium |
string | "Paid" |
no (from attribution) |
utm_campaign |
string | "Default_Campaign" |
no (from attribution) |
through |
string | "partner-x" |
no (from attribution) |
Source: MainTabs.jsx
Attribution Captured
Fires when a URL with attribution params is opened (cold start or warm start), regardless of whether a deep link route is present. Independent of Deep Link Opened.
| Property | Type | Example | Required |
|---|---|---|---|
source |
string | "Ross2" |
no |
through |
string | "partner-x" |
no |
utm_source |
string | "Facebook" |
no |
utm_medium |
string | "Paid" |
no |
utm_campaign |
string | "Default_Campaign" |
no |
Source: analytics.js via captureAttribution()
Profile Pull (Phase 2 dual-run — shadow telemetry, removed with the element kill)
Session Data Divergence
Pre-APPLY divergence between the /me pull and current state — rollout health metric while the reactive element coexists; after element deletion it measures boot-cache staleness. Expected benign noise: transient coins divergence when a /me lands between an optimistic coin update and the reactive echo — judge by rate and persistence, not single events. Any uid divergence is a P1 signal — investigate immediately.
| Property | Type | Example | Required |
|---|---|---|---|
via |
string | "boot" | "boot_late" | "foreground" | "post_auth" |
yes |
fields |
string[] | ["coins", "email"] |
yes |
count |
number | 1 |
yes |
boot_late = the boot /me resolved after Bubble's page event had already moved the app to main; the session decision is discarded but the authenticated data still applies (pre-fix, this path silently dropped the snapshot — the frozen-balance bug from QA 13-07-26). fun_zone_settled is RETIRED in BNS-1088 slice 2 — the fun-zone creation now charges inside one awaited HTTP workflow and returns the post-deduct balance in the response, so there is nothing left to reconcile: no /me pull, no setUserData push, and no race between them (#427 added the pull, #430 removed it from the delivered path; the migration removed the last of it). Check the Mixpanel boards before dropping the value from any saved report.
Source: App.jsx (session validation incl. late-boot apply, foreground refresh, post-auth checkpoint). The former ActivityView.jsx fun-zone-create-timeout refresh (FunZoneContext.refreshBalance) was removed with fun_zone_settled in BNS-1088 slice 2 — the awaited workflow response is now the only balance writer.
Authentication
Sign In Started
Fires when user initiates sign-in (OAuth or email).
| Property | Type | Example | Required |
|---|---|---|---|
method |
string | "google" / "apple" / "email" |
yes |
Source: SignInScreen.jsx
Sign In Completed
Fires on first successful identity merge (anonymous → authenticated). Reads bonds_auth_method from localStorage. Since BNS-1136 the Sign-In screen tags SSO attempts too, so method reports "google" / "apple" / "email"; "oauth" remains only as the fallback when the tag is missing (e.g. the Android OAuth redirect wiped localStorage). Boards filtering on method = "oauth" should switch to the concrete provider values.
| Property | Type | Example | Required |
|---|---|---|---|
method |
string | "google" / "apple" / "email" / "oauth" (fallback) |
yes |
Source: UserContext.jsx
Sign In Failed
Fires when sign-in fails (wrong password, Bubble error).
| Property | Type | Example | Required |
|---|---|---|---|
error |
string | "wrong_password" / "not_found" |
yes |
Source: App.jsx (via setSignInError)
Sign In Blocked
Fires when sign-in detects a non-existing account (UID comparison or Bubble search).
| Property | Type | Example | Required |
|---|---|---|---|
reason |
string | "account_not_found" |
yes |
Source: App.jsx (via onSigninComplete)
Onboarding
Onboarding Step Completed
Fires after each onboarding question is answered.
| Property | Type | Example | Required |
|---|---|---|---|
step |
number | 2 |
yes |
question_id |
string | "comm_style" |
yes |
type |
string | "single-select" / "slider" / "multi-select" / "open-question" |
yes |
answer_value |
string | "Direct" |
yes |
partner_gender |
string|null | "female" / "male" / "other" / null |
step 0 only — partner gender follow-up |
Source: OnboardingFlow.jsx
Onboarding Completed
Fires when user finishes all onboarding steps.
| Property | Type | Example | Required |
|---|---|---|---|
total_steps |
number | 12 |
yes |
coins |
number | 12 |
yes |
loveLanguage |
string | "Words of Affirmation" |
no |
partnerLoveLanguage |
string | "Physical Touch" |
no |
skippedTiebreaker |
boolean | true |
no |
partner_gender |
string|null | "female" / "male" / "other" / null |
no |
Source: OnboardingFlow.jsx
Onboarding Abandoned
Fires when user exits onboarding via back button on step 0.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: App.jsx
Onboarding Resumed
Fires when onboarding session is resumed, either automatically on app reload (source: 'auto') or when user chooses to continue via the resume dialog (source: 'dialog').
| Property | Type | Example | Required |
|---|---|---|---|
phase |
string | "questions" / "signup" / "insight" |
yes |
step |
number/string | 5 / "signup" / "insight" |
yes |
source |
string | "auto" / "dialog" |
yes |
Source: App.jsx
Onboarding Intro Viewed
Fires when the one-time intro video screen is mounted.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingIntro.jsx
Onboarding Intro Completed
Fires when user taps the CTA after the intro video has finished playing.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingIntro.jsx
Onboarding Intro Skipped
Fires when user taps the CTA before the intro video finishes (skips remaining video).
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingIntro.jsx
Onboarding Intro Replayed
Fires when user taps replay to watch the intro video again.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingIntro.jsx
Onboarding Intro Backed Out
Fires when user navigates back from the intro screen to welcome.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingIntro.jsx
Two Last Questions Shown
Fires when the "Two last questions" popup appears just before the first open question (Groups A & C only — gated by useOnboardingCoinsHidden()). PRD UI Outputs #2. The CTA emits Element Clicked { screen: 'onboarding', element: 'two_last_questions_continue' }.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingFlow.jsx
Onboarding Insight Started
Fires when insight generation begins after all questions answered.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight Generated
Fires when the generate_onboarding_insight HTTP call returns a usable insight.
| Property | Type | Example | Required |
|---|---|---|---|
insight_id |
string | "ins_abc123" |
yes |
duration_ms |
number | 4800 |
yes |
Source: OnboardingInsight.jsx
Onboarding Insight Generation Failed
Fires when generation fails: a transport error, the workflow's own
quota_exceeded / generation_failed status, or an empty body. The user sees
the retry dialog. Replaces the old Onboarding Insight Timed Out (BNS-1088 s3).
| Property | Type | Example | Required |
|---|---|---|---|
reason |
string | "quota_exceeded" |
yes |
duration_ms |
number | 250 |
yes |
reason enum: http_5xx, http_4xx, timeout, network (transport, via
failureReason), quota_exceeded, generation_failed (workflow status),
empty_content (ok response, no text).
Source: OnboardingInsight.jsx
Onboarding Insight Manual Retry
Fires when the user taps "Try again" on the failure dialog.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight Prewatch Shown
Fires when the before-watching gate appears ("This insight usually uses 3 coins, but this one's on us"). Groups A & C only — the gate exists to frame the coin reward, so Group B (no coin economy) has no gate and auto-plays the insight directly, never firing this event. PRD UI Outputs #4.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight Watch Tapped
Fires when the user taps "Watch" on the before-watching gate, starting insight playback. Groups A & C only (Group B has no gate — see Onboarding Insight Prewatch Shown).
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight Playback Completed
Fires when TTS playback finishes and user taps Continue.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight Playback Skipped
Fires when user taps Continue before TTS playback finishes.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight CTA Tapped
Fires when user taps "Create Your Account" after insight playback.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OnboardingInsight.jsx
Onboarding Insight Timed Out — RETIRED (BNS-1088 slice 3)
Removed when onboarding insight moved to the awaited HTTP call. The old 90s
JS2B timeout no longer exists; failures now fire Onboarding Insight Generation Failed (with a reason, including timeout). Kept here as a tombstone —
check Mixpanel boards referencing it before final removal.
Onboarding Data Connected
Fires after signup when the onboarding data was successfully committed to Bubble
for the new user (the connect_anon_data_to_user HTTP workflow returned 200).
Success-only since 2026-07 — previously this also fired on the (since removed)
JS2B fallback path, where delivery wasn't confirmed.
| Property | Type | Example | Required |
|---|---|---|---|
coins |
number | 10 |
yes |
answer_count |
number | 10 |
yes |
insight_id |
string | "ins_abc123" |
yes |
Source: App.jsx
Onboarding Data Connect Failed
Compensating negative event: fires when the post-signup connect_anon_data_to_user
HTTP call fails after retries (or the surrounding connect block throws). The user is
NOT transitioned to the main app; onboarding data stays in storage for recovery.
Together with Onboarding Data Connected this resolves the post-signup funnel into
connected + failed.
| Property | Type | Example | Required |
|---|---|---|---|
error |
string | "HTTP 500" |
yes |
Source: App.jsx
Onboarding Step Back
Fires when user navigates backward during onboarding.
| Property | Type | Example | Required |
|---|---|---|---|
from_step |
number | 8 |
yes |
to_step |
number | 7 |
yes |
Source: OnboardingFlow.jsx
Onboarding Mic Permission Denied
Fires when microphone permission is denied during onboarding open question.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: OpenQuestion.jsx
Onboarding Mic Unavailable
Fires when microphone/speech recognition is unavailable for a non-permission reason.
| Property | Type | Example | Required |
|---|---|---|---|
reason |
string | "network" / "no_api" |
yes |
Source: OpenQuestion.jsx
Onboarding Speech API Unavailable
Fires when SpeechRecognition API doesn't exist on the device and dialog auto-switches to text mode.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: InsightOtherDialog.jsx
Dictation Stopped On Edit
Fires when the user manually edits the textarea (typing/deleting) while dictation is live, which ends the recognition session (BNS-1057 — an edit is authoritative, so the session terminates rather than reconciling with in-flight speech results). Signals how often users correct mid-dictation and re-tap the mic. Emitted from the shared useSpeechRecognition hook, so it covers every voice surface (Act "other outcome", journey "what's on your mind", insight "other").
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: useSpeechRecognition.js
Signup
Sign Up Started
Fires when user initiates sign-up (OAuth or email).
| Property | Type | Example | Required |
|---|---|---|---|
method |
string | "google" / "apple" / "email" |
yes |
Source: SignUpScreen.jsx
Sign Up Completed
Fires on successful signup. Detects email vs OAuth via bonds_auth_method localStorage flag.
| Property | Type | Example | Required |
|---|---|---|---|
method |
string | "email" / "oauth" |
yes |
Source: App.jsx
Sign Up Failed
Fires when sign-up fails (Bubble error).
| Property | Type | Example | Required |
|---|---|---|---|
error |
string | "already_exists" |
yes |
Source: App.jsx (via setSignUpError)
Sign Up Blocked
Fires when sign-up detects an existing account (UID comparison or Bubble search).
| Property | Type | Example | Required |
|---|---|---|---|
reason |
string | "existing_account" |
yes |
Source: App.jsx (via onSignupComplete)
Email Authentication
Email OTP Sent
Fires when an OTP code is successfully sent to the user's email.
| Property | Type | Example | Required |
|---|---|---|---|
flow |
string | "signup" / "forgot" |
yes |
Source: SignUpScreen.jsx, SignInScreen.jsx
Email OTP Verified
Fires when user successfully verifies the OTP code.
| Property | Type | Example | Required |
|---|---|---|---|
flow |
string | "signup" / "forgot" |
yes |
Source: SignUpScreen.jsx, SignInScreen.jsx
Email OTP Failed
Fires when OTP verification fails (wrong or expired code).
| Property | Type | Example | Required |
|---|---|---|---|
flow |
string | "signup" / "forgot" |
yes |
error |
string | "invalid" / "expired" |
yes |
Source: SignUpScreen.jsx, SignInScreen.jsx
Email OTP Resent
Fires when user taps resend code.
| Property | Type | Example | Required |
|---|---|---|---|
flow |
string | "signup" / "forgot" |
yes |
Source: SignUpScreen.jsx, SignInScreen.jsx
Password Reset Completed
Fires after a successful password reset + auto sign-in.
Source: SignInScreen.jsx
Sharing
Share Created
Fires when user creates a shareable link.
| Property | Type | Example | Required |
|---|---|---|---|
content_type |
string | "insight" / "simulator_results" / "activity" |
yes |
content_id |
string | "abc123" |
yes |
Source: InsightPlayback.jsx, SimulatorResults.jsx, ActivityView.jsx
Share Viewed
Fires when someone views a shared link.
| Property | Type | Example | Required |
|---|---|---|---|
content_type |
string | "insight" / "simulator_results" / "activity" |
yes |
content_id |
string | "abc123" |
yes |
Source: ShareViewer.jsx
App Shared
Fires when a user shares the app itself (not a piece of content) — the "Share {app}" CTA opens the OS share sheet with the brand's shareLink (brand.urls.shareLink, default https://<domain>/?source=share_app; white-label tenants override via setAppConfig). When the friend opens the link, the cold-start attribution capture fires Attribution Captured with source: "share_app" — that's how web-side joins from a share are tracked. For store-install attribution, point shareLink at a per-tenant Branch quick link (Branch is already integrated; parseDeepLink unwraps $deeplink_path) whose web fallback is this URL — Branch then reports installs by link, and the deferred deep link restores source=share_app on first open. Fires on initiation: the native share sheet (window.natively.shareText) is fire-and-forget, so share completion isn't observable. Co-fires the Appsflyer af_share event (af_description: "app").
| Property | Type | Example | Required |
|---|---|---|---|
source |
string | "profile" / "chapter_celebration" |
yes |
chapter |
number | 3 |
no (only from chapter_celebration) |
Source: MainTabs.jsx (Profile → Support), ChapterCelebration.jsx — both via shareApp() in utils/deepLink.js
Share Reward Granted
Fires when the user is granted coins (EARN.share, currently 10) for sharing the app, capped to once per day. Granted on share tap — when a share sheet opens (grantShareRewardOncePerDay() in utils/shareReward.js), the surface calls the gift_coins Bubble action and shows the CoinGrant confirmation dialog (rendered behind the native sheet, revealed on dismiss). (Granted on tap, not on app-return: the native share sheet is a modal that often doesn't background the app, and Natively's shareText has no completion callback — a foreground-return gate missed ~80% of shares in QA. The daily cap, persisted via bonds_share_reward_date, bounds the "grants even on cancel" trade-off to once/day.)
Cohort gating: only fires for coin-eligible users — non-premium Groups A & C. Group B (paywall-first) and premium subscribers never see the coin economy, so they get a plain "Share {app}" CTA with no reward and this event never fires for them (App Shared still fires for everyone). Eligibility = useCoinRewardActive() (!isSubscribed && variant !== 'B').
Abuse note (MVP): this does not verify the friend installed — and since it grants on tap, cancelling the share sheet still grants. Accepted for MVP; watched via the ratio of this event to App Shared. Closing the loop on the invitee joining is the deferred proper fix.
| Property | Type | Example | Required |
|---|---|---|---|
source |
string | "profile" / "chapter_celebration" |
yes |
amount |
number | 10 |
yes |
Source: ShareAppRow.jsx (Profile) and ChapterCelebration.jsx via grantShareRewardOncePerDay() in utils/shareReward.js
Account
Account Deleted
Fires when user confirms account deletion.
Source: MainTabs.jsx (AccountInfoPage)
Onboarding Question Skipped
Fires when user skips an open-ended onboarding question.
| Property | Type | Example | Required |
|---|---|---|---|
question |
string | "What do you wish your partner would do more of" |
yes |
Source: OpenQuestion.jsx
Journey
Chapter numbering convention. Two property names coexist for historical reasons, both 1-based:
chapter— used by step-level events (Chapter Started,Chapter Completed,Journey Step Started/Completed/Abandoned,Journey Video Watched,Journey Practice Started/Completed). Value is the chapter'sindexfrom Bubble (thetemp_indexfield).chapter_number— used by chapter-level / UI events (Chapter Confirmation *,Chapter Start Tapped,Chapter Step Start Tapped,Chapter Paused State Shown,Chapter Menu Opened,Chapter Menu Chapter Selected).They agree numerically. When building a funnel across both groups, Mixpanel's "formula" column can alias one to the other.
dynamicsegmentation. Every chapter-scoped event carries a booleandynamicproperty (Phase 2 dynamic chapters vs predefined). Use it to split funnels and retention by chapter kind. Predefined chapters reportdynamic: false; LLM-generated 3-node chapters reportdynamic: true. ForSimulator Session *events the property is present only whenmode === 'journey'.
Chapter Started
Fires when the user pays the chapter cost (10 coins per the Coin Economy price card) via the coin gate and a new chapter begins. Not tied to a specific step — mirrors Chapter Completed.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
chapter_title |
string | "Active Listening" |
yes |
coin_cost |
number | 10 |
yes |
dynamic |
boolean | false |
yes |
Source: JourneyPath.jsx
Chapter Start Failed
Fires when the chapter_started HTTP workflow fails after all central retries and the optimistic start is reverted (BNS-1112). Chapter Started fires optimistically before the HTTP call, and the next Start tap after a revert fires it again — subtract this event to de-duplicate the start funnel. Rare by design (transient failures are retried with backoff).
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
chapter_title |
string | "Active Listening" |
yes |
dynamic |
boolean | false |
yes |
Source: JourneyPath.jsx
Journey Step Started
Fires when a journey step begins (learn, practice, insight, or act).
| Property | Type | Example | Required |
|---|---|---|---|
step_type |
string | "learn" |
yes |
chapter |
number | 0 |
yes |
chapter_title |
string | "Active Listening" |
yes |
dynamic |
boolean | false |
yes |
Source: ChapterFlow.jsx
Journey Step Completed
Fires when a journey step finishes successfully.
| Property | Type | Example | Required |
|---|---|---|---|
step_type |
string | "practice" |
yes |
chapter |
number | 0 |
yes |
coins |
number | 3 |
yes |
replay |
boolean | false |
yes |
dynamic |
boolean | false |
yes |
Source: ChapterFlow.jsx
Journey Step Abandoned
Fires when user exits a journey step without completing it.
| Property | Type | Example | Required |
|---|---|---|---|
step_type |
string | "learn" |
yes |
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
Source: ChapterFlow.jsx
Journey Video Watched
Fires when the learn video finishes playing.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
Source: LearnStep.jsx
Journey Practice Started
Fires when user starts a practice simulator session from the journey.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
Source: PracticeStep.jsx
Journey Practice Completed
Fires when practice simulator session ends with a score.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
score |
number | 4 |
yes |
coins |
number | 3 |
yes |
attempt |
number | 1 |
yes |
dynamic |
boolean | false |
yes |
Source: PracticeStep.jsx
Chapter Completed
Fires when all steps in a chapter are done.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
chapter_title |
string | "Active Listening" |
yes |
dynamic |
boolean | false |
yes |
Source: ChapterFlow.jsx
Chapter Start Tapped
Fires when the user taps the external "START CHAPTER · N COINS" button on the journey map. This button replaces the in-bubble START for the first step of a chapter (PRD Phase 1, page 2).
| Property | Type | Example | Required |
|---|---|---|---|
screen |
string | "journey_map" |
yes |
chapter_number |
number | 1 |
yes |
via |
string | "external_cta" |
yes |
step_type |
string | "learn" |
yes |
dynamic |
boolean | false |
yes |
Source: JourneyScreen.jsx
Chapter Step Start Tapped
Fires when the user taps the in-bubble START button on a non-first step (Practice/Insight/Act) on the journey map.
| Property | Type | Example | Required |
|---|---|---|---|
screen |
string | "journey_map" |
yes |
chapter_number |
number | 1 |
yes |
via |
string | "bubble" |
yes |
step_type |
string | "practice" / "insight" / "act" |
yes |
dynamic |
boolean | false |
yes |
Source: JourneyScreen.jsx
Chapter Paused State Shown
Fires when the paused view is rendered the day after a chapter is completed (PRD Phase 1, page 4). Deduped by completed → next pair so a single paused session fires the event once.
| Property | Type | Example | Required |
|---|---|---|---|
screen |
string | "chapter_paused" |
yes |
next_chapter_number |
number | 2 |
yes |
completed_chapter_number |
number | 1 |
yes |
dynamic |
boolean | false |
yes |
Source: JourneyScreen.jsx
First Step Spotlight Shown
BNS-1077 orientation beat. Fires when the first-step spotlight (dim map + glowing cutout around the chapter's first step circle + explainer bubble) appears — beat 1 of the standalone Ch1 confirmation overlay, shown once after the post-FTUE dialog chain.
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
screen |
string | "first_step_spotlight" |
yes | |
chapter_number |
number | 1 |
yes | |
step_type |
string | "practice" | "learn" |
yes | dynamic → practice, predefined → learn |
dynamic |
boolean | true |
yes |
The user's tap on Next fires Element Clicked { screen: "first_step_spotlight", element_type: "button", element: "next", dynamic }.
Source: MainTabs.jsx
First Step Spotlight Skipped
Compensating event: the spotlight auto-skipped straight to the Start-Chapter sheet because the target circle couldn't be measured. Spotlight Shown funnels resolve into Element Clicked (next) + this event.
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
reason |
string | "target_not_found" |
yes | |
dynamic |
boolean | true |
yes |
Source: MainTabs.jsx
Chapter Confirmation Shown
Fires when a predefined confirmation card is shown. Two sources — when building the Confirmation → Start → Complete funnel, union screen ∈ { ch1_predefined_confirmation, chapter_predefined_confirmation }:
- Ch1 — beat 2 of the standalone post-FTUE overlay (after the first-step spotlight) with
screen: "ch1_predefined_confirmation",via: "standalone_overlay". History: the event originally fired from the last FTUE walkthrough slide (step/is_replayprops); it went silent (~2026-05-08) when the card moved to the standalone overlay, and was revived with the BNS-1077 wiring — treat the gap accordingly in funnels. - Ch2 / Ch3 — standalone bottom sheet shown at session start if the active chapter is an unstarted Ch2 or Ch3 and the user hasn't yet responded (PRD Phase 1, page 8 — Figma 1590:1791). Evaluated once per session; not re-triggered on chapter scroll. Uses
screen: "chapter_predefined_confirmation".
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
screen |
string | "ch1_predefined_confirmation" | "chapter_predefined_confirmation" |
yes | |
via |
string | "standalone_overlay" |
Ch1 only | |
chapter_number |
number | 1, 2, 3 |
yes | |
chapter_id |
string | "1776754765374x…" |
Ch2/Ch3 | Bubble chapter id |
chapter_title |
string | "Slowing the Reaction" |
Ch2/Ch3 | |
dynamic |
boolean | false |
yes | chapter kind (Phase 2) |
Source: MainTabs.jsx (Ch1), JourneyPath.jsx (Ch2 / Ch3).
Chapter Confirmation Accepted
Fires when the user taps "Start Chapter" on the predefined confirmation card (Ch1 FTUE or Ch2/Ch3 standalone sheet).
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
screen |
string | "ch1_predefined_confirmation" | "chapter_predefined_confirmation" |
yes | |
chapter_number |
number | 1, 2, 3 |
yes | |
chapter_id |
string | "1776754765374x…" |
Ch2/Ch3 | |
chapter_title |
string | "Slowing the Reaction" |
Ch2/Ch3 | |
via |
string | "start_chapter" |
yes | |
dynamic |
boolean | false |
yes | chapter kind (Phase 2) |
Source: MainTabs.jsx (Ch1 standalone overlay), JourneyPath.jsx (Ch2 / Ch3). The DrLeoWalkthrough.jsx last-slide path is unreachable in production.
Chapter Confirmation Dismissed
Fires when the user taps "No thanks, later" on the predefined confirmation card.
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
screen |
string | "ch1_predefined_confirmation" | "chapter_predefined_confirmation" |
yes | |
chapter_number |
number | 1, 2, 3 |
yes | |
chapter_id |
string | "1776754765374x…" |
Ch2/Ch3 | |
chapter_title |
string | "Slowing the Reaction" |
Ch2/Ch3 | |
via |
string | "no_thanks_later" |
yes | |
dynamic |
boolean | false |
yes | chapter kind (Phase 2) |
Source: MainTabs.jsx (Ch1 standalone overlay), JourneyPath.jsx (Ch2 / Ch3). The DrLeoWalkthrough.jsx last-slide path is unreachable in production.
FTUE First Slide Shown
Fires when the FTUE first slide ("Generating your chapter…") is shown (PRD Phase 1, page 5).
| Property | Type | Example | Required |
|---|---|---|---|
screen |
string | "ftue_first_slide" |
yes |
step |
number | 0 |
yes |
is_replay |
boolean | false |
yes |
Source: DrLeoWalkthrough.jsx
FTUE Skipped
Fires when the user taps Skip on any FTUE step. Per PRD §6, Skip now jumps to the last FTUE step instead of closing the walkthrough.
| Property | Type | Example | Required |
|---|---|---|---|
screen |
string | "ftue_first_slide" | "ftue_walkthrough" |
yes |
step |
number | 0 |
yes |
is_replay |
boolean | false |
yes |
Source: DrLeoWalkthrough.jsx
Chapter Menu Opened
Fires on the OPEN transition of the chapter-list dropdown (chevron tap when the menu was closed). Does not fire on close — use the companion Element Clicked { element: 'chapter_menu_chevron' } event for bi-directional tap counts.
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
screen |
string | "journey_map" |
yes | |
chapter_number |
number | 2 |
yes | focal chapter at the moment of tap (1-based) |
chapter_id |
string | "1776754765374x…" |
yes | Bubble chapter id |
dynamic |
boolean | false |
yes | focal chapter's kind |
Source: JourneyScreen.jsx
Chapter Menu Chapter Selected
Fires when the user picks a chapter from the chapter-list dropdown. Funnels dropdown engagement → scroll navigation.
| Property | Type | Example | Required | Notes |
|---|---|---|---|---|
screen |
string | "journey_map" |
yes | |
from_chapter_number |
number | 1 |
yes | 1-based; the user's active chapter at tap time |
to_chapter_number |
number | 3 |
yes | 1-based; the chapter they picked |
to_chapter_id |
string | "1776754765374x…" |
yes | |
to_chapter_title |
string | "Slowing the Reaction" |
yes | |
dynamic |
boolean | false |
yes | target chapter's kind |
Source: JourneyPath.jsx
Insight
Insight Flow Started
Fires when an insight generation flow begins.
| Property | Type | Example | Required |
|---|---|---|---|
type |
string | "learn" / "activity" / "journey" |
yes |
learn_type_id |
string | "lt_01" |
when type=learn |
activity_id |
string | "act_01" |
when type=activity |
chapter |
number | 0 |
when type=journey |
replay |
boolean | false |
when type=journey |
dynamic |
boolean | false |
when type=journey |
Source: InsightFlow.jsx · InsightStep.jsx
Insight Question Answered
Fires per insight question response.
| Property | Type | Example | Required |
|---|---|---|---|
answer |
string | "yes" / "no" / custom text |
yes |
Source: InsightFlow.jsx · InsightStep.jsx
Insight Generated
Fires when insight generation succeeds. For type: 'learn' it fires when generation is triggered (legacy JS2B path). For type: 'journey' (BNS-1119, HTTP path) it fires when the synchronous generate_journey_insight call returns the insight, with the round-trip duration.
| Property | Type | Example | Required |
|---|---|---|---|
type |
string | "learn" / "journey" |
yes |
learn_type_id |
string | "lt_01" |
when type=learn |
chapter |
number | 0 |
when type=journey |
dynamic |
boolean | false |
when type=journey |
duration_ms |
number | 3100 |
when type=journey |
Source: InsightFlow.jsx (learn) · InsightStep.jsx (journey)
Insight Generation Failed
Fires when the journey insight HTTP generation fails after its transport retry (BNS-1119 — replaces Insight Generation Timed Out for the journey surface). The user sees the Retry UI.
| Property | Type | Example | Required |
|---|---|---|---|
type |
string | "journey" |
yes |
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
reason |
string | "timeout" / "http_5xx" / "http_4xx" / "network" |
yes |
duration_ms |
number | 51000 |
yes |
Source: InsightStep.jsx
Insight Generation Manual Retry
Fires when the user taps Retry on the journey insight failure screen.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
mode |
string | "generate" / "replay" |
yes |
Source: InsightStep.jsx
Insight Replay Loaded
Fires when opening a completed chapter's insight (replay) and the get_journey_insight read returns the stored insight.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
duration_ms |
number | 800 |
yes |
Source: InsightStep.jsx
Insight Replay Load Failed
Fires when opening a completed chapter's insight (replay) and the get_journey_insight read fails or finds no stored insight.
| Property | Type | Example | Required |
|---|---|---|---|
chapter |
number | 0 |
yes |
dynamic |
boolean | false |
yes |
reason |
string | "not_found" / "timeout" / "http_5xx" / "http_4xx" / "network" |
yes |
Source: InsightStep.jsx
Insight Generation Timed Out
Fires when insight generation exceeds the timeout. Journey surface removed in BNS-1119 (replaced by Insight Generation Failed); Activity surface removed in BNS-1088 slice 2 (replaced by Activity Create Failed — the 90s poll timer it fired from is deleted). From that release this event only fires for the Tab/Learn surfaces.
| Property | Type | Example | Required |
|---|---|---|---|
type |
string | "learn" (was also "activity" before BNS-1088 slice 2) |
yes |
learn_type_id |
string | "lt_01" |
when type=learn (always sent, may be null) |
activity_id |
string | — | RETIRED (was: when type=activity). The activity surface moved to Activity Create Failed in BNS-1088 slice 2; type is now learn-only, so this property no longer fires. |
Source: InsightFlow.jsx (learn/activity)
Insight Playback Completed
Fires when TTS playback finishes and user taps Continue.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: InsightFlow.jsx · InsightStep.jsx
Insight Playback Skipped
Fires when user taps Continue before TTS playback finishes.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: InsightFlow.jsx · InsightStep.jsx
Insight Flow Closed
Fires when user exits the insight flow without completing playback.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: InsightFlow.jsx
Simulator
Element Clicked — simulator / toggle / input_mode / mode (voice/text) / SimulatorSection. BNS-1071: the voice/text landing toggle persists the user's choice as their default next session (one shared preference across Ask, Practice and the Simulator). Fired only on an explicit toggle — never on the forced voice→text fallback, a "Try in simulator" seed, or a re-tap of the already-selected pill.
Mid-session breadcrumbs & drop position (BNS-1142). The session-boundary events say THAT a user dropped, not WHERE — and a user who backgrounds/kills the app emits nothing at all, so drop position is recovered from the last breadcrumb seen before the session went silent (
Simulator Clarification Asked/Submitted,Simulator Turn Completed). All simulator events carryseconds_since_start(whole seconds since the session'sSimulator Session Started). Recovery/exit events additionally carry the shared position props:
phase—"clarification"(dynamic/freeform Stage 1 Q&A) /"intro"(predefined-chapter intro card) /"conversation"(Stage 2 incl. the connecting transition) /"eval"(Stage 3) /null(before the first stage signal). Seeded (Try-in-simulator) sessions briefly report"clarification"during the pre-connect window even though they skip Stage 1 — accepted imprecision.clarifying_questions/clarifying_answers— Dr Leo questions delivered / user answers submitted this session (0 for predefined chapters). Reset when a Stage-1 restart (disconnect recovery, switch-to-text, mic granted) tears down the coach session and re-asks from question 1.turn_number— last completed Stage 2 conversation turn (0 if none; partner turns are odd 1/3, user turns even 2/4). Resets when an auto-restart re-enters Stage 2 with a fresh conversation. Bookkeeping lives insrc/utils/simulatorAnalytics.js.
Simulator Session Started
Fires when a simulator session begins (freeform or journey practice).
| Property | Type | Example | Required |
|---|---|---|---|
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
seconds_since_start |
number | 0 (always — funnel anchor) |
yes |
source |
string | "ask" |
only when the freeform session was pre-seeded by Ask Dr Leo's "Try in simulator" (omitted for plain Tab sessions) — enables the Ask → practice funnel |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Simulator Clarification Asked
Fires each time Dr Leo finishes delivering a clarifying question in Stage 1 (dynamic chapters and freeform/Tab sessions only — predefined chapters have no clarification phase). The count includes Leo's opening utterance, so question_number reads as "coach turn N". This is the key drop-localization breadcrumb for the dynamic-chapter mid-session churn analysis: Started with no Clarification Asked = died before Leo ever spoke (setup/mic/connection); last event = Asked {n} with no answer = quit while question n was on screen.
| Property | Type | Example | Required |
|---|---|---|---|
question_number |
number | 2 |
yes |
seconds_since_start |
number | 41 |
yes |
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
source |
string | "ask" |
when seeded from Ask "Try in simulator" |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | true |
when mode=journey (in practice always true — predefined never fires it) |
Source: SimulatorSession.jsx (bookkeeping in simulatorAnalytics.js)
Simulator Clarification Submitted
Fires when the user's answer to a clarifying question is finalized (voice transcription final or text send). question_number is the answer's own counter (answer N), pairing with Simulator Clarification Asked for engagement depth.
Same properties as Simulator Clarification Asked.
Source: SimulatorSession.jsx
Simulator Turn Completed
Fires each time a Stage 2 conversation turn finishes (partner utterance finalized / user reply transcribed or sent). Partner turns are odd (1, 3), user turns even (2, 4) — partner speaks first, two exchanges per session.
| Property | Type | Example | Required |
|---|---|---|---|
turn_number |
number | 3 |
yes |
speaker |
string | "partner" / "user" |
yes |
seconds_since_start |
number | 95 |
yes |
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
source |
string | "ask" |
when seeded from Ask "Try in simulator" |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSession.jsx
Simulator Session Completed
Fires when AI evaluation is received and results are shown.
| Property | Type | Example | Required |
|---|---|---|---|
score |
number | 4 |
yes |
skill_level |
string | "Finding Your Groove" |
yes |
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
seconds_since_start |
number | 142 |
yes |
clarifying_questions |
number | 3 (0 for predefined) |
yes |
source |
string | "ask" |
when seeded from Ask "Try in simulator" (see Simulator Session Started) — the completion end of the Ask → practice funnel |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Simulator Session Closed
Fires when user manually ends a session (via close dialog). Only covers in-app quits — a user who backgrounds/kills the app emits nothing; recover those drops from the last breadcrumb (see section note).
| Property | Type | Example | Required |
|---|---|---|---|
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
source |
string | "ask" |
when seeded from Ask "Try in simulator" (see Simulator Session Started) |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
| position props | — | phase / clarifying_questions / clarifying_answers / turn_number / seconds_since_start (see section note) |
yes |
Source: SimulatorSection.jsx
Simulator Session Retried
Fires when user retries the session from the results screen.
| Property | Type | Example | Required |
|---|---|---|---|
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
seconds_since_start |
number | 160 (session clock keeps running across the retry) |
yes |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Simulator Session Error
Fires on unrecoverable session error. For the JSON-extraction boundaries (Stage 1, Stage 3), this only fires after parse retries are exhausted — see Simulator Issue Retry / Simulator Eval Retry below for the per-attempt signal.
| Property | Type | Example | Required |
|---|---|---|---|
code |
string | "TIMEOUT" / "EVAL_PARSE_FAILED" / "ISSUE_PARSE_FAILED" / "UNKNOWN" |
yes |
message |
string | "Connection lost" |
yes |
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" / "text" |
yes |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
| position props | — | phase / clarifying_questions / clarifying_answers / turn_number / seconds_since_start (see section note) |
yes |
Source: SimulatorSection.jsx
Simulator Mic Permission Denied
Fires when the mic-permission dialog (MicPermissionDialogs) is shown — either a live PERMISSION_DENIED from the realtime session or the predefined-chapter Start-practice preflight detecting an already-denied mic. Negative-signal counterpart to Simulator Mic Permission Granted; separates mic-friction drops from clarifying-question churn.
| Property | Type | Example | Required |
|---|---|---|---|
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" |
yes |
chapter_id / dynamic / source |
— | as on Simulator Session Started |
conditional |
| position props | — | phase ("intro" on the predefined preflight) etc. (see section note) |
yes |
Source: SimulatorSession.jsx
Simulator Mic Permission Granted
Fires when the user returns from Settings and the visibility probe confirms mic access; the session silently restarts at the stage it was in. A Stage-1 restart tears down the coach session and re-asks from question 1 (clarification counters reset — see section note); a Stage-2 restart re-enters with a fresh conversation (turn numbering resets).
| Property | Type | Example | Required |
|---|---|---|---|
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" (still voice at fire time — the grant keeps voice) |
yes |
chapter_id / dynamic |
— | as on Simulator Session Started |
when mode=journey |
| position props | — | see section note | yes |
Source: SimulatorSection.jsx
Simulator Switch To Text
Fires when the user picks the text fallback from the mic-trouble dialogs; the session restarts stage-aware on the WebSocket transport (same Stage-1/Stage-2 reset semantics as Simulator Mic Permission Granted above).
| Property | Type | Example | Required |
|---|---|---|---|
mode |
string | "journey" / "freeform" |
yes |
input_mode |
string | "voice" (fires before the flip to text) |
yes |
chapter_id / dynamic |
— | as on Simulator Session Started |
when mode=journey |
| position props | — | see section note | yes |
Source: SimulatorSection.jsx
Simulator Eval Retry
Fires per attempt when the Stage 3 evaluation JSON fails to parse and we re-fire the silent response.create on the same session. After today's tool-calling change this should be near-zero in production — the model is structurally forced into schema-valid JSON. Kept as belt-and-suspenders.
| Property | Type | Example | Required |
|---|---|---|---|
attempt |
number | 1 |
yes |
max_retries |
number | 2 |
yes |
raw_length |
number | 412 |
yes |
raw_text_hash |
string | "a3f1b29c" (FNV-1a fingerprint of raw response, no PII) |
yes |
mode |
string | "journey" / "freeform" |
yes |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Simulator Eval Retry Recovered
Fires once when a Stage 3 eval succeeds after at least one retry. Lets analytics distinguish first-try clean sessions from "drift caught and recovered" sessions, both of which otherwise look identical at Simulator Session Completed.
| Property | Type | Example | Required |
|---|---|---|---|
attempts_total |
number | 2 (1 retry + final success = 2) |
yes |
mode |
string | "journey" / "freeform" |
yes |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Simulator Issue Retry
Stage 1 (issue-extraction) counterpart to Simulator Eval Retry. Same shape, same near-zero post-tool-call expectation.
| Property | Type | Example | Required |
|---|---|---|---|
attempt |
number | 1 |
yes |
max_retries |
number | 2 |
yes |
raw_length |
number | 188 |
yes |
raw_text_hash |
string | "7e22c014" |
yes |
mode |
string | "journey" / "freeform" |
yes |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Simulator Issue Retry Recovered
Stage 1 counterpart to Simulator Eval Retry Recovered.
| Property | Type | Example | Required |
|---|---|---|---|
attempts_total |
number | 2 (1 retry + final success = 2) |
yes |
mode |
string | "journey" / "freeform" |
yes |
chapter_id |
string | "ch_01" |
when mode=journey |
dynamic |
boolean | false |
when mode=journey |
Source: SimulatorSection.jsx
Coins Deducted
Fires when coins are spent — to unlock stage 2 of a freeform simulator session, or on the first paid turn of an Ask Dr Leo session.
| Property | Type | Example | Required |
|---|---|---|---|
amount |
number | 8 |
yes |
context |
string | "simulator" | "ask" |
yes |
via |
string | "journey_map" |
ask only (BNS-1067) |
via (journey_map | step_end | dr_leo_tab | deep_link) is sent on the
context: 'ask' deduct so paywall attribution separates the ask entry points; the
context: 'simulator' deduct does not carry it.
Source: SimulatorSection.jsx (simulator) · AskSession.jsx (ask)
Coins Gifted
Fires when the user receives a one-time coin gift (e.g. from the "Not Enough Coins" dialog).
| Property | Type | Example | Required |
|---|---|---|---|
amount |
number | 3 |
yes |
previousBalance |
number | 0 |
yes |
Source: NotEnoughCoinsDialog.jsx
Activities
Activity Create Started
Fires when user initiates creating a new activity.
| Property | Type | Example | Required |
|---|---|---|---|
act_type_id |
string | "1712x459…" (Bubble unique id) |
yes |
act_type_name |
string | "Funky Date" |
no (absent if the type list hasn't loaded yet, e.g. deep-link cold start) |
Source: ActivityView.jsx
Activity Created
Fires when generate_activity_insight returns an activity — the moment the user
is charged. Pairs with Activity Create Started to give the fun-zone creation
funnel: every Started resolves into exactly one Activity Created or one
Activity Create Failed (unless the user leaves mid-flight, which fires
neither — the client bails before the billable call). Added in BNS-1088 slice 2.
| Property | Type | Example | Required |
|---|---|---|---|
act_type_id |
string | "1710515984265x220966944209895420" (Bubble unique id) |
yes |
act_type_name |
string | "Funky date" |
no (absent if the type list hasn't loaded yet) |
duration_ms |
number | 3120 (typically ~3000-5000) |
yes |
Source: ActivityView.jsx
Activity Create Failed
Fires when the creation call fails, or when the server rejects it for balance.
Replaces the fun-zone use of Insight Generation Timed Out (the 90s poll timer
that this migration deleted). Added in BNS-1088 slice 2.
| Property | Type | Example | Required |
|---|---|---|---|
act_type_id |
string | "1710515984265x220966944209895420" |
yes |
act_type_name |
string | "Funky date" |
no |
reason |
string | "http_5xx" | "http_4xx" | "timeout" | "network" | "insufficient_coins" | "empty_content" |
yes |
duration_ms |
number | 25010 |
yes |
insufficient_coins is the server's balance gate and means no charge and no
activity — the client already gates on SPEND.funZoneAct before opening the
view, so it only fires when the client's balance was stale. empty_content
means the workflow charged and returned ok but the act parsed to zero
steps — the (already applied) balance stands, but the unusable activity is not
saved to history and the user gets the error/retry screen. The other four come
from the shared failureReason enum (src/utils/failureReason.js), identical
across every migrated LLM surface so the funnels stay comparable.
Source: ActivityView.jsx
Activity View Completed
Fires when user finishes viewing/listening to an activity.
| Property | Type | Example | Required |
|---|---|---|---|
activity_id |
string | "1712x460…" (Bubble unique id) |
yes |
title |
string | "A picnic under the stars" |
no (absent if the history record has no title) |
replay |
boolean | false |
yes |
Source: ActivityView.jsx
Activity View Closed
Fires when user closes an activity without completing it.
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: ActivityView.jsx
Daily Question
Daily Question Viewed
Fires when the daily question appears on screen.
| Property | Type | Example | Required |
|---|---|---|---|
question_id |
string | "dq_20260318" |
yes |
category |
string | "intimacy" |
yes |
Source: DailyQuestion.jsx
Daily Question Voted
Fires when user selects an answer.
| Property | Type | Example | Required |
|---|---|---|---|
question_id |
string | "dq_20260318" |
yes |
index |
number | 1 |
yes |
Source: DailyQuestion.jsx
Partner
Partner Connected
Fires when a partner is connected for the first time (partner_connected transitions false → true).
| Property | Type | Example | Required |
|---|---|---|---|
| (none) | — | — | — |
Source: UserContext.jsx
Payments
All payment events run on the React side via the Natively RevenueCat bridge and a direct RC REST read for entitlement state — Bubble is uninvolved.
Removed:
Paywall Viewed/Paywall Dismissed— these only fired from RC's hosted paywall (EntitlementContext.showPaywall()), which has been removed. The app uses a single custom paywall (PlansScreen), tracked viaPlans Screen Shown(see below).
Purchase Started
Fires when buy(packageId) is called.
package_id(string)source(string)
Purchase Completed
Fires when the purchasePackage callback returns status: 'SUCCESS'.
package_id(string)transaction_id(string|null)
Purchase Failed
Fires when the purchasePackage callback returns a non-SUCCESS status (or never
fires — a null bridge response after the 10s timeout).
package_id(string)reason(string) — best available failure signal, preferringerror, thenmessage, thenstatus;bridge_timeoutwhen the callback never fired, elseunknown. (Natively reports cancels/errors viastatus/message, not alwayserror.)user_cancelled(bool) — true whenstatusisCANCELLEDor any signal mentions "cancel"callback_status(string) — raw bridgestatus(missingif absent)callback_message(string|null) — raw bridgemessagebridge_elapsed_ms/total_elapsed_ms(number) — bridge callback time and total incl. entitlement re-poll
Genuine failures (not user cancellations) also emit a log.error → Sentry Issue;
cancellations emit log.warn (queryable log, no Issue).
Purchase Pending
Fires when buy() can't confirm the outcome (callback returned no usable signal
and the entitlement hasn't flipped within buy()'s window) — the purchase may
still have succeeded. The recovery watcher keeps polling the entitlement.
package_id(string)reason(string) — usuallybridge_timeout/unknownbridge_elapsed_ms/total_elapsed_ms(number)
Purchase Confirmed Late
Fires when the recovery watcher confirms a pending purchase via the RevenueCat
entitlement flip (after buy() already returned). Routes into the normal
celebration. This is a success that the old code would have logged as Purchase Failed.
package_id(string)elapsed_ms(number) — time frombuy()start to confirmationrecovered_via(string) —mount|foreground|backoff|poll
Purchase Unconfirmed
Fires once when a pending purchase is still unconfirmed after the active confirm window (~5 min) — the Refresh/Restore give-up affordance is shown.
package_id(string)elapsed_ms(number)
Purchase Restore Tapped
Fires when the user taps Restore Purchases.
source(string)
Purchase Restore Completed
Fires after the post-restore entitlement refresh.
became_subscribed(bool) — true if restore flipped state from non-paying → paying
Plans screen events
Custom in-app paywall at PlansScreen.jsx. Pushed from Profile → Subscription.
Screen Viewed { screen: 'plans' }— on mount.Element Clicked { screen: 'plans', element_type: 'card', element: 'plan_<label>' }— when a plan row is selected (<label>= key fromAPP_CONFIG.purchases.packages, e.g.monthly).Element Clicked { screen: 'plans', element_type: 'button', element: 'subscribe' }— Subscribe CTA tapped (followed by the standardPurchase Started/Purchase Completed/Purchase Failedchain).Element Clicked { screen: 'plans', element_type: 'link', element: 'restore' }— Restore link tapped (followed byPurchase Restore Tapped/Purchase Restore Completed).Element Clicked { screen: 'plans', element_type: 'button', element: 'refresh_pending' }— Refresh tapped on the purchase-pending give-up affordance (re-reads the entitlement).Element Clicked { screen: 'profile', element_type: 'button', element: 'subscription' }— Subscription row in Profile tapped (entry into the plans screen).
Purchase Started/Purchase Completed/Purchase Failed/Purchase Restore Tapped/Purchase Restore Completed already fire from EntitlementContext.buy/restore; the plans screen passes source: 'plans_screen' so the funnel can be segmented by entry point.
Entitlement Changed
Fires whenever isSubscribed flips on EntitlementContext (renewal, expiry, cross-device sync).
is_subscribed(bool)
Also: is_subscribed is set as a Mixpanel People property every time
EntitlementContext resolves, so the latest known state lives on the user
profile for funnel segmentation.
Cohort coin-gate events
Fired by the shared useCoinGate hook (src/hooks/useCoinGate.js), which
funnels every coin-spending action (chapter start, simulator session, activity
creation, etc.) through one cohort-aware path. Replaces the legacy local
if (coins < N) checks scattered across feature files.
Cohort ('A' | 'B' | 'C') is not encoded on these events — it lives on the
Mixpanel user profile as the payment_variant People property (the effective
value incl. admin overrides, set by <PaymentVariantSeeder>). Segment/break down
any coin-gate event by payment_variant for cohort funnels. (The raw Statsig
bucket is intentionally not mirrored to Mixpanel — Statsig keeps the exposure for
experiment analysis.)
What these events add instead is gate_outcome — which branch the gate took,
orthogonal to cohort:
premium— subscribed, gate bypassed (no variant consulted).sufficient— enough coins, gate passed (no variant consulted).insufficient— balance short, the cohort drove the decision (A → auto-refill, B/C → paywall). Combine withpayment_variantto see the A-vs-B/C split.Coin Gate Triggered— every timegate.request()orgate.requestAccess()fires. Properties:source,cost,coins_before,gate_outcome,is_subscribed. Use this as the top of any coin-gate funnel.Coin Gate Blocked— fires when the user is short on coins (always theinsufficientbranch). Properties:source,cost. Segment bypayment_variantfor the cohort.Coin Auto Refill Granted— Group A path (also fires for unbucketed users who fall through to A). Properties:source,amount(EARN.autoRefill= 20 since BNS-1118, was 30).Coin Grant Dialog Shown— theCoinGrantDialogbottom-sheet. Two variants (Properties:amount,variant):variant: 'refill'— Group-A auto-refill notice;amount20. Two divided parts since BNS-1149 (the old "out of coins / lucky day" copy read as a usage barrier):- Part 1 — "Here's 20 coins, on us / Coins are already added to your
account. Keep going" with a Continue button that closes the sheet and
fires
Element Clicked { screen: 'coin_grant', element_type: 'button', element: 'continue' }(a CTA-driven close — noDismissedevent, same convention as the paywall CTAs). - Part 2 — the ambassador upsell, a bordered box reading "Become a
{appName} ambassador / book a call and get a free / 1-year unlimited
subscription" (BNS-1118, renamed from
give_feedback) — opens a one-click call booking (Lior's Google Calendar, system browser) instead of Intercom, and firesElement Clicked { screen: 'coin_grant', element_type: 'link', element: 'book_ambassador_call' }.
- Part 1 — "Here's 20 coins, on us / Coins are already added to your
account. Keep going" with a Continue button that closes the sheet and
fires
variant: 'share'— the share-reward confirmation ("Thanks for sharing! +10 … come back tomorrow");amount=EARN.share(10). No feedback link.
Coin Grant Dialog Dismissed— theCoinGrantDialogwas closed without a CTA (backdrop tap or swipe-down; the Continue button instead fires itsElement Clicked— see above). Property:variant('refill'|'share'). Fires once per open. For therefillvariant any close (Continue or dismiss) is load-bearing (BNS-1118): closing is what fires the deferred gated action (create act / start chapter), and both closes are the "declined the ambassador offer" negative signal — the booking link does not close the sheet. So theCoin Grant Dialog Shownfunnel resolves intoElement Clicked { element: 'book_ambassador_call' }(engaged) +Element Clicked { element: 'continue' }+ this dismiss.Insufficient Coins Paywall Shown— Group B / C path. Properties:source. Segment bypayment_variantfor the cohort. Followed by the standardPurchase Startedchain when the user taps Go Premium.- The paywall is a bottom-sheet (
InsufficientCoinsPaywallDialog) with three CTAs, each firingElement Clicked { screen: 'insufficient_coins_paywall', element_type, element }:element: 'go_premium'(→ PlansScreen),'daily_questions'(→ Fun Zone tab),'give_feedback'(→ profile feedback). Insufficient Coins Paywall Dismissed— fires when the sheet is closed without picking a CTA (backdrop tap), closing theInsufficient Coins Paywall Shown→ CTA / dismiss funnel. Properties:source. A CTA-driven close does not fire this (theElement Clickedalready records that outcome).
- The paywall is a bottom-sheet (
Coin Balance Panel Shown— fires when the non-premium user taps the coin counter and the "Your coins" sheet (CoinBalancePanel) opens. Properties:coins. The counter tap itself firesElement Clicked { screen, element_type: 'button', element: 'coin_counter' }(screen ∈ the surface:home/journey/daily_question); the sheet's CTA firesElement Clicked { screen: 'coin_balance_panel', element: 'go_premium' }.Payment Variant Stamped— fires once when<PaymentVariantSeeder>seeds the Bubblepayment_variantfield on a fresh server-empty report. Properties:variant('A' | 'B' | 'C'),source('statsig'for a real assignment, or'unbucketed_default'when the user is unbucketed and gets control'A'). Does not fire for users who already have a value (Bubble source of truth / admin override) or when Statsig is unavailable (outage → retries next session).
Premium-user surfaces
Premium Badge Tapped— fires when a subscriber taps the top-bar premium badge. Properties:surface('home' | 'daily_question').Premium Status Viewed— fires when thePremiumStatusDialogopens. Properties:subscription_type,expires_at.Manage Subscription Tapped— fires when the user taps "Cancel subscription" in the status dialog (deep-links to the OS subscription manager). Properties:platform('ios' | 'android' | 'web').
Subscription lifecycle screens
Plans Screen Shown— fires every time the redesignedPlansScreenmounts or is surfaced as an overlay. Properties:source('profile' | 'post_onboarding_b' | 'insufficient_coins' | 'coin_panel' | 'expired_modal' | 'badge_tap'). Coexists with the olderScreen Viewed { screen: 'plans' }event for back-compat; new funnels should usePlans Screen Shownto segment by entry point.Plans Screen Dismissed— fires when the user leavesPlansScreenwithout converting (closes thePlans Screen Shown→ subscribe / restore / dismiss funnel). Properties:source(same enum asPlans Screen Shown),reason('back'= back chevron on the normal plans view;'already_subscribed'= back chevron or Continue on the "You're Premium" state). Does not fire on the mandatory Group-B gate (back is hidden — the only exits are subscribe or restore).Subscription Success Shown— fires when the post-purchase celebration screen mounts. Properties:package_id,subscription_type. The Continue button that exits the screen firesElement Clicked { screen: 'subscription_success', element_type: 'button', element: 'continue' }.Subscription Expired Modal Shown— fires when the expired-notice dialog appears (RC entitlement flipped subscribed → not since last session). Properties:last_type('yearly' | 'quarterly' | 'monthly' | null).Subscription Expired Action— fires when the user resolves the expired notice. Properties:last_type,action('renew' | 'continue'),bonus_granted(bool — true if Bubble was expected to grant 10 welcome-back coins).
People + super properties
subscription_type is pushed as a Mixpanel People property by
EntitlementContext on every entitlement refresh — RevenueCat is the source of
truth (yearly/quarterly/monthly, null when not subscribed), set
alongside is_subscribed. It is not sourced from Bubble/UserContext (that
field was never reliably pushed).
The cohort variant is carried by a single People (user-profile) property:
payment_variant: "A" | "B" | "C"— the effective variant (Bubble source-of-truth field, including admin overrides),people.setby<PaymentVariantSeeder>(src/hooks/usePaymentVariant.js). It's a profile property (not a super property) because the cohort is sticky and server-owned: it survivesreset(), is queryable on the user level, and remains segmentable in event reports.
Use payment_variant for all cohort-segmented funnel analysis — break events
down by it in reports; it reflects the experience the user actually got. The raw Statsig assignment is not mirrored
to Mixpanel as experiment_payments (the payments experiment is suppressed in
attachMixpanelMirror, src/utils/experiments.js, to keep one cohort property);
use Statsig's own exposure data for randomized (intent-to-treat) experiment
analysis.
System
Report Submitted
Fires when a bug report is sent (via clipboard copy or Slack relay).
| Property | Type | Example | Required |
|---|---|---|---|
platform |
string | "ios" |
yes |
has_logs |
boolean | true |
yes |
log_count |
number | 42 |
yes |
delivery |
string | "slack" / "clipboard" |
yes |
Source: logCapture.js
Profile Photo Updated
Fires when user crops and saves a new profile photo.
| Property | Type | Example | Required |
|---|---|---|---|
screen |
string | "profile" |
yes |
Source: MainTabs.jsx
FTUE Walkthrough Completed
Fires when the user finishes all steps of the first-time user experience walkthrough.
| Property | Type | Example | Required |
|---|---|---|---|
steps_seen |
number | 5 |
yes |
is_replay |
boolean | true |
yes |
Source: DrLeoWalkthrough.jsx
TTS Error Occurred
Fires when TTS streaming fails (network error, proxy down, etc.). Centralized in useTTS hook — all TTS consumers get tracking automatically.
| Property | Type | Example | Required |
|---|---|---|---|
backend |
string | "mse" / "pcm" |
yes |
error_message |
string | "Failed to fetch" |
yes |
screen |
string | "insight_playback" / "activity_view" / "simulator_results" |
yes (via caller context) |
Source: useTTS.js
Support Unread Badge Shown
Fires when the Intercom unread badge appears (count rises from 0 to n) — a red dot on the profile nav tab plus a count pill on the Profile → Support "Share feedback" row. Fires once per appearance (rising edge), not on every count change; the badge clears itself when the user opens the messenger (Intercom marks the conversation read). Only possible after Intercom boots (logged-in, JWT delivered).
| Property | Type | Example | Required |
|---|---|---|---|
count |
number | 2 |
yes |
Source: MainTabs.jsx (count from useIntercomUnread / utils/intercom.js)
Push Notification Prompt
Push Prompt Shown
Fires when the pre-permission dialog appears.
| Property | Type | Example | Required |
|---|---|---|---|
trigger |
string | "post_signup" / "session_start" / "reinstall" / "act_reminder" |
yes |
mode |
string | "first_time" / "settings_redirect" |
no (only present for act_reminder trigger) |
Source: MainTabs.jsx, useNotificationGate.jsx
Push Prompt Accepted
Fires when user taps "Enable notifications" in the pre-permission dialog. Tracked BEFORE the native OS prompt.
| Property | Type | Example | Required |
|---|---|---|---|
trigger |
string | "post_signup" / "session_start" / "reinstall" / "act_reminder" |
yes |
mode |
string | "first_time" / "settings_redirect" |
no (only present for act_reminder trigger) |
Source: MainTabs.jsx, useNotificationGate.jsx
Push Prompt Dismissed
Fires when user taps "Not now" in the pre-permission dialog.
| Property | Type | Example | Required |
|---|---|---|---|
trigger |
string | "post_signup" / "session_start" / "reinstall" / "act_reminder" |
yes |
Source: MainTabs.jsx, useNotificationGate.jsx
Push Settings Redirect Shown
Fires when the "previously denied" dialog appears in Profile.
Source: MainTabs.jsx
Push Settings Redirect Accepted
Fires when user taps "Open Settings" from the profile notification dialog.
Source: MainTabs.jsx
Store Reviews (rating prompt)
The custom Like/Dislike pre-prompt that gates the native OS store-review sheet.
Screen: rating_prompt. See the plan: docs/superpowers/plans/2026-06-29-store-reviews-rating-prompt.md.
Rating Prompt Shown
Fires when the pre-prompt is shown (after the gate passes and the home surface settled).
| Property | Type | Example | Required |
|---|---|---|---|
prompt_ordinal |
number | 1 (first) or 2 (second) |
✅ |
trigger |
text | chapters | single_steps | sessions | both (both only on the 2nd prompt when both engagement deltas fire) |
✅ |
chapters_finished |
number | 2 |
✅ |
single_step_count |
number | 3 |
✅ |
session_count |
number | 4 |
✅ |
Source: useRatingPrompt.js
Native Store Review Requested
Fires when the user taps "Rate Bonds in the store" and the native review sheet is invoked. Note: fire-and-forget — the OS decides whether to display it, and a submitted review is not detectable.
| Property | Type | Example | Required |
|---|---|---|---|
prompt_ordinal |
number | 1 |
✅ |
Source: useRatingPrompt.js → appReview.js
Rating Feedback Opened
Fires when the user taps "Not yet" → "Please tell us how we can do better" and the feedback flow opens.
| Property | Type | Example | Required |
|---|---|---|---|
prompt_ordinal |
number | 1 |
✅ |
Source: useRatingPrompt.js → openFeedback
Rating Prompt Dismissed
Fires when the pre-prompt closes without a response — either the user taps the X / backdrop (reason: 'user'), or the prompt auto-hides because the user left the settled home surface while it was visible (reason: 'left_surface', BNS-1115). Every Rating Prompt Shown therefore resolves into a response event or exactly one Dismissed (modulo app kills).
| Property | Type | Example | Required |
|---|---|---|---|
prompt_ordinal |
number | 1 |
✅ |
reason |
text | user | left_surface — X/backdrop vs auto-hide on leaving home |
✅ |
from_step |
text | ask | loved | notYet — which step the X was tapped on (isolates the "tapped 👍 but didn't rate" cohort) |
only when reason: 'user' |
Source: useRatingPrompt.js
5. Element Clicked Reference
All Element Clicked events. Properties always include screen, element_type, and element. Extra context properties listed in the last column.
Welcome & Auth
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
welcome |
button |
lets_go |
— | WelcomeScreen |
welcome |
button |
sign_in |
— | WelcomeScreen |
signin |
button |
back |
— | SignInScreen |
signin |
button |
go_to_signup |
— | SignInScreen |
signin |
link |
continue_with_email |
— | SignInScreen |
signin |
link |
forgot_password |
— | SignInScreen |
signup |
button |
back |
— | SignUpScreen |
signup |
button |
go_to_signin |
— | SignUpScreen |
signup |
link |
continue_with_email |
— | SignUpScreen |
signup |
link |
go_to_signin |
— | SignUpScreen |
env_switch_prompt |
button |
switch_here / go_home |
— | App.jsx (EnvSwitchPrompt, staging QA only) |
FTUE Walkthrough
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
ftue_walkthrough |
button |
next |
step |
DrLeoWalkthrough |
ftue_walkthrough |
button |
skip |
step |
DrLeoWalkthrough |
ftue_walkthrough |
button |
back |
step |
DrLeoWalkthrough |
Main Navigation
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
main |
tab |
journey / dr-leo / fun / profile |
— | MainTabs |
| {activeTab} | button |
back |
— | MainTabs (stack) |
Dr Leo
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
dr_leo_choice |
button |
ask_anything |
— | DrLeoChoiceScreen |
dr_leo_choice |
button |
conversation_simulator |
— | DrLeoChoiceScreen |
dr_leo_choice |
link |
history_link |
— | DrLeoChoiceScreen |
ask_landing |
button |
ask_start |
mode |
AskLanding |
ask_landing |
toggle |
input_mode |
mode (voice/text) |
AskFlow (BNS-1071 — persists the choice as the default next session) |
ask_session |
chip |
suggested_question |
— | AskSession (tap a suggested-question chip) |
ask_answer |
button |
act_upon_it / try_in_simulator / follow_up_question / ask_close / ask_share / ask_play / ask_pause / ask_replay / ask_mute |
muted (on ask_mute) |
AskAnswer |
ask_act |
button |
act_accept / act_decline / act_back / retry_generation |
via (all but retry_generation) |
AskActFlow |
act_acceptance |
button |
ok_open_drawer / choose_moment |
source (ask) |
AskActFlow — both CTAs open the same moment drawer; neither commits |
act_acceptance |
button |
moment_today / moment_tomorrow / moment_in_two_days / moment_skip |
source (ask), when |
AskActFlow — the commit resolve point. moment_skip sends when: 'ok' |
act_acceptance |
sheet |
moment_dismissed |
source (ask) |
AskActFlow — backdrop/drag dismiss. Not a commit, unlike moment_skip |
act_acceptance |
button |
acceptance_back |
source (ask), via |
AskActFlow — the header back control while on the acceptance screen. Attributed here, NOT to ask_act, so the proposal funnel isn't polluted and the acceptance funnel has an abandonment leg |
how_did_it_go |
button |
tried_meaningful / tried_uncomfortable / hesitated / no_moment / other |
source (ask) |
AskActFlow — the five outcome pills. other advances to the text screen and reports on submit, not here |
other_input |
button |
submit_other |
source (ask) |
AskActFlow — submits the free-text reflection AND the other outcome together |
other_input |
button |
mic_start / mic_stop |
source (ask), via |
AskActFlow — the mic toggle on the reflection screen. Recording auto-starts on entry, so mic_stop is the common first press. Journey acts fire the same pair scoped by chapter |
other_input |
button |
other_back |
source (ask), via |
AskActFlow — backing out of the free-text screen without submitting. Act Outcome Reported { outcome: 'other' } already fired on the pill but the server write is deferred to submit, so this is the leg that resolves the other count into submitted + abandoned |
act_not_done |
button |
set_new_reminder / stop_notifications |
source (ask) |
AskActFlow |
act_not_done |
button |
reschedule_today / reschedule_tomorrow / reschedule_in_two_days |
source (ask) |
AskActFlow — the moment drawer reopened in reschedule mode. Skip is a no-op here (there is no "reschedule to no time") |
act_not_done |
sheet |
moment_dismissed |
source (ask) |
AskActFlow — dismissing the reschedule drawer changes nothing |
act_check_in |
button |
check_in_done |
source (ask), via |
AskActFlow — the shared exit from the performed / thank-you / not-done screens |
act_check_in |
button |
check_in_back |
source (ask), via |
AskActFlow — the shell header's back/close control on the outcome-pill screen, fired alongside Act Check-In Dismissed. Every phase past the pills ships its own header (and its own check_in_done), so the shell header is not rendered there at all |
journey_map |
button |
ask_hand_reveal |
chapter_id |
AskHand on the journey map — intro (first-exposure) tap 1 only, reveals the "Tap to ask" label. Also settles the intro animation for the rest of the session. A settled hand skips this and opens on one tap (BNS-1147). |
journey_map |
button |
ask_hand_open |
step, chapter_id |
AskHand on the journey map — opens the Ask intro screen (tap 2 on a first-exposure hand, or tap 1 on a settled hand — BNS-1147). Between chapters (the paused / generic open, where the ask isn't tied to a chapter) step and chapter_id are OMITTED — so the event isn't falsely attributed to a chapter. |
learn_video / insight_playback / simulator_results |
button |
ask_hand_reveal |
step, chapter_id |
AskHand at the end of a chapter step — intro (first-exposure) tap 1 only (a settled step-end hand opens on one tap — BNS-1147). |
learn_video / insight_playback / simulator_results |
button |
ask_hand_open |
step, chapter_id |
AskHand at the end of a chapter step — opens the Ask intro screen (tap 2 first-exposure, or tap 1 settled). |
Ask hand (BNS-1067 — Ask Dr Leo PART 2)
The floating Ask affordance, on the journey map and at the end of the Learn, Insight and Practice steps. It is a two-tap control on first exposure only: the intro (unseen) hand reveals the "Tap to ask" label on tap 1 and opens on tap 2, whereas a settled hand (already exposed this session) opens on a single tap and never re-shows the explanation (BNS-1147). So the reveal leg is present only for the first-exposure funnel:
- First exposure (
phase: 'intro'):Ask Hand Shown→ask_hand_reveal(tap 1) →ask_hand_open(tap 2) - Settled (
phase: 'settled'):Ask Hand Shown→ask_hand_open(single tap; noask_hand_reveal)
A user who sees the hand and never opens it (ignores it, or reveals the label and
walks away) fires Ask Hand Dismissed, so Ask Hand Shown fully resolves into
opens + walk-aways — the negative branch is an explicit event, not just a derived
gap.
| Event | Properties | Fires when | Source |
|---|---|---|---|
Ask Hand Shown |
surface (journey_map / step_end), phase (intro / settled), step, chapter_id |
The hand mounts on a surface. phase: 'intro' means the user is seeing the first-run animation for this session; settled means they've already tapped it this session. |
AskHand via JourneyScreen (map) / StepEndAskHand (step ends) |
Ask Hand Dismissed |
surface (journey_map / step_end), reason (navigated_away / step_advanced) |
The user leaves without ever opening Ask. step_end (step_advanced): fires once when the step-end hand unmounts un-opened (Continue/Done or leaving the step). journey_map (navigated_away): fires once per map visit — on leaving the map (tab switch / opening a chapter) if the hand was shown that visit but never opened. Scrolling between chapters is engagement, not a dismissal, so the map hand does NOT fire it per chapter even though Ask Hand Shown re-fires per chapter on scroll. |
StepEndAskHand (via AskHand unmount) / JourneyScreen (map-visit level) |
Property notes:
surface—journey_map, orstep_endfor the end of Learn / Insight / Practice. Cross-referencescreenon theElement Clickedrows to tell the three step ends apart.step— the step just performed, NOTprogress.currentStep(which is where the user goes next). At a step end it is that step (learn/insight/practice). On the map it is the most recently completed step of the chapter on screen —actfor a completed chapter.chapter_id— the chapter the hand carries into Ask. On a paused ("To be revealed tomorrow…") or freshly-unlocked-but-unstarted map this is the previous, completed chapter, not the one the map is nominally showing.phaseis deliberately reported rather than inferred: the intro plays once per app session, sointrovssettleddistinguishes first contact from repeat exposure within that session.
No Act step end — by design. The PRD excludes it ("we have critical actions
we don't want to compete with"), compensating with the map hand afterwards. So
surface: 'step_end' will never carry step: 'act'; an act-related ask arrives
as surface: 'journey_map', step: 'act'. A funnel that expects four step ends
is mis-specified.
One intro per SESSION, not per surface and not per chapter (BNS-1147,
product 2026-07-27 — this superseded the per-chapter scope shipped for
BNS-1067). The "already tapped" flag lives in sessionStorage and is shared
across all four surfaces, so a reveal at the end of Learn makes the map hand —
and every later chapter's hand — report phase: 'settled'. Expect at most one
phase: 'intro' per app session, and a fresh one after a relaunch.
Board warning: any funnel or breakdown that assumed one
introper chapter will see theintrocount drop sharply from this release, with the difference landing insettled. The property values are unchanged; only how often each occurs is.
via on the Ask events (BNS-1067)
Every Ask domain event below carries via — where the ask was opened from:
via |
Means |
|---|---|
dr_leo_tab |
The Dr Leo tab's "How can I help?" choice screen (the original entry). |
journey_map |
The Ask hand on the journey map. Carries chapter + step context. |
step_end |
The Ask hand at the end of Learn / Insight / Practice. Carries chapter + step context. |
deep_link |
A bare ?dl=/dr-leo/ask link — seeds the Ask screen with no context. |
Use via, not source. source is already taken on the Ask↔simulator
funnel and means the opposite direction (source: "ask" on
Simulator Session Started marks Ask → simulator outbound, see the
Simulator section). Reusing it for inbound entry would corrupt that funnel.
via is on all ask events including the negative ones (Ask Session Error,
Ask Voice Fallback, Ask Session Abandoned) — otherwise a contextual ask's
failure paths would be unattributable and the per-entry funnel couldn't resolve.
An ask reached by tapping through the choice screen reports dr_leo_tab even if
that tab was originally opened from the journey: walking back to the choice
screen clears the context, so the ask genuinely is no longer about that chapter.
Ask Dr Leo (domain events)
| Event | Properties | Fires when | Source |
|---|---|---|---|
Ask Session Started |
mode (text/voice), is_follow_up, via |
A new ask session connects | AskSession.jsx |
Ask Answer Received |
mode, clarifying_turns, via |
Leo delivers the marked answer | AskSession.jsx |
Ask Act Generated |
via, duration_ms |
generate_ask_act returned an act. Replay-reuse, NOT strict idempotency: normal re-entry (decline then tap again) finds the stored completed Act (the workflow gates on non-empty text, so an act blanked by a failed generation is not replayed) and returns it without billing, so the event fires again with a short duration_ms (reused <1.5s vs fresh ≈4-5s). It is a search-then-create workflow with no uniqueness constraint or lock, so concurrent retries (a transport timeout racing the original) can still create a second Act and bill a second generation — the design is duplicate-tolerant, not duplicate-proof. Don't treat repeat events as guaranteed-free. |
AskActFlow.jsx |
Ask Act Generation Failed |
via, reason (http_5xx | http_4xx | timeout | network — the shared utils/failureReason enum, same as every other LLM surface), duration_ms |
Generation failed and the retry screen is showing. Pairs with Element Clicked { element: 'retry_generation' }. |
AskActFlow.jsx |
Ask Act Screen Viewed |
via |
The act proposal actually painted (NOT the loader). Funnel head for accept-vs-decline. | AskActFlow.jsx |
Ask Act Declined |
via |
User tapped "I'm not ready yet". The negative branch that lets Ask Act Screen Viewed resolve fully into accept + decline + back. |
AskActFlow.jsx |
Act Acceptance Screen Viewed |
source (ask), via |
The acceptance screen painted after "I'll give it a try". Separates "accepted the act" from "picked a moment" — the drop-off between the two is otherwise invisible. | AskActFlow.jsx |
Act Commit Failed |
source (ask), when, reason (shared utils/failureReason enum) |
act_commit did not return 200. Compensating event for the optimistic-looking flow: the user stays on the acceptance screen, an inline notice appears, and the CTAs act as a live retry. |
AskActFlow.jsx |
Act Acceptance Abandoned |
source (ask), via |
User left the acceptance screen via the header back control without picking a moment or skipping. The third leg that makes Act Acceptance Screen Viewed fully resolvable into committed + failed + abandoned. |
AskActFlow.jsx |
Act Check-In Viewed |
source (ask), via (deep_link | app_open) |
The "How did it go?" screen painted. Funnel head for the check-in leg. | AskActFlow.jsx |
Act Check-In Dismissed |
source (ask), via |
User left "How did it go?" via the header back control without picking an outcome — the check-in funnel's negative signal, so Act Check-In Viewed = Outcome Reported + Dismissed (+ rare app kills). Fires only from the outcome-pill screen: past it the loop is already closed server-side, so a back-out there is a plain exit and reports the check_in_back click alone. Journey acts fire the same event with chapter. |
AskActFlow.jsx (ask) / ActStep.jsx (journey) |
Act Check-In Surface Aborted |
reason (tab_changed), dropped, tab |
The app-open queue was built but abandoned before its entries surfaced — the user left the journey tab during the get_due_acts round-trip or the deferred push. Nothing is stamped, so every dropped entry is still due on the next launch. This is what makes Act Check-In Surfaced resolvable: count - dropped = Act Check-In Viewed{via: app_open}. deferred is NOT in this equation — count is already the post-cap queue size, and the deferred entries sit outside it (subtracting them undercounts the check-ins that were actually seen). Without this event, aborted queues read as lost events. |
MainTabs.jsx |
Act Check-In Prefetch Failed |
source (journey), via (app_open), reason |
A journey act was about to surface on app open and its chapter body could not be prefetched. The check-in still shows (ChapterFlow falls back to its own loader), so without this event the symptom is a check-in that renders slowly or blank with no way to count it. Ask acts carry their text inline and never hit this path, hence source: journey only. |
MainTabs.jsx |
Act Check-In Unavailable |
source (ask), via (deep_link | app_open), reason (fetch_failed, else absent) |
via: 'app_open' is always reason: 'fetch_failed' — the app-open gate's get_due_acts call failed and the queue degraded to journey-only; it is what separates "nobody had a due ask act" from "the endpoint was down", which Act Check-In Surfaced alone cannot. via: 'deep_link': a check-up push was tapped but the act is no longer due — already reported, or the reminder was cancelled — or the get_due_acts lookup failed. The user lands on the Dr Leo choice screen. Without this, a tapped push that goes nowhere is invisible; it is the denominator for "did the check-up actually deliver a check-in?". |
DrLeoSection.jsx |
Act Outcome Reported |
source (ask), outcome, via |
Fires for ALL five pills including other, so the funnel covers every option the user actually picked — even though other's server write is deferred to submit. |
AskActFlow.jsx |
Act Outcome Report Failed |
source (ask), outcome, reason, via |
act_report_outcome did not return 200. The screen has already advanced (reporting is a record, not a gate), but the act stays due and WILL resurface — so this is not cosmetic, it's the signal that a reported outcome was lost. |
AskActFlow.jsx |
Act Other Submitted |
source (ask), via, length, input_mode (text/voice) |
Free-text/voice reflection submitted. length only (never the text) — the reflection is personal content and does not belong in analytics; the journey twin logs response raw under the BNS-1129 exception, the ask side deliberately does not. input_mode is text if the user typed at any point, else voice. |
AskActFlow.jsx |
Act Reminder Rescheduled |
source (ask), when, via |
User picked a new moment from the not-done screen. Journey acts fire the same event with chapter. |
AskActFlow.jsx |
Act Reminder Reschedule Failed |
source (ask), when, reason, via |
act_update_reminder did not return 200 on the reschedule path (the cancel path fires Act Notifications Stop Failed). Compensating event: without it a failed reschedule looks identical to a successful one — the drawer closes either way. The user stays on the not-done screen with an inline notice, so the moment CTAs act as a live retry. |
AskActFlow.jsx |
Act Notifications Stopped |
source (ask), via |
User chose to stop check-up notifications for this act, and the cancel landed. | AskActFlow.jsx |
Act Notifications Stop Failed |
source (ask), reason, via |
The stop-notifications call did not return 200. Its own event rather than a when: 'cancel' variant of Act Reminder Reschedule Failed — overloading that one leaves Act Notifications Stopped with no resolvable negative branch unless every query remembers to split on when. The flow now stays open with an inline notice: the push is still armed server-side, so closing would tell the user notifications are off when they are not. |
AskActFlow.jsx |
Ask Session Completed |
mode, via |
Same beat as Answer Received (answer landed) | AskSession.jsx |
Ask Session Error |
error_code, refunded, via |
Realtime/transport error | AskSession.jsx |
Ask Session Abandoned |
mode, is_follow_up, via, phase (connecting/live/composing) |
User closes the live session via the header X before an answer lands. The answered / error phases have their own terminal events (Ask Answer Received / Ask Session Error) and do not fire this. Closes the previously-invisible abandonment leg (started − answered − errored was only an implied count). |
AskSession.jsx |
Ask Voice Fallback |
reason (mic_error/permission_denied), is_follow_up, via |
A voice session hits a mic error or permission denial and the user taps Switch to Text. Measures the voice→text fallback rate (relevant while Text input mode is new). | AskSession.jsx |
Coins Deducted |
amount, context, via (ask only) |
Shared event across features. Canonical context values: 'ask' (amount 6, AskSession) · 'simulator' (amount 8, SimulatorSection). First question/turn of a paid ask fires context: 'ask' with via (journey_map | step_end | dr_leo_tab | deep_link, BNS-1067) so paywall attribution separates the ask entry points. NOT fired for follow-ups or Try-in-simulator — both are free (product decision 2026-06-18), so try_in_simulator / follow_up_question clicks log Element Clicked but no Coins Deducted (the simulator's context: 'simulator' deduct is also skipped on the seeded path). |
AskSession.jsx · SimulatorSection.jsx |
Act (domain events)
Closing-the-loop telemetry for the Act step (commit → check-up reminder → outcome). The Element Clicked rows for these screens are in the Act table below; this table is the domain-event funnel. chapter is the chapter's 1-based index; dynamic is the chapter kind (Phase 2). (Documented retroactively — these shipped in #338 but weren't in the catalog.)
| Event | Properties | Fires when | Source |
|---|---|---|---|
Act Insight Generated |
chapter, dynamic, duration_ms |
BNS-1088: the HTTP act generation (generate_journey_act_insight, stateless Responses API) resolved — fires just before the ready phase paints. duration_ms spans the whole attempt incl. the user-context fetch. |
ActStep.jsx |
Act Insight Generation Failed |
chapter, dynamic, reason (timeout/network/http_4xx/http_5xx), duration_ms |
BNS-1088: the generation call failed after its transport retry — the loader parks on the retry screen. The retry tap is the existing Element Clicked { screen: 'act', element: 'retry' }. Replaces the old silent 90s JS2B poll timeout (which only logged, never tracked — no event to retire). |
ActStep.jsx |
Act Screen Viewed |
chapter, dynamic, reused (bool) |
An act card paints (ready phase). reused: true (BNS-1088) = a previously generated act was re-shown from the chapter payload (generate → back → re-enter no longer re-bills a generation); false = a fresh generation just landed (pairs 1:1 with Act Insight Generated). |
ActStep.jsx |
Act Committed |
chapter (journey only) | source (ask), when (today/tomorrow/in_two_days/ok), reminder_set (bool) |
User picks a moment (or Skip/OK) and commits to the act. reminder_set is true only for a specific moment. Journey acts carry chapter and no source; ask acts carry source: 'ask' and no chapter — split the funnel on whichever is present. Ask acts fire this only after act_commit returns 200; a failed commit fires Act Commit Failed instead and the user stays on the acceptance screen. |
ActStep.jsx (journey) / AskActFlow.jsx (ask) |
Act Check-In Surfaced |
count, journey_count, ask_count, deferred |
The app-open gate (BNS-1113) finds due, unreported acts on a fresh launch and starts auto-opening "How did it go?" — fires once per launch with the number of acts queued (each act then fires its own Act Check-In Viewed with via: 'app_open'). Throttled: an act surfaced today isn't re-queued until the next day. Since BNS-1089 the queue merges journey acts with ask acts (get_due_acts), FIFO by commitment time; journey_count/ask_count split count by source, and deferred is how many the per-open cap (2) held back — those are not dropped, they re-queue on the next open. A get_due_acts failure degrades to journey-only (ask_count: 0), so a run of zero ask_count is a signal worth checking against the endpoint's error rate. |
MainTabs.jsx |
Act Check-In Viewed |
chapter, dynamic, via (app_open/deep_link/in_app) |
User lands on "How did it go?" to report on a committed act — the return leg of the funnel. via: 'app_open' = auto-surfaced by the app-open gate (BNS-1113); 'deep_link' = arrived from the check-up push notification (deep link sets initialPhase); 'in_app' = tapped the Act node on the journey map. Fires once per mount (ref-guarded so an other_input → back → how_did_it_go bounce doesn't double-count). |
ActStep.jsx |
Act Details Viewed |
chapter, dynamic, via |
User taps the underlined act title on "How did it go?" and the read-only act page paints (Slack 10-07-26 — the 2-word title alone doesn't remind users what they committed to). Kept separate from Act Screen Viewed so the pre-commit suggestion funnel stays clean. via mirrors the check-in entry. |
ActStep.jsx |
Act Outcome Reported |
chapter, outcome (tried_meaningful/tried_uncomfortable/hesitated/no_moment/other), via |
User taps an outcome pill. Always fires (incl. other, which then also fires Act Other Submitted on its own submit). via mirrors the check-in entry, so outcomes can be split by reminder-driven vs organic without a join. |
ActStep.jsx |
Act Check-In Dismissed |
chapter, dynamic, via |
User leaves "How did it go?" via the header ✕/back without reporting — the funnel's negative signal. Viewed = Outcome Reported + Dismissed (+ rare app kills). Split by via: 'app_open' to measure how often the auto-surfaced check-in is waved away. |
ActStep.jsx |
Act Performed |
chapter, coins (7) |
Outcome is tried_meaningful or tried_uncomfortable (the +coins branches). coins is single-sourced via ACT_REWARD_COINS and mirrors the server grant. |
ActStep.jsx |
Act Other Submitted |
chapter, input_mode (text/voice), response (raw free-text answer) |
User submits a free-text/voice reflection on the "Other" path. response is the raw user text (BNS-1129 — product wants to read the answers in Mixpanel); this is a deliberate, single exception to the repo's length/hash-only convention for free-text, so it must not be hashed. |
ActStep.jsx |
Act Reminder Rescheduled |
chapter, when |
User sets a new check-up reminder from the act-not-done screen (Bubble action reschedule_reminder) |
ActStep.jsx |
Act Notifications Stopped |
chapter |
User taps "Stop notifications" on the act-not-done screen | ActStep.jsx |
Deferred —
days_since_commit(reminder-latency).Act Check-In Viewed/Act Outcome Reportedwould ideally carrydays_since_commit(latency fromAct Committed), but there is no client-durable commit timestamp that survives the cold-start return a days-later check-up triggers. Required to enable: add a servercommitted_dateto the act and surface it on the chapter's reactive payload, then setdays_since_commit = round((Date.now() − Date.parse(committed_date)) / 86_400_000)on the check-in event.reschedule_countis already derivable in Mixpanel by countingAct Reminder Rescheduledper user+chapter before the outcome — no denormalized property needed.
Onboarding
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
onboarding |
button |
back |
step |
OnboardingFlow |
onboarding |
button |
error_go_back |
— | OnboardingFlow |
onboarding |
button |
retry_fetch |
— | OnboardingFlow |
onboarding_open |
button |
mic |
— | OpenQuestion |
onboarding_open |
button |
type |
— | OpenQuestion |
onboarding_open |
button |
skip |
— | OpenQuestion |
onboarding_open |
button |
share_anyway |
— | OpenQuestion |
Journey
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
journey |
button |
chapter_menu |
— | JourneyPath |
journey |
button |
chapter_select |
chapter |
JourneyPath |
journey |
button |
node |
node_id, node_type |
JourneyPath |
journey |
button |
start_lesson |
node_id, node_type |
JourneyPath |
journey |
button |
review_step |
node_id, node_type |
JourneyPath |
journey |
button |
repractice_step |
node_id, node_type |
JourneyPath |
journey |
button |
replay_insight |
node_id, node_type |
JourneyPath |
journey |
button |
milestone_review |
node_id |
JourneyPath |
journey |
button |
select_journey |
— | HomeSection |
journey |
button |
send / chat / notifications / previous_journey / next_journey / change_topic / conversation_coach / practical_actions / ask_question |
— | HomeSection |
Learn Video
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
learn_video |
button |
play |
— | LearnStep |
learn_video |
button |
pause |
— | LearnStep |
learn_video |
button |
speed |
speed |
LearnStep |
learn_video |
button |
rewind |
— | LearnStep |
learn_video |
button |
mute / unmute |
— | LearnStep |
learn_video |
button |
replay |
— | LearnStep |
learn_video |
button |
skip_to_questions / continue_to_questions |
— | LearnStep |
Practice
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
practice |
button |
start_practice |
chapter |
PracticeStep |
practice |
toggle |
input_mode |
mode (voice/text) |
PracticeStep (BNS-1071) |
Act
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
act |
button |
accept |
chapter |
ActStep |
act |
button |
decline |
chapter |
ActStep |
act |
button |
retry |
chapter |
ActStep |
how_did_it_go |
link |
view_act |
chapter |
ActStep |
act_review |
button |
back |
chapter |
ActStep |
act_reminder |
button |
reminder_tomorrow |
chapter |
ReminderSheet |
act_reminder |
button |
reminder_3_days |
chapter |
ReminderSheet |
act_reminder |
button |
reminder_7_days |
chapter |
ReminderSheet |
act_reminder |
button |
skip_reminder |
chapter |
ReminderSheet |
Chapter Celebration
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
chapter_celebration |
button |
continue |
chapter |
ChapterCelebration |
Step Review
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
step_review |
button |
rewatch_video |
— | StepReview |
step_review |
button |
close_video |
— | StepReview |
step_review |
button |
play / pause / replay |
— | StepReview |
step_review |
button |
rewind |
— | StepReview |
step_review |
button |
speed |
speed |
StepReview |
step_review |
button |
mute / unmute |
— | StepReview |
Insight Questions
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
insight_questions |
button |
back |
— | InsightQuestions |
insight_questions |
button |
close |
— | InsightQuestions |
insight_questions |
button |
mic |
— | InsightQuestions |
insight_questions |
button |
type |
— | InsightQuestions |
insight_questions |
button |
yes / no |
— | InsightQuestions |
insight_questions |
button |
nudge_continue |
— | InsightQuestions |
insight_questions |
button |
nudge_custom |
— | InsightQuestions |
Insight Other Dialog
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
insight_other |
button |
mic_start / mic_stop |
— | InsightOtherDialog |
insight_other |
button |
share |
— | InsightOtherDialog |
insight_other |
button |
dialog_closed |
had_text |
InsightOtherDialog |
insight_other |
input |
typing_started |
— | InsightOtherDialog |
insight_other |
button |
open_settings |
— | InsightOtherDialog |
Insight Playback
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
insight_playback |
button |
play / pause |
— | InsightPlayback |
insight_playback |
button |
speed |
speed |
InsightPlayback |
insight_playback |
button |
replay |
— | InsightPlayback |
insight_playback |
button |
mute / unmute |
— | InsightPlayback |
insight_playback |
button |
continue |
— | InsightPlayback |
Fun Zone
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
fun |
banner |
daily_question |
— | FunZoneSection |
fun |
card |
activity_type |
type_id, type_name |
FunZoneSection |
fun |
card |
history_item |
section: 'activity', item_id, title |
FunZoneSection |
Activity View
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
activity_view |
button |
close |
— | ActivityView |
activity_view |
button |
listen |
— | ActivityView |
activity_view |
button |
play / pause |
— | ActivityView |
activity_view |
button |
speed |
speed |
ActivityView |
activity_view |
button |
replay |
— | ActivityView |
activity_view |
button |
retry |
— | ActivityView |
activity_view |
button |
continue |
— | ActivityView |
Type Detail
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
type_detail |
button |
create_new |
section, type_id, type_name |
TypeDetailScreen |
type_detail |
card |
history_item |
section, item_id, title |
TypeDetailScreen |
Simulator Results
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
simulator_results |
button |
play / pause |
— | SimulatorResults |
simulator_results |
button |
speed |
speed |
SimulatorResults |
simulator_results |
button |
replay |
— | SimulatorResults |
simulator_results |
button |
retry |
— | SimulatorResults |
simulator_results |
button |
done |
— | SimulatorResults |
Profile & Settings
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
profile |
button |
upload_photo |
— | MainTabs |
profile |
button |
save_name |
— | MainTabs |
profile |
button |
share_feedback |
unread_count (number — Intercom unread messages at tap time; > 0 means the user tapped through the unread badge) |
FeedbackRow |
profile |
button |
how_bonds_works |
— | MainTabs |
profile |
button |
account_info |
— | MainTabs |
profile |
toggle |
dark_mode |
preference |
ThemeContext |
Account Info
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
account_info |
button |
logout |
— | MainTabs |
account_info |
button |
delete_account |
— | MainTabs |
Name Prompt
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
name_prompt |
button |
submit_name |
— | MainTabs |
name_prompt |
button |
prefer_not_to_say |
— | MainTabs |
Partner Name Prompt
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
partner_name_prompt |
button |
submit_partner_name |
— | MainTabs |
partner_name_prompt |
button |
skip_partner_name |
— | MainTabs |
Profile Save (Account Info)
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
profile |
button |
save_partner_name |
— | MainTabs (AccountInfoPage) |
Daily Question
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
daily_question |
button |
close |
— | DailyQuestion |
Share Viewer
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
share_viewer |
button |
get_bonds |
— | ShareCTA |
Push Notifications
| Event | Properties | When | Source |
|---|---|---|---|
Push Permission Requested |
— | Before native OS prompt is shown | notifications.js |
Push Permission Responded |
granted (bool) |
After user responds to OS prompt or settings redirect | notifications.js |
Push Player ID Obtained |
— | First time player ID is fetched in a session | notifications.js |
Push Permission Respondedis gated on the native OS-dialog callback (no timeout), so a slow approval is no longer mis-logged as a denial (BNS-1025); if the callback is never delivered, no outcome event fires (honest "unknown"). The same outcome also writes thepush_enabledPeople property, which additionally re-syncs on every cold start — see the People Properties table.
Store Reviews (rating prompt)
| Screen | Type | Element | Extra | Source |
|---|---|---|---|---|
rating_prompt |
button |
loved |
— | useRatingPrompt (👍 step 1) |
rating_prompt |
button |
not_yet |
— | useRatingPrompt (👎 step 1) |
The terminal CTAs ("Rate Bonds in the store", "Please tell us…") and the X-dismiss fire dedicated domain events (
Native Store Review Requested,Rating Feedback Opened,Rating Prompt Dismissed), notElement Clicked— see Domain Events › Store Reviews.
6. Funnels
Onboarding → Insight → Signup Funnel
1. Screen Viewed { screen: 'welcome' }
2. Element Clicked { element: 'lets_go' }
3. Onboarding Intro Viewed ← one-time only
4. Onboarding Intro Completed ← if video finished
OR Onboarding Intro Skipped ← if CTA tapped before video ends
5. Screen Viewed { screen: 'onboarding' }
6. Onboarding Step Completed ← repeats per step
7. Onboarding Completed
8. Onboarding Insight Started
9. Onboarding Insight Generated
10. Onboarding Insight Playback Completed / Skipped
11. Onboarding Insight CTA Tapped
12. Screen Viewed { screen: 'signup' }
13. Sign Up Started { method }
14. Sign Up Completed
15. Onboarding Data Connected
16. Screen Viewed { screen: 'ftue_walkthrough' }
17. FTUE Walkthrough Completed
Drop-off: Onboarding Abandoned (back on step 0)
Onboarding Intro Backed Out (back from intro to welcome)
Resume: Onboarding Resumed { phase, step, source }
Replay: Onboarding Intro Replayed (user replays intro video)
Gen fail: Onboarding Insight Generation Failed { reason, duration_ms } → Onboarding Insight Manual Retry
Connect: Onboarding Data Connect Failed { error } (no transition; data kept for recovery)
Back: Onboarding Step Back { from_step, to_step }
Mic err: Onboarding Mic Permission Denied / Onboarding Mic Unavailable
No API: Onboarding Speech API Unavailable
Skip: Element Clicked { screen: 'ftue_walkthrough', element: 'skip' }
Authentication Funnel
1. Element Clicked { element: 'sign_in' }
2. Screen Viewed { screen: 'signin' }
3. Sign In Started { method }
4. Sign In Completed { method }
Journey Chapter Funnel
Each chapter follows this step sequence:
0. Chapter Started { chapter, chapter_title, coin_cost: 10 } ← fires on coin-gate confirm
1. Journey Step Started { step_type: 'learn' }
2. Journey Video Watched
3. Journey Step Completed { step_type: 'learn' }
4. Journey Step Started { step_type: 'practice' }
5. Journey Practice Started
6. Simulator Session Started { mode: 'journey' }
6a. Simulator Clarification Asked { question_number } ← dynamic only, repeats
6b. Simulator Clarification Submitted { question_number } ← dynamic only, repeats
6c. Simulator Turn Completed { turn_number: 1..4, speaker } ← repeats
7. Simulator Session Completed
8. Journey Practice Completed
9. Journey Step Completed { step_type: 'practice' }
Mid-session drop position = last 6a/6b/6c breadcrumb seen (app-kills emit no
Closed event); in-app quits also carry phase/turn on Simulator Session Closed.
10. Journey Step Started { step_type: 'insight' }
11. Insight Flow Started { type: 'journey' }
12. Insight Question Answered ← repeats
13. Insight Generated { type: 'journey' } ← or Insight Generation Failed → Insight Generation Manual Retry
14. Insight Playback Completed / Skipped
15. Journey Step Completed { step_type: 'insight' }
16. Journey Step Started { step_type: 'act' }
17. Element Clicked { element: 'accept' or 'decline' }
18. Journey Step Completed { step_type: 'act' }
19. Chapter Completed
Drop-off at any stage: Journey Step Abandoned { step_type }
Simulator Funnel (Freeform)
1. Screen Viewed { screen: 'simulator' }
2. Simulator Session Started { mode: 'freeform' }
2a. Simulator Clarification Asked / Submitted { question_number } ← repeats
3. Coins Deducted { amount, context: 'simulator' } ← fires at stage 2 start
3a. Simulator Turn Completed { turn_number: 1..4, speaker } ← repeats
4. Simulator Session Completed { score, skill_level }
Alt exits:
- Simulator Session Closed (user quits mid-session)
- Simulator Session Error { code, message }
Loop: Simulator Session Retried → back to step 3
Insight Funnel (Standalone)
1. Insight Flow Started { type: 'learn' or 'activity' }
2. Insight Question Answered ← repeats
3. Insight Generated
4. Insight Playback Completed / Skipped
Timeout: Insight Generation Timed Out
Drop-off: Insight Flow Closed
Daily Question Funnel
1. Daily Question Viewed { question_id, category }
2. Daily Question Voted { question_id, index }
Partner Connection
1. Partner Connected
Note: Partner invitation is handled in Bubble, not tracked with Element Clicked in the React UI.
Push Notification Permission Funnel
Triggers: post_signup (MainTabs startup), session_start (7-day re-prompt), reinstall (fresh install), act_reminder (journey act reminder gate)
1. Push Prompt Shown
2. Push Prompt Accepted (vs Push Prompt Dismissed = drop-off)
3. Push Permission Requested
4. Push Permission Responded { granted: true }
5. Push Player ID Obtained
7. Recommended Mixpanel Reports
| Report | Event(s) | Insight |
|---|---|---|
| DAU / WAU / MAU | App Opened |
Daily/weekly/monthly active users |
| Session Duration | App Opened → App Backgrounded |
Average time in app per session |
| Retention | Cohort by signup_date, retained via App Opened |
Day 1/7/30 retention |
| Onboarding Conversion | Onboarding funnel above | Step-by-step drop-off rates |
| Journey Progress | Chapter Started → Chapter Completed by chapter |
Chapter start/complete rates |
| Simulator Engagement | Simulator Session Started → Completed |
Completion rate, avg score by mode |
| Feature Adoption | % of authenticated users who triggered each feature start | Which features resonate |
| Coin Economy | Coins Deducted vs coins earned (Journey, Onboarding) |
Earn/spend balance |
| Daily Question Engagement | Daily Question Viewed → Voted |
Participation rate |
| Partner Adoption | Partner Connected / total authenticated users |
Invite conversion |
8. Developer Checklist
When adding or changing analytics tracking:
- Event name is Title Case, Object-Action, past tense
- Property names are snake_case
- Import
trackfromsrc/utils/analytics.js(never importmixpanel-browserdirectly) - UI interactions use
Element Clickedwithscreen,element_type,element - Business/funnel actions get their own domain event name
- Update this document — add the event to the appropriate section
- Update funnels section if adding a new step to an existing flow
- If adding a new people property, add it to the People Properties table
Code patterns
Track a UI interaction:
import { track } from '../utils/analytics';
track('Element Clicked', {
screen: 'screen_name',
element_type: 'button', // button | tab | banner | card | toggle | link
element: 'element_name',
// ...extra context
});
Track a domain event:
track('Feature Action', { property: value });
// e.g. track('Partner Connected');
Track a screen view:
import { screen } from '../utils/analytics';
screen('screen_name');