Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/web/src/components/command-palette/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<PaletteEntry[]>(() => {
// 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,
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/dashboard/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<Sidebar collapsible="icon">
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/components/dashboard/nav-items.test.ts
Original file line number Diff line number Diff line change
@@ -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}`)
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
27 changes: 19 additions & 8 deletions apps/web/src/components/dashboard/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] },
{
Expand All @@ -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",
Expand Down Expand Up @@ -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<string>()
const push = (entry: PaletteNavEntry) => {
Expand All @@ -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 ?? []) {
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/settings/settings-nav.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/hooks/use-organization-feature-flags.ts
Original file line number Diff line number Diff line change
@@ -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
17 changes: 15 additions & 2 deletions apps/web/src/lib/organization-feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
})
17 changes: 16 additions & 1 deletion apps/web/src/lib/organization-feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,33 @@ const DisabledByDefaultFeatureFlag = Schema.Unknown.pipe(
*/
export const OrganizationFeatureFlags = Schema.Struct({
aiAutoTriage: DisabledByDefaultFeatureFlag,
webAnalytics: DisabledByDefaultFeatureFlag,
}).pipe(
Schema.encodeKeys({
aiAutoTriage: "aiautotriage",
webAnalytics: "webanalytics",
}),
)

export type OrganizationFeatureFlags = Schema.Schema.Type<typeof OrganizationFeatureFlags>

const decodeOrganizationFeatureFlags = Schema.decodeUnknownOption(OrganizationFeatureFlags)

const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = {
/** Every rollout off — the value for malformed metadata, and for the pre-load window. */
export 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. */
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/routes/analytics/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -58,6 +60,28 @@ 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 { 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 <NotFoundError />

return <WebAnalyticsPageContent />
}

function WebAnalyticsPageContent() {
const search = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })

Expand Down
Loading
Loading