Closed-beta funnel: PostHog taxonomy, identity stitching, funnel insight
Card #4708, epic #4701. Extends docs/architecture/posthog-raxx-adoption.md section 5 (conventions there apply: flat snake_case <object>_<verb_past_tense>, typed property values, base properties app and environment). Emitters in #4707, #4709 and #4710 import the constants; they do not retype names.
Constants (kept identical, enforced by backend_v2/tests/test_beta_funnel_events_parity_4708.py):
| Consumer | Module |
|---|---|
Raptor (backend_v2) |
backend_v2/api/services/beta_funnel_events.py |
| Console | console/app/services/beta_funnel_events.py (byte-identical copy; Console deploys separately and cannot import backend_v2) |
Antlers (frontend/raxx-next) |
frontend/raxx-next/lib/betaFunnelEvents.ts |
1. Rules for every funnel event
- No PII.
distinct_idis an id, never an email. Forbidden property keys:email,email_address,name,token,invite_token,ip,ip_address(FORBIDDEN_PROP_KEYS). Invite tokens never appear in any payload or URL property. - Server capture goes through
api/services/posthog_capture.py(flagposthog_events_enabled, fire-and-forget, no-PII backstop). Console never holds a PostHog key; it asks Raptor's internal endpoint to capture (epic default 4). - Consent gate: client events go through
AnalyticsGate.queue()and are buffered until consent (or the NDA-ack interim basis for authenticated beta pages, per adoption doc section 7). Server-truth events (state changes already audited) fire regardless of the browser consent state, carry only ids and enums, and are dropped entirely whenposthog_events_enabledis off. Pre-account events never carry anything derivable to an email withoutANALYTICS_ID_KEY. - Customer-facing copy never names a broker or vendor; framing is retrospective.
2. Event taxonomy
"Emitter" is the service that fires the event. Funnel order is BETA_FUNNEL_EVENT_NAMES.
| # | Event | Fires when | Emitter | distinct_id | Properties (besides app, environment) |
|---|---|---|---|---|---|
| 1 | waitlist_signed_up |
waitlist signup accepted (already shipped client-side by getraxx waitlistAnalytics.js) |
getraxx client (existing); Raptor server capture on /api/waitlist/signup for the keyed id (#4707) |
pre-account | existing attribution props; id_kind=pre_account |
| 2 | beta_phase1_invited |
Console sends the Phase 1 invite (phase1_invited_at set) |
Console via Raptor internal capture (#4709) | pre-account | cohort, phase=1 |
| 3 | beta_phase1_opened |
first load of the preview tour (/api/beta/preview/<token>/...) |
Raptor (server-rendered pages; client PostHog cannot instrument inside) | pre-account | cohort, phase=1 |
| 4 | beta_phase1_completed |
Phase 1 rubric (screen 5) submitted | Raptor | pre-account | cohort, phase=1 |
| 5 | beta_phase2_invited |
Console sends the Phase 2 invite (phase2_invited_at set) |
Console via Raptor | pre-account | cohort, phase=2 |
| 6 | beta_phase2_opened |
first load of /beta/join/<token>. Name is the existing client event beta_invite_page_viewed; beta_phase2_opened is the server-side first-open mark (once per invite) used as the funnel step so ad-blockers do not distort it |
Raptor (beta_phase2_opened); Antlers client keeps beta_invite_page_viewed |
pre-account | cohort, phase=2 |
| 7 | beta_phase2_joined |
account + passkey created from the invite. Fires alongside signup_completed / passkey_enrolled (unchanged, still emitted by Raptor) |
Raptor (webauthn_service path) |
str(user_id) after alias (section 3) |
cohort=beta, phase=2 |
| 8 | beta_phase2_completed |
Phase 2 walkthrough survey (/beta/walk/<token>/survey) submitted |
Raptor | str(user_id) if joined, else pre-account |
cohort, phase=2 |
| 9 | beta_access_granted |
operator grants app access in Console (explicit gate, epic default 2) | Console via Raptor | str(user_id) |
cohort=beta |
| 10 | beta_first_run_started |
granted tester's first authenticated app load after the grant (the first-run welcome region renders) | Antlers client (#4706): components/beta/BetaFirstRun.tsx via AnalyticsGate.queue, consent- and posthog_events_enabled-gated, once per user per browser. Raptor does not emit it; if that changes, drop the client call |
PostHog browser id until identifyPostHogUser merges it to str(user_id) |
cohort=beta |
| 11 | first_paper_trade_placed |
first paper fill (already emitted by trade_window.py) |
Raptor | str(user_id) |
existing props, unchanged |
| 12 | first_backtest_completed |
first backtest run finishes successfully | Raptor | str(user_id) |
cohort |
| 13 | first_strategy_created |
first strategy saved | Raptor | str(user_id) |
cohort |
| 14 | beta_micro_survey_shown |
post-core-action micro-survey rendered | Antlers client (#4710) | str(user_id) |
survey_trigger, survey_id |
| 15 | beta_micro_survey_answered |
micro-survey submitted | Antlers client; Raptor on receipt of the answer | str(user_id) |
survey_trigger, survey_id, answered_count (integer; answer text is never sent) |
| 16 | beta_micro_survey_dismissed |
tester closes or defers it | Antlers client | str(user_id) |
survey_trigger, survey_id, dismiss_reason (closed, later, timeout) |
| 17 | beta_lab_started | tester opens the Rules Lab for the first time (lab_started_at set) | Antlers client (#4760) | str(user_id) | cohort=beta |
| 18 | beta_lab_mission_started | a mission screen is entered | Antlers client | str(user_id) | mission (1-5) |
| 19 | beta_lab_mission_completed | a mission is completed | Antlers client | str(user_id) | mission (1-5), duration_s |
| 20 | beta_lab_rule_violation_shown | the blocked-order panel renders (mission 4) | Raptor, on the 422 lab_rule_violation | str(user_id) | rule_type (max_loss, max_positions, delta_range, spread_width); never the symbol or the limit values |
| 21 | beta_lab_backtest_completed | mission 5 rule-aware backtest returns | Antlers client | str(user_id) | none beyond defaults |
| 22 | beta_lab_completed | recap screen reached (lab_completed_at set) | Antlers client | str(user_id) | none beyond defaults |
| 23 | beta_lab_abandoned | tester leaves the lab before the recap (page hide / navigate away) | Antlers client | str(user_id) | last_mission (0-5) |
Lab events carry no tickers, symbols, rule values, comments or tokens. ticker, symbol, underlying, comment and answers are in FORBIDDEN_PROP_KEYS and are stripped by the capture helpers.
Enums: cohort in (preview, beta); survey_trigger in (first_paper_trade_placed, first_backtest_completed, first_strategy_created, lab_completed); rule_type in (max_loss, max_positions, delta_range, spread_width); mission 1-5.
Existing related events, unchanged and not part of the new constant list except as compatibility names (BETA_RELATED_EXISTING_EVENT_NAMES): beta_invite_page_viewed, signup_completed, passkey_enrolled.
Compatibility note: lib/enrollmentFunnel.ts still declares legacy names beta_join_page_viewed and passkey_enrollment_success. The shipped, tested names are those in tests/fixtures/phase1FunnelEventTaxonomy.ts (beta_invite_page_viewed, ...). New code must use the constants above; the legacy names are not funnel steps.
3. Identity stitching
Problem: Phase 1 and the Phase 2 invite happen before an account exists, across Raptor and Console, so PostHog's browser anonymous id is unavailable (server-rendered pages, email-link flows).
Design:
- Pre-account
distinct_id=pre_+HMAC-SHA256(ANALYTICS_ID_KEY, lowercase(strip(email)))hex (pre_account_distinct_id()). Deterministic across Raptor and Console, so every pre-account event for one tester lands on one person; not reversible or joinable to an email without the secret; the email never reaches PostHog. - At join/enrollment (
beta_phase2_joined, and also plainsignup_completedfor a waitlist email), Raptor calls PostHogalias(previous_id=pre_account_id, distinct_id=str(user_id))once, then continues withstr(user_id)(the existingposthog_captureconvention). Raptor computes the pre-account id from the account email at that moment; no email is sent. - Events after the alias use
str(user_id)only. Events for an email with no account stay on the pre-account id. - The client-side
identifyPostHogUser(userId)merge from the adoption doc section 5 stays as is for the browser anonymous id; the alias above is the server-side link and is independent of it. - Missing secret:
AnalyticsIdKeyMissingis raised; emitters must skip capture and log, never fall back to a hash of the bare email or the email itself. - Rotation: rotating
ANALYTICS_ID_KEYsplits identities for tester emails not yet aliased. Rotate only with a plan (re-alias or accept the split); aliased users are unaffected.
Secret: ANALYTICS_ID_KEY, 32 random bytes hex. Vault: /Raxx/Raptor/ANALYTICS_ID_KEY in Infisical environments prod and staging. Heroku config var on raxx-api-prod, raxx-api-staging, raxx-console-prod, raxx-console-staging (same value everywhere so one email maps to one id across environments in the shared PostHog project).
4. Funnel insight
PostHog project raxx (id in vault /MooseQuest/posthog/PH_PROJECT_ID_RAXX). The vault holds only the capture key (no Personal API Key), so the insight could not be created via API; create it in the UI: Product analytics, New insight, Funnels.
Definition:
- Name:
Closed beta funnel: waitlist to first core action - Type: Funnel, Steps, ordered ("sequential"), conversion window 60 days, count unique persons.
- Steps in order:
waitlist_signed_up,beta_phase1_invited,beta_phase1_opened,beta_phase1_completed,beta_phase2_invited,beta_phase2_opened,beta_phase2_joined,beta_phase2_completed,beta_access_granted,beta_first_run_started, then an "any of" final step:first_paper_trade_placedORfirst_backtest_completedORfirst_strategy_created. - Global filter:
environment = prod. - Breakdown (optional second copy):
cohort. - Separate trends insight:
beta_micro_survey_shown,beta_micro_survey_answered,beta_micro_survey_dismissedbroken down bysurvey_trigger.
API equivalent (POST /api/projects/<id>/insights/, Personal API Key with insight:write):
{
"name": "Closed beta funnel: waitlist to first core action",
"query": {
"kind": "InsightVizNode",
"source": {
"kind": "FunnelsQuery",
"series": [
{"kind": "EventsNode", "event": "waitlist_signed_up"},
{"kind": "EventsNode", "event": "beta_phase1_invited"},
{"kind": "EventsNode", "event": "beta_phase1_opened"},
{"kind": "EventsNode", "event": "beta_phase1_completed"},
{"kind": "EventsNode", "event": "beta_phase2_invited"},
{"kind": "EventsNode", "event": "beta_phase2_opened"},
{"kind": "EventsNode", "event": "beta_phase2_joined"},
{"kind": "EventsNode", "event": "beta_phase2_completed"},
{"kind": "EventsNode", "event": "beta_access_granted"},
{"kind": "EventsNode", "event": "beta_first_run_started"},
{"kind": "ActionsNode", "name": "First core action",
"custom_name": "First core action (any of paper trade, backtest, strategy)"}
],
"funnelsFilter": {"funnelVizType": "steps", "funnelOrderType": "ordered",
"funnelWindowInterval": 60, "funnelWindowIntervalUnit": "day"},
"properties": [{"type": "event", "key": "environment", "operator": "exact", "value": ["prod"]}]
}
}
}
The last step is an Action ("First core action") defined in PostHog as any of the three events; create the action first and put its id in the final ActionsNode.