Raxx · internal docs

internal · gated

PostHog Adoption for Raxx — Evaluation-First Design

Status: Draft — benefit matrix awaiting operator review before sub-cards are filed Owner: software-architect Date: 2026-07-22 UTC Related ADR: 0145 Related docs: workflow-uuid-tracing.md, passkey-e2e-with-opt-in-shadow-analytics.md, cloudflare-pages-projects.md


1. Context

This is not a greenfield adoption. Three prior, independent efforts already wired partial PostHog instrumentation, all pre-launch and flag-gated OFF, all against no live PostHog project (org has imhere, Default, Project-Avenue — nothing for Raxx):

Prior effort Surface Flag Event style Server mirror
#491/#482 frontend/trademaster_ui demo flow demo_posthog_events flat snake_case (demo_session_started, ...) Raptor posthog-python mirrors demo_session_started
#219/#2124 getraxx-landing (unmerged branch) landing_posthog_events dot-namespaced (landing.view, landing.hero_cta_click) + flat waitlist_signup none
#3899 raxx-next (Antlers) posthog_session_replay flat snake_case (beta_join_page_viewed, passkey_enrollment_success, ...) none

This design's job is to unify these into one topology and one taxonomy before evaluating what else PostHog is worth adopting, and to name the gaps: the posthog_session_replay flag currently bundles funnel-event autocapture together with full session replay — meaning today there is no way to ship Phase-1 events without also flipping on replay. AnalyticsGate._dispatch in raxx-next is a stub that does not actually call PostHog yet, so consent gating is not real for that surface. The #219 branch uses a different naming convention than everywhere else.


2. Invariants restated

No new service is introduced (PostHog is consumed as hosted SaaS via SDKs) — the language-tier checklist does not apply.


3. Benefit Matrix

