Skip to content

Route browser analytics through first-party API - #7484

Closed
lawrencecchen wants to merge 7 commits into
mainfrom
task-avoid-ublock-posthog-proxy
Closed

lawrencecchen wants to merge 7 commits into
mainfrom
task-avoid-ublock-posthog-proxy

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Move browser analytics capture to /api/analytics/browser-events, an allowlisted first-party route that forwards to PostHog server-side.
  • Disable PostHog browser SDK network capture surfaces while keeping local distinct-id persistence for attribution.
  • Send pageviews, clicks, and waitlist signups through the first-party helper instead of direct r.cmux.com or /e/ browser requests.

Testing

  • bun test tests/browser-analytics-route.test.ts
  • bun run typecheck
  • bunx eslint app/[locale]/posthog.tsx app/[locale]/(landing)/tracked-link.tsx app/[locale]/components/download-button.tsx app/[locale]/components/faq-platform-answer.tsx app/[locale]/components/github-button.tsx app/[locale]/components/github-stars.tsx app/[locale]/components/nav-links.tsx app/[locale]/components/pro-cta-link.tsx app/[locale]/components/waitlist-callout.tsx app/[locale]/components/waitlist-dialog.tsx app/lib/analytics.ts app/api/analytics/browser-events/route.ts services/analytics/browserEventPolicy.ts tests/browser-analytics-route.test.ts
  • bun run lint currently fails on unrelated pre-existing lint errors in billing/vault/test files.

Issues

  • Related: user-reported uBlock Origin block of https://r.cmux.com/i/v0/e/?...

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches all marketing analytics and waitlist person properties in PostHog; misconfiguration of the new rate limiter or forwarding could drop events or block signups in production until env is set.

Overview
Browser analytics no longer posts directly to r.cmux.com from the client. Clicks, pageviews, and waitlist signups go through new helpers in app/lib/analytics.ts that POST to /api/analytics/browser-events, which allowlists events, sanitizes payloads, rate-limits on Vercel via CMUX_BROWSER_ANALYTICS_RATE_LIMIT_ID, and forwards to PostHog /batch/ server-side.

posthog-js is reconfigured so the SDK does not drive ingest: api_host points at a disabled path, autocapture/pageleave/session recording/surveys/flags/decide are off, and $pageview is sent via the first-party helper. The SDK remains mainly for get_distinct_id, with a localStorage/in-memory fallback when that is unavailable.

Waitlist signup stops using a direct browser POST to PostHog’s /e/ endpoint; it uses captureAnalyticsEvent and the route re-validates email, platforms, and distinctId before applying server-controlled $set / Early Access enrollment.

Landing and marketing UI components swap posthog.capture for captureAnalyticsClick (download, GitHub, pricing, pro CTA, tracked links, waitlist opens).

Reviewed by Cursor Bugbot for commit be9c2bc. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Route all browser analytics through a first‑party API that forwards events to PostHog server‑side, with a dedicated Vercel rate limiter, a persistent fallback distinct id, anonymous‑profile behavior for non‑signup events, and strict server validation for waitlist signups. This avoids uBlock/extension blocks on r.cmux.com while keeping attribution via the SDK or a local fallback.

  • New Features

    • Added /api/analytics/browser-events to allowlist events, sanitize payloads, rate‑limit via Vercel Firewall using CMUX_BROWSER_ANALYTICS_RATE_LIMIT_ID, and forward to PostHog /batch/ (returns 429 when limited, 503 if misconfigured). Sets $process_person_profile: false for forwarded events.
    • Validates waitlist signups: requires distinctId === email, allowlists platforms, and normalizes $set/$set_once (adds $feature_enrollment/*); rejects invalid payloads before forwarding while preserving enrollment for valid signups.
    • Added app/lib/analytics.ts with captureAnalyticsEvent and captureAnalyticsClick, plus a stored fallback distinct id (localStorage with in‑memory backup). $pageview and clicks use this helper. Tests cover forwarding, event validation (incl. waitlist), rate limiting, distinct‑id fallback, and profile‑processing behavior.
  • Refactors

    • Replaced direct posthog-js captures with captureAnalyticsClick across links/buttons and routed waitlist signup through the first‑party API; UI waits for server acceptance.
    • Reconfigured posthog-js to not send network analytics (disabled autocapture, pageleave, session recording, surveys, flags/decide, and external dependency loading) and set api_host to a disabled path; SDK remains for get_distinct_id only.

Written for commit be9c2bc. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a browser analytics API endpoint that validates allowlisted events, enforces rate limiting, and forwards approved events for tracking.
  • Bug Fixes / Improvements
    • Centralized click and pageview tracking through a shared client analytics helper, removing direct third-party tracking calls across key UI flows.
    • Improved waitlist tracking to use the shared helper, with safer delivery/acceptance handling.
    • Added stricter event allowlisting, payload sanitization, and clearer handling for unknown and rate-limited requests.
    • Added distinct-id fallback logic with local persistence when needed.
  • Tests
    • Added automated coverage for the analytics route and distinct-id behavior.

@vercel

vercel Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 7, 2026 3:47am
cmux-staging Building Building Preview, Comment Jul 7, 2026 3:47am

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Analytics tracking now routes through shared client helpers and a new server endpoint. Browser events are validated and forwarded server-side, while click, pageview, and waitlist flows use the new helper APIs and env gating.

Changes

Analytics Proxy Migration

Layer / File(s) Summary
Analytics helper library
web/app/lib/analytics.ts, web/tests/analytics-client.test.ts
New client utility defines analytics payload types, browser-property collection, distinct-id fallback, and event/click capture helpers with tests for distinct-id selection.
Server-side event policy validation
web/services/analytics/browserEventPolicy.ts
New allowlist, limits, sanitization, and parsing logic for browser analytics payloads.
Browser events route and tests
web/app/api/analytics/browser-events/route.ts, web/tests/browser-analytics-route.test.ts
New POST route validates, rate-limits, and forwards browser analytics events; tests cover forwarding and rejection paths.
PostHog init and pageview tracking
web/app/[locale]/posthog.tsx
PostHog init disables direct capture behaviors and pageviews now use captureAnalyticsClick.
Waitlist signup capture
web/app/[locale]/components/waitlist-dialog.tsx
Waitlist signup recording now delegates delivery to captureAnalyticsEvent instead of manual PostHog fetch logic.
Marketing click tracking updates
web/app/[locale]/(landing)/tracked-link.tsx, web/app/[locale]/components/download-button.tsx, web/app/[locale]/components/faq-platform-answer.tsx, web/app/[locale]/components/github-button.tsx, web/app/[locale]/components/github-stars.tsx, web/app/[locale]/components/nav-links.tsx, web/app/[locale]/components/pro-cta-link.tsx, web/app/[locale]/components/waitlist-callout.tsx
Click handlers switch from posthog.capture to captureAnalyticsClick with the same event names and metadata.
Analytics env gating
web/app/env.ts, web/tests/client-config-env.test.ts
The browser analytics rate-limit env var is added to runtime validation and deployment checks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#7232: Both PRs change web/app/[locale]/posthog.tsx initialization behavior for PostHog client handling.
  • manaflow-ai/cmux#7307: Both PRs touch web/app/[locale]/components/faq-platform-answer.tsx and the cmuxterm_waitlist_opened tracking flow.
  • manaflow-ai/cmux#7255: Both PRs modify the shared PostHog setup in web/app/[locale]/posthog.tsx.
🚥 Pre-merge checks | ✅ 25
✅ Passed checks (25 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Cmux Swift Actor Isolation ✅ Passed Diff touches only web TS/JS files; no Swift production code was changed, so actor-isolation rules are not applicable.
Cmux Swift Blocking Runtime ✅ Passed The PR only changes TypeScript/web files; no Swift code or blocking runtime primitives were introduced.
Cmux Browser Automation Off-Main ✅ Passed The PR only changes web analytics files; it doesn’t touch the Swift browser-routing files or any browser.* socket commands the rule covers.
Cmux Expensive Synchronous Load ✅ Passed Diff only touches web analytics/tests; no Swift files or sync-load main-actor paths were changed, so the Swift-specific check is not applicable.
Cmux Cache Substitution Correctness ✅ Passed No fresh authoritative read was replaced by a cache in a persistence/history/undo/snapshot path; the localStorage ID is only a fallback with cold-cache generation.
Cmux No Hacky Sleeps ✅ Passed No fixed sleeps or polling were introduced; the only new timing primitive is AbortSignal.timeout for a bounded fetch deadline.
Cmux Algorithmic Complexity ✅ Passed New loops are bounded by tiny caps (64 props, 20 array items, depth 3) or fixed lists; no hot-path nested scans or large in-memory joins were added.
Cmux Swift Concurrency ✅ Passed No .swift files were changed; diff only touches TypeScript analytics code, so the Swift concurrency rule is not applicable.
Cmux Swift @Concurrent ✅ Passed No Swift files or Swift concurrency changes are in this diff; it only touches web TypeScript and tests, so the Swift rule is not applicable.
Cmux Swift File And Package Boundaries ✅ Passed No Swift files were changed in this PR, so the Swift file/package boundary rule is not triggered.
Cmux Swiftpm Lockfiles ✅ Passed HEAD only changes web TS/JS files; no SwiftPM, Xcode, .gitignore, workflow, or dependency-lock paths were touched.
Cmux Swift Logging ✅ Passed No Swift files changed; the diff is limited to web TypeScript and tests, so the Swift logging rule is not applicable.
Cmux User-Facing Error Privacy ✅ Passed User-facing responses are generic (analytics_unavailable, rate_limited, forward_failed, unknown_event) and no vendor/internal details are exposed.
Cmux Full Internationalization ✅ Passed PASS: No new user-facing copy was added; touched UI still uses existing next-intl keys, and the new API returns machine-readable error codes only.
Cmux Swiftui State Layout ✅ Passed PASS: The diff only touches web/TypeScript analytics files; no Swift/SwiftUI files or state/layout patterns are present.
Cmux Architecture Rethink ✅ Passed PR touches only web/TypeScript files; no Swift files are changed, so the Swift architectural rethink rule is not applicable.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR only changes web TypeScript files; no Swift window/controller code or cmuxAuxiliaryWindowIdentifiers changes are present.
Cmux Source Artifacts ✅ Passed All changed paths are source/test files (app, api, lib, services, tests, env); no logs, caches, build output, temp dirs, or other source-control artifacts appear in the diff.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PR only changes web TypeScript files; no Swift production Sources/ code or test/debug seam is touched.
Cmux No Ambient Global State ✅ Passed PR diff touches only web TypeScript files; no Swift files or ambient-global-state patterns are present, so the Swift-specific rule is not applicable.
Title check ✅ Passed The title clearly summarizes the main change: routing browser analytics through a first-party API.
Description check ✅ Passed The description covers the summary and testing, but it omits the demo video, review trigger block, and checklist from the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-avoid-ublock-posthog-proxy

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

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

@greptile-apps

greptile-apps Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves browser analytics through a first-party server route. The main changes are:

  • New /api/analytics/browser-events forwarding endpoint with event allowlisting and rate limiting.
  • Shared client analytics helper for pageviews, clicks, and waitlist signup events.
  • PostHog browser SDK network capture disabled while keeping local distinct-id persistence.
  • Waitlist signup payload validation and server-side enrollment property generation.
  • Tests for route forwarding, validation, rate limiting, and distinct-id fallback.

Confidence Score: 4/5

This is close, but the analytics proxy should be tightened before merging.

  • The new route validates event names and waitlist signup fields.
  • Non-waitlist events can still carry PostHog mutation properties through the server-side forwarder.
  • The remaining issue can corrupt analytics profile or group data from a public endpoint.

web/app/api/analytics/browser-events/route.ts

Security Review

The public analytics route still forwards caller-supplied PostHog control properties on non-waitlist events, so forged allowlisted events can mutate analytics profile or group data through the server-side proxy.

Important Files Changed

Filename Overview
web/app/api/analytics/browser-events/route.ts Adds the browser analytics route with rate limiting, validation, and PostHog forwarding; the generic forwarding path still needs to filter mutation properties.
web/services/analytics/browserEventPolicy.ts Adds the browser event allowlist and bounded property sanitization used by the new route.
web/app/lib/analytics.ts Adds the client helper that sends browser analytics through the first-party route and maintains a fallback distinct ID.
web/app/[locale]/components/waitlist-dialog.tsx Routes waitlist signup tracking through the new helper with server-validated enrollment properties.
web/app/[locale]/posthog.tsx Disables PostHog SDK network capture and routes pageviews through the first-party helper.

Reviews (6): Last reviewed commit: "Validate proxied waitlist analytics" | Re-trigger Greptile

return jsonResponse({ error: event.error }, 400);
}

const forwarded = await forwardBrowserEvent(event.value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Unauthenticated Event Forwarding

Any caller can POST an allowlisted event with an arbitrary distinctId and properties, and this line forwards it to PostHog from the cmux server. A simple script can generate fake pageviews, clicks, or waitlist signups through /api/analytics/browser-events, which can poison funnels or burn analytics quota unless the route adds a compulsory abuse control before forwarding.

email,
platforms,
location,
$set: { email, ...enrollment },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Waitlist Identity Split

The signup event now uses the email address as the forwarded distinct_id, while pageviews and clicks still use the local anonymous PostHog ID from getAnalyticsDistinctId(). Because this path does not send an alias or $anon_distinct_id, a normal visitor who browses anonymously and then joins the waitlist can end up with the waitlist event on a separate email-keyed person, breaking attribution from the earlier pageviews and clicks.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9686abc. Configure here.

if (typeof window !== "undefined") {
posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", {
api_host: "https://r.cmux.com",
api_host: "/api/analytics/posthog-sdk-disabled",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing PostHog SDK stub route

Medium Severity

posthog.init now sets api_host to /api/analytics/posthog-sdk-disabled, but the web app has no matching API handler under app/api/analytics. The SDK still issues requests to paths such as /e/ beneath that host, so they return 404 instead of a deliberate no-op sink.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9686abc. Configure here.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/app/api/analytics/browser-events/route.ts`:
- Around line 38-52: The PostHog forward in the browser events route can hang
indefinitely if the upstream stalls; update the fetch in the browser-events
route handler to use the existing AbortSignal.timeout(...) pattern so the
request is canceled before the platform timeout. Keep the change scoped to the
fetch that posts to POSTHOG_HOST/batch and preserve the current payload and
headers.

In `@web/app/lib/analytics.ts`:
- Around line 48-55: The fallback in getAnalyticsDistinctId currently returns a
shared "anonymous" value, causing unrelated users to collapse into one analytics
identity. Update getAnalyticsDistinctId to generate a per-browser fallback
distinct id when posthog.get_distinct_id() is missing or invalid, and persist it
in a first-party browser store so it is reused across sessions. Reuse the
existing getAnalyticsDistinctId entry point and keep the returned value stable
and non-empty for each browser.

In `@web/tests/browser-analytics-route.test.ts`:
- Around line 48-49: The test in browser-analytics-route.test.ts is asserting a
hardcoded production PostHog key instead of the configured test project key.
Update the expectation in the route test to use the test fixture value already
emitted by the route, or import and assert the shared configured constant used
by the analytics setup. Use the existing browser analytics route test assertions
and the emitted api_key field to locate the check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9014d2d2-167c-4bf2-8019-962910e2be55

📥 Commits

Reviewing files that changed from the base of the PR and between f5ce40c and 9686abc.

📒 Files selected for processing (14)
  • web/app/[locale]/(landing)/tracked-link.tsx
  • web/app/[locale]/components/download-button.tsx
  • web/app/[locale]/components/faq-platform-answer.tsx
  • web/app/[locale]/components/github-button.tsx
  • web/app/[locale]/components/github-stars.tsx
  • web/app/[locale]/components/nav-links.tsx
  • web/app/[locale]/components/pro-cta-link.tsx
  • web/app/[locale]/components/waitlist-callout.tsx
  • web/app/[locale]/components/waitlist-dialog.tsx
  • web/app/[locale]/posthog.tsx
  • web/app/api/analytics/browser-events/route.ts
  • web/app/lib/analytics.ts
  • web/services/analytics/browserEventPolicy.ts
  • web/tests/browser-analytics-route.test.ts

Comment thread web/app/api/analytics/browser-events/route.ts
Comment thread web/app/lib/analytics.ts Outdated
Comment thread web/tests/browser-analytics-route.test.ts Outdated

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/app/api/analytics/browser-events/route.ts (1)

61-84: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cancel the response body before returning. Unread fetch bodies keep the Node/undici socket occupied until they’re drained or canceled, which can exhaust the pool on this forwarding path.

♻️ Proposed fix
     if (!response.ok) {
+      await response.body?.cancel();
       return { ok: false, status: response.status >= 500 ? 502 : 400 };
     }
+    await response.body?.cancel();
     return { ok: true };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/analytics/browser-events/route.ts` around lines 61 - 84, The
forwarding logic in the browser analytics route leaves the fetch response body
unread, which can keep the undici socket busy; update the try block around the
POSTHOG fetch so the Response is explicitly canceled before every return path.
In the handler that performs the `fetch` call, ensure the body is canceled after
checking `response.ok` and also when handling non-OK responses or errors, so the
socket is released promptly and the forwarding path does not exhaust the pool.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@web/app/api/analytics/browser-events/route.ts`:
- Around line 61-84: The forwarding logic in the browser analytics route leaves
the fetch response body unread, which can keep the undici socket busy; update
the try block around the POSTHOG fetch so the Response is explicitly canceled
before every return path. In the handler that performs the `fetch` call, ensure
the body is canceled after checking `response.ok` and also when handling non-OK
responses or errors, so the socket is released promptly and the forwarding path
does not exhaust the pool.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 11548e69-74fe-4df1-bd40-ffd857651484

📥 Commits

Reviewing files that changed from the base of the PR and between 9686abc and 946ce40.

📒 Files selected for processing (2)
  • web/app/api/analytics/browser-events/route.ts
  • web/tests/browser-analytics-route.test.ts

const BROWSER_ANALYTICS_FORWARD_TIMEOUT_MS = 3_000;

export async function POST(request: Request): Promise<Response> {
if (process.env.VERCEL === "1") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Rate Limit Bypass

This route only checks checkRateLimit when VERCEL === "1". When the app runs in a self-hosted or non-Vercel environment, the same public endpoint skips that guard and still forwards allowlisted events with caller-controlled distinctId, timestamp, $set, and $set_once to PostHog using the server-side project key. That leaves an unauthenticated event proxy reachable in those deployments, so forged pageviews, clicks, or waitlist signups can still poison analytics or mutate person properties. The route should fail closed unless a real abuse control is active.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/app/lib/analytics.ts`:
- Around line 15-17: `getFallbackDistinctId` currently prefers `localStorage`
over the module-level `memoryFallbackDistinctId`, which can let a later storage
read overwrite an already-cached in-memory id and split a tab’s session. Update
the fallback resolution in `web/app/lib/analytics.ts` so the function returns
`memoryFallbackDistinctId` immediately when it is already set, and only consults
`FALLBACK_DISTINCT_ID_KEY` or generate a new id when memory is empty. Keep
`memoryFallbackDistinctId` and storage in sync when a new value is chosen, and
add a reset hook for the module-level cache if needed for tests, matching the
companion `analytics-client.test.ts` expectations.

In `@web/tests/analytics-client.test.ts`:
- Around line 23-27: The analytics tests are order-dependent because the
module-level memoryFallbackDistinctId cache in analytics.ts is not reset between
tests. Add a test-only reset hook on the analytics module (or equivalent helper)
and invoke it from the beforeEach in analytics-client.test.ts alongside the
existing localStorage setup. Make sure the reset clears the cached fallback id
so getFallbackDistinctId exercises the fresh-generation path in each test, using
the existing test helpers and the analytics.ts symbols that manage the fallback
state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 00d36d6e-6d91-47b4-93d5-d71a31079f25

📥 Commits

Reviewing files that changed from the base of the PR and between 946ce40 and 8574810.

📒 Files selected for processing (2)
  • web/app/lib/analytics.ts
  • web/tests/analytics-client.test.ts

Comment thread web/app/lib/analytics.ts
Comment on lines +15 to +17
const FALLBACK_DISTINCT_ID_KEY = "cmux.analytics.distinct_id";
let memoryFallbackDistinctId: string | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

getFallbackDistinctId lets storage silently override an already-cached memory id.

Storage is checked before memoryFallbackDistinctId on every call. If two tabs race before either persists (both see an empty store), each generates a different fallback id; whichever tab writes last "wins" in localStorage, and the other tab's next call will read that foreign value and overwrite its own in-memory id — splitting a single tab's session across two distinct ids mid-flight. Prefer the already-cached memory value once set, and only fall back to storage/generation when memory is empty.

🔧 Proposed fix
 function getFallbackDistinctId(): string {
+  if (memoryFallbackDistinctId) {
+    return memoryFallbackDistinctId;
+  }
+
   const storage = browserStorage();
   const stored = readStoredDistinctId(storage);
   if (stored) {
     memoryFallbackDistinctId = stored;
     return stored;
   }
 
-  if (memoryFallbackDistinctId) {
-    writeStoredDistinctId(storage, memoryFallbackDistinctId);
-    return memoryFallbackDistinctId;
-  }
-
   const generated = createFallbackDistinctId();
   memoryFallbackDistinctId = generated;
   writeStoredDistinctId(storage, generated);
   return generated;
 }

Also consider exporting a reset hook for the module-level cache — see the companion comment in web/tests/analytics-client.test.ts.

As per coding guidelines, .github/review-bot-rules/reliability-single-source-of-truth.md flags "more than one disagreeing source of truth for the same fact."

Also applies to: 69-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/lib/analytics.ts` around lines 15 - 17, `getFallbackDistinctId`
currently prefers `localStorage` over the module-level
`memoryFallbackDistinctId`, which can let a later storage read overwrite an
already-cached in-memory id and split a tab’s session. Update the fallback
resolution in `web/app/lib/analytics.ts` so the function returns
`memoryFallbackDistinctId` immediately when it is already set, and only consults
`FALLBACK_DISTINCT_ID_KEY` or generate a new id when memory is empty. Keep
`memoryFallbackDistinctId` and storage in sync when a new value is chosen, and
add a reset hook for the module-level cache if needed for tests, matching the
companion `analytics-client.test.ts` expectations.

Sources: Coding guidelines, Path instructions

Comment on lines +23 to +27
beforeEach(() => {
postHogDistinctId.mockClear();
currentPostHogDistinctId = () => "posthog-distinct-id";
setLocalStorage(new MemoryStorage());
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

memoryFallbackDistinctId leaks across tests, making test 4 pass for the wrong reason.

beforeEach resets localStorage but not the module-level memoryFallbackDistinctId cache in analytics.ts. Tracing execution order: test 2 ("persists a generated fallback id...") leaves memoryFallbackDistinctId set to a generated id; test 3 masks the leak because storage is checked first and explicitly seeded. By test 4 ("keeps a page-lifetime fallback id when browser storage is unavailable"), memoryFallbackDistinctId still holds test 3's "cmux-web-existing-browser-id", so getFallbackDistinctId() returns that leaked value via the if (memoryFallbackDistinctId) branch instead of generating a fresh id. The assertions (toStartWith("cmux-web-"), second === first) pass coincidentally against the stale value, so this test currently does not exercise the "storage unavailable → generate new id" path at all.

Add a test-only reset hook and call it in beforeEach so each test starts from a clean module state.

🔧 Proposed fix
// web/app/lib/analytics.ts
+export function __resetAnalyticsDistinctIdCacheForTests(): void {
+  memoryFallbackDistinctId = null;
+}
-const { getAnalyticsDistinctId } = await import("../app/lib/analytics");
+const { getAnalyticsDistinctId, __resetAnalyticsDistinctIdCacheForTests } = await import(
+  "../app/lib/analytics"
+);

 beforeEach(() => {
   postHogDistinctId.mockClear();
   currentPostHogDistinctId = () => "posthog-distinct-id";
   setLocalStorage(new MemoryStorage());
+  __resetAnalyticsDistinctIdCacheForTests();
 });

As per path instructions for web/tests/**, "Do not make tests order-dependent on shared static/global state... unless it is reset per test."

Also applies to: 54-64

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/tests/analytics-client.test.ts` around lines 23 - 27, The analytics tests
are order-dependent because the module-level memoryFallbackDistinctId cache in
analytics.ts is not reset between tests. Add a test-only reset hook on the
analytics module (or equivalent helper) and invoke it from the beforeEach in
analytics-client.test.ts alongside the existing localStorage setup. Make sure
the reset clears the cached fallback id so getFallbackDistinctId exercises the
fresh-generation path in each test, using the existing test helpers and the
analytics.ts symbols that manage the fallback state.

Source: Path instructions

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/app/api/analytics/browser-events/route.ts`:
- Around line 91-93: The cmuxterm_waitlist_signup branch in browserEventPolicy
currently returns event.properties unchanged, which allows reserved PostHog
control fields to pass through. Update this special-case path to filter or
reject reserved keys the same way as the existing $process_person_profile
handling, ideally by applying the same guard logic or an explicit allowlist in
browserEventPolicy before returning the properties.
- Around line 87-99: The waitlist event name is duplicated in postHogProperties,
so update it to reuse the exported constant from browserEventPolicy.ts instead
of hardcoding the string. Add or reuse a named export for the cmuxterm waitlist
event in browserEventPolicy and import it into the browser-events route so the
PostHog special case stays aligned with the allowlist if the event name changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b2c068e-cf7b-4405-a360-9435f787a378

📥 Commits

Reviewing files that changed from the base of the PR and between 8574810 and bfc8950.

📒 Files selected for processing (2)
  • web/app/api/analytics/browser-events/route.ts
  • web/tests/browser-analytics-route.test.ts

Comment on lines +87 to +99
function postHogProperties(event: {
readonly event: string;
readonly properties: Record<string, unknown>;
}): Record<string, unknown> {
if (event.event === "cmuxterm_waitlist_signup") {
return event.properties;
}

return {
...event.properties,
$process_person_profile: false,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "cmuxterm_waitlist_signup" web/services/analytics/browserEventPolicy.ts web/app/lib/analytics.ts -C 3

Repository: manaflow-ai/cmux

Length of output: 690


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== browserEventPolicy exports =="
sed -n '1,220p' web/services/analytics/browserEventPolicy.ts

echo
echo "== route imports and postHogProperties =="
sed -n '1,180p' web/app/api/analytics/browser-events/route.ts

echo
echo "== repo-wide WAITLIST_SIGNUP_EVENT search =="
rg -n "WAITLIST_SIGNUP_EVENT" web . -S || true

Repository: manaflow-ai/cmux

Length of output: 7683


Export the waitlist event name from browserEventPolicy.ts and reuse it here. The special-case duplicates the same string literal as the policy allowlist, so a rename would leave PostHog forwarding behavior out of sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/analytics/browser-events/route.ts` around lines 87 - 99, The
waitlist event name is duplicated in postHogProperties, so update it to reuse
the exported constant from browserEventPolicy.ts instead of hardcoding the
string. Add or reuse a named export for the cmuxterm waitlist event in
browserEventPolicy and import it into the browser-events route so the PostHog
special case stays aligned with the allowlist if the event name changes.

Source: Coding guidelines

Comment thread web/app/api/analytics/browser-events/route.ts

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/tests/browser-analytics-route.test.ts (1)

98-128: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

This test locks in the reserved-key passthrough as expected behavior.

The assertion that $set/$set_once are forwarded verbatim for the waitlist event codifies the vulnerability flagged in route.ts (lines 87-99). Once the route is fixed to strip reserved keys on this path, this test needs to be updated to assert they're filtered rather than preserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/tests/browser-analytics-route.test.ts` around lines 98 - 128, The
waitlist enrollment test in browser-analytics-route.test.ts currently asserts
that reserved properties are passed through unchanged, which conflicts with the
intended fix in the route handler. Update the expectation for the POST path used
by the waitlist event so that $set and $set_once are filtered or removed rather
than preserved, and keep the assertion around the payload produced by
POST/request to match the corrected behavior in route.ts.
♻️ Duplicate comments (2)
web/app/api/analytics/browser-events/route.ts (2)

91-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated event-name literal drifts from the allowlist.

"cmuxterm_waitlist_signup" is hardcoded here and separately in browserEventPolicy.ts's allowlist. A rename in one place silently breaks this special case. Export and reuse a shared constant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/analytics/browser-events/route.ts` at line 91, The special-case
event name is duplicated and can drift from the allowlist; extract the
`"cmuxterm_waitlist_signup"` value into a shared constant and reuse it in both
`route.ts` and `browserEventPolicy.ts`. Update the comparison in
`browser-events/route.ts` to reference that constant, and make the allowlist in
`browserEventPolicy.ts` import the same symbol so there is one source of truth.

87-99: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reserved PostHog keys still pass through unfiltered on the waitlist path.

For cmuxterm_waitlist_signup, event.properties is forwarded to PostHog completely unchecked. Since this is a public, unauthenticated endpoint accepting client-supplied distinctId and properties, a caller can inject reserved PostHog control fields ($set, $set_once, $ip, etc.) to overwrite person-profile properties or geolocation for an arbitrary distinctId — including IDs belonging to other real visitors. This is the same gap flagged in a prior review round (no "Addressed" marker on that comment, and the behavior is unchanged in this diff). The companion test (web/tests/browser-analytics-route.test.ts lines 98-128) now asserts this passthrough as expected behavior, effectively codifying the gap.

Apply the same $process_person_profile guard/allowlist to the waitlist branch instead of returning event.properties verbatim.

🔒 Proposed fix to strip reserved keys on the waitlist path
+const RESERVED_POSTHOG_PROPERTY_KEYS = new Set([
+  "$set",
+  "$set_once",
+  "$unset",
+  "$ip",
+  "$process_person_profile",
+  "$group_0",
+  "$group_1",
+  "$group_2",
+  "$group_3",
+  "$group_4",
+]);
+
 function postHogProperties(event: {
   readonly event: string;
   readonly properties: Record<string, unknown>;
 }): Record<string, unknown> {
   if (event.event === "cmuxterm_waitlist_signup") {
-    return event.properties;
+    return Object.fromEntries(
+      Object.entries(event.properties).filter(
+        ([key]) => !RESERVED_POSTHOG_PROPERTY_KEYS.has(key),
+      ),
+    );
   }

   return {
     ...event.properties,
     $process_person_profile: false,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/analytics/browser-events/route.ts` around lines 87 - 99, The
waitlist branch in postHogProperties is still returning client-supplied
properties verbatim, which allows reserved PostHog fields to slip through.
Update postHogProperties so cmuxterm_waitlist_signup applies the same protection
as the other events: strip or allowlist properties and ensure
$process_person_profile is set to false rather than forwarding event.properties
directly. Make the change in the postHogProperties helper and adjust any
browser-events tests that currently expect unrestricted passthrough so they
verify reserved keys are removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@web/tests/browser-analytics-route.test.ts`:
- Around line 98-128: The waitlist enrollment test in
browser-analytics-route.test.ts currently asserts that reserved properties are
passed through unchanged, which conflicts with the intended fix in the route
handler. Update the expectation for the POST path used by the waitlist event so
that $set and $set_once are filtered or removed rather than preserved, and keep
the assertion around the payload produced by POST/request to match the corrected
behavior in route.ts.

---

Duplicate comments:
In `@web/app/api/analytics/browser-events/route.ts`:
- Line 91: The special-case event name is duplicated and can drift from the
allowlist; extract the `"cmuxterm_waitlist_signup"` value into a shared constant
and reuse it in both `route.ts` and `browserEventPolicy.ts`. Update the
comparison in `browser-events/route.ts` to reference that constant, and make the
allowlist in `browserEventPolicy.ts` import the same symbol so there is one
source of truth.
- Around line 87-99: The waitlist branch in postHogProperties is still returning
client-supplied properties verbatim, which allows reserved PostHog fields to
slip through. Update postHogProperties so cmuxterm_waitlist_signup applies the
same protection as the other events: strip or allowlist properties and ensure
$process_person_profile is set to false rather than forwarding event.properties
directly. Make the change in the postHogProperties helper and adjust any
browser-events tests that currently expect unrestricted passthrough so they
verify reserved keys are removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 48967811-b803-4ad2-9371-e2b155b6f8c3

📥 Commits

Reviewing files that changed from the base of the PR and between bfc8950 and a89f129.

📒 Files selected for processing (4)
  • web/app/api/analytics/browser-events/route.ts
  • web/app/env.ts
  • web/tests/browser-analytics-route.test.ts
  • web/tests/client-config-env.test.ts

Comment on lines +109 to +113
value: {
...event,
properties: {
...event.properties,
$process_person_profile: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Filter Control Properties
This generic event path still forwards caller-controlled PostHog control properties. A script can POST an allowlisted $pageview or click with any distinctId plus $set, $set_once, or $groups; sanitizeProperties only bounds those values, and this spread sends them to PostHog from the server. That lets the public endpoint mutate profile or group data for arbitrary analytics IDs instead of only forwarding inert click/pageview metadata. Strip PostHog person and group mutation keys here, and keep the server-generated $set fields limited to the waitlist path.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — be9c2bc7 Deployed Jul 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants