PostHog event catalog
Catalog of every product-analytics event in the PostHogEvent union in apps/frontend/src/lib/analytics.ts. Events fire via:
import { trackProductEvent } from '@/lib/analytics'
trackProductEvent('thing_done', { season_id, source })
The helper is a no-op when PostHog isn't initialized (no key, SSR, errors), so it's safe to call from any client component without guards. Initialization happens once via components/analytics/PostHogProvider.tsx mounted in the root layout; the provider reads NEXT_PUBLIC_POSTHOG_KEY and uses person_profiles: 'identified_only' so anonymous traffic doesn't create person profiles. identifyUser() is called after sign-in to link the anonymous distinct_id to a Supabase user id.
A small set of events — graphene_plus_subscription_active, graphene_plus_subscription_canceled, episode_tip_paid — are captured server-side from the Stripe webhook in apps/backend/src/routes/payment.ts via services/analytics-server.ts. They're listed in the union for type safety + documentation; do not fire them from the frontend (would double-count).
Three events also dual-write to our self-hosted funnel table for /dashboard/admin/monetization so the conversion-rate denominator doesn't depend on PostHog's API: graphene_locked_chapter_impression, graphene_plus_modal_opened, graphene_plus_checkout_started. See services/monetization-funnel.ts and the funnel-stage mapper at the bottom of analytics.ts.
There is no super-admin exclusion at the PostHog layer today — admins viewing the production app generate the same events real users do. Filter in PostHog views if this matters.
For the convention on naming + payload shape for new events, see docs/ai/CONVENTIONS.md → Tracking events via PostHog.
Citizen Science
DreamPro template, replication, build, and video flows. Source: apps/frontend/src/app/dreampro/.
| Event | Source surface(s) | Typical payload | Notes |
|---|
template_viewed | dreampro/templates/[id]/page.tsx | { template_id, slug } | |
template_cloned | dreampro/templates/[id]/page.tsx | { template_id, replication_id } | |
clone_progress_updated | — | { replication_id, progress } | // not currently fired |
replication_submitted | components/dreampro/ReplicationModal.tsx | { template_id, replication_id, outcome } | |
build_submitted | components/dreampro/BuildModal.tsx | { template_id, build_id } | |
video_submitted | components/dreampro/VideoSubmissionModal.tsx | { video_id, source: 'replication' | 'build' } | |
video_upvoted | — | { video_id } | // not currently fired |
Participant flows
Opt-in to the citizen-scientist map, family teams, classrooms, meetups.
| Event | Source surface(s) | Typical payload | Notes |
|---|
citizen_scientist_opted_in | dreampro/citizen-scientist/page.tsx | { participant_id, city } | City-centroid only; never exact lat/lng |
family_team_registered | dreampro/family/page.tsx | { team_id, member_count } | |
classroom_registered | dreampro/classroom/page.tsx | { classroom_id } | |
meetup_registered | dreampro/meetup-organizer/page.tsx | { organizer_id } | |
meetup_event_created | dreampro/meetups/[id]/page.tsx | { event_id, meetup_id } | |
meetup_event_rsvped | dreampro/meetups/[id]/page.tsx | { event_id, response } | |
| Event | Source surface(s) | Typical payload | Notes |
|---|
sponsor_applied | dreampro/sponsors/apply/page.tsx | { application_id, org_name } | |
opportunity_viewed | dreampro/opportunities/[slug]/page.tsx | { opportunity_id, slug } | |
outreach_sent | dreampro/sponsorable/page.tsx | { outreach_id, sponsor_id } | |
Competition
| Event | Source surface(s) | Typical payload | Notes |
|---|
competition_leaderboard_viewed | dreampro/competition/[slug]/page.tsx | { competition_slug } | |
competition_scores_recomputed | — | { competition_slug, participant_count } | // not currently fired |
Map
| Event | Source surface(s) | Typical payload | Notes |
|---|
map_viewed | dreampro/map/page.tsx | {} | |
map_participant_clicked | — | { participant_id } | // not currently fired |
Kit waitlist
| Event | Source surface(s) | Typical payload | Notes |
|---|
kit_waitlist_signup | dreampro/kits/page.tsx | { kit_slug, email } | |
Extension
| Event | Source surface(s) | Typical payload | Notes |
|---|
extension_cta_clicked | jq-extension/page.tsx | { source: 'hero' | 'footer' | ... } | Listed twice in the union — single category here |
Auth & onboarding
| Event | Source surface(s) | Typical payload | Notes |
|---|
signup_completed | auth/signup/page.tsx | { user_id, method } | |
signin_completed | auth/signin/page.tsx | { user_id, method } | |
signup_started | — | { source } | // not currently fired (GA sign_up_started covers this surface today) |
Journal
| Event | Source surface(s) | Typical payload | Notes |
|---|
entry_published | dashboard/entries/new/page.tsx | { entry_id, notebook_id, word_count } | |
entry_updated | dashboard/entries/new/page.tsx | { entry_id, notebook_id } | |
Goals & dreams
| Event | Source surface(s) | Typical payload | Notes |
|---|
dream_created | dashboard/dreampro/page.tsx | { dream_id, category } | |
goal_created | dashboard/goals/new/page.tsx | { goal_id, category } | |
notebook_created | dashboard/notebooks/new/page.tsx | { notebook_id, notebook_type } | |
Social
| Event | Source surface(s) | Typical payload | Notes |
|---|
jq_bridge_invitation_sent | dashboard/jq-bridge/page.tsx | { invitation_id, recipient_email } | |
jq_bridge_invitation_accepted | — | { invitation_id } | // not currently fired |
Odessa story engine
The personalized story engine — journals → metaphorical graphic novel → optional Graphene port.
| Event | Source surface(s) | Typical payload | Notes |
|---|
odessa_seed_generated | dashboard/odessa/page.tsx | { seed_id, length } | |
odessa_story_started | dashboard/odessa/page.tsx | { notebook_id, length } | |
odessa_full_story_generated | dashboard/odessa/page.tsx | { notebook_id, page_count } | |
odessa_next_episode | dashboard/notebooks/[id]/page.tsx | { notebook_id, episode_number } | |
odessa_branch_chosen | dashboard/notebooks/[id]/page.tsx | { notebook_id, branch_id } | |
odessa_story_shared | dashboard/notebooks/[id]/page.tsx | { notebook_id, source } | |
odessa_story_ported_to_graphene | dashboard/notebooks/[id]/page.tsx | { notebook_id, season_id } | |
season_link_copied | seasons/[id]/SeasonClient.tsx | { season_id, source: 'public_reader_finished' | ... } | |
story_link_copied | stories/[id]/page.tsx | { story_id, source } | |
season_publish_toggled | seasons/[id]/SeasonClient.tsx | { season_id, action: 'publish' | 'unpublish', source: 'publish_strip' } | |
Import funnel
| Event | Source surface(s) | Typical payload | Notes |
|---|
journal_import_started | dashboard/import/page.tsx, dashboard/import/apple-notes/page.tsx | { source: 'apple_notes' | 'csv' | ... } | |
journal_import_completed | same as above | { source, entry_count } | |
see_your_story_visited | see-your-story/page.tsx | {} | Funnel-shaping landing |
General engagement
| Event | Source surface(s) | Typical payload | Notes |
|---|
vision_page_viewed | vision/page.tsx, hum/page.tsx, hum/prototype/page.tsx | { slug } | |
docs_page_viewed | — | { slug } | // not currently fired |
extension_cta_clicked | (see Extension above) | (see above) | Duplicated divider — same event |
Graphene / Story Seasons
The public Graphene slate + per-season detail pages. The trailer hero + poster cards drive most engagement events.
| Event | Source surface(s) | Typical payload | Notes |
|---|
graphene_poster_hover | components/graphene/PosterCard.tsx | { season_id, dwell_ms, had_trailer } | Only fires after 400ms dwell |
graphene_poster_clicked | components/graphene/PosterCard.tsx | { season_id } | |
graphene_primary_cta_clicked | graphene/GrapheneClient.tsx | { season_id, source: 'override' | 'freshest_trailer' } | |
graphene_subscribe_clicked | graphene/GrapheneClient.tsx | { location: 'hero' | 'closing_band' | 'modal_submit' } | |
graphene_filter_tag_clicked | components/graphene/RotatingHeroTags.tsx | { kind: 'genre' | 'theme' | 'topic', value } | |
graphene_stream_card_clicked | components/graphene/GrapheneStreamCard.tsx | { season_id, slot_index } | Stream-injected promo cards |
graphene_filter_cleared | graphene/GrapheneClient.tsx | { previous_filter } | |
graphene_taste_saved | components/graphene/GrapheneTasteIntro.tsx | { genre_count, tone_count, pace } | |
graphene_taste_skipped | components/graphene/GrapheneTasteIntro.tsx | {} | |
season_trailer_view | components/seasons/TrailerHero.tsx | { season_id, kind, surface } | Impression — gated on ≥50% visible for ~1s via useImpressionRef, NOT on mount, so instant-bounce ad traffic doesn't inflate the play-rate denominator |
season_trailer_play | components/seasons/TrailerHero.tsx | { season_id } | |
season_trailer_completed | components/seasons/TrailerHero.tsx | { season_id, duration_ms } | |
Graphene+ ($5/mo listener subscription)
The paywall + checkout funnel. Three events here also dual-write to monetization_funnel_events for server-side conversion-rate reporting (see intro).
| Event | Source surface(s) | Typical payload | Notes |
|---|
graphene_plus_modal_opened | components/graphene/GraphenePlusModal.tsx | { source: 'locked_chapter_row' | 'locked_chapter_card' | 'hero_pill' | 'letters_priority' | 'dashboard' | 'subscribed_query_param' | 'profile' | 'other' } | Dual-write → modal_opened |
graphene_plus_checkout_started | components/graphene/GraphenePlusModal.tsx | { source, plan_key, experiment_key?, variant_key? } | Dual-write → checkout_started |
graphene_locked_chapter_impression | seasons/[id]/SeasonClient.tsx | { season_id, episode_number, source: 'chapter_row' | 'chapter_card' } | Dual-write → locked_impression |
graphene_plus_subscription_active | services/analytics-server.ts (Stripe webhook) | { plan_key, stripe_price_id, source: 'webhook' } | Server-side only |
graphene_plus_subscription_canceled | services/analytics-server.ts (Stripe webhook) | { plan_key, source: 'webhook', cancel_at_period_end, ended_immediately, current_period_end } | Server-side only |
Per-episode tipping
Mirrors the subscription funnel shape — frontend captures the intent, the webhook captures the success.
| Event | Source surface(s) | Typical payload | Notes |
|---|
episode_tip_started | components/seasons/TipChapterModal.tsx | { season_id, episode_number, amount_cents } | |
episode_tip_paid | services/analytics-server.ts (Stripe webhook) | { season_id, episode_number, amount_cents, source: 'webhook' } | Server-side only |