Product Rating Why
Product analytics (events/funnels/trends) NOW Core gap — no funnel visibility today across waitlist→signup→passkey→paper-trade→structure. Zero overlap with Sentry/Clarity/custom flags.
Web analytics dashboard NOW Clarity is replay/heatmaps only, no traffic/funnel dashboard; PostHog web analytics fills that for getraxx + raxx.app pre-auth pages. No overlap.
Session replay LATER — trigger: cookie-consent banner (#3215/epic #3126) ships AND a portfolio/$-value/symbol DOM-masking inventory is signed off by operator+BLR HIGH PII risk (portfolio, positions, dollar values on screen). getraxx already has Clarity (locked scope) — do not duplicate there.
Heatmaps NOT (getraxx — Clarity already covers it) / LATER (raxx.app — same trigger as replay) Same DOM-capture PII posture as replay.
Feature flags NOT Custom feature_flags.yaml + B1 console gate is delivery-of-record; PostHog flag delivery would create a second source of truth. Narrow exception: PostHog flags may still assign experiment variants (see Experiments row) — never product/access gates.
Experiments LATER — trigger: ≥500 weekly-active sessions per surface under test, sustained 2 weeks (operator to confirm number) Pre-launch volume is too small for decision-grade results; existing HeroHeadlineAB.jsx variant-assignment scaffold (#219 branch) may stay dark-wired but must not be treated as decision-grade before the trigger.
Surveys NOT Custom closed-beta survey system (walkthrough surveys, digest, Console admin) is the system of record. Revisit only if operator deprecates it — not this phase.
Error tracking NOT Sentry is vendor-locked for errors/APM. Duplicate = pure overlap, no reason to add.
Data warehouse LATER — trigger: a concrete BI need to join PostHog events with Raptor/billing data (e.g., LTV by channel) once there's real revenue/retention data Hypothetical today; also a metered cost add-on.
Cohorts/retention/lifecycle LATER — trigger: beta cohort stabilizes, ~4-6 weeks of repeat-usage data Needs both volume and elapsed time, not just event plumbing.
Group analytics NOT Raxx has no household/team/org entity in the product model today. Revisit only if a multi-seat account concept ships.
Workflows/messaging (email/campaign) NOT Email is the single, verified contact channel; Postmark/durable-email-delivery is system of record. A second messaging path risks bypassing verification/consent bookkeeping — invariant conflict, not just overlap.
LLM analytics NOT No production LLM calls exist; AI surfaces are flag-gated pre-launch and execution stays deterministic. Revisit if an AI-augmented surface ships live.
MCP analytics NOT Raxx exposes no MCP server to users or agents today. N/A.

4. Project/env topology

Decision: one dedicated project, raxx, in the MooseQuest LLC org (distinct from imhere/Default/Project-Avenue). Environment is a property (environment: prod|staging|dev|ci), not a separate project — mirrors the Console's existing "environment is inferred, not chosen" mental model. Surface is a second property, app: getraxx|antlers|console|ios.

Why not raxx-prod + raxx-staging projects: the activation funnel (waitlist on getraxx → signup on raxx.app → passkey → paper trade → structure) needs one distinct_id space to link. Splitting projects doubles API keys to rotate and taxonomy definitions to keep in sync, for a benefit (env isolation) that a saved-insight filter (environment = prod) already gives for free. Staging/CI noise is excluded from every default dashboard filter, not architecturally separated.

Keys, via Infisical only: PostHog issues one project API key for event capture (PH_PROJECT_API_KEY_RAXX in vault at /MooseQuest/posthog/), used identically by client SDKs and server SDKs — there is no separate secret-class "server capture key" distinct from the public/write-only project key; both are the same value delivered to different surfaces. - Client delivery: CF Pages/build-time env vars (NEXT_PUBLIC_POSTHOG_KEY for Antlers, REACT_APP_POSTHOG_KEY for getraxx via its existing Vite define bridge) and iOS build-time xcconfig, all sourced from vault at deploy/build time. - Server delivery: Raptor via heroku config:set POSTHOG_API_KEY (silenced output per standing convention), rotatable without redeploy — same underlying key value as the client delivery above. - No new env-var-naming churn on the legacy REACT_APP_POSTHOG_API_KEY (trademaster_ui/demo, CRA, slated for retirement) — freeze it, don't touch.


5. Event taxonomy v1

Naming convention: flat snake_case, <object>_<verb_past_tense> (e.g. passkey_enrolled, paper_trade_placed). This is already the majority pattern (demo events, raxx-next funnel events, waitlist_signup). The #219 branch's dot-namespaced landing.* events are the outlier and should be renamed before/at merge (Phase-1 sub-card #4 below) rather than adopting two conventions — dot-names also complicate the org-wide event-definitions search.

Phase-1 events (activation funnel):

Event Trigger Capture
waitlist_page_viewed getraxx landing view (was landing.view) client
waitlist_cta_clicked hero CTA click (was landing.hero_cta_click) client
waitlist_scroll_depth scroll milestone (was landing.scroll_depth) client
waitlist_section_viewed section enters viewport (was landing.section_view) client
waitlist_form_submitted waitlist form submit (was landing.waitlist_click) client
waitlist_page_exited exit intent (was landing.exit) client
waitlist_signed_up 200 from /api/waitlist/signup client
beta_invite_page_viewed /beta/join/<token> landed client
passkey_setup_clicked "Set up passkey" clicked client
passkey_enrollment_started WebAuthn ceremony begins client
passkey_enrolled credential persisted server
passkey_enrollment_failed ceremony failed (error_category only) client
signup_completed account row created server
onboarding_step_viewed step N rendered client
onboarding_step_completed step N submitted client
onboarding_completed final step done client
paper_account_funded simulated funding set server
first_paper_trade_placed first paper fill (reuses graduate.py's first_paper_trade_at) server
paper_trade_placed every paper fill server
structure_builder_opened IC/Wheel builder entered client
structure_drafted legs configured, not saved client
first_structure_built first wheel/IC position row inserted server
structure_built every position row inserted server

Property conventions: snake_case keys, typed values only (string enum, integer, boolean, ISO-8601 UTC). Every event carries base properties app, environment, and — inside an authenticated workflow — workflow_id (wfl_*, reusing the existing tracing identifier so PostHog events can be cross-referenced to the audited trace log without duplicating trace data).

Hard no-PII rule: no email key ever (domain-only, as waitlistAnalytics.js already does); never symbol + quantity + dollar_amount together in one payload — symbol alone is fine for structure-funnel context, but combining all three is a future ADR-level decision, not a default; no broker/vendor names.

Person identification (corrected #4311 follow-up, PR #4326 QA Finding 1 — the original text below was wrong about WHERE the anon→identified merge happens): pre-auth distinct_id is PostHog's own anonymous ID, assigned client-side by posthog-js and held in browser storage — the server never sees it. The anon→identified merge is a client-side operation only: immediately after a successful passkey registration (signup complete), raxx-next calls identifyPostHogUser(userId) (lib/posthogTracker.ts), which runs posthog.identify(String(userId)). posthog-js automatically attaches the browser's current $anon_distinct_id to that call — that attachment is the actual merge mechanism, the same underlying posthog.identify() primitive waitlistAnalytics.js uses (though that precedent re-identifies with the same anonymous id, purely to attach non-PII properties, not a new server-issued id). userId is always the internal user id, never email.

Raptor also calls posthog-python's identify() server-side at signup_completed and other server-truth events, but this is a separate, complementary step: it sets the allowed person properties below on the already-identified profile — it does not, and structurally cannot, perform the anon-session merge, because the anonymous distinct_id lives only in the visitor's browser and is never transmitted to or known by the server. If the client-side identifyPostHogUser() call is ever skipped or fails, the pre-signup and post-signup halves of the funnel remain two disconnected distinct_ids regardless of what Raptor does server-side — the client-side call is load-bearing for ADR-0145's single-distinct_id-space goal, not optional.

Allowed person properties (allowlist): signup_at, platform, is_founder, founder_status, plan_tier, onboarding_completed_at, first_paper_trade_at, first_structure_built_at, waitlist_referred. No display name, no email, ever.


6. SDK placement

Surface SDK Captures Why
getraxx (Vite, CF Pages, pre-auth) posthog-js Acquisition events, client-truth (low stakes, no account yet)
Antlers/raxx-next (Next.js App Router, CF Pages, edge runtime) posthog-js (dynamic import, existing pattern) UI-engagement events only (onboarding, structure-builder UI, passkey clicks) posthog-node cannot run in the CF Pages edge runtime — no Node http/fs primitives. If Antlers ever needs a server-truth event without a Raptor round-trip, use a raw fetch() POST to PostHog's HTTP capture endpoint, never the posthog-node package. Default path stays: Raptor is truth.
Raptor (Flask, Heroku, regular dyno) posthog-python (already posthog>=3.0 in requirements.txt, precedent: #491) signup_completed, passkey_enrolled, paper_account_funded, first_paper_trade_placed, paper_trade_placed, first_structure_built, structure_built These are billing/trading/auth-adjacent state changes that already have an audit_log row and a workflow_id; Raptor is the single place they're true — mirroring here prevents a compromised/ad-blocked client from spoofing or hiding them.
Console none in Phase 1 Operator-usage analytics is a distinct, unscoped LATER idea, not part of this funnel.
iOS (Swift) posthog-ios Same UI-engagement event set, tagged platform: ios Billing/trading events stay server-captured from Raptor regardless of client origin.

7. Privacy/consent posture


sequenceDiagram
    participant U as Visitor/User
    participant FE as Antlers/getraxx client
    participant Gate as AnalyticsGate
    participant PH as PostHog
    participant Raptor as Raptor (server)

    U->>FE: page interaction
    FE->>Gate: queue(event, props)
    alt consent already granted
        Gate->>PH: capture(event, props)
    else no consent yet
        Gate-->>Gate: buffer event
    end
    U->>FE: accepts "Analytics" cookie
    FE->>Gate: flush()
    Gate->>PH: init(key) + replay buffered events
    U->>Raptor: signup / passkey / paper-trade / structure state change
    Raptor->>Raptor: write audit_log + workflow_id
    Raptor->>PH: capture(server-truth event, distinct_id=user_uuid)

9. Migrations

No Raptor/Postgres schema changes — PostHog is external SaaS. Feature-flag changes only:


10. Rollout plan


11. Security considerations

PII collected: coarse UI-engagement signals + typed state-change mirrors, no email/no combined symbol+quantity+dollar, no credentials. Retention: PostHog default (rely on org-level retention setting, set to match Raxx's GDPR retention policy — a Phase-1 sub-card configures this). Deletion on DSR: person-delete API call keyed on internal_user_uuid, added to the existing DSR fulfillment path. Audit: PostHog capture is additive to audit_log, never a substitute. No credential ever replayable via PostHog. Breach: PostHog is a sub-processor: incident triggers the existing vendor-breach notification path. Secrets: Infisical vault, rotatable without redeploy. Kill-switch: both flags default OFF and are single-flip kill switches, no redeploy needed.


12. Open questions

  1. Do PostHog surveys ever replace the custom beta survey system? (Non-blocking for Phase 1.)
  2. Exact date to flip raxx.app's legal basis from NDA-ack to cookie-consent once #3215 ships.
  3. Confirm the experiments volume trigger (proposed: ≥500 weekly-active sessions/surface, sustained 2 weeks).
  4. Is Console operator-usage analytics ever wanted? (Currently out of scope.)
  5. What concrete BI need would justify the data-warehouse product?

13. Proposed card decomposition (Phase 1)

Epic: PostHog Phase 1 — Raxx activation funnel foundation

  1. Provision raxx PostHog project + vault-backed key delivery (topology cutover).
  2. Split posthog_session_replay into posthog_events_enabled + retained replay-only flag; B1 migration.
  3. Wire AnalyticsGate._dispatch to actually call PostHog (fix the stub) + add app/environment base properties.
  4. Rename the six getraxx landing.* dot-events to flat snake_case per §5 mapping (coordinate with #219).
  5. Raptor server-side capture for signup_completed/passkey_enrolled/paper_account_funded/first_paper_trade_placed/paper_trade_placed/first_structure_built/structure_built.
  6. Antlers client-side onboarding + structure-builder funnel events.
  7. Geo-suppression parity for getraxx pre-auth capture (EU/EEA + Quebec).
  8. iOS posthog-ios integration for Phase-1 event parity + ASC privacy-label update.

Sub-cards are not filed as GitHub issues yet — product-manager files them after operator review of this matrix.