diff --git a/apps/web/src/components/command-palette/command-palette.tsx b/apps/web/src/components/command-palette/command-palette.tsx index 7a7a3f500..5fad4fc87 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 { flags: 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..7ad3491ff 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,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() + // 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/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..bbfbc0a46 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 { flags: 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..19eb0aa8c --- /dev/null +++ b/apps/web/src/hooks/use-organization-feature-flags.ts @@ -0,0 +1,77 @@ +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 +} + +/** + * Clerk mode: rollout flags live in the organization's public metadata. + * + * `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. + */ +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.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..586229470 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", }), ) @@ -29,8 +31,21 @@ export type OrganizationFeatureFlags = Schema.Schema.Type + + 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..8cf35c985 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().flags.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}