Skip to main content

Landing-page A/B/C analytics event dictionary

This is the source of truth for the PostHog event surface across the three landing-page arms of the landing-page-conversion-v02 experiment. If you ship code that touches components/landing-page-v{3,4,5}.tsx or anything under components/landing-v{4,5}/, keep this document in sync.
Flag key note (2026-09-21): the experiment was bumped from landing-page-conversion-v01 (2-arm test) to landing-page-conversion-v02 (3-arm test) alongside the tracking overhaul that added landing_arm as a PostHog super-property. Data from V01 is not directly comparable to V02 because pre-overhaul events did not carry arm attribution on signed_up / signup_page_viewed / any downstream event. See the docblock on LANDING_CONVERSION_EXPERIMENT_KEY in hooks/use-landing-conversion-experiment.ts for the ops checklist.

The three arms

The router that picks the arm is components/home-landing-router.tsx (reads the PostHog multivariate flag landing-page-conversion-v02 via hooks/use-landing-conversion-experiment.ts).

The one-property rule for cross-arm comparison

Every event on every arm stamps arm: 'control' | 'test' | 'test-b'. That’s the single canonical dimension PostHog insights / funnels / cohorts use to compare arms on landing-page events. Do NOT introduce a new arm-tagging property — extend arm if we add a fourth arm. Additionally:
  • V4 events also stamp variant: 'v4' and landing_revision: 'hero-cta-v2' (legacy — kept so pre-existing PostHog insights don’t break).
  • V5 events also stamp variant: 'v5' and landing_revision: 'framer-unframer-v1' (same pattern; bump landing_revision for within-arm event-level revisions that shouldn’t re-randomize traffic).

The landing_arm super-property

Every event fired anywhere in the app AFTER a visitor’s variant resolves — and within the same PostHog session — also carries a landing_arm: 'control' | 'test' | 'test-b' property. This includes signup_page_viewed, signup_plan_intent_captured, signed_up, signed_in, every $pageview on /sign-up, $autocapture, and any custom event fired downstream. The registration happens in useLandingConversionExperiment immediately after the variant is resolved — see the exposure useEffect for the implementation. Why both arm (event-level) AND landing_arm (super-property)?
  • arm is the primary dimension for filtering landing-page events — it’s on the event body, cheap to read, and can’t drift with PostHog persistence quirks.
  • landing_arm is the same value promoted to a super-property so it automatically attaches to events fired OUTSIDE the landing page — where we have no arm-aware trackEvent closure to add it (the Clerk-owned sign-up widget, /auth/callback captures, onboarding, and every $pageview in the funnel). Without this the primary conversion funnel — landing_page_viewed → signed_up grouped by arm — is unanswerable.
