Skip to content

feat(analytics): gate Web Analytics behind a webAnalytics rollout flag - #386

Merged
Makisuo merged 2 commits into
mainfrom
feat/web-analytics-flag-gate
Aug 10, 2026
Merged

feat(analytics): gate Web Analytics behind a webAnalytics rollout flag#386
Makisuo merged 2 commits into
mainfrom
feat/web-analytics-flag-gate

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Web Analytics shipped visible to every organization. This puts it behind the rollout mechanism that already exists — OrganizationFeatureFlags in organization-feature-flags.ts, decoded from Clerk organization public metadata, which already carried aiAutoTriage.

Turning it on

Set webanalytics: true in the org's Clerk public metadata:

{ "webanalytics": true }

Keys are lowercase-squashed in Clerk and camelCase in code; the schema's encodeKeys owns that mapping. Only the literal boolean true enables — the string "true", which is what hand-editing the Clerk dashboard tends to produce, reads as off (there's a test for it).

All four entry points

Closing fewer than all of them doesn't hide anything:

Surface How
Sidebar row navGroups(flags) filters it
⌘K palette paletteNavItems(flags) — derives from navGroups, so it isn't findable by name either
The route /analytics renders the router's own NotFoundError, so an unflagged org sees what a nonexistent route gives
Replays header button This was a gap in the previous commit — the Analytics button was still rendered, so an unflagged org could click straight into "Page not found"

Supporting changes

useOrganizationFeatureFlags is new and is the only way consumers read flags, so the two rules that make a flag safe live in one place rather than being re-derived per call site:

  • Fail closed while Clerk metadata loads — useOrganization() returns undefined in that window, and a surface that flashes in then disappears is worse than one arriving a beat late.
  • Fail open when !isClerkAuthEnabled — a self-hosted deployment has no Clerk to read metadata from, and treating that as "all flags off" would hide flagged features from self-hosters permanently. settings-nav had already made this exact call for aiAutoTriage.

settings-nav moves onto the hook, replacing its inline organizationFeatureFlagsFrom(organization?.publicMetadata), so the two can't drift.

Scope

Client-side only, matching aiAutoTriage. The API does not read Clerk org metadata, so this hides the surface, not the data — and the data was never exposed: every warehouse query is org-scoped through CurrentTenant. Server-side enforcement doesn't exist for aiAutoTriage either and would mean adding a field to TenantSchema plus a Clerk fetch in apps/api; worth doing if you want it, but it's a separate change.

Verification

  • Typecheck 37/37 packages; oxlint clean (remaining warnings are pre-existing useEffect ones in other files)
  • 18 tests pass across nav-items.test.ts and organization-feature-flags.test.ts — both flag states for the nav and the palette, per-flag independence, and the "true"-string case
  • Browser, flag off: sidebar reads Explore → Dashboards with no Web Analytics row; /analytics renders "Page not found"; ⌘K search for "analytics" returns nothing; zero /analytics anchors anywhere in the DOM

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Open in Devin Review

Web Analytics shipped visible to every organization. Put it behind the existing
per-org rollout mechanism instead: `OrganizationFeatureFlags`, decoded from Clerk
organization public metadata, which already carried `aiAutoTriage`. Enable per org
by setting `webanalytics: true` in that org's Clerk public metadata (keys are
lowercase-squashed there, camelCase in code — the schema's `encodeKeys` owns the
mapping).

All three entry points are gated, because closing fewer than all of them does not
hide anything:

- the sidebar row — `navGroups(flags)`, filtered
- ⌘K — `paletteNavItems(flags)`, which derives from `navGroups`, so a flagged-off
  page is not findable by typing its name either
- the route — `/analytics` renders the router's own `NotFoundError`, so an
  unflagged org sees exactly what a nonexistent route would give it

The fourth was a gap in the previous commit: the Analytics button in the Session
Replays header was still rendered, so an unflagged org could click straight into
"Page not found". It is gated on the same flag now.

Two supporting changes. `useOrganizationFeatureFlags` is new and is the only way
consumers read flags, so the two rules that make a flag safe live in one place:
fail closed while Clerk metadata loads (a surface that flashes in and then
disappears is worse than one that arrives late), and fail open when
`!isClerkAuthEnabled`, since a self-hosted deployment has no Clerk to read and
would otherwise have flagged features hidden permanently. `settings-nav` moves
onto that hook, replacing its inline decode — it had already made the same
self-hosted call for `aiAutoTriage`, and this stops the two from drifting.

