feat(flows): carry the PostHog distinct id into the Cloud handoff - #102
Conversation
The /flows -> Google -> Cloud funnel showed Cloud users as fresh visitors with no funnel history. Continuity between the two apps rested entirely on posthog-js persistence being shared across one origin, which we could not measure and which silently breaks on preview hosts, in dev, and wherever browser storage is blocked or partitioned. Send the marketing anonymous distinct id in the handoff so Cloud can tell whether that storage continuity held, and repair it with alias() when it did not. Only PostHog's anonymous device id travels, alongside the journey id that is already there: no email, no name, nothing typed into onboarding. It stays in the URL fragment, so it never reaches OAuth state or server access logs, and it is omitted when PostHog is unconfigured or the visitor opted out - cloud_handoff_started now reports distinct_id_sent so that case is visible rather than indistinguishable from a bug. Also document the identity contract in the shared funnel report: the two mechanisms for continuity, the properties both sides emit, and the fact that person-based funnels only become valid once identify is verified in production with both apps on the same PostHog project key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEWvpvSzoGUFoHZDGbYz7a
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe onboarding flow now retrieves an anonymous PostHog distinct ID, passes it through the Cloud handoff fragment, records whether it was sent, and documents the related identity events, properties, and coverage limits. ChangesFlows analytics identity handoff
Priority: ⬇️ Low Estimated code review effort: 2 (Simple) | ~10 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant FactoryBuilder
participant useFlowAnalytics
participant RunOptions
participant cloudConnectionsHref
FactoryBuilder->>useFlowAnalytics: obtain getDistinctId
FactoryBuilder->>RunOptions: pass getDistinctId
RunOptions->>useFlowAnalytics: request anonymous distinct ID
RunOptions->>cloudConnectionsHref: pass journeyId and distinctId
cloudConnectionsHref->>cloudConnectionsHref: encode analytics in URL fragment
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 5 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit tracks the distinct ID, Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 60f49d1111
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const getDistinctId = useCallback(() => { | ||
| try { | ||
| if (!process.env.NEXT_PUBLIC_POSTHOG_KEY || !ph || ph.has_opted_out_capturing()) return undefined; | ||
| return ph.get_distinct_id() || undefined; |
There was a problem hiding this comment.
Carry the anonymous device ID instead of the current person ID
When this browser has previously visited Cloud and Cloud has called identify, the shared PostHog persistence makes get_distinct_id() return that authenticated user's ID, not the anonymous device ID described here. If the next handoff reaches an origin where storage did not carry over—the exact repair case for this change—the Cloud-side alias will treat the prior user's ID as the marketing anonymous ID and can merge that prior identity into the newly authenticated account, particularly on shared browsers or after switching Google accounts. Read PostHog's persisted $device_id (or otherwise verify the current ID is anonymous) rather than forwarding the current person distinct ID.
Useful? React with 👍 / 👎.
|
Preview deployed!
This is a Cloudflare Workers preview version of this PR's build. |
PostHog reported Cloud users arriving as fresh visitors with no funnel history, which breaks the
/flows→ Google → Cloud dashboard funnel. This is the marketing-site half of the fix.Why
Identity continuity between the two apps rested entirely on one mechanism: both apps are served from the same origin in production (marketing at
agentrelay.com/, Cloud atagentrelay.com/cloud), and posthog-js persists its anonymousdistinct_idunder the project token, so the anonymous person survives the handoff and the Google round trip.That mechanism is invisible — we could not measure whether it held — and it silently breaks on preview hosts, in local dev where the two apps are different origins, and anywhere browser storage is blocked or partitioned. When it breaks, the pre-auth marketing events belong to a person that Cloud's user never becomes.
So marketing now hands Cloud its anonymous id explicitly. Cloud (companion PR, below) reports whether the ids matched, calls
identify(userId)on the first authenticated view after Google sign-in, and callsalias()on the marketing id when storage did not carry over, merging the two anonymous people into the signed-in user.Marketing still never calls
identify: the visitor there is anonymous, and that remains correct.What changed
web/lib/flow-onboarding.ts—cloudConnectionsHref(draft, handoffId, journeyId?, distinctId?)addsanalytics.distinctIdbeside the existinganalytics.journeyId. Theanalyticsobject is emitted only when at least one of the two is present, so a payload with neither is unchanged from today.web/app/flows/onboarding/useFlowAnalytics.ts— newgetDistinctId(), returningph.get_distinct_id()only when PostHog is configured and the visitor has not opted out, and never throwing.web/app/flows/onboarding/RunOptions.tsx(+FactoryBuilder.tsxplumbing) — passes it through, and addsdistinct_id_sent: booleantocloud_handoff_startedso an absent id is a visible, expected state rather than something indistinguishable from a bug.web/analytics/flows-funnel.txt— documents the identity contract: the two continuity mechanisms, the new properties on both sides (distinct_id_sent,handoff_distinct_id_matches), the Cloud-side$identify/$create_alias/cloud_auth/cloud_dashboard_viewed from=deployedsteps, and that person-based funnels become valid once identify is verified in production.What does not travel
Only PostHog's anonymous device identifier. No email, no name, no repository, no ticket, no source filters, nothing typed into onboarding. It stays in the URL fragment, like the rest of the handoff, so it never enters OAuth state, cookies, or server access logs. No change to the PostHog provider config in
web/app/layout.tsx.Tests
web/lib/test/flow-analytics.test.ts— extended the existingcloudConnectionsHrefcase: the fragment payload carriesanalytics.distinctIdwhen given; a distinct id without a journey and a journey without a distinct id each carry only their own field; an empty string for either is omitted; neither present leaves no emptyanalyticsobject behind; the search string stays empty.cd web && npx vitest run— 20 files, 208 tests, all pass.cd web && npx tsc --noEmit— clean.lintscript inpackage.json), so none was run.Operator checks (cannot be verified from code)
NEXT_PUBLIC_POSTHOG_KEYGitHub Actions variable in this repo must equal the Cloud project key (phc_2uDu01…, PostHog project 296966, hard-coded in Cloud'sinfra/posthog-ingest-keys.ts). Different keys mean different projects, and no joined funnel regardless ofidentifyoralias.cloud_handoff_startedcarriesdistinct_id_sent = true, that the matchingcloud_import_receivedreportshandoff_distinct_id_matches, and that a$identifyevent follows Google sign-in on the same distinct id ascloud_auth.flows_landing_viewed→flows_onboarding_cloud_handoff_started→cloud_auth→flows_onboarding_cloud_flow_saved→flows_onboarding_cloud_dashboard_viewed, filtered tofunnel_version = 2. Until then, thejourney_idgrouping remains the reading that does not depend on identity.Companion PR
The Cloud half is on branch
claude/festive-meitner-6o2mhminAgentWorkforce/cloud. It parses the newanalytics.distinctId, so it should merge alongside this one — but the two are independent: an older Cloud ignores the extra field, and this change is inert without it.🤖 Generated with Claude Code
https://claude.ai/code/session_01UEWvpvSzoGUFoHZDGbYz7a
Generated by Claude Code
Note
Low Risk
Analytics-only marketing changes; handoff adds an optional anonymous device id in the fragment and is backward-compatible with Cloud that ignores the field.
Overview
Fixes broken marketing → Cloud PostHog funnel continuity by explicitly passing the visitor’s anonymous PostHog
distinct_idin the Cloud handoff, so Cloud (companion PR) can verify same-origin storage held and alias when it did not.cloudConnectionsHrefnow accepts optionaldistinctIdand addsanalytics.distinctIdnext tojourneyIdin the URL fragment only (still no query string). Theanalyticsblock is omitted unless at least one id is present.Onboarding exposes
getDistinctId()fromuseFlowAnalytics(PostHog configured, not opted out), wires it throughRunOptions, and setsdistinct_id_sentoncloud_handoff_startedso missing ids are visible rather than silent failures.flows-funnel.txtdocuments the identity contract (shared storage vs handoff payload), new properties (distinct_id_sent,handoff_distinct_id_matches), Cloud-side$identify/ alias /cloud_authsteps, and that both apps must share the same PostHog project key.Tests cover fragment payload shape and empty-string omission.
Reviewed by Cursor Bugbot for commit 60f49d1. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Fixes the
/flows→ Google → Cloud funnel so Cloud users no longer appear as fresh visitors. The marketing site now passes its anonymous PostHog distinct id through the Cloud handoff, letting Cloud confirm identity continuity and alias when it did not hold; the Cloud app must read the new field for this to work.Bug Fixes
posthog-jsstorage, which silently breaks on preview hosts, in dev, and when browser storage is blocked or partitioned.analytics.distinctIdbeside the existinganalytics.journeyId; the handoff payload is unchanged when neither is present.cloud_handoff_startednow reportsdistinct_id_sentso an absent id is explicit rather than indistinguishable from a bug.Migration
NEXT_PUBLIC_POSTHOG_KEYmust equal the Cloud project key; different keys mean different projects and no joined funnel regardless ofidentifyoralias.$identifyfollows Google sign-in before building person-based funnels; keep using thejourney_idgrouping until then.Written for commit 60f49d1. Summary will update on new commits.