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 thelanding-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 fromlanding-page-conversion-v01(2-arm test) tolanding-page-conversion-v02(3-arm test) alongside the tracking overhaul that addedlanding_armas a PostHog super-property. Data from V01 is not directly comparable to V02 because pre-overhaul events did not carry arm attribution onsigned_up/signup_page_viewed/ any downstream event. See the docblock onLANDING_CONVERSION_EXPERIMENT_KEYinhooks/use-landing-conversion-experiment.tsfor 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 stampsarm: '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'andlanding_revision: 'hero-cta-v2'(legacy — kept so pre-existing PostHog insights don’t break). - V5 events also stamp
variant: 'v5'andlanding_revision: 'framer-unframer-v1'(same pattern; bumplanding_revisionfor 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)?
armis 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_armis the same value promoted to a super-property so it automatically attaches to events fired OUTSIDE the landing page — where we have no arm-awaretrackEventclosure to add it (the Clerk-owned sign-up widget, /auth/callback captures, onboarding, and every$pageviewin the funnel). Without this the primary conversion funnel —landing_page_viewed → signed_upgrouped by arm — is unanswerable.
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’strackEvent 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 forNot 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,x-middleware-next, which Clerk’sdecorateRequestremoves from everynext()(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 atest-bon 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.
?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 firepricing_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’spricing_cta_clickedalways sendsinterval: 'month'because Framer’sAnimatedPriceSwitchtoggle state isn’t cleanly exposed via a stable DOM signal. Documented incomponents/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 carrylanding_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 showarm: 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
- If it fires from ONE arm only: use that arm’s
trackEventclosure directly. It’s already stamped witharm+ device context + any arm-specific variant props. - 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.
- 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. - If it’s a scroll milestone: use
useLandingScrollDepth(track, extraProps).extraPropsshould be the arm’sLANDING_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. - 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 armX:
landing_arm_assignedfiltered toarm = X. This is the like-for-like denominator (see above).landing_page_viewedstill works within an arm, but overstates V5 against V3/V4.signup_button_clicked OR pricing_cta_clicked— clickthrough intentsignup_page_viewedfiltered tolanding_arm = X(super-prop)signed_upfiltered tolanding_arm = X(the client capture explicitly stampslanding_armon this event)
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_armsuper-property + person-property registrationcomponents/landing-page-v3.tsx—trackEvent+useLandingEngagement+useLandingScrollDepthcomponents/landing-page-v4.tsx— same +LANDING_V4_ANALYTICS_PROPSstampscomponents/landing-page-v5.tsx— same +LANDING_V5_ANALYTICS_PROPSstampscomponents/landing-v4/analytics.ts— V4 arm/variant constantscomponents/landing-v5/analytics.ts— V5 arm/variant constants + sharedTrackFn/noopTrackhooks/use-section-viewed.ts— shared IO-based impression hookhooks/use-landing-scroll-depth.ts— 25/50/75/100 scroll milestoneshooks/use-landing-engagement.ts— 5s/15s/30s dwell pingslib/landing/device-context.ts— thedevice_bucket/viewport_*stampslib/landing/plan-intent.ts— plan-CTA → sign-up → Stripe checkout handoff (signup_plan_intent_captured,pricing_intent_carried_through)lib/activity-tracker.ts— the globalisAnalyticsEnabled()gate (DNT / consent / dev opt-out)components/site-top-nav.tsx— acceptstrackprop, fires nav-click eventscomponents/site-footer.tsx— acceptstrackprop, fires footer-click / social eventsapp/(public)/auth/callback/page.tsx— client-sidesigned_upcapture (double-stampslanding_arm)app/api/webhooks/clerk/route.ts— server-sideuser_signed_upcapture (nolanding_arm; use Person cohort filter)