Skip to content

feat(flows): carry the PostHog distinct id into the Cloud handoff - #102

Merged
willwashburn merged 1 commit into
mainfrom
claude/festive-meitner-6o2mhm
Sep 19, 2026
Merged

willwashburn merged 1 commit into
mainfrom
claude/festive-meitner-6o2mhm

Conversation

@willwashburn

@willwashburn willwashburn commented Sep 19, 2026

Copy link
Copy Markdown
Member

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 at agentrelay.com/cloud), and posthog-js persists its anonymous distinct_id under 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 calls alias() 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.tscloudConnectionsHref(draft, handoffId, journeyId?, distinctId?) adds analytics.distinctId beside the existing analytics.journeyId. The analytics object 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 — new getDistinctId(), returning ph.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.tsx plumbing) — passes it through, and adds distinct_id_sent: boolean to cloud_handoff_started so 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=deployed steps, 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 existing cloudConnectionsHref case: the fragment payload carries analytics.distinctId when 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 empty analytics object behind; the search string stays empty.
  • cd web && npx vitest run — 20 files, 208 tests, all pass.
  • cd web && npx tsc --noEmit — clean.
  • No lint step exists in this repo (no eslint config or lint script in package.json), so none was run.

Operator checks (cannot be verified from code)

  • Required for this fix to work at all: the marketing NEXT_PUBLIC_POSTHOG_KEY GitHub Actions variable in this repo must equal the Cloud project key (phc_2uDu01…, PostHog project 296966, hard-coded in Cloud's infra/posthog-ingest-keys.ts). Different keys mean different projects, and no joined funnel regardless of identify or alias.
  • After deploy, confirm in PostHog that a cloud_handoff_started carries distinct_id_sent = true, that the matching cloud_import_received reports handoff_distinct_id_matches, and that a $identify event follows Google sign-in on the same distinct id as cloud_auth.
  • Build the funnel on persons only after that check: flows_landing_viewedflows_onboarding_cloud_handoff_startedcloud_authflows_onboarding_cloud_flow_savedflows_onboarding_cloud_dashboard_viewed, filtered to funnel_version = 2. Until then, the journey_id grouping remains the reading that does not depend on identity.

Companion PR

The Cloud half is on branch claude/festive-meitner-6o2mhm in AgentWorkforce/cloud. It parses the new analytics.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_id in the Cloud handoff, so Cloud (companion PR) can verify same-origin storage held and alias when it did not.

cloudConnectionsHref now accepts optional distinctId and adds analytics.distinctId next to journeyId in the URL fragment only (still no query string). The analytics block is omitted unless at least one id is present.

Onboarding exposes getDistinctId() from useFlowAnalytics (PostHog configured, not opted out), wires it through RunOptions, and sets distinct_id_sent on cloud_handoff_started so missing ids are visible rather than silent failures.

flows-funnel.txt documents the identity contract (shared storage vs handoff payload), new properties (distinct_id_sent, handoff_distinct_id_matches), Cloud-side $identify / alias / cloud_auth steps, 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

  • Identity continuity previously depended entirely on same-origin shared posthog-js storage, which silently breaks on preview hosts, in dev, and when browser storage is blocked or partitioned.
  • Adds analytics.distinctId beside the existing analytics.journeyId; the handoff payload is unchanged when neither is present.
  • Only the anonymous device id travels, and it stays in the URL fragment: no email, name, repository, or onboarding input, and no change to PostHog provider config.
  • cloud_handoff_started now reports distinct_id_sent so an absent id is explicit rather than indistinguishable from a bug.

Migration

  • Marketing's NEXT_PUBLIC_POSTHOG_KEY must equal the Cloud project key; different keys mean different projects and no joined funnel regardless of identify or alias.
  • After deploy, verify in PostHog that $identify follows Google sign-in before building person-based funnels; keep using the journey_id grouping until then.

Written for commit 60f49d1. Summary will update on new commits.

Review in cubic

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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-19T16:24:17.033599Z 60f49d1 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: cf212220-073c-489e-a507-fbaf97bb78d3

📥 Commits

Reviewing files that changed from the base of the PR and between d0eea77 and 60f49d1.

📒 Files selected for processing (6)
  • web/analytics/flows-funnel.txt
  • web/app/flows/onboarding/FactoryBuilder.tsx
  • web/app/flows/onboarding/RunOptions.tsx
  • web/app/flows/onboarding/useFlowAnalytics.ts
  • web/lib/flow-onboarding.ts
  • web/lib/test/flow-analytics.test.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The 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.

Changes

Flows analytics identity handoff

Layer / File(s) Summary
Identity contract and funnel definitions
web/analytics/flows-funnel.txt
Documents anonymous marketing identity, Cloud identification and aliasing, cloud_auth, funnel properties, production verification, and coverage limits.
Distinct ID retrieval and handoff wiring
web/app/flows/onboarding/useFlowAnalytics.ts, web/app/flows/onboarding/FactoryBuilder.tsx, web/app/flows/onboarding/RunOptions.tsx
Exposes a guarded getDistinctId callback, passes it to RunOptions, includes the ID in the Cloud handoff, and records distinct_id_sent.
Handoff payload construction and validation
web/lib/flow-onboarding.ts, web/lib/test/flow-analytics.test.ts
Adds optional distinctId support to the analytics fragment and tests combinations of journey IDs, distinct IDs, empty values, and URL placement.

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
Loading

Suggested reviewers: kjgbot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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 … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: carrying the PostHog distinct ID into the Cloud handoff.
Description check ✅ Passed The description directly explains the PostHog identity continuity problem, the handoff changes, instrumentation, tests, and operational checks.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

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.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

A rabbit tracks the distinct ID,
Through handoff paths it hops free,
Cloud records the journey bright,
Tests guard each fragment right,
Analytics blooms in morning light.

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Preview deployed!

Environment URL
Web https://e124b457-agentrelay-web.agent-workforce.workers.dev

This is a Cloudflare Workers preview version of this PR's build.

@willwashburn
willwashburn merged commit 3310ffe into main Sep 19, 2026
5 checks passed
@willwashburn
willwashburn deleted the claude/festive-meitner-6o2mhm branch September 19, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants