The latest run, laid out in the lifecycle
Every mulberry chip is a real event record read back out of PostHog from the 19 Aug verification run — tap one to see the payload as stored. Green chips are catalogue events verified on earlier runs but not exercised in this one.
This run entered at /quiz directly, so no landing view — the event is verified from earlier drives.
Catalogue
Three sittings in one browser: the drive reloaded the page twice, and
each reload before the results correctly fired quiz_abandoned for the sitting
it ended — an abandon next to a fresh quiz_started is a reload boundary, not
a logging error. These records predate the 19 Aug view/submit split: choice rode the next
screen's view (null on interstitials). Since the split, arrival fires
quiz_screen_viewed and the answer fires quiz_screen_submitted
with the choice.
Scrolled to the bottom — all four depth thresholds and nine named sections
recorded — then left without checking out, which is exactly what
paywall_abandoned says.
Catalogue — not in this run
This run signed in as Maggie (already a subscriber) rather than paying
again — purchase_completed is once-per-person by design. Verified on her
original purchase.
Catalogue
The moment of the weld: $identify carries the anonymous quiz
id in $anon_distinct_id, joining this run's quiz sitting to Maggie's person.
Her second attempt at this activity (attempt_no: 2). She
skipped the first movement 20 seconds in, then quit during a cooldown — the abandonment
rode sendBeacon out of a dying page and still landed.
Catalogue — not in this run
Wired 19 Aug (commit 817a074); fires on the first real cancellation or full refund with the churn autopsy: week number, activities completed, days since the last activity.
Evidence ledger
All-time counts in the Dev project as of 19 Aug 2026, 02:1x HKT. Every browser and server event that has ever been designed is on this list — nothing verified is omitted, nothing unverified is dressed up.
| Event | Trigger | Key payload | Status | Count |
|---|---|---|---|---|
| Funnel — static pages | ||||
| landing_viewed | Marketing page loads, once per browser | in_app_browser | verified | 5 |
| quiz_started | Her first answer tap, once per pageload — not merely loading /quiz | in_app_browser | verified | 8 |
| quiz_screen_viewed | Arriving on any screen, deduped per sitting | screen_id · previous_screen · seconds_on_previous | verified | 94 |
| quiz_screen_submitted | Her answer became final — she moved on with it picked. Re-fires only if a back-navigation changes it. Split from the view 19 Aug | screen_id · act · choice · seconds_on_screen | wire-verified, ingesting | 3 sent |
| quiz_abandoned | Page hidden/unloaded before results | last_screen_id · answered_count · seconds_on_screen | verified | 2 |
| quiz_completed | Results screen reached | emphasis · secondary_emphasis · mode · answered_count | verified | 3 |
| Paywall | ||||
| paywall_viewed | Paywall screen reached | emphasis · mode · plan_default | verified | 3 |
| paywall_scrolled | Crossing 25/50/75/100% depth, once each | depth_pct | verified | 4 |
| paywall_section_viewed | A named section scrolls into view | section | verified | 11 |
| plan_selected | She taps a different plan | plan · price_usd · from_default | verified | 3 |
| checkout_started | “Start my plan”, before the Stripe redirect | plan · price_usd | verified | 3 |
| checkout_failed | No session URL, or network failure | reason · plan | never fired | 0 |
| paywall_abandoned | Leaving the paywall without checkout | seconds_on_paywall · max_scroll · plans_viewed · sections_read | verified | 2 |
| Purchase — server-side | ||||
| purchase_completed | Fulfilment, exactly once per person | plan · price_usd · had_quiz | verified | 1 |
| $create_alias | Every fulfilment — the identity weld | quiz id ↔ person id | verified | — |
| $identify | Every fulfilment + app pages | name · plan · stripe ids · $anon_distinct_id | verified | — |
| The app | ||||
| app_opened | /today loads for a signed-in woman — once per page load, not per re-render | programme_week · days_since_purchase · has_trained_before | verified | 5 |
| activity_started | The player mounts | activity_id · attempt_id · attempt_no · resumed · week | verified | 2 +5* |
| move_completed | A movement finished — same values as the DB row | activity_id · attempt_id · move_slug · ordinal | verified | 3 |
| move_skipped | Next tapped mid-movement | move_slug · ordinal · seconds_in_move | verified | 2 |
| activity_completed | The closing step | duration_s · moves_done · moves_repeated | verified | 1 +1* |
| activity_abandoned | Exit tapped or tab closed — sendBeacon | at_step_kind · pct_complete · seconds_in · last_move_slug | verified | 1 +3* |
| checkin_submitted | “How did that feel?” after completion | exertion · activity_id · week | verified | 2 |
| media_autoplay_blocked | A rejected play() the player swallowed | where · in_app_browser | verified | 2 |
| Churn — server-side | ||||
| subscription_cancelled | customer.subscription.deleted webhook | week_number · activities_completed_total · days_since_last_activity | wired 19 Aug, never fired | 0 |
| subscription_refunded | A full refund | same as cancelled | wired 19 Aug, never fired | 0 |
| faq_opened | — | — | not wired | 0 |
| retest_completed | — | — | not wired | 0 |
* Counts marked +N* are verified rows under the
old session_* names, from before the 18 Aug rename to activity_*.
Identity weld
Verified again in this run: the anonymous quiz sitting and Maggie's signed-in training resolve to one person.
<redacted> once.Five silent failures
Every one of these returned success and produced no data. They are why this page verifies by reading events back out of PostHog instead of trusting the code.
- The scrubber ate posthog-js's own token. The PII denylist redacted
properties.token— the project API key. PostHog answers Ok to an invalid token and discards the event. Three days of blank funnel. - Abandonment died on unload. A queued fetch does not survive the page going away.
activity_abandonednow ridessendBeacon— the record in the flow above is the proof it survives. - The bot filter measured nothing. posthog-js silently drops every capture from a “likely bot”, so headless QA drives verified a pipeline that recorded zero events. Filter disabled on dev hosts only.
- The scrubber ate
name. Persons displayed as<redacted>. Now an explicit exception; everything else stays denied. - choiceOn() threw inside the capture arguments. It reached for a DOM helper scoped
to a different closure; the ReferenceError killed every
quiz_screen_viewedexcept the first screen — andquiz_completedandpaywall_viewedbehind it. Found while building this page; fixed in commit 5173338 and verified by this run's 33 screen records.
Method
Every mulberry record in the flow is the row PostHog returned for the 19 Aug run — HogQL
queries against the Dev project selecting each event's full custom payload, plus
$device_type and the identity fields. SDK metadata ($browser, $screen, session
recording internals) is omitted from the payload views for legibility; nothing custom is.
Verified in the ledger means returned by a read-back query — never inferred from
source. Times are HKT.
Canonical event reference: docs/analytics-events.md in the repo — trigger,
full payload, and call site for every event, kept in the same commits as the code it
describes.