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 stored credentials — PostHog never sees a WebAuthn credential, challenge, or broker token; none are DOM-renderable, so replay/autocapture structurally cannot leak them.
- Passkeys/WebAuthn only — unaffected; PostHog is observability, not auth.
- Email is the single contact channel, post-verification — PostHog is never a messaging path (see Benefit Matrix, Workflows/Messaging row).
- GDPR by default — event/person data follows the same DSR, retention, and erasure obligations as any other PII-adjacent store.
- Paper-first gating — trading-adjacent events are informational mirrors of an already-audited state change, never a gate themselves.
- Credentials into infra, not code — all API keys via Infisical vault, never committed.
- Audit trail for state changes — PostHog capture is additive telemetry; it never substitutes for
audit_logor Workflow-UUID tracing. - No vendor names in customer-facing surfaces — irrelevant to PostHog itself (internal tool) but event properties must never leak broker names.
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
- getraxx + raxx.app both gate PostHog init behind the in-flight cookie-consent banner (#3215/epic #3126)
AnalyticsGate. Until it ships, raxx.app (authenticated, NDA-disclosed beta) continues on the NDA acknowledgment as interim legal basis, per the existing code comment — carried forward, not relitigated. The banner ship date is the hard trigger to switch legal basis and is a Phase-1 blocking dependency for one sub-card, not the whole epic. - getraxx pre-auth capture must also structurally suppress all events (not just consent-gate) for visitors in the EU/EEA + Quebec geo-block list — reuse the existing signup geo-block detection. A consent banner alone is not sufficient; we don't operate there.
- iOS:
posthog-iosdoes not use IDFA by default — no App Tracking Transparency prompt required, since this is account-linked product analytics, not cross-app ad tracking. App Store privacy nutrition label must still disclose "Usage Data" (Product Interaction) linked to identity. - Session-replay masking, if ever adopted: the existing
maskAllInputs/email-maskTextFnprimitives inposthogTracker.tsare necessary but not sufficient — dollar values, portfolio balances, and symbol+quantity combinations need explicitph-no-captureblocking independent of the user-togglableantlers_obfuscate_modesetting, because a platform invariant cannot depend on a preference the user can flip off. This masking inventory is a Phase-3 prerequisite, not solved by today's code.
8. Consent-gated capture sequence
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:
- Split
posthog_session_replayintoposthog_events_enabled(Phase 1: autocapture off, explicit events only) and a retainedposthog_session_replay(Phase 3: replay only, additionally gated on the masking-inventory sign-off in §7). New B1console_flag_promotionsmigration required forposthog_events_enabled, following the existing pattern (migrations 0044/0063/0219 precedent). - Rename the six
landing.*dot-events per the mapping in §5, coordinated with whoever lands #219. - No changes to
demo_posthog_events(already-correct convention, left as-is).
10. Rollout plan
- Phase 1 (this epic): topology cutover, flag split, consent-gate wiring fix, event renames, Phase-1 event set client+server, iOS parity, geo-suppression.
- Phase 2 (post-beta): retention/cohort/lifecycle dashboards once ~4-6 weeks of repeat usage exists; evaluate data warehouse against a concrete BI need.
- Phase 3 (post-launch triggers): experiments once volume trigger is met; session replay + heatmaps once cookie banner has shipped and the masking inventory is signed off.
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
- Do PostHog surveys ever replace the custom beta survey system? (Non-blocking for Phase 1.)
- Exact date to flip raxx.app's legal basis from NDA-ack to cookie-consent once #3215 ships.
- Confirm the experiments volume trigger (proposed: ≥500 weekly-active sessions/surface, sustained 2 weeks).
- Is Console operator-usage analytics ever wanted? (Currently out of scope.)
- What concrete BI need would justify the data-warehouse product?
13. Proposed card decomposition (Phase 1)
Epic: PostHog Phase 1 — Raxx activation funnel foundation
- Provision
raxxPostHog project + vault-backed key delivery (topology cutover). - Split
posthog_session_replayintoposthog_events_enabled+ retained replay-only flag; B1 migration. - Wire
AnalyticsGate._dispatchto actually call PostHog (fix the stub) + addapp/environmentbase properties. - Rename the six getraxx
landing.*dot-events to flat snake_case per §5 mapping (coordinate with #219). - Raptor server-side capture for
signup_completed/passkey_enrolled/paper_account_funded/first_paper_trade_placed/paper_trade_placed/first_structure_built/structure_built. - Antlers client-side onboarding + structure-builder funnel events.
- Geo-suppression parity for getraxx pre-auth capture (EU/EEA + Quebec).
- iOS
posthog-iosintegration 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.