Client-side only, matching `aiAutoTriage`: the API does not read Clerk org
metadata, so this hides the surface rather than protecting data. The data was
never exposed — every warehouse query is org-scoped through `CurrentTenant`.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +29 to +33
export function useOrganizationFeatureFlags(): OrganizationFeatureFlags {
const { organization } = useOrganization()
if (!isClerkAuthEnabled) return ENABLED_ORGANIZATION_FEATURE_FLAGS
return organizationFeatureFlagsFrom(organization?.publicMetadata)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Self-hosted deployments crash on every dashboard page

The organization's rollout settings are looked up through Clerk (useOrganization() at apps/web/src/hooks/use-organization-feature-flags.ts:30) before the code checks whether Clerk is even in use, so self-hosted installs — which never load Clerk — hit an error instead of the sidebar.
Impact: Anyone running Maple themselves sees a blank/error screen on the main dashboard, command palette, replays and analytics pages.

Clerk hooks assert a surrounding provider; self-hosted builds render without one

apps/web/src/main.tsx:178-194 mounts ClerkProvider only when isClerkAuthEnabled; the self-hosted branch renders SelfHostedInnerApp with no provider. Clerk v5 hooks (@clerk/clerk-react ^5.61.3) assert they are wrapped by ClerkProvider and throw otherwise — which is exactly why the codebase gates every other call at a hook-free boundary: apps/web/src/components/dashboard/org-switcher.tsx:45-52 picks ClerkOrgSwitcher vs SelfHostedOrgSwitcher before any hook runs, and apps/web/src/routes/select-plan.tsx:27-33 documents the same pattern ("Clerk hooks below require ClerkProvider, which is absent when auth is disabled (self-hosted). Gate at this hook-free boundary").

The new hook inverts that order: useOrganization() runs first and the !isClerkAuthEnabled early return comes after, so the throw happens before the self-hosted fallback can be returned. The hook is now called from apps/web/src/components/dashboard/app-sidebar.tsx:542 (rendered on every dashboard page), the command palette, apps/web/src/routes/replays/index.tsx:128 and apps/web/src/routes/analytics/index.tsx:70.

The fix is to keep the Clerk read in a component/hook that only runs in Clerk mode, or to branch on isClerkAuthEnabled before touching useOrganization (e.g. split into useClerkOrganizationFeatureFlags used behind a gated boundary).

Prompt for agents
useOrganizationFeatureFlags calls useOrganization() unconditionally and only afterwards checks isClerkAuthEnabled. In self-hosted builds apps/web/src/main.tsx renders the app without ClerkProvider, and Clerk v5 hooks throw when no provider is present (see the existing gating patterns in apps/web/src/components/dashboard/org-switcher.tsx and apps/web/src/routes/select-plan.tsx, which explicitly branch at a hook-free boundary). Because the new hook is now used by AppSidebar, the command palette, the replays route and the analytics route, self-hosted deployments would throw on essentially every dashboard page. Restructure so the Clerk hook is only evaluated when isClerkAuthEnabled is true — e.g. keep a Clerk-only inner hook/component and have consumers pick between it and the fixed ENABLED_ORGANIZATION_FEATURE_FLAGS value, or otherwise avoid invoking useOrganization in the self-hosted branch.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread apps/web/src/routes/analytics/index.tsx Outdated
Comment on lines +70 to +73
const featureFlags = useOrganizationFeatureFlags()
if (!featureFlags.webAnalytics) return <NotFoundError />

return <WebAnalyticsPageContent />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Analytics page briefly shows "Page not found" for organizations that have the feature enabled

The analytics page decides the feature is off while the organization's settings are still being fetched (if (!featureFlags.webAnalytics) at apps/web/src/routes/analytics/index.tsx:71), so an entitled organization sees a "Page not found" screen before the real page appears.
Impact: Users with Web Analytics enabled get a confusing not-found flash on every fresh load or reload of the page.

Loading state is indistinguishable from "flag off"

useOrganizationFeatureFlags (apps/web/src/hooks/use-organization-feature-flags.ts:29-33) returns all flags false while useOrganization() has not resolved (organization is undefined). Failing closed is fine for hiding a sidebar row (nothing is rendered), but on the route itself it renders a definite negative answer — NotFoundError — during the loading window, then swaps to the real page once Clerk resolves. Using Clerk's isLoaded (or the organization-loaded state) to render a neutral loading state before deciding would avoid the flash.

Prompt for agents
WebAnalyticsPage in apps/web/src/routes/analytics/index.tsx renders NotFoundError as soon as featureFlags.webAnalytics is false, but useOrganizationFeatureFlags returns all-false while Clerk's organization data is still loading. For an org that actually has the flag on, this shows 'Page not found' first and then the real page. Consider exposing a loading/isLoaded signal from useOrganizationFeatureFlags (Clerk's useOrganization provides isLoaded) and rendering a skeleton/boot state until the flags are known, only rendering NotFoundError once the flags have actually resolved to disabled.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…t flash not-found

Both issues Devin flagged on the flag-gating commit were real.

**Self-hosted crashed on every dashboard page.** `useOrganizationFeatureFlags`
called `useOrganization()` and only then checked `isClerkAuthEnabled` — too late,
because the hook has already run. `apps/web/src/main.tsx` mounts `ClerkProvider`
only in Clerk mode and Clerk v5 hooks throw without one, which is precisely why
`org-switcher.tsx` and `select-plan.tsx` branch at a hook-free boundary
("Clerk hooks below require ClerkProvider, which is absent when auth is disabled
(self-hosted). Gate at this hook-free boundary"). The new hook broke that rule and
then got called from `AppSidebar`, so the blast radius was every page rather than
one component.

The implementation is now chosen at module scope off the build-time constant: each
variant calls its hooks unconditionally, hook order is fixed for the lifetime of
the bundle, and the self-hosted build never reaches the Clerk variant. This also
makes `settings-nav` safer than it was before this branch — it used to call
`useOrganization()` unconditionally itself.

**An entitled org saw "Page not found" flash before the page.** Flags fail closed
while Clerk resolves, which is right for a nav row (nothing renders either way)
but wrong for a route, where "off" becomes a visible verdict. The state now
carries `isLoaded`, and the route renders nothing until the flags are the org's
real answer instead of asserting not-found during the load window. The sidebar and
chrome come from the layout, so that window is a brief empty content area, not a
blank app.

`isLoaded` is documented on the type rather than at the call site, since every
future consumer that turns a disabled flag into a visible conclusion needs the
same guard.
@Makisuo
Makisuo merged commit fef841d into main Aug 10, 2026
28 checks passed
@Makisuo
Makisuo deleted the feat/web-analytics-flag-gate branch August 10, 2026 16:27
@Makisuo
Makisuo deployed to pr-preview August 10, 2026 16:27 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit fa1cc4f · View workflow run

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.

1 participant