From 4d0a8b918290de8e5f297f92ed792298e7c1ab98 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 10 Aug 2026 18:07:45 +0200 Subject: [PATCH 1/2] feat(analytics): gate Web Analytics behind a webAnalytics rollout flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../command-palette/command-palette.tsx | 9 +++- .../src/components/dashboard/app-sidebar.tsx | 3 +- .../components/dashboard/nav-items.test.ts | 30 +++++++++++- .../web/src/components/dashboard/nav-items.ts | 27 +++++++---- .../src/components/settings/settings-nav.tsx | 9 ++-- .../hooks/use-organization-feature-flags.ts | 33 +++++++++++++ .../lib/organization-feature-flags.test.ts | 17 ++++++- .../web/src/lib/organization-feature-flags.ts | 14 ++++++ apps/web/src/routes/analytics/index.tsx | 16 +++++++ apps/web/src/routes/replays/index.tsx | 47 +++++++++++-------- 10 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/hooks/use-organization-feature-flags.ts diff --git a/apps/web/src/components/command-palette/command-palette.tsx b/apps/web/src/components/command-palette/command-palette.tsx index 7a7a3f500..d2417cdd0 100644 --- a/apps/web/src/components/command-palette/command-palette.tsx +++ b/apps/web/src/components/command-palette/command-palette.tsx @@ -30,6 +30,7 @@ import { openGlobalChat } from "@/components/chat/global-chat-sheet" import { paletteNavItems } from "@/components/dashboard/nav-items" import { useDashboardPreferences } from "@/hooks/use-dashboard-preferences" import { useDashboardsRead } from "@/hooks/use-dashboard-store" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" import { useAtomValue } from "@/lib/effect-atom" import { Result } from "@/lib/effect-atom" import { getTracesFacetValuesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -132,12 +133,16 @@ function PaletteContent({ } } + // Flagged-off pages must not be findable by name either, so the palette reads + // the same flags the sidebar does and passes them to the same builder. + const featureFlags = useOrganizationFeatureFlags() + const entries = useMemo(() => { // Sections *and* their children — Traces, Logs, Metrics, Replays, Hosts, // the K8s lists and the integration pages are all reachable by name here, // which is what lets the sidebar fold them into two sections. const navigation: PaletteEntry[] = [ - ...paletteNavItems().map((item) => ({ + ...paletteNavItems(featureFlags).map((item) => ({ id: item.id, title: item.title, group: "Navigation" as const, @@ -222,7 +227,7 @@ function PaletteContent({ ] return [...navigation, ...serviceEntries, ...dashboardEntries, ...actions] - }, [dashboards, favorites, serviceNames, theme, setTheme, onShowShortcuts]) + }, [dashboards, favorites, serviceNames, theme, setTheme, onShowShortcuts, featureFlags]) // Browse mode shows only a taste of the services list — the full set stays // searchable, but dozens of service rows shouldn't bury Dashboards/Actions. diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index 884cdb45b..293a8d9bb 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -57,6 +57,7 @@ import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { clearSelfHostedSessionToken } from "@/lib/services/common/self-hosted-auth" import { useDashboardsRead } from "@/hooks/use-dashboard-store" import { useDashboardPreferences } from "@/hooks/use-dashboard-preferences" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" /** * A 2px lane is reserved on every row so icons share one vertical line whether @@ -539,7 +540,7 @@ function FooterCluster() { // (instead of bare useRouterState) keeps it quiet during loader/pending ticks. export const AppSidebar = memo(function AppSidebar() { const currentPath = useRouterState({ select: (s) => s.location.pathname }) - const groups = navGroups() + const groups = navGroups(useOrganizationFeatureFlags()) return ( diff --git a/apps/web/src/components/dashboard/nav-items.test.ts b/apps/web/src/components/dashboard/nav-items.test.ts index 862db3ddb..0ff618461 100644 --- a/apps/web/src/components/dashboard/nav-items.test.ts +++ b/apps/web/src/components/dashboard/nav-items.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest" import { isNavItemActive, isPathActive, navGroups, paletteNavItems, type NavItem } from "./nav-items" +import type { OrganizationFeatureFlags } from "@/lib/organization-feature-flags" + +/** Flags with every rollout on, for the tests that assert the flagged rows exist. */ +const ALL_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: true, webAnalytics: true } function findItem(title: string): NavItem { - const item = navGroups() + const item = navGroups(ALL_FLAGS) .flatMap((group) => group.items) .find((candidate) => candidate.title === title) if (!item) throw new Error(`no nav item titled ${title}`) @@ -50,8 +54,23 @@ describe("isNavItemActive", () => { }) describe("navGroups", () => { - it("renders ten top-level rows at rest", () => { + it("renders nine top-level rows with no flags", () => { const rows = navGroups().flatMap((group) => group.items) + expect(rows.map((item) => item.title)).toEqual([ + "Overview", + "Services", + "Service Map", + "Infrastructure", + "Explore", + "Dashboards", + "Investigations", + "Errors", + "Alerts", + ]) + }) + + it("renders ten with every rollout enabled", () => { + const rows = navGroups(ALL_FLAGS).flatMap((group) => group.items) expect(rows.map((item) => item.title)).toEqual([ "Overview", "Services", @@ -66,6 +85,13 @@ describe("navGroups", () => { ]) }) + it("omits Web Analytics from the palette when its flag is off", () => { + // The palette derives from navGroups, so a flagged-off page must not be + // findable by typing its name either — that would defeat hiding the row. + expect(paletteNavItems().map((entry) => entry.href)).not.toContain("/analytics") + expect(paletteNavItems(ALL_FLAGS).map((entry) => entry.href)).toContain("/analytics") + }) + it("gives every child of a previewed section an icon", () => { // The closed row previews its children by drawing their glyphs (see // `NavRow`), and draws nothing at all unless *every* child has one — so diff --git a/apps/web/src/components/dashboard/nav-items.ts b/apps/web/src/components/dashboard/nav-items.ts index d5ff94608..c1c4778b8 100644 --- a/apps/web/src/components/dashboard/nav-items.ts +++ b/apps/web/src/components/dashboard/nav-items.ts @@ -18,6 +18,7 @@ import { ServerIcon, } from "@/components/icons" import { PLANETSCALE_COLOR } from "@/components/infra/planetscale/metrics" +import type { OrganizationFeatureFlags } from "@/lib/organization-feature-flags" export interface NavSubItem { title: string @@ -117,8 +118,22 @@ const exploreItem: NavItem = { * The sidebar's information architecture, and the single source the command * palette flattens. Anomalies is reachable at /anomalies but stays out of both * until the detector has been validated against production baselines. + * + * `flags` gates staged rollouts. Omitting it hides every flagged row, which is + * the safe default for a caller that has no organization context — the flags are + * decoded from Clerk metadata and are unavailable while it loads, and a row that + * appears and then vanishes is worse than one that arrives a beat late. */ -export function navGroups(): NavGroup[] { +export function navGroups(flags?: OrganizationFeatureFlags): NavGroup[] { + // Rows behind a rollout flag. Filtered out of both the sidebar and ⌘K, since + // `paletteNavItems` derives from this — a flagged-off page that is still + // findable by typing its name is not hidden. + const analyzeItems: NavItem[] = [exploreItem] + if (flags?.webAnalytics) { + analyzeItems.push({ title: "Web Analytics", href: "/analytics", icon: ChartBarHorizontalIcon }) + } + analyzeItems.push({ title: "Dashboards", href: "/dashboards", icon: GridSquareCirclePlusIcon }) + return [ { id: "overview", items: [overviewItem] }, { @@ -133,11 +148,7 @@ export function navGroups(): NavGroup[] { { id: "analyze", label: "Analyze", - items: [ - exploreItem, - { title: "Web Analytics", href: "/analytics", icon: ChartBarHorizontalIcon }, - { title: "Dashboards", href: "/dashboards", icon: GridSquareCirclePlusIcon }, - ], + items: analyzeItems, }, { id: "triage", @@ -179,7 +190,7 @@ export interface PaletteNavEntry { * are the entries that keep muscle memory working, and they were never in the * palette before this. */ -export function paletteNavItems(): PaletteNavEntry[] { +export function paletteNavItems(flags?: OrganizationFeatureFlags): PaletteNavEntry[] { const entries: PaletteNavEntry[] = [] const seen = new Set() const push = (entry: PaletteNavEntry) => { @@ -189,7 +200,7 @@ export function paletteNavItems(): PaletteNavEntry[] { entries.push(entry) } - for (const group of navGroups()) { + for (const group of navGroups(flags)) { for (const item of group.items) { push({ id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }) for (const sub of item.subItems ?? []) { diff --git a/apps/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx index f110beb99..2f2935d22 100644 --- a/apps/web/src/components/settings/settings-nav.tsx +++ b/apps/web/src/components/settings/settings-nav.tsx @@ -1,12 +1,11 @@ -import { useOrganization } from "@clerk/clerk-react" import { useMapleCustomer } from "@/hooks/use-maple-customer" import { Result, useAtomValue } from "@/lib/effect-atom" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { hasBringYourOwnCloudAddOn } from "@/lib/billing/plan-gating" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" -import { organizationFeatureFlagsFrom } from "@/lib/organization-feature-flags" import { BellIcon, CircleCheckIcon, @@ -153,7 +152,10 @@ export function useVisibleSettingsSections() { const sessionResult = useAtomValue(MapleApiAtomClient.query("auth", "session", {})) const isAdmin = useIsOrgAdmin() const { data: customer, isLoading: isCustomerLoading } = useMapleCustomer() - const { organization } = useOrganization() + // Shared with the main sidebar and the flagged routes, so a flag can't be read + // one way here and another way there (it already force-enables when self-hosted, + // which is what the `!isClerkAuthEnabled` branch below used to do inline). + const featureFlags = useOrganizationFeatureFlags() const visibleSections = navSections .map((section) => ({ @@ -185,7 +187,6 @@ export function useVisibleSettingsSections() { } const canAccessDataPlatform = isAdmin && hasBringYourOwnCloudAddOn(customer) - const featureFlags = organizationFeatureFlagsFrom(organization?.publicMetadata) const canAccessAi = isAdmin && featureFlags.aiAutoTriage const dataSections = navSections diff --git a/apps/web/src/hooks/use-organization-feature-flags.ts b/apps/web/src/hooks/use-organization-feature-flags.ts new file mode 100644 index 000000000..5670e605a --- /dev/null +++ b/apps/web/src/hooks/use-organization-feature-flags.ts @@ -0,0 +1,33 @@ +import { useOrganization } from "@clerk/clerk-react" + +import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" +import { + ENABLED_ORGANIZATION_FEATURE_FLAGS, + organizationFeatureFlagsFrom, + type OrganizationFeatureFlags, +} from "@/lib/organization-feature-flags" + +/** + * The organization's rollout flags, read from Clerk public metadata. + * + * Every consumer goes through here rather than calling + * `organizationFeatureFlagsFrom(organization?.publicMetadata)` inline, so the + * two rules that make a flag safe live in one place instead of being re-derived + * per call site: + * + * 1. **Fail closed on the managed product.** `organizationFeatureFlagsFrom` + * already returns everything disabled for missing or malformed metadata, and + * `useOrganization()` returns `undefined` while Clerk is still loading — so a + * flagged surface stays hidden during that window rather than flashing into + * view and then disappearing. + * 2. **Fail open when self-hosted.** `isClerkAuthEnabled` is a build-time + * constant; with no Clerk there is no metadata to read, and treating that as + * "all flags off" would permanently hide flagged features from anyone running + * Maple themselves. `settings-nav` already made this call for `aiAutoTriage`; + * this keeps the two from disagreeing. + */ +export function useOrganizationFeatureFlags(): OrganizationFeatureFlags { + const { organization } = useOrganization() + if (!isClerkAuthEnabled) return ENABLED_ORGANIZATION_FEATURE_FLAGS + return organizationFeatureFlagsFrom(organization?.publicMetadata) +} diff --git a/apps/web/src/lib/organization-feature-flags.test.ts b/apps/web/src/lib/organization-feature-flags.test.ts index 4818568ac..9cc8e2ae8 100644 --- a/apps/web/src/lib/organization-feature-flags.test.ts +++ b/apps/web/src/lib/organization-feature-flags.test.ts @@ -2,29 +2,42 @@ import { describe, expect, it } from "vitest" import { organizationFeatureFlagsFrom } from "./organization-feature-flags" describe("organizationFeatureFlagsFrom", () => { + it("gates each flag independently", () => { + expect(organizationFeatureFlagsFrom({ webanalytics: true })).toEqual({ + aiAutoTriage: false, + webAnalytics: true, + }) + }) + it("decodes every organization rollout flag", () => { expect( organizationFeatureFlagsFrom({ aiautotriage: true, + webanalytics: true, unrelated_metadata: "preserved by Clerk, ignored here", }), - ).toEqual({ aiAutoTriage: true }) + ).toEqual({ aiAutoTriage: true, webAnalytics: true }) }) it("disables a missing or malformed flag", () => { expect(organizationFeatureFlagsFrom({})).toEqual({ aiAutoTriage: false, + webAnalytics: false, }) + // The string "true" is the shape a hand-edited Clerk dashboard field + // produces, and it must not read as enabled. expect( organizationFeatureFlagsFrom({ aiautotriage: "true", + webanalytics: "true", }), - ).toEqual({ aiAutoTriage: false }) + ).toEqual({ aiAutoTriage: false, webAnalytics: false }) }) it("fails closed when public metadata is unavailable", () => { expect(organizationFeatureFlagsFrom(undefined)).toEqual({ aiAutoTriage: false, + webAnalytics: false, }) }) }) diff --git a/apps/web/src/lib/organization-feature-flags.ts b/apps/web/src/lib/organization-feature-flags.ts index 5c11c36f8..fd5bd9a1b 100644 --- a/apps/web/src/lib/organization-feature-flags.ts +++ b/apps/web/src/lib/organization-feature-flags.ts @@ -19,9 +19,11 @@ const DisabledByDefaultFeatureFlag = Schema.Unknown.pipe( */ export const OrganizationFeatureFlags = Schema.Struct({ aiAutoTriage: DisabledByDefaultFeatureFlag, + webAnalytics: DisabledByDefaultFeatureFlag, }).pipe( Schema.encodeKeys({ aiAutoTriage: "aiautotriage", + webAnalytics: "webanalytics", }), ) @@ -31,6 +33,18 @@ const decodeOrganizationFeatureFlags = Schema.decodeUnknownOption(OrganizationFe const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: false, + webAnalytics: false, +} + +/** + * Every rollout on, for the self-hosted build where there is no Clerk to read + * metadata from. Mirrors how `settings-nav` treats `!isClerkAuthEnabled`: a flag + * is a staged-rollout tool for the managed product, and leaving them all off + * would permanently hide the features from anyone running Maple themselves. + */ +export const ENABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { + aiAutoTriage: true, + webAnalytics: true, } /** Decode Clerk metadata, falling back to every rollout disabled for non-object input. */ diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx index 5bc045463..c61dc2863 100644 --- a/apps/web/src/routes/analytics/index.tsx +++ b/apps/web/src/routes/analytics/index.tsx @@ -9,6 +9,8 @@ import { formatNumber, formatPercent } from "@maple/ui/lib/format" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { QueryErrorState } from "@/components/common/query-error-state" import { PageHero } from "@/components/infra/primitives/page-hero" +import { NotFoundError } from "@/components/route-error" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" import { PlayRotateClockwiseIcon } from "@/components/icons" import { StatRail, StatRailItem, StatRailLoading } from "@/components/infra/primitives/stat-rail" import { chartBucketSeconds } from "@/components/infra/chart-utils" @@ -58,6 +60,20 @@ export const Route = createFileRoute("/analytics/")({ }) function WebAnalyticsPage() { + // Hiding the nav row is not hiding the page — the URL still resolves, and ⌘K, + // browser history and a shared link all reach it. So the flag is enforced here + // too, rendering the router's own not-found component so an unflagged org sees + // exactly what it would see for a route that does not exist. Client-side only, + // like `aiAutoTriage`: the API does not read Clerk org metadata, so this hides + // the surface rather than protecting the data (which is already org-scoped by + // CurrentTenant on every query). + const featureFlags = useOrganizationFeatureFlags() + if (!featureFlags.webAnalytics) return + + return +} + +function WebAnalyticsPageContent() { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) diff --git a/apps/web/src/routes/replays/index.tsx b/apps/web/src/routes/replays/index.tsx index 4a213c349..61a2b0218 100644 --- a/apps/web/src/routes/replays/index.tsx +++ b/apps/web/src/routes/replays/index.tsx @@ -22,6 +22,7 @@ import { ToolbarStat } from "@maple/ui/components/toolbar" import { Button } from "@maple/ui/components/ui/button" import { Link } from "@tanstack/react-router" import { ChartBarHorizontalIcon } from "@/components/icons" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" const replaysSearchSchema = Schema.Struct({ service: Schema.optional(Schema.String), @@ -124,6 +125,8 @@ function ReplaysPage() { // toggling either surface keeps the other in sync. const engagedOnly = search.activeMin === 30 && search.activeMax == null + const webAnalyticsEnabled = useOrganizationFeatureFlags().webAnalytics + const headerActions = ( <>
@@ -134,25 +137,31 @@ function ReplaysPage() { one session at a time versus the aggregate — so each is the obvious next question from the other. The time range travels with the link; arriving at a different window than the one you were just looking at is what makes - a cross-link feel like it lost your place. */} - + a cross-link feel like it lost your place. + + Gated on the same flag as the sidebar row and the route itself: hiding two + of the three entry points leaves a button that navigates to "Page not + found". */} + {webAnalyticsEnabled ? ( + + ) : null} Date: Mon, 10 Aug 2026 18:21:42 +0200 Subject: [PATCH 2/2] fix(analytics): never touch a Clerk hook outside Clerk mode, and don't flash not-found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../command-palette/command-palette.tsx | 2 +- .../src/components/dashboard/app-sidebar.tsx | 4 +- .../src/components/settings/settings-nav.tsx | 2 +- .../hooks/use-organization-feature-flags.ts | 84 ++++++++++++++----- .../web/src/lib/organization-feature-flags.ts | 3 +- apps/web/src/routes/analytics/index.tsx | 12 ++- apps/web/src/routes/replays/index.tsx | 2 +- 7 files changed, 82 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/command-palette/command-palette.tsx b/apps/web/src/components/command-palette/command-palette.tsx index d2417cdd0..5fad4fc87 100644 --- a/apps/web/src/components/command-palette/command-palette.tsx +++ b/apps/web/src/components/command-palette/command-palette.tsx @@ -135,7 +135,7 @@ function PaletteContent({ // Flagged-off pages must not be findable by name either, so the palette reads // the same flags the sidebar does and passes them to the same builder. - const featureFlags = useOrganizationFeatureFlags() + const { flags: featureFlags } = useOrganizationFeatureFlags() const entries = useMemo(() => { // Sections *and* their children — Traces, Logs, Metrics, Replays, Hosts, diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index 293a8d9bb..7ad3491ff 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -540,7 +540,9 @@ function FooterCluster() { // (instead of bare useRouterState) keeps it quiet during loader/pending ticks. export const AppSidebar = memo(function AppSidebar() { const currentPath = useRouterState({ select: (s) => s.location.pathname }) - const groups = navGroups(useOrganizationFeatureFlags()) + // Fail-closed during load is correct here: a row that appears a beat late is + // better than one that appears and then vanishes. + const groups = navGroups(useOrganizationFeatureFlags().flags) return ( diff --git a/apps/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx index 2f2935d22..bbfbc0a46 100644 --- a/apps/web/src/components/settings/settings-nav.tsx +++ b/apps/web/src/components/settings/settings-nav.tsx @@ -155,7 +155,7 @@ export function useVisibleSettingsSections() { // Shared with the main sidebar and the flagged routes, so a flag can't be read // one way here and another way there (it already force-enables when self-hosted, // which is what the `!isClerkAuthEnabled` branch below used to do inline). - const featureFlags = useOrganizationFeatureFlags() + const { flags: featureFlags } = useOrganizationFeatureFlags() const visibleSections = navSections .map((section) => ({ diff --git a/apps/web/src/hooks/use-organization-feature-flags.ts b/apps/web/src/hooks/use-organization-feature-flags.ts index 5670e605a..19eb0aa8c 100644 --- a/apps/web/src/hooks/use-organization-feature-flags.ts +++ b/apps/web/src/hooks/use-organization-feature-flags.ts @@ -2,32 +2,76 @@ import { useOrganization } from "@clerk/clerk-react" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { + DISABLED_ORGANIZATION_FEATURE_FLAGS, ENABLED_ORGANIZATION_FEATURE_FLAGS, organizationFeatureFlagsFrom, type OrganizationFeatureFlags, } from "@/lib/organization-feature-flags" +export interface OrganizationFeatureFlagsState { + readonly flags: OrganizationFeatureFlags + /** + * Whether `flags` is the organization's real answer yet. + * + * Load and disabled are separate states on purpose. Both leave `flags` all + * false, which is the right thing for hiding a nav row — nothing renders + * either way. But a *route* that treats them alike renders a definite negative + * ("Page not found") during the load window and then swaps to the real page, + * so an entitled org gets a not-found flash on every reload. Anything drawing a + * conclusion from a disabled flag must check this first. + */ + readonly isLoaded: boolean +} + /** - * The organization's rollout flags, read from Clerk public metadata. + * Clerk mode: rollout flags live in the organization's public metadata. * - * Every consumer goes through here rather than calling - * `organizationFeatureFlagsFrom(organization?.publicMetadata)` inline, so the - * two rules that make a flag safe live in one place instead of being re-derived - * per call site: - * - * 1. **Fail closed on the managed product.** `organizationFeatureFlagsFrom` - * already returns everything disabled for missing or malformed metadata, and - * `useOrganization()` returns `undefined` while Clerk is still loading — so a - * flagged surface stays hidden during that window rather than flashing into - * view and then disappearing. - * 2. **Fail open when self-hosted.** `isClerkAuthEnabled` is a build-time - * constant; with no Clerk there is no metadata to read, and treating that as - * "all flags off" would permanently hide flagged features from anyone running - * Maple themselves. `settings-nav` already made this call for `aiAutoTriage`; - * this keeps the two from disagreeing. + * `isLoaded` comes straight from Clerk — `organization` is `undefined` both while + * loading and when there is genuinely no org, and only Clerk can tell those apart. */ -export function useOrganizationFeatureFlags(): OrganizationFeatureFlags { - const { organization } = useOrganization() - if (!isClerkAuthEnabled) return ENABLED_ORGANIZATION_FEATURE_FLAGS - return organizationFeatureFlagsFrom(organization?.publicMetadata) +function useClerkOrganizationFeatureFlags(): OrganizationFeatureFlagsState { + const { organization, isLoaded } = useOrganization() + return { + flags: isLoaded + ? organizationFeatureFlagsFrom(organization?.publicMetadata) + : DISABLED_ORGANIZATION_FEATURE_FLAGS, + isLoaded, + } +} + +/** Self-hosted: nothing to load, and every rollout is on. Touches no Clerk hook. */ +function useSelfHostedOrganizationFeatureFlags(): OrganizationFeatureFlagsState { + return SELF_HOSTED_STATE +} + +const SELF_HOSTED_STATE: OrganizationFeatureFlagsState = { + flags: ENABLED_ORGANIZATION_FEATURE_FLAGS, + isLoaded: true, } + +/** + * The organization's rollout flags — the single way consumers read them, so the + * three rules that make a flag safe live here instead of being re-derived per + * call site. + * + * **Fail closed while loading.** Clerk has not answered yet, so no flagged + * surface renders. Callers that draw a conclusion from "off" must gate on + * `isLoaded` (see its doc comment). + * + * **Fail open when self-hosted.** There is no Clerk to read metadata from, and + * treating that as "all flags off" would hide flagged features from self-hosters + * permanently — a rollout flag is a tool for staging the managed product. + * + * **Never call a Clerk hook outside Clerk mode.** `apps/web/src/main.tsx` mounts + * `ClerkProvider` only when `isClerkAuthEnabled`, and Clerk v5 hooks throw + * without one. An `if (!isClerkAuthEnabled) return …` *inside* a hook is too late + * — `useOrganization()` has already run and thrown. So the implementation is + * chosen here at module scope, off a 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 one. This is the hook-level form + * of the boundary `org-switcher.tsx` and `select-plan.tsx` draw at the component + * level. + */ +export const useOrganizationFeatureFlags: () => OrganizationFeatureFlagsState = isClerkAuthEnabled + ? useClerkOrganizationFeatureFlags + : useSelfHostedOrganizationFeatureFlags diff --git a/apps/web/src/lib/organization-feature-flags.ts b/apps/web/src/lib/organization-feature-flags.ts index fd5bd9a1b..586229470 100644 --- a/apps/web/src/lib/organization-feature-flags.ts +++ b/apps/web/src/lib/organization-feature-flags.ts @@ -31,7 +31,8 @@ export type OrganizationFeatureFlags = Schema.Schema.Type + const { flags, isLoaded } = useOrganizationFeatureFlags() + + // `isLoaded` is load-bearing, not defensive. Flags fail closed while Clerk + // resolves, and a route — unlike a nav row — turns "off" into a visible verdict: + // without this, an org that *has* the flag would see "Page not found" flash and + // then the real page on every load. Render nothing for that window instead; the + // sidebar and page chrome come from the layout, so this is a brief empty content + // area rather than a blank app. + if (!isLoaded) return null + if (!flags.webAnalytics) return return } diff --git a/apps/web/src/routes/replays/index.tsx b/apps/web/src/routes/replays/index.tsx index 61a2b0218..8cf35c985 100644 --- a/apps/web/src/routes/replays/index.tsx +++ b/apps/web/src/routes/replays/index.tsx @@ -125,7 +125,7 @@ function ReplaysPage() { // toggling either surface keeps the other in sync. const engagedOnly = search.activeMin === 30 && search.activeMax == null - const webAnalyticsEnabled = useOrganizationFeatureFlags().webAnalytics + const webAnalyticsEnabled = useOrganizationFeatureFlags().flags.webAnalytics const headerActions = ( <>