diff --git a/.changeset/hyperdx-labs.md b/.changeset/hyperdx-labs.md
new file mode 100644
index 0000000000..b2d59a4a88
--- /dev/null
+++ b/.changeset/hyperdx-labs.md
@@ -0,0 +1,13 @@
+---
+'@hyperdx/common-utils': minor
+'@hyperdx/api': minor
+'@hyperdx/app': minor
+---
+
+Add HyperDX Labs, a per-user opt-in for features that are still being built.
+Open it from the user menu in the nav to see what's available and switch
+individual experiments on or off. Everything is off by default, and your choices
+are saved to your account rather than the browser, so they follow you across
+devices (in local mode, where there is no account, they are kept in the browser).
+Adds `PATCH /me/labs` and surfaces the opt-ins on `GET /me`. No experiments ship
+in this release — this is the mechanism they'll use.
diff --git a/AGENTS.md b/AGENTS.md
index 24c1c24400..94c83253a8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,6 +65,9 @@ directory:
actively coding)
- `agent_docs/observability.md` - Instrumentation standards (tracing, metrics,
context) and the shared helpers (read when adding or changing a feature)
+- `agent_docs/labs.md` - HyperDX Labs: putting an unfinished feature behind a
+ per-user opt-in instead of a branch or a redeploy, and how to graduate or
+ retire it (read before adding a feature flag of any kind)
**Package-specific guides** (read when working on that package):
diff --git a/agent_docs/README.md b/agent_docs/README.md
index 3a1f5444ea..c7093582ef 100644
--- a/agent_docs/README.md
+++ b/agent_docs/README.md
@@ -20,6 +20,7 @@ Instead of stuffing all instructions into `AGENTS.md` (which goes into every con
- **`data_viz_colors.md`** - Chart, heatmap, and semantic status colors. Read before adding or changing any color in a chart, sparkline, heatmap, legend, or status pill.
- **`themes.md`** - How the brand theme system (HyperDX vs ClickStack) and color mode (light/dark/system) work. Read before changing anything in `packages/app/src/theme/`, adding semantic CSS variables, or touching brand-conditional UI.
- **`evals.md`** - MCP eval framework: dual-slot setup, running A/B comparisons between branches, interpreting results. Read before running evals or benchmarking MCP changes.
+- **`labs.md`** - HyperDX Labs: how to put a half-finished feature behind a per-user opt-in, and how to graduate or retire it. Read before adding a feature flag of any kind.
## Usage Pattern
diff --git a/agent_docs/labs.md b/agent_docs/labs.md
new file mode 100644
index 0000000000..09a9d7897f
--- /dev/null
+++ b/agent_docs/labs.md
@@ -0,0 +1,173 @@
+# HyperDX Labs
+
+Per-user, server-persisted opt-ins for features that aren't finished yet.
+
+The point is to stop choosing between a long-lived branch and a redeploy. Merge
+the half-built thing to `main` behind a lab, let the people who want it turn it
+on, and collect feedback while you finish. Off by default, so nobody is
+surprised.
+
+**Labs are a user choice.** If the toggle is really a deployment decision — this
+install doesn't have the backend, or the feature is off everywhere until launch —
+it isn't a lab, it's a constant in `packages/app/src/config.ts` next to
+`IS_MTVIEWS_ENABLED`.
+
+## Adding a lab
+
+Two edits. Add an entry to `packages/app/src/labs/registry.ts`:
+
+```ts
+export const LABS: readonly Lab[] = [
+ {
+ id: 'trace-flamegraph',
+ title: 'Trace flamegraph',
+ description:
+ 'Renders the trace waterfall as a flamegraph. Span links are not drawn yet, and very wide traces can be slow to lay out.',
+ badge: 'Alpha',
+ addedAt: '2026-08-14',
+ owner: '@your-handle',
+ },
+];
+```
+
+Then gate the feature:
+
+```ts
+import { useIsLabEnabled } from '@/labs/useLabs';
+
+const isFlamegraphEnabled = useIsLabEnabled('trace-flamegraph');
+...
+{isFlamegraphEnabled ? : }
+```
+
+No API change, no schema change, no migration, no new endpoint. The server
+stores whatever ids the client sends (bounded — see below), so it never needs to
+learn about your lab.
+
+Write the `description` for someone deciding whether to opt in. Say what's rough,
+not just what's new: "span links aren't drawn yet" beats "improved trace view".
+
+### Checklist
+
+- `id` is kebab-case (`[a-z0-9]+(-[a-z0-9]+)*`) and permanent. It's persisted on
+ the user document, so renaming it silently resets everyone's opt-in.
+- `addedAt` is today, `owner` is you. Both feed the graduate-or-retire sweep.
+- Add an E2E test that toggles the lab, reloads, and asserts it stuck.
+ `packages/app/tests/e2e/components/LabsModalComponent.ts` has `setLab(id, on)`
+ (clicks and waits for the PATCH) and `labSwitch(id)` (the input, for
+ `toBeChecked()`) waiting for exactly this. Both were verified against a
+ throwaway entry, but the committed suite only covers the empty state, so yours
+ will be the first run in CI.
+- A changeset, if the lab is visible to users at all.
+
+## Gating rules
+
+`useIsLabEnabled(id)` returns `false` when the user hasn't opted in and while the
+stored set is still loading. Two consequences worth knowing:
+
+**The loading window is real** (outside local mode). `/me` isn't resolved on
+first paint — `AuthLoadingBlocker` is only mounted on the landing page, so it
+does not gate the app. A lab therefore reads OFF and then flips ON a moment
+later. For the common shape (an extra tab, an extra button, an alternate
+renderer) that's fine. If the flip is user-visible — a redirect, a default tab, a
+one-shot effect, a mount-time fetch — use `useLabs()` and branch on `isLoading`
+first:
+
+```ts
+const { enabled, isLoading } = useLabs();
+if (isLoading) return ;
+```
+
+**Labs are available everywhere, including local mode**, so there is no
+deployment flag to flip and no environment where the menu entry is missing. Local
+mode has no API server and no user identity, so opt-ins there live in
+localStorage under `hdx-labs` instead of on the user document. That branch is
+confined to `useLabs.ts`; nothing else needs to know which store is in play.
+
+What local mode does *not* change: whether your feature can actually run. If it
+needs a backend, gate on that too, the way `IS_IAC_EXPORT_ENABLED` does:
+
+```ts
+const isEnabled = useIsLabEnabled('remote-mtviews') && !IS_LOCAL_MODE;
+```
+
+Now that a lab can be switched on in local mode, this is load-bearing rather
+than decorative — without it, a local-mode user can opt into something that
+cannot work. Add it whenever the feature depends on the API server.
+
+## Graduating and retiring
+
+**A lab is a commitment to decide, not a commitment to ship.** About 60 days
+after `addedAt`, the owner picks one of two exits. "Leave it in Labs" is not an
+exit — that's how you end up with fourteen flags nobody can explain.
+
+- **Graduate** — delete the registry entry, delete the gate, keep the new branch
+ of the conditional.
+- **Retire** — delete the registry entry, delete the gate, delete the feature.
+
+Either way: **after deleting the entry, grep for the id.** A gate whose id has no
+registry entry silently reads `false` forever, so nothing breaks loudly — the
+gated code just goes dead and stays in the tree. The grep is the whole retirement
+checklist.
+
+Stored `true` values for a deleted id go inert immediately (the hook derives
+state from the registry, not from what's stored) and are pruned from the document
+the next time that user toggles anything. No migration, no cleanup job.
+
+Enforcement is a review habit, not CI. A date-based test would fail on somebody
+else's unrelated PR at 2am and get its constant bumped within the hour. The
+registry is one short file — reading it is a 30-second sweep, and that
+single-file-ness is the actual anti-rot mechanism.
+
+## How it works
+
+| Piece | Where |
+| --- | --- |
+| Registry (ids + UI copy) | `packages/app/src/labs/registry.ts` |
+| Hook — the only seam | `packages/app/src/labs/useLabs.ts` |
+| Modal (nav user menu) | `packages/app/src/labs/LabsModal.tsx` |
+| Storage | `labs` on the `User` document, `packages/api/src/models/user.ts` |
+| Read / write | `GET /me` and `PATCH /me/labs`, `packages/api/src/routers/api/me.ts` |
+| Local-mode storage | `hdx-labs` in localStorage, via the atom in `useLabs.ts` |
+| Shape contract | `UserLabsSchema` in `packages/common-utils/src/types.ts` |
+
+There is no deployment flag. Labs is always present; individual labs are what
+get toggled.
+
+State is an **enabled-set**: a key present with `true` is on, an absent key is
+off. Writes are **full replace** — the client always holds the whole registry, so
+it can always compute the complete desired set, and that's what makes retired ids
+self-pruning.
+
+**The server validates shape, not ids.** It bounds the key format (kebab-case,
+which also excludes `$`, `.` and `_`, so Mongo operators, dotted paths and
+`__proto__` can't be keys) and the entry count, but it deliberately does not know
+which labs exist — that's what keeps adding one to a single file. The trade-off:
+a typo'd id can't be rejected server-side, so
+`packages/app/src/labs/__tests__/registry.test.ts` parses every registry id
+against `LabIdSchema` to catch `my_lab` or `My-Lab` at CI time instead of as a
+mystery 400.
+
+Reads compare `=== true` rather than truthiness, because a key like `constructor`
+passes the id regex and is inherited from `Object.prototype`, where it's truthy.
+
+**Multi-tab / multi-device:** a toggle is visible immediately in the tab you
+clicked it in (the mutation is optimistic), in other tabs on next focus, and on
+other devices on next load. In local mode there is nothing to sync: the choice
+stays in that browser.
+
+Because a full replacement is only safe if the newest one lands last, the
+mutation carries `scope: { id: 'user-labs' }`, which makes react-query send
+toggles one at a time in call order. Flipping two switches quickly is therefore
+safe within a tab. Across two tabs or two devices it is still last-write-wins —
+they are separate clients with separate queues, so the loser has to flip again.
+If that ever stops being acceptable, merge server-side in `setUserLabs`.
+
+## Related
+
+`packages/app/src/hooks/useIsVariablesEnabled.ts` predates this and is the
+clearest example of what labs are for: an env-var toggle
+(`NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES`) shipped because variable substitution
+wasn't implemented yet, wrapped in a hook whose `isLoading: false` was left in
+place, per its own comment, "to support team-level toggle loading in the future."
+That's the shape `useLabs` now provides — a good first graduation candidate.
diff --git a/packages/api/src/controllers/user.ts b/packages/api/src/controllers/user.ts
index 55258c2db6..61a4b4a0b7 100644
--- a/packages/api/src/controllers/user.ts
+++ b/packages/api/src/controllers/user.ts
@@ -1,3 +1,4 @@
+import type { UserLabs } from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';
import type { ObjectId } from '@/models';
@@ -20,6 +21,28 @@ export function findUsersByTeam(team: string | ObjectId) {
return User.find({ team }).sort({ createdAt: 1 });
}
+/**
+ * Replaces a user's lab opt-ins wholesale.
+ *
+ * Whole-object `$set`, deliberately not `$set: { ['labs.' + id]: value }`: a
+ * dotted path is the one place a client-supplied key stops being update *data*
+ * and becomes part of the update *instruction*. Keeping keys in value position
+ * removes that class of bug outright, which is what lets LabIdSchema's key
+ * regex be defense-in-depth rather than the only defense.
+ *
+ * There is nothing to read first because the semantics are full-replace: the
+ * read-modify-write happens on the client, which is the only place that knows
+ * the current lab registry and therefore the only place that can prune the ids
+ * of retired labs. See agent_docs/labs.md.
+ */
+export function setUserLabs(userId: ObjectId, labs: UserLabs) {
+ return User.findByIdAndUpdate(
+ userId,
+ { $set: { labs } },
+ { new: true, projection: { labs: 1 } },
+ );
+}
+
export async function deleteTeamMember(
teamId: string | ObjectId,
userIdToDelete: string,
diff --git a/packages/api/src/models/user.ts b/packages/api/src/models/user.ts
index 8f60fadeb5..6e0513d51a 100644
--- a/packages/api/src/models/user.ts
+++ b/packages/api/src/models/user.ts
@@ -12,6 +12,17 @@ export interface IUser {
email: string;
name: string;
team: ObjectId;
+ /**
+ * Per-user opt-ins for in-development features ("HyperDX Labs"). An
+ * enabled-set: a key present with `true` is on, an absent key is off.
+ *
+ * Deliberately not typed per-lab. The registry lives in
+ * packages/app/src/labs/registry.ts so that adding a lab never has to touch
+ * this file; ids and count are bounded on the write path by UserLabsSchema.
+ * Absent on every document created before labs existed, which reads as "no
+ * labs enabled". See agent_docs/labs.md.
+ */
+ labs?: Record;
}
export type UserDocument = mongoose.HydratedDocument;
@@ -30,6 +41,13 @@ const UserSchema = new Schema(
return uuidv4();
},
},
+ // Mixed, not Map: a Mongoose Map's own toJSON() returns a *native* Map
+ // unless handed { flattenMaps: true }, and `GET /me` passes this value
+ // straight to res.json() — it would serialize as `{}` forever. See the
+ // MongooseMap note in packages/api/src/models/webhook.ts. Mixed round-trips
+ // as a plain object. Its one weakness (no change tracking on nested paths)
+ // never comes up because setUserLabs only ever $sets the whole object.
+ labs: { type: Schema.Types.Mixed },
},
{
timestamps: true,
diff --git a/packages/api/src/routers/api/__tests__/me.int.test.ts b/packages/api/src/routers/api/__tests__/me.int.test.ts
new file mode 100644
index 0000000000..23bec4122d
--- /dev/null
+++ b/packages/api/src/routers/api/__tests__/me.int.test.ts
@@ -0,0 +1,168 @@
+import { getAgent, getLoggedInAgent, getServer } from '@/fixtures';
+import User from '@/models/user';
+
+describe('me router', () => {
+ const server = getServer();
+
+ beforeAll(async () => {
+ await server.start();
+ });
+
+ afterEach(async () => {
+ await server.clearDBs();
+ });
+
+ afterAll(async () => {
+ await server.stop();
+ });
+
+ describe('GET /me', () => {
+ it('returns an empty lab set for a user that has never opted in', async () => {
+ const { agent } = await getLoggedInAgent(server);
+
+ const resp = await agent.get('/me').expect(200);
+
+ // `labs` is absent on the document; the route normalizes it so the client
+ // never has to distinguish "no field" from "nothing enabled".
+ expect(resp.body.labs).toEqual({});
+ });
+ });
+
+ describe('PATCH /me/labs', () => {
+ it('persists an opt-in and reflects it on GET /me', async () => {
+ const { agent } = await getLoggedInAgent(server);
+
+ const patchResp = await agent
+ .patch('/me/labs')
+ .send({ labs: { 'my-lab': true } })
+ .expect(200);
+
+ expect(patchResp.body).toEqual({ labs: { 'my-lab': true } });
+
+ const meResp = await agent.get('/me').expect(200);
+ expect(meResp.body.labs).toEqual({ 'my-lab': true });
+ });
+
+ it('serializes labs as a plain object', async () => {
+ // Regression guard for the storage type. A Mongoose Map's toJSON()
+ // returns a native Map unless given { flattenMaps: true }, which
+ // JSON.stringify renders as `{}` — so a future switch away from Mixed
+ // would silently start returning nothing. See models/webhook.ts.
+ const { agent } = await getLoggedInAgent(server);
+
+ await agent
+ .patch('/me/labs')
+ .send({ labs: { 'lab-one': true, 'lab-two': true } })
+ .expect(200);
+
+ const resp = await agent.get('/me').expect(200);
+
+ expect(typeof resp.body.labs).toBe('object');
+ expect(Array.isArray(resp.body.labs)).toBe(false);
+ expect(resp.body.labs).toEqual({ 'lab-one': true, 'lab-two': true });
+ expect(Object.keys(resp.body.labs).sort()).toEqual([
+ 'lab-one',
+ 'lab-two',
+ ]);
+ });
+
+ it('replaces the whole set rather than merging', async () => {
+ // Full-replace is what makes ids from retired labs self-pruning.
+ const { agent } = await getLoggedInAgent(server);
+
+ await agent
+ .patch('/me/labs')
+ .send({ labs: { 'lab-a': true, 'lab-b': true } })
+ .expect(200);
+
+ await agent
+ .patch('/me/labs')
+ .send({ labs: { 'lab-a': true } })
+ .expect(200);
+
+ const resp = await agent.get('/me').expect(200);
+ expect(resp.body.labs).toEqual({ 'lab-a': true });
+ });
+
+ it.each([
+ ['an underscore', { my_lab: true }],
+ ['uppercase', { 'My-Lab': true }],
+ ['a dotted path', { 'a.b': true }],
+ ['a mongo operator', { $set: true }],
+ ['a leading dash', { '-lab': true }],
+ ['an empty id', { '': true }],
+ ])('rejects a lab id containing %s', async (_label, labs) => {
+ const { agent } = await getLoggedInAgent(server);
+
+ await agent.patch('/me/labs').send({ labs }).expect(400);
+ });
+
+ it('rejects __proto__ as a lab id without polluting Object.prototype', async () => {
+ const { agent } = await getLoggedInAgent(server);
+
+ // Sent as a raw JSON string so `__proto__` arrives as an own property,
+ // which is how express.json() parses it off the wire.
+ await agent
+ .patch('/me/labs')
+ .set('Content-Type', 'application/json')
+ .send('{"labs":{"__proto__":true}}')
+ .expect(400);
+
+ expect(Object.getPrototypeOf({})).toBe(Object.prototype);
+ expect({}).not.toHaveProperty('labs');
+ });
+
+ it('rejects a non-boolean value', async () => {
+ const { agent } = await getLoggedInAgent(server);
+
+ await agent
+ .patch('/me/labs')
+ .send({ labs: { 'lab-a': 'true' } })
+ .expect(400);
+ });
+
+ it('rejects more lab entries than a user may store', async () => {
+ const { agent } = await getLoggedInAgent(server);
+
+ const tooMany: Record = {};
+ for (let i = 0; i < 65; i++) {
+ tooMany[`lab-${i}`] = true;
+ }
+
+ await agent.patch('/me/labs').send({ labs: tooMany }).expect(400);
+ });
+
+ it('rejects an unauthenticated request', async () => {
+ const anon = getAgent(server);
+
+ await anon
+ .patch('/me/labs')
+ .send({ labs: { 'lab-a': true } })
+ .expect(401);
+ });
+
+ it('scopes the write to the requesting user, not the team', async () => {
+ // The second user is created directly rather than via getLoggedInAgent:
+ // POST /register/password refuses once a team exists, so a second session
+ // would need the invite flow. The concern here is only that the $set
+ // targets req.user._id, which this covers.
+ const { agent, team, user: userA } = await getLoggedInAgent(server);
+ const userB = await User.create({
+ email: 'second@example.com',
+ name: 'Second',
+ team: team._id,
+ });
+
+ await agent
+ .patch('/me/labs')
+ .send({ labs: { 'lab-a': true } })
+ .expect(200);
+
+ const storedA = await User.findById(userA._id);
+ expect(storedA?.labs).toEqual({ 'lab-a': true });
+
+ const storedB = await User.findById(userB._id);
+ expect(storedB?.labs).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/api/src/routers/api/me.ts b/packages/api/src/routers/api/me.ts
index b574928528..65d55df698 100644
--- a/packages/api/src/routers/api/me.ts
+++ b/packages/api/src/routers/api/me.ts
@@ -1,9 +1,17 @@
-import type { MeApiResponse } from '@hyperdx/common-utils/dist/types';
+import type {
+ MeApiResponse,
+ UpdateUserLabsApiResponse,
+} from '@hyperdx/common-utils/dist/types';
+import { UpdateUserLabsRequestSchema } from '@hyperdx/common-utils/dist/types';
import express from 'express';
+import { processRequest } from 'zod-express-middleware';
import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config';
import { getTeam } from '@/controllers/team';
+import { setUserLabs } from '@/controllers/user';
+import { getNonNullUserWithTeam } from '@/middleware/auth';
import { Api404Error } from '@/utils/errors';
+import { setBusinessContext } from '@/utils/instrumentation';
import { sendJson } from '@/utils/serialization';
const router = express.Router();
@@ -19,6 +27,7 @@ router.get('/', async (req, res: express.Response, next) => {
accessKey,
createdAt,
email,
+ labs,
name,
team: teamId,
} = req.user;
@@ -37,10 +46,46 @@ router.get('/', async (req, res: express.Response, next) => {
team,
usageStatsEnabled: USAGE_STATS_ENABLED,
aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY),
+ // Absent on every user created before labs existed; `{}` reads as
+ // "nothing enabled", which is what the client expects.
+ labs: labs ?? {},
});
} catch (e) {
next(e);
}
});
+/**
+ * Replaces the caller's lab opt-ins. A sub-path rather than `PATCH /me` because
+ * `GET /me` also returns email, name, accessKey and team — an endpoint that
+ * merely *looks* like it accepts edits to those is a hazard worth avoiding.
+ */
+router.patch(
+ '/labs',
+ processRequest({ body: UpdateUserLabsRequestSchema }),
+ async (req, res: express.Response, next) => {
+ try {
+ const { userId } = getNonNullUserWithTeam(req);
+ const { labs } = req.body;
+
+ const user = await setUserLabs(userId, labs);
+ if (user == null) {
+ throw new Api404Error(`User not found for id ${userId}`);
+ }
+
+ // A wide-event span attribute, deliberately not a metric: lab ids are
+ // client-supplied, so using them as metric attribute values would be
+ // unbounded cardinality. Team/user context is already on the span from
+ // isUserAuthenticated. See agent_docs/observability.md.
+ setBusinessContext({
+ 'hyperdx.user.labs.enabled': Object.keys(labs).filter(id => labs[id]),
+ });
+
+ return sendJson(res, { labs: user.labs ?? {} });
+ } catch (e) {
+ next(e);
+ }
+ },
+);
+
export default router;
diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts
index 21facd9c05..23a826cd3a 100644
--- a/packages/app/src/api.ts
+++ b/packages/app/src/api.ts
@@ -76,6 +76,12 @@ export const hdxServer = (
});
};
+/**
+ * Query key for `useMe`. Exported so code that writes into the `/me` cache
+ * (src/labs/useLabs.ts) can't drift from the key `useMe` reads.
+ */
+export const ME_QUERY_KEY = ['me'] as const;
+
const api = {
useCreateAlert() {
return useMutation<{ data: Alert }, Error, Alert>({
@@ -291,7 +297,7 @@ const api = {
},
useMe() {
return useQuery({
- queryKey: [`me`],
+ queryKey: ME_QUERY_KEY,
queryFn: () => {
if (IS_LOCAL_MODE) {
return null;
diff --git a/packages/app/src/components/AppNav/AppNav.components.tsx b/packages/app/src/components/AppNav/AppNav.components.tsx
index 95344a749c..553b098f75 100644
--- a/packages/app/src/components/AppNav/AppNav.components.tsx
+++ b/packages/app/src/components/AppNav/AppNav.components.tsx
@@ -20,6 +20,7 @@ import {
IconChevronDown,
IconChevronRight,
IconChevronUp,
+ IconFlask,
IconHelp,
IconKeyboard,
IconLogout,
@@ -68,6 +69,7 @@ type AppNavUserMenuProps = {
teamName?: string;
logoutUrl?: string | null;
onClickUserPreferences?: () => void;
+ onClickLabs?: () => void;
};
const getUserInitials = (userName: string) => {
@@ -85,6 +87,7 @@ export const AppNavUserMenu = ({
teamName,
logoutUrl,
onClickUserPreferences,
+ onClickLabs,
}: AppNavUserMenuProps) => {
const { isCollapsed } = React.useContext(AppNavContext);
const resolvedUserName = userName.trim() || 'User';
@@ -161,6 +164,13 @@ export const AppNavUserMenu = ({
>
User Preferences
+ }
+ onClick={onClickLabs}
+ >
+ HyperDX Labs
+
{logoutUrl && (
<>
diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx
index 13dc89e695..0d794fd116 100644
--- a/packages/app/src/components/AppNav/AppNav.tsx
+++ b/packages/app/src/components/AppNav/AppNav.tsx
@@ -34,6 +34,7 @@ import { IS_LOCAL_MODE } from '@/config';
import { Dashboard, useDashboards } from '@/dashboard';
import { useFavorites } from '@/favorites';
import InstallInstructionModal from '@/InstallInstructionsModal';
+import { LabsModal } from '@/labs/LabsModal';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches } from '@/savedSearch';
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
@@ -269,6 +270,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
{ close: closeUserPreferences, open: openUserPreferences },
] = useDisclosure(false);
+ const [labsOpen, { close: closeLabs, open: openLabs }] = useDisclosure(false);
+
const {
userPreferences: { isUTC },
} = useUserPreferences();
@@ -518,6 +521,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
userName={meData?.name}
teamName={meData?.team?.name}
onClickUserPreferences={openUserPreferences}
+ onClickLabs={openLabs}
logoutUrl={IS_LOCAL_MODE ? null : `/api/logout`}
/>
{meData?.usageStatsEnabled && (
@@ -539,6 +543,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
opened={UserPreferencesOpen}
onClose={closeUserPreferences}
/>
+
);
}
diff --git a/packages/app/src/labs/LabsModal.tsx b/packages/app/src/labs/LabsModal.tsx
new file mode 100644
index 0000000000..fe933790a4
--- /dev/null
+++ b/packages/app/src/labs/LabsModal.tsx
@@ -0,0 +1,119 @@
+import * as React from 'react';
+import { Badge, Card, Group, Modal, Stack, Switch, Text } from '@mantine/core';
+import { IconFlask } from '@tabler/icons-react';
+
+import type { Lab } from '@/labs/registry';
+import { LABS } from '@/labs/registry';
+import { useLabs } from '@/labs/useLabs';
+
+const LabCard = ({
+ lab,
+ enabled,
+ disabled,
+ onChange,
+}: {
+ lab: Lab;
+ enabled: boolean;
+ disabled: boolean;
+ onChange: (enabled: boolean) => void;
+}) => {
+ return (
+
+
+
+ {/*
+ Two testids on purpose. Mantine puts arbitrary props on the ,
+ which it hides behind an aria-hidden track — so that one is assertable
+ (toBeChecked) but not clickable. wrapperProps lands on the enclosing
+
+
+ );
+};
+
+export const LabsModal = ({
+ opened,
+ onClose,
+}: {
+ opened: boolean;
+ onClose: () => void;
+}) => {
+ const { enabled, isLoading, setLabEnabled } = useLabs();
+
+ return (
+
+
+
+ HyperDX Labs
+
+ Try features that are still being built
+
+
+
+ }
+ size="lg"
+ padding="lg"
+ keepMounted={false}
+ opened={opened}
+ onClose={onClose}
+ >
+ {/*
+ Inner content carries its own testid: a Mantine Modal's root stays in the
+ DOM with zero dimensions, so it never reads as "visible" to Playwright.
+ Open-state assertions have to target content, closed-state the root.
+ */}
+
+ {LABS.length === 0 ? (
+
+ No experiments are available right now. Check back soon.
+
+ ) : (
+ <>
+
+ These are unfinished, so expect rough edges. Everything here is
+ off by default and you can turn it off again at any time. Your
+ choices are saved to your account, so they follow you across
+ browsers and devices.
+
+ {LABS.map(lab => (
+ setLabEnabled(lab.id, value)}
+ />
+ ))}
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/app/src/labs/__tests__/registry.test.ts b/packages/app/src/labs/__tests__/registry.test.ts
new file mode 100644
index 0000000000..1013c34cbb
--- /dev/null
+++ b/packages/app/src/labs/__tests__/registry.test.ts
@@ -0,0 +1,42 @@
+import { LabIdSchema, LABS_MAX_KEYS } from '@hyperdx/common-utils/dist/types';
+
+import { LABS } from '@/labs/registry';
+
+/**
+ * The server bounds the *shape* of what a user can store but deliberately does
+ * not know the lab id list, so it cannot reject a typo'd id — see the Labs
+ * comment in common-utils/src/types.ts. These assertions are what catch
+ * `my_lab` or `My-Lab` at CI time instead of as a mystery 400 in the browser.
+ *
+ * They pass trivially while the registry is empty and start earning their keep
+ * with the first lab.
+ */
+describe('labs registry', () => {
+ it('gives every lab an id the API will accept', () => {
+ for (const lab of LABS) {
+ const result = LabIdSchema.safeParse(lab.id);
+ expect(result.success).toBe(true);
+ }
+ });
+
+ it('has no duplicate ids', () => {
+ const ids = LABS.map(lab => lab.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('stays within the per-user storage cap', () => {
+ // Every lab in the registry can be enabled at once, so the registry itself
+ // must fit inside what UserLabsSchema will accept.
+ expect(LABS.length).toBeLessThanOrEqual(LABS_MAX_KEYS);
+ });
+
+ it('describes every lab well enough to opt into', () => {
+ for (const lab of LABS) {
+ expect(lab.title.trim()).not.toBe('');
+ expect(lab.description.trim()).not.toBe('');
+ expect(lab.owner.trim()).not.toBe('');
+ // addedAt drives the graduate-or-retire sweep; see agent_docs/labs.md.
+ expect(lab.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ }
+ });
+});
diff --git a/packages/app/src/labs/__tests__/useLabs.localMode.test.tsx b/packages/app/src/labs/__tests__/useLabs.localMode.test.tsx
new file mode 100644
index 0000000000..d7076520ce
--- /dev/null
+++ b/packages/app/src/labs/__tests__/useLabs.localMode.test.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react';
+
+import { hdxServer } from '@/api';
+import { useLabs } from '@/labs/useLabs';
+
+// Separate file because jest.mock is file-scoped and local mode is the only
+// case that takes the localStorage path.
+jest.mock('@/config', () => ({ IS_LOCAL_MODE: true }));
+
+// Mirrors real local mode, where useMe()'s queryFn short-circuits to null.
+jest.mock('@/api', () => {
+ const { useQuery } = jest.requireActual('@tanstack/react-query');
+ return {
+ __esModule: true,
+ ME_QUERY_KEY: ['me'],
+ hdxServer: jest.fn(),
+ default: {
+ useMe: () => useQuery({ queryKey: ['me'], queryFn: () => null }),
+ },
+ };
+});
+
+jest.mock('@/labs/registry', () => ({
+ LABS: [
+ {
+ id: 'lab-a',
+ title: 'Lab A',
+ description: 'A',
+ addedAt: '2026-01-01',
+ owner: '@test',
+ },
+ {
+ id: 'lab-b',
+ title: 'Lab B',
+ description: 'B',
+ addedAt: '2026-01-01',
+ owner: '@test',
+ },
+ ],
+}));
+
+jest.mock('@mantine/notifications', () => ({
+ notifications: { show: jest.fn() },
+}));
+
+const STORAGE_KEY = 'hdx-labs';
+const mockHdxServer = jest.mocked(hdxServer);
+
+function renderLabs() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderHook(() => useLabs(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ localStorage.clear();
+});
+
+describe('useLabs in local mode', () => {
+ it('never reports loading, since localStorage is synchronous', () => {
+ const { result } = renderLabs();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isSaving).toBe(false);
+ expect(result.current.enabled).toEqual({ 'lab-a': false, 'lab-b': false });
+ });
+
+ it('reads opt-ins from localStorage', async () => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'lab-a': true }));
+
+ const { result } = renderLabs();
+
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+ expect(result.current.enabled['lab-b']).toBe(false);
+ });
+
+ it('persists a toggle to localStorage without calling the API', async () => {
+ const { result } = renderLabs();
+
+ act(() => result.current.setLabEnabled('lab-b', true));
+
+ await waitFor(() => expect(result.current.enabled['lab-b']).toBe(true));
+ expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({
+ 'lab-b': true,
+ });
+ // There is no API server in local mode; a request here would 404.
+ expect(mockHdxServer).not.toHaveBeenCalled();
+ });
+
+ it('prunes ids that are no longer in the registry on write', async () => {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ 'lab-a': true, 'retired-lab': true }),
+ );
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+
+ act(() => result.current.setLabEnabled('lab-b', true));
+
+ await waitFor(() => expect(result.current.enabled['lab-b']).toBe(true));
+ expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({
+ 'lab-a': true,
+ 'lab-b': true,
+ });
+ });
+
+ it('clears the entry when a lab is switched off', async () => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'lab-a': true }));
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+
+ act(() => result.current.setLabEnabled('lab-a', false));
+
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(false));
+ expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({});
+ });
+});
diff --git a/packages/app/src/labs/__tests__/useLabs.test.tsx b/packages/app/src/labs/__tests__/useLabs.test.tsx
new file mode 100644
index 0000000000..bc62edd536
--- /dev/null
+++ b/packages/app/src/labs/__tests__/useLabs.test.tsx
@@ -0,0 +1,330 @@
+import React from 'react';
+import type { MeApiResponse } from '@hyperdx/common-utils/dist/types';
+import { notifications } from '@mantine/notifications';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react';
+
+import { useIsLabEnabled, useLabs } from '@/labs/useLabs';
+
+/**
+ * The mocked `hdxServer`, typed as what it actually is rather than as ky's
+ * `ResponsePromise`. Reaching for the real signature would mean asserting a
+ * bare `{ json }` stub into a generic `json()` interface, and the honest
+ * narrow type here avoids that cast entirely. The hook only ever calls
+ * `.json()` on the result.
+ */
+type HdxServerMock = jest.Mock<
+ { json: () => Promise },
+ [string, { method?: string; json?: unknown }?]
+>;
+
+// A never-resolving /me keeps the query pending, which is how the OFF -> ON
+// window is exercised.
+const PENDING = Symbol('pending');
+
+let meFixture: Partial | null | typeof PENDING = null;
+
+// `useMe` is mocked as a *real* useQuery over the same key rather than as a
+// static return value. The optimistic update writes straight into the
+// react-query cache, so the cache has to be the actual source of truth or the
+// optimism and rollback assertions would be testing nothing.
+jest.mock('@/api', () => {
+ const { useQuery } = jest.requireActual('@tanstack/react-query');
+ return {
+ __esModule: true,
+ ME_QUERY_KEY: ['me'],
+ hdxServer: jest.fn(),
+ default: {
+ useMe: () =>
+ useQuery({
+ queryKey: ['me'],
+ queryFn: () =>
+ meFixture === PENDING
+ ? new Promise(() => {
+ /* never resolves */
+ })
+ : meFixture,
+ }),
+ },
+ };
+});
+
+// Explicit rather than relying on the env default: which store useLabs reads
+// now hinges on this, and local mode is covered in useLabs.localMode.test.tsx.
+jest.mock('@/config', () => ({ IS_LOCAL_MODE: false }));
+
+jest.mock('@/labs/registry', () => ({
+ LABS: [
+ {
+ id: 'lab-a',
+ title: 'Lab A',
+ description: 'A',
+ addedAt: '2026-01-01',
+ owner: '@test',
+ },
+ {
+ id: 'lab-b',
+ title: 'Lab B',
+ description: 'B',
+ addedAt: '2026-01-01',
+ owner: '@test',
+ },
+ ],
+}));
+
+jest.mock('@mantine/notifications', () => ({
+ notifications: { show: jest.fn() },
+}));
+
+const { hdxServer: mockHdxServer } = jest.requireMock<{
+ hdxServer: HdxServerMock;
+}>('@/api');
+const mockShow = jest.mocked(notifications.show);
+
+function resolvePatch(body: unknown = { labs: {} }) {
+ mockHdxServer.mockReturnValue({ json: () => Promise.resolve(body) });
+}
+
+/** Returns a `resolve`/`reject` pair so a test can hold the PATCH in flight. */
+function deferPatch() {
+ let settle: { resolve: () => void; reject: () => void };
+ const promise = new Promise((res, rej) => {
+ settle = {
+ resolve: () => res({ labs: {} }),
+ reject: () => rej(new Error('nope')),
+ };
+ });
+ mockHdxServer.mockReturnValue({ json: () => promise });
+
+ return settle!;
+}
+
+function renderLabs() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return renderHook(() => useLabs(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+}
+
+/** The PATCH body the hook sent, as an object. */
+function lastPatchBody() {
+ const call = mockHdxServer.mock.calls.at(-1);
+ return call?.[1]?.json;
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ meFixture = null;
+ resolvePatch();
+});
+
+describe('useLabs', () => {
+ it('reports loading and every lab off while /me is pending', async () => {
+ meFixture = PENDING;
+
+ const { result } = renderLabs();
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.enabled).toEqual({
+ 'lab-a': false,
+ 'lab-b': false,
+ });
+ });
+
+ it('reads every lab as off when /me carries no labs field', async () => {
+ // The pre-existing-user path: the field is absent on documents created
+ // before labs existed.
+ meFixture = { name: 'Test' };
+
+ const { result } = renderLabs();
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.enabled).toEqual({
+ 'lab-a': false,
+ 'lab-b': false,
+ });
+ });
+
+ it('reflects stored opt-ins', async () => {
+ meFixture = { labs: { 'lab-a': true } };
+
+ const { result } = renderLabs();
+
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+ expect(result.current.enabled['lab-b']).toBe(false);
+ });
+
+ it('ignores stored ids that are not in the registry', async () => {
+ // A graduated or retired lab. It must not reach a consumer.
+ meFixture = { labs: { 'retired-lab': true, 'lab-a': true } };
+
+ const { result } = renderLabs();
+
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+ expect(result.current.enabled).not.toHaveProperty('retired-lab');
+ expect(Object.keys(result.current.enabled).sort()).toEqual([
+ 'lab-a',
+ 'lab-b',
+ ]);
+ });
+
+ it('does not treat a non-boolean stored value as enabled', async () => {
+ // Guards the `=== true` comparison rather than truthiness.
+ meFixture = {
+ labs: { 'lab-a': 'true' } as unknown as { 'lab-a': boolean },
+ };
+
+ const { result } = renderLabs();
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.enabled['lab-a']).toBe(false);
+ });
+
+ it('sends a full-replace payload that prunes unknown ids and omits disabled labs', async () => {
+ meFixture = { labs: { 'lab-a': true, 'retired-lab': true } };
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+
+ act(() => result.current.setLabEnabled('lab-b', true));
+
+ await waitFor(() => expect(mockHdxServer).toHaveBeenCalled());
+ expect(mockHdxServer).toHaveBeenCalledWith(
+ 'me/labs',
+ expect.objectContaining({ method: 'PATCH' }),
+ );
+ // retired-lab is gone, and nothing is stored as `false`.
+ expect(lastPatchBody()).toEqual({
+ labs: { 'lab-a': true, 'lab-b': true },
+ });
+ });
+
+ it('sends an empty set when the last enabled lab is switched off', async () => {
+ meFixture = { labs: { 'lab-a': true } };
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+
+ act(() => result.current.setLabEnabled('lab-a', false));
+
+ await waitFor(() => expect(mockHdxServer).toHaveBeenCalled());
+ expect(lastPatchBody()).toEqual({ labs: {} });
+ });
+
+ it('applies the toggle optimistically before the request settles', async () => {
+ meFixture = { labs: {} };
+ const settle = deferPatch();
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => result.current.setLabEnabled('lab-b', true));
+
+ // Still in flight, but the UI already shows the new state.
+ await waitFor(() => expect(result.current.enabled['lab-b']).toBe(true));
+ expect(result.current.isSaving).toBe(true);
+
+ await act(async () => {
+ settle.resolve();
+ });
+ });
+
+ it('serializes overlapping toggles so an older payload cannot land last', async () => {
+ // Without a mutation scope both requests fly concurrently, and if the first
+ // one's response lands last the server keeps its payload — dropping the
+ // second lab, which the next /me refetch then reverts in the UI.
+ meFixture = { labs: {} };
+ const first = deferPatch();
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => result.current.setLabEnabled('lab-a', true));
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+
+ act(() => result.current.setLabEnabled('lab-b', true));
+ await waitFor(() => expect(result.current.enabled['lab-b']).toBe(true));
+
+ // The second toggle is queued behind the first, not racing it.
+ expect(mockHdxServer).toHaveBeenCalledTimes(1);
+ expect(lastPatchBody()).toEqual({ labs: { 'lab-a': true } });
+
+ // Let the first finish; the queued one then sends the cumulative set. The
+ // fixture stands in for the persisted document once both writes have landed
+ // in order, so the refetch that onSettled triggers is what proves the second
+ // toggle survives rather than reverting.
+ meFixture = { labs: { 'lab-a': true, 'lab-b': true } };
+ resolvePatch();
+ await act(async () => {
+ first.resolve();
+ });
+
+ await waitFor(() => expect(mockHdxServer).toHaveBeenCalledTimes(2));
+ expect(lastPatchBody()).toEqual({ labs: { 'lab-a': true, 'lab-b': true } });
+ await waitFor(() =>
+ expect(result.current.enabled).toEqual({
+ 'lab-a': true,
+ 'lab-b': true,
+ }),
+ );
+ });
+
+ it('rolls back and notifies when the request fails', async () => {
+ meFixture = { labs: { 'lab-a': true } };
+ const settle = deferPatch();
+
+ const { result } = renderLabs();
+ await waitFor(() => expect(result.current.enabled['lab-a']).toBe(true));
+
+ act(() => result.current.setLabEnabled('lab-b', true));
+ await waitFor(() => expect(result.current.enabled['lab-b']).toBe(true));
+
+ await act(async () => {
+ settle.reject();
+ });
+
+ await waitFor(() => expect(result.current.enabled['lab-b']).toBe(false));
+ // The pre-existing opt-in survived the rollback.
+ expect(result.current.enabled['lab-a']).toBe(true);
+ expect(mockShow).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'red' }),
+ );
+ });
+});
+
+describe('useIsLabEnabled', () => {
+ function renderIsEnabled(labId: string) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderHook(() => useIsLabEnabled(labId), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+ }
+
+ it('returns true only for an enabled lab', async () => {
+ meFixture = { labs: { 'lab-a': true } };
+
+ const { result } = renderIsEnabled('lab-a');
+
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+
+ it('returns false for an id with no registry entry', async () => {
+ meFixture = { labs: { 'retired-lab': true } };
+
+ const { result } = renderIsEnabled('retired-lab');
+
+ // Deleting a registry entry is safe: the gate goes false rather than
+ // throwing. The flip side is that a typo fails silently.
+ await waitFor(() => expect(result.current).toBe(false));
+ });
+});
diff --git a/packages/app/src/labs/registry.ts b/packages/app/src/labs/registry.ts
new file mode 100644
index 0000000000..92fa00e77e
--- /dev/null
+++ b/packages/app/src/labs/registry.ts
@@ -0,0 +1,47 @@
+/**
+ * The HyperDX Labs registry — the single source of truth for which experiments
+ * exist and how they're described to users.
+ *
+ * Adding a lab is intended to be a one-file change: add an entry here, then
+ * gate your feature with `useIsLabEnabled('your-id')`. No API change, no schema
+ * change, no migration. Read agent_docs/labs.md before adding one — in
+ * particular the graduate-or-retire rule, which is what keeps this file from
+ * accumulating zombie flags.
+ */
+
+export type Lab = {
+ /**
+ * Stable kebab-case id, validated by `LabIdSchema` in common-utils. This is
+ * persisted on the user document, so changing it silently resets everyone's
+ * opt-in — treat it as permanent once shipped.
+ */
+ id: string;
+ /** Short, user-facing name. Sentence case. */
+ title: string;
+ /**
+ * What it does *and* what's still rough. Someone opting into unfinished work
+ * deserves to know where the edges are, so prefer "Span links aren't drawn
+ * yet and wide traces are slow" over "improved trace view".
+ */
+ description: string;
+ /** Optional maturity hint shown next to the title, e.g. 'Alpha', 'Beta'. */
+ badge?: string;
+ /** ISO day (YYYY-MM-DD). Drives the graduate-or-retire sweep. */
+ addedAt: string;
+ /** Who decides this lab's fate. GitHub or Slack handle. */
+ owner: string;
+};
+
+/**
+ * Deliberately empty. This ships the mechanism, not any experiments.
+ *
+ * Typed as `readonly Lab[]` rather than `[...] as const satisfies readonly
+ * Lab[]`. The const form is tempting because it would narrow lab ids to a
+ * literal union, making a deleted entry a compile error at every gate. But on
+ * an *empty* registry that union is `never`: every `lab.id` in a `.map` fails
+ * to typecheck, and `useIsLabEnabled` becomes uncallable, including from its
+ * own tests. Retirement is covered instead by the documented step of grepping
+ * for the id after deleting the entry — see agent_docs/labs.md. Worth
+ * revisiting once a few labs exist and the empty case is behind us.
+ */
+export const LABS: readonly Lab[] = [];
diff --git a/packages/app/src/labs/useLabs.ts b/packages/app/src/labs/useLabs.ts
new file mode 100644
index 0000000000..69ac810106
--- /dev/null
+++ b/packages/app/src/labs/useLabs.ts
@@ -0,0 +1,170 @@
+import * as React from 'react';
+import { useAtom } from 'jotai';
+import { atomWithStorage } from 'jotai/utils';
+import type { MeApiResponse, UserLabs } from '@hyperdx/common-utils/dist/types';
+import { notifications } from '@mantine/notifications';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import api, { hdxServer, ME_QUERY_KEY } from '@/api';
+import { IS_LOCAL_MODE } from '@/config';
+import { LABS } from '@/labs/registry';
+
+export type LabsState = {
+ /**
+ * Enabled state for every lab in the current registry, keyed by lab id. Ids
+ * stored on the user but absent from the registry (a graduated or retired
+ * lab) are not surfaced here — see agent_docs/labs.md.
+ */
+ enabled: Record;
+ /**
+ * True until the stored answer is known. Every lab reads OFF while this is
+ * true, so anything where an OFF -> ON flip is user-visible (a redirect, a
+ * default tab, a one-shot effect, a mount-time fetch) should branch on it
+ * rather than on `enabled` alone. Always false in local mode, which reads
+ * synchronously from localStorage.
+ */
+ isLoading: boolean;
+ /** True while a toggle is in flight. */
+ isSaving: boolean;
+ setLabEnabled: (labId: string, enabled: boolean) => void;
+};
+
+/**
+ * Local-mode store. Local mode has no API server and no user identity —
+ * `useMe()` resolves to null there — so there is no account to hang an opt-in
+ * on, and the choice lives in localStorage instead. Same jotai + localStorage
+ * shape as the other per-browser preferences in this app.
+ *
+ * This is the only reason `useLabs` branches on IS_LOCAL_MODE, and it is
+ * deliberately confined to this file.
+ */
+const localLabsAtom = atomWithStorage('hdx-labs', {});
+
+/**
+ * Persists the caller's full lab set. Optimistic, because this backs a Switch:
+ * with a plain mutate-then-refetch the control would sit dead for two round
+ * trips. Shape mirrors src/favorites.ts, including the isMutating guard.
+ */
+function useUpdateUserLabs() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ // Shared with the /me query key so concurrent toggles coordinate their
+ // refetch rather than racing each other.
+ mutationKey: ME_QUERY_KEY,
+ // Serializes toggles: react-query runs same-scope mutations one at a time,
+ // in call order. Without this, two quick toggles race on the network and an
+ // older full-replacement payload can land last, dropping the newer lab from
+ // Mongo — the UI would then revert it on the next /me refetch. Each queued
+ // payload is built after the previous onMutate has written the cache, so
+ // serialized sends are cumulative rather than conflicting.
+ scope: { id: 'user-labs' },
+ mutationFn: (labs: UserLabs) =>
+ hdxServer('me/labs', {
+ method: 'PATCH',
+ json: { labs },
+ }).json<{ labs: UserLabs }>(),
+ onMutate: async (labs: UserLabs) => {
+ // Cancel outgoing /me refetches so they can't overwrite our optimistic update
+ await queryClient.cancelQueries({ queryKey: ME_QUERY_KEY });
+
+ const previous = queryClient.getQueryData(
+ ME_QUERY_KEY,
+ );
+
+ queryClient.setQueryData(ME_QUERY_KEY, old =>
+ old ? { ...old, labs } : old,
+ );
+
+ return { previous };
+ },
+ onError: (_err, _labs, context) => {
+ if (context !== undefined) {
+ queryClient.setQueryData(ME_QUERY_KEY, context.previous);
+ }
+ notifications.show({
+ color: 'red',
+ message: 'Failed to update HyperDX Labs',
+ });
+ },
+ onSettled: () => {
+ // Only refetch once the last in-flight toggle settles, so a /me refetch
+ // carrying partially-committed state can't clobber a still-pending
+ // optimistic update from another toggle.
+ if (queryClient.isMutating({ mutationKey: ME_QUERY_KEY }) === 1) {
+ queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
+ }
+ },
+ });
+}
+
+/**
+ * The single seam for HyperDX Labs state. Local-mode branching, loading
+ * semantics, and the write all live here — consumers gating a feature should
+ * use {@link useIsLabEnabled} instead.
+ */
+export function useLabs(): LabsState {
+ const { data: me, isPending } = api.useMe();
+ const { mutate: updateLabs, isPending: isSaving } = useUpdateUserLabs();
+ const [localLabs, setLocalLabs] = useAtom(localLabsAtom);
+
+ // The one branch: an account when there is one, this browser when there isn't.
+ const stored = IS_LOCAL_MODE ? localLabs : me?.labs;
+
+ const enabled = React.useMemo(() => {
+ // Derived from the registry, not from what's stored: an id the registry no
+ // longer knows about can never reach a consumer or the modal.
+ const result: Record = {};
+ for (const lab of LABS) {
+ // `=== true` rather than truthiness. A key like `constructor` passes the
+ // lab-id regex and is inherited from Object.prototype, where it is
+ // truthy; only an own `true` should read as enabled.
+ result[lab.id] = stored?.[lab.id] === true;
+ }
+ return result;
+ }, [stored]);
+
+ const setLabEnabled = React.useCallback(
+ (labId: string, value: boolean) => {
+ // Full-replace payload rebuilt from the registry. Two things fall out of
+ // this for free: ids of retired labs are pruned, and only enabled entries
+ // are stored, which keeps the document small.
+ const next: UserLabs = {};
+ for (const lab of LABS) {
+ const isOn = lab.id === labId ? value : stored?.[lab.id] === true;
+ if (isOn) {
+ next[lab.id] = true;
+ }
+ }
+
+ if (IS_LOCAL_MODE) {
+ setLocalLabs(next);
+ return;
+ }
+ updateLabs(next);
+ },
+ [stored, updateLabs, setLocalLabs],
+ );
+
+ return {
+ enabled,
+ // localStorage is read synchronously, so local mode is never pending.
+ isLoading: !IS_LOCAL_MODE && isPending,
+ isSaving: IS_LOCAL_MODE ? false : isSaving,
+ setLabEnabled,
+ };
+}
+
+/**
+ * Whether the current user has opted into a lab. Returns `false` while the
+ * stored set is still loading; if that transition is user-visible, use
+ * {@link useLabs} and branch on `isLoading`.
+ *
+ * An id with no registry entry always reads `false`, which is what makes
+ * deleting an entry safe — but it also means a typo fails silently, so grep for
+ * the id after you delete one. See agent_docs/labs.md.
+ */
+export function useIsLabEnabled(labId: string): boolean {
+ const { enabled } = useLabs();
+ return enabled[labId] === true;
+}
diff --git a/packages/app/tests/e2e/components/LabsModalComponent.ts b/packages/app/tests/e2e/components/LabsModalComponent.ts
new file mode 100644
index 0000000000..f11045fa69
--- /dev/null
+++ b/packages/app/tests/e2e/components/LabsModalComponent.ts
@@ -0,0 +1,90 @@
+/**
+ * LabsModalComponent - the "HyperDX Labs" modal opened from the nav user menu.
+ *
+ * Toggling a lab is deliberately not covered here: with an empty registry there
+ * is nothing to toggle. When the first lab lands, add a `setLab(id, enabled)`
+ * helper alongside `labSwitch` and assert the choice survives a reload — that is
+ * the test worth having. See agent_docs/labs.md.
+ */
+import { expect, Locator, Page } from '@playwright/test';
+
+export class LabsModalComponent {
+ readonly page: Page;
+ private readonly userMenuTrigger: Locator;
+ private readonly menuItem: Locator;
+ private readonly modal: Locator;
+ private readonly content: Locator;
+ private readonly emptyState: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.userMenuTrigger = page.locator('[data-testid="user-menu-trigger"]');
+ this.menuItem = page.locator('[data-testid="hyperdx-labs-menu-item"]');
+ this.modal = page.locator('[data-testid="labs-modal"]');
+ this.content = page.locator('[data-testid="labs-modal-content"]');
+ this.emptyState = page.locator('[data-testid="labs-empty-state"]');
+ }
+
+ /**
+ * The modal root. Always present in the DOM with zero dimensions, so assert
+ * `toBeHidden()` on this for closed state and use {@link content} for open.
+ */
+ get container() {
+ return this.modal;
+ }
+
+ /** The modal body — this is what actually reads as visible when open. */
+ get body() {
+ return this.content;
+ }
+
+ /** The user-menu entry that opens this modal. */
+ get trigger() {
+ return this.menuItem;
+ }
+
+ /** Opens the nav user menu and waits for the Labs entry to be clickable. */
+ async openUserMenu() {
+ await this.userMenuTrigger.scrollIntoViewIfNeeded();
+ await this.userMenuTrigger.waitFor({ state: 'attached' });
+ await this.userMenuTrigger.click({ timeout: 10000 });
+ await expect(this.menuItem).toBeVisible();
+ }
+
+ /** Opens the modal from the nav user menu. */
+ async open() {
+ await this.openUserMenu();
+ await this.menuItem.click();
+ await expect(this.content).toBeVisible();
+ }
+
+ /**
+ * A lab's checkbox input — use for state assertions (`toBeChecked()`).
+ * Mantine hides it behind an aria-hidden track, so it is not clickable;
+ * use {@link setLab} to change it.
+ */
+ labSwitch(labId: string) {
+ return this.page.locator(`[data-testid="lab-switch-${labId}"]`);
+ }
+
+ /** Toggles a lab and waits for the write to be persisted. */
+ async setLab(labId: string, enabled: boolean) {
+ const input = this.labSwitch(labId);
+ if ((await input.isChecked()) === enabled) {
+ return;
+ }
+
+ const persisted = this.page.waitForResponse(
+ r => r.url().includes('/me/labs') && r.request().method() === 'PATCH',
+ );
+ // The label, not the input: the input is visually hidden.
+ await this.page.locator(`[data-testid="lab-toggle-${labId}"]`).click();
+ const response = await persisted;
+ expect(response.status()).toBe(200);
+ await expect(input).toBeChecked({ checked: enabled });
+ }
+
+ async expectEmptyState() {
+ await expect(this.emptyState).toBeVisible();
+ }
+}
diff --git a/packages/app/tests/e2e/core/navigation.spec.ts b/packages/app/tests/e2e/core/navigation.spec.ts
index 08738a7da4..358add4dce 100644
--- a/packages/app/tests/e2e/core/navigation.spec.ts
+++ b/packages/app/tests/e2e/core/navigation.spec.ts
@@ -1,3 +1,4 @@
+import { LabsModalComponent } from '../components/LabsModalComponent';
import { expect, test } from '../utils/base-test';
test.describe('Navigation', { tag: ['@core'] }, () => {
@@ -109,6 +110,26 @@ test.describe('Navigation', { tag: ['@core'] }, () => {
//todo: Add tests that verify user pref behavior
});
+ test('should open HyperDX Labs from the user menu', async ({ page }) => {
+ const labsModal = new LabsModalComponent(page);
+
+ await test.step('Wait for page to load', async () => {
+ await expect(
+ page.locator('[data-testid="nav-link-search"]'),
+ ).toBeVisible();
+ });
+
+ await test.step('Open the Labs modal from the user menu', async () => {
+ await labsModal.open();
+ });
+
+ await test.step('Verify the empty registry renders its empty state', async () => {
+ // No labs ship with the mechanism, so this is the expected content.
+ // Replace with a toggle assertion when the first lab lands.
+ await labsModal.expectEmptyState();
+ });
+ });
+
test('should open help menu', async ({ page }) => {
await test.step('Navigate to and click help menu trigger', async () => {
// Wait for page to be fully loaded first
diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts
index 96a9883bff..21dee2ed36 100644
--- a/packages/common-utils/src/types.ts
+++ b/packages/common-utils/src/types.ts
@@ -2403,6 +2403,77 @@ export type InstallationApiResponse = z.infer<
typeof InstallationApiResponseSchema
>;
+// Labs (per-user opt-in experiments)
+//
+// The lab *registry* — the ids and their UI copy — deliberately lives in
+// packages/app/src/labs/registry.ts, not here. Adding a lab has to be a
+// one-file change or the mechanism won't get used, and the server gains
+// nothing from knowing the id list: the value space is `boolean` and the blast
+// radius is the requester's own document. So what follows is a *shape*
+// contract, not an allow-list. It bounds what a user can store without
+// enumerating ids.
+//
+// The trade-off, stated plainly: the server cannot tell a typo'd lab id from a
+// real one. That's caught client-side instead, by the registry unit test that
+// parses every id against LabIdSchema. See agent_docs/labs.md.
+export const LAB_ID_MAX_LENGTH = 64;
+export const LABS_MAX_KEYS = 64;
+
+/**
+ * Strict kebab-case. This is a storage-safety rule, not cosmetics: it excludes
+ * `$` and `.` (Mongo operator and dotted-path characters) and `_` (so
+ * `__proto__` and friends are unrepresentable as keys).
+ *
+ * Split into a plain character class plus a dash-placement check rather than the
+ * idiomatic `^[a-z0-9]+(?:-[a-z0-9]+)*$`. That one-liner is in fact linear —
+ * the `-` separator is disjoint from `[a-z0-9]`, so there is no ambiguity to
+ * backtrack over — but it has the `(x+)*` shape that ReDoS linters flag, and on
+ * a validator guarding Mongo keys it is better to not have the argument than to
+ * suppress it.
+ */
+export const LabIdSchema = z
+ .string()
+ .min(1)
+ .max(LAB_ID_MAX_LENGTH)
+ .regex(
+ /^[a-z0-9-]+$/,
+ 'Lab ids may only contain lowercase letters, numbers and dashes',
+ )
+ .refine(
+ id => !id.startsWith('-') && !id.endsWith('-') && !id.includes('--'),
+ { message: 'Lab ids must be kebab-case' },
+ );
+
+/**
+ * An enabled-set: a key present with `true` is on, an absent key is off.
+ * `false` is accepted on the write path so an older client is never rejected,
+ * but the current client only ever persists `true` entries.
+ */
+export const UserLabsSchema = z
+ .record(LabIdSchema, z.boolean())
+ .refine(labs => Object.keys(labs).length <= LABS_MAX_KEYS, {
+ message: `A user may store at most ${LABS_MAX_KEYS} lab entries`,
+ });
+
+export type UserLabs = z.infer;
+
+/**
+ * Full replace, not a patch: the client always holds the whole registry, so it
+ * can always compute the complete desired set — and that is what makes ids
+ * from retired labs self-pruning. See agent_docs/labs.md.
+ */
+export const UpdateUserLabsRequestSchema = z.object({ labs: UserLabsSchema });
+
+export type UpdateUserLabsRequest = z.infer;
+
+export const UpdateUserLabsApiResponseSchema = z.object({
+ labs: UserLabsSchema,
+});
+
+export type UpdateUserLabsApiResponse = z.infer<
+ typeof UpdateUserLabsApiResponseSchema
+>;
+
// Me
export const MeApiResponseSchema = z.object({
accessKey: z.string(),
@@ -2418,6 +2489,13 @@ export const MeApiResponseSchema = z.object({
}).merge(TeamClickHouseSettingsSchema),
usageStatsEnabled: z.boolean(),
aiAssistantEnabled: z.boolean(),
+ /**
+ * Enabled-set of lab opt-ins. Optional because a rolling deploy can serve
+ * this from an older API pod that doesn't know about labs yet — the client
+ * already reads it as `me?.labs?.[id] === true`, so this is honest typing
+ * rather than a lie the hook would have to defend against.
+ */
+ labs: UserLabsSchema.optional(),
});
export type MeApiResponse = z.infer;