Raxx · internal docs

internal · gated

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

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:

  1. 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.
  2. At join/enrollment (beta_phase2_joined, and also plain signup_completed for a waitlist email), Raptor calls PostHog alias(previous_id=pre_account_id, distinct_id=str(user_id)) once, then continues with str(user_id) (the existing posthog_capture convention). Raptor computes the pre-account id from the account email at that moment; no email is sent.
  3. Events after the alias use str(user_id) only. Events for an email with no account stay on the pre-account id.
  4. 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.
  5. Missing secret: AnalyticsIdKeyMissing is raised; emitters must skip capture and log, never fall back to a hash of the bare email or the email itself.
  6. Rotation: rotating ANALYTICS_ID_KEY splits 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:

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.