Session scoping (Bugbot + CodeRabbit PR #4156): the super-property is registered via plain posthog.register() (cross-tab, localStorage- backed) alongside landing_arm_session_id which stores posthog.get_session_id() at register time. The paired ensureFreshLandingArm(posthog) helper (exported from the landing conversion hook, called from the hook itself AND from the sign-up page’s signup_page_viewed effect) compares the stored session ID to the current one and unregisters both properties on mismatch. Scenario matrix: Why NOT posthog.register_for_session()? Its name implies session scoping but its implementation uses per-tab sessionStorage (sessionPersistence?.register(properties)). Cmd-click new tabs get empty sessionStorage and therefore no arm — the exact regression Bugbot caught on the initial fix attempt (commit 5fcb3b7). Using posthog’s session-id explicitly for scoping gives us the cross-tab persistence we need for cmd-click while still expiring on session rotation. See LANDING_ARM_SUPER_PROPERTY docblock in hooks/use-landing-conversion-experiment.ts for the full rationale. Person-level attribution (durable): the same exposure effect also calls posthog.setPersonProperties({ last_landing_arm, last_landing_arm_at }). Person properties persist FOREVER on the Person and are NOT scoped to session — so cohort analysis across long time windows uses last_landing_arm (durable) while funnel-per-session analysis uses landing_arm (session-scoped). Session-storage mirror (belt-and-suspenders): also written to LANDING_ARM_SESSION_STORAGE_KEY ('landing:arm') so /auth/callback can defensively double-stamp landing_arm onto the client-side signed_up capture even if PostHog’s persistence has been cleared. The callback uses conditional spread — it only stamps landing_arm when sessionStorage has a value; passing an explicit undefined would overwrite the session super-property and strip attribution from cmd-click new-tab signups (Bugbot PR #4156).

Common properties on every event

Every event fired through a landing page’s trackEvent closure carries: These are evaluated at emit time (not at page load) so mid-session device rotations, dev-tools device-toolbar toggles, and viewport resizes are captured with the shape the visitor actually saw.

Server-side assignment log: the fair denominator

landing_arm_assigned

Fired by middleware (lib/landing/server-landing-assignment.ts) for every visitor it assigns on /, in all three arms, at the same point in the request, with the same properties and the same dedupe: once per visitor per 30-minute idle window. $lib is silvia-middleware. The landing_assignment_logged cookie holds <arm>:<distinct_id> of the last assignment logged. A visitor whose arm changes inside the window is logged again, and so is one who changes back, because only the most recent arm is remembered. For a given visitor the arm is fixed by a hash of their id, so it only moves when the flag’s variants or weights are edited. Treat any such edit as the start of a new analysis window rather than attributing across it. Use this, not landing_page_viewed, as the per-arm visitor count. landing_page_viewed is recorded at different moments per arm: V5’s copy is captured server-side at request time, V3/V4’s in the browser once the app has booted and Clerk has loaded. So V5 alone counts visitors who leave before any script runs.
Known bad window: 2026-09-24 20:24 UTC until the fix in PR “fix(landing): forward bootstrap through Clerk’s rewrite” reaches production. Do not compare arms across it. New visitors were genuinely mis-routed, not just miscounted. Middleware’s bootstrap step checked for x-middleware-next, which Clerk’s decorateRequest removes from every next() (turning it into a same-URL rewrite), so the step never ran. A new visitor’s minted id was never persisted or forwarded; the layout minted a second id and re-bucketed it, and a test-b on either draw ended on V5. Measured 2026-09-25/27: new visitors 17 / 18 / 65 (control / test / test-b), returning visitors, whose id was already in the cookie, 31 / 29 / 39. The same bug meant this event was only ever logged for V5 until the fix. An earlier version of this page blamed the counting asymmetry alone; that was wrong. The asymmetry is real but accounts for the smaller part.
Not logged, and arm-neutral by construction because each is decided before any arm exists: requests that are not a real browser page load, bots, signed-in visitors, ?ref= and QA-override links, and requests where the server’s flags call timed out. Those still resolve their arm client-side as before, so this event covers the server-assigned majority, not every visitor. The client handoff to Framer (framer-proxy-v1: the one-shot landing_v5_framer_handoff cookie that middleware.ts serves before anything else) is deliberately not logged either. It only happens for visitors whose arm was resolved in the browser because the server did not assign them. Their V3/V4 counterparts are not logged, since they never make a second request to /. Logging the handoff would count that group for V5 alone and bring back the one-arm bias this event exists to remove. (During the bad window above, most handoffs were instead the mis-routed new visitors; once the bootstrap reaches the layout, only genuinely unassigned visitors take this path.) Best-effort delivery. Like every middleware capture it is sent after the response, so the dedupe cookie is already set by the time delivery is tried, and a batch that still fails after the capture helper’s retries is not retried on a later load. During a PostHog outage the total can undercount. The loss is arm-neutral, since all three arms share the same capture path, so use this event for the per-arm split, not as an exact visitor census.

Shared events (fire from every arm)

These events fire on all three arms with the same name and the same payload shape. Compare arms on these directly.

landing_page_viewed

Fires once per session (sessionStorage dedupe) on first mount.

signup_button_clicked

Fires when a visitor clicks any primary “Get started” CTA — hero, intro, final CTA, and the nav “Sign up” button. Routes to /sign-up.
Pricing-tier CTAs (Get Free / Get Pro / Get Max) do NOT fire this event — they fire pricing_cta_clicked (below). The primary conversion funnel should treat both as sign-up intent: signup_button_clicked OR pricing_cta_clicked → signed_up.

pricing_cta_clicked

Fires when a visitor clicks any tier CTA on the pricing card (Free / Pro / Max — both signed-out “Get X” and signed-in “Manage plan” / “Go to Dashboard”). Payload shape standardized across V3, V4, V5 (2026-09-21) so a single filter groups all three arms.
V5 gotcha: V5’s pricing_cta_clicked always sends interval: 'month' because Framer’s AnimatedPriceSwitch toggle state isn’t cleanly exposed via a stable DOM signal. Documented in components/landing-v5/pricing.tsx. Cross-arm annual-vs-monthly analysis needs to filter to V3/V4 arms only.

landing_scroll_depth

Fires at 25 / 50 / 75 / 100% document scroll depth. Each bucket fires at most once per session per arm. Emitted by useLandingScrollDepth.

landing_page_engaged

Dwell-time pings from useLandingEngagement. Fires at 5 / 15 / 30 second thresholds if the tab stays visible.

<section>_section_viewed

Fires the first time each section reaches 25% viewport intersection. Emitted by useSectionViewed. Section slug is the event name minus the _section_viewed suffix, also stamped as section in the payload for easier funnel authoring. V5 (test-b) sections that emit: hero_section_viewed, intro_section_viewed, features_section_viewed, integrations_section_viewed, why_section_viewed, about_section_viewed, pricing_section_viewed, numbers_proof_section_viewed, faq_section_viewed, final_cta_section_viewed. V3 and V4 emit an equivalent set for their own section names — grep useSectionViewed in components/landing-page-v{3,4}.tsx for the full list per arm.

Sign-up funnel events (downstream, but arm-attributed)

These fire OUTSIDE the landing page but carry landing_arm (via the super-property) so they roll up cleanly by arm.

signup_page_viewed

Fires when a visitor lands on /sign-up. Emitted by app/(public)/sign-up/[[...sign-up]]/page.tsx.

signup_plan_intent_captured

Fires when the visitor arrives at /sign-up?plan=<free|pro|premium> (a landing-pricing-CTA handoff). Stashes plan intent into sessionStorage for the post-onboarding Stripe checkout redirect.

auth_social_button_clicked

Fires when the visitor clicks Clerk’s Google or Coinbase OAuth button on /sign-up or /sign-in. Emitted by components/auth/social-auth-click-tracker.tsx.

signed_up (client-side)

Fires when a new user reaches /auth/callback after Clerk finishes signup. Deduped by posthog_signed_up_tracked sessionStorage latch.

user_signed_up (server-side, from Clerk webhook)

Fires from app/api/webhooks/clerk/route.ts on user.created. Bulletproof — fires even if the browser tab closes mid-redirect. Does NOT carry landing_arm today (webhook has no session context). For cohort analysis, filter Persons by last_landing_arm (set by the landing hook’s setPersonProperties call) and count user_signed_up events per person.

V5-specific interaction events

V5 has richer interactive components than V3/V4 (Framer’s tabs, carousel, integrations wall). These events are V5-only — they will show arm: test-b exclusively. Delegated click tracking (Features tabs, Why carousel, Integrations chips, NumbersProof chips) uses an onClickCapture handler that reads event.target.closest('a, button') — so a click on any interactive descendant of a Framer .Responsive component is captured without forking the unframer output.

Adding a new event

  1. If it fires from ONE arm only: use that arm’s trackEvent closure directly. It’s already stamped with arm + device context + any arm-specific variant props.
  2. If it fires from EVERY arm: also add the event to this doc’s “Shared events” section, use the same name in all three arm files, and match the property shape exactly so PostHog funnels compare cleanly.
  3. If it’s a section impression: prefer useSectionViewed('<name>_section_viewed', track, extraProps) over hand-rolled IntersectionObserver code — it already handles sessionStorage dedupe with mid-session variant-flip refresh.
  4. If it’s a scroll milestone: use useLandingScrollDepth(track, extraProps). extraProps should be the arm’s LANDING_V{4,5}_ANALYTICS_PROPS (or an equivalent tagged object for V3) so a mid-session arm flip refires the buckets under the new arm.
  5. For any interactive tile / button, tag the DOM node with data-analytics-cta='<section>_<element>' for dev-tools inspection and for future delegated-listener additions.

Building the primary conversion funnel

Build the canonical primary funnel once per arm, not as a single funnel broken down by arm. For arm X:
  1. landing_arm_assigned filtered to arm = X. This is the like-for-like denominator (see above). landing_page_viewed still works within an arm, but overstates V5 against V3/V4.
  2. signup_button_clicked OR pricing_cta_clicked — clickthrough intent
  3. signup_page_viewed filtered to landing_arm = X (super-prop)
  4. signed_up filtered to landing_arm = X (the client capture explicitly stamps landing_arm on this event)
Why per arm. A single funnel broken down by the first step’s arm uses PostHog’s default first-touchpoint attribution, so a visitor logged in control and later in test would have a signup made under test credited to control. Filtering the assignment step by arm and every later step by landing_arm credits each conversion to the arm the visitor was in when they converted. A reassigned visitor enters step 1 of both arms but converts in at most one. Reassignment only follows a flag edit, which should start a new analysis window anyway (see the dedupe note above). Put the three per-arm funnels side by side to compare control vs test vs test-b end-to-end. Add landing_scroll_depth = 100 or landing_page_engaged { seconds: 30 } for a “scrolled deep / stayed long” pre-conversion cohort. For the “actual conversion rate” bottom-line KPI, use the person-level cohort filter Person WHERE last_landing_arm = 'test-b' and count user_signed_up events against that cohort — the webhook is the most reliable signal because it can’t be lost to a browser closing mid-redirect.

Files owning the analytics contract

  • hooks/use-landing-conversion-experiment.ts — flag key, arm resolution, landing_arm super-property + person-property registration
  • components/landing-page-v3.tsx — trackEvent + useLandingEngagement + useLandingScrollDepth
  • components/landing-page-v4.tsx — same + LANDING_V4_ANALYTICS_PROPS stamps
  • components/landing-page-v5.tsx — same + LANDING_V5_ANALYTICS_PROPS stamps
  • components/landing-v4/analytics.ts — V4 arm/variant constants
  • components/landing-v5/analytics.ts — V5 arm/variant constants + shared TrackFn / noopTrack
  • hooks/use-section-viewed.ts — shared IO-based impression hook
  • hooks/use-landing-scroll-depth.ts — 25/50/75/100 scroll milestones
  • hooks/use-landing-engagement.ts — 5s/15s/30s dwell pings
  • lib/landing/device-context.ts — the device_bucket / viewport_* stamps
  • lib/landing/plan-intent.ts — plan-CTA → sign-up → Stripe checkout handoff (signup_plan_intent_captured, pricing_intent_carried_through)
  • lib/activity-tracker.ts — the global isAnalyticsEnabled() gate (DNT / consent / dev opt-out)
  • components/site-top-nav.tsx — accepts track prop, fires nav-click events
  • components/site-footer.tsx — accepts track prop, fires footer-click / social events
  • app/(public)/auth/callback/page.tsx — client-side signed_up capture (double-stamps landing_arm)
  • app/api/webhooks/clerk/route.ts — server-side user_signed_up capture (no landing_arm; use Person cohort filter)