Skip to content

feat: add HyperDX Labs for per-user opt-in to unfinished features - #2876

Draft
teeohhem wants to merge 3 commits into
mainfrom
tom/hyperdx-labs-settings
Draft

feat: add HyperDX Labs for per-user opt-in to unfinished features#2876
teeohhem wants to merge 3 commits into
mainfrom
tom/hyperdx-labs-settings

Conversation

@teeohhem

Copy link
Copy Markdown
Contributor

Summary

Shipping a half-finished feature currently means either holding a long-lived branch or adding a hardcoded constant to packages/app/src/config.ts and redeploying to change it, which is why IS_MTVIEWS_ENABLED = false and NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES both exist today. HyperDX Labs replaces that with a per-user toggle, so unfinished work can merge to main default-off and the people who want it can switch it on themselves. Opt-ins live on the User document and come down with GET /me, so they follow a user across browsers and devices; in local mode, where there is no account, they fall back to localStorage. Adding a lab is one entry in packages/app/src/labs/registry.ts plus a useIsLabEnabled('id') call, with no API, schema, or migration change, and that one-file property is deliberate. The registry ships empty — this is the mechanism, not any experiment.

Design notes

  • The server validates shape, not ids. It bounds the key format and entry count but does not know which labs exist, which is what keeps adding one to a single file. A typo'd id therefore can't be rejected server-side, so labs/__tests__/registry.test.ts parses every registry id against LabIdSchema to catch it in CI instead.
  • Storage is Schema.Types.Mixed, not a Mongoose Map. A Map's toJSON() returns a native Map unless given { flattenMaps: true }, and GET /me hands the value straight to res.json(), so it would have serialized as {} forever (the same trap already documented in models/webhook.ts). There's a regression test pinning it.
  • Writes are full-replace, not a patch. The client always holds the whole registry, so it can compute the complete desired set, which is what makes ids from retired labs self-pruning with no migration or cleanup job.
  • Graduate-or-retire is a written rule, not a CI check (agent_docs/labs.md). A date-based test would fail on an unrelated PR and get its constant bumped within the hour; the registry being one short file is the actual anti-rot mechanism.

Screenshots or video

No screenshot: with the registry empty, the modal renders only its empty state ("No experiments are available right now"). The user-visible surface in this PR is the HyperDX Labs entry in the nav user menu and that empty modal.

How to test on Vercel preview

Preview routes: /search

Steps:

  1. Click the user menu at the bottom of the left nav (data-testid="user-menu-trigger").
  2. Confirm a HyperDX Labs item appears in the menu, below User Preferences.
  3. Click HyperDX Labs.
  4. Verify a modal titled "HyperDX Labs" opens and shows the text "No experiments are available right now. Check back soon."

Testing

PATCH /me/labs has integration coverage for the storage round-trip, full-replace semantics, per-user scoping, and rejection of ids containing _, ., $, uppercase, or __proto__. The hook has unit coverage for both stores, including the loading window, stale-id pruning, optimistic apply, and rollback-on-failure. The toggle-and-persist path was verified end-to-end against a temporary registry entry, but the committed E2E only covers the empty state, so the first real lab should add that assertion (LabsModalComponent.setLab() exists for it).


Compound Engineering
Claude Code

Shipping a half-finished feature previously meant either holding a
long-lived branch or adding a hardcoded constant to the app's config and
redeploying to change it. Labs replaces that with a per-user toggle so
unfinished work can merge to main default-off and the people who want it
can turn it on themselves.

Opt-ins are stored on the User document and surfaced via GET /me, so they
follow a user across browsers and devices. Adding a lab is a single entry
in packages/app/src/labs/registry.ts plus a useIsLabEnabled call; the
server validates the shape of what it stores but deliberately does not
know the lab id list, which is what keeps it to one file.

The registry ships empty. This is the mechanism, not any experiment.
Drops the IS_LABS_ENABLED deployment gate so there is no flag to flip and no
environment where the menu entry is missing.

Local mode is the reason a gate existed: it has no API server and no user
identity, so there is no account to hang an opt-in on and toggling would have
failed. Opt-ins there go to localStorage under `hdx-labs` instead. The branch
stays inside useLabs.ts, so nothing else knows which store is in play, and
labs remain server-persisted wherever there is an account.

One consequence for lab authors, now documented: a lab whose feature needs the
API server must AND with !IS_LOCAL_MODE, because a local-mode user can now
switch it on.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview Aug 12, 2026 2:35pm
hyperdx-storybook Ready Ready Preview Aug 12, 2026 2:35pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93313df

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/common-utils Minor
@hyperdx/api Minor
@hyperdx/app Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a per-user HyperDX Labs mechanism with account-backed persistence and a localStorage fallback for local mode.

  • Adds validated GET /me and PATCH /me/labs storage contracts.
  • Adds the Labs registry, state hook, modal, and navigation entry.
  • Serializes same-client toggle mutations so full-replacement writes are sent in call order.
  • Adds API integration, hook, registry, and navigation coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported same-client replacement race is addressed by serializing Labs mutations in call order.

Important Files Changed

Filename Overview
packages/app/src/labs/useLabs.ts Implements registry-derived Labs state, optimistic full-replacement persistence, local-mode storage, rollback, and serialized account writes.
packages/api/src/routers/api/me.ts Exposes normalized Labs state through GET /me and adds authenticated, schema-validated replacement writes.
packages/api/src/controllers/user.ts Adds a user-scoped whole-object update for persisted Labs selections.
packages/common-utils/src/types.ts Defines the shared bounded Labs identifier, request, response, and state contracts.
packages/app/src/labs/LabsModal.tsx Adds the user-facing Labs modal and registry-driven toggle controls.
packages/app/src/components/AppNav/AppNav.tsx Integrates the Labs modal into the persistent application navigation.
packages/app/src/labs/tests/useLabs.test.tsx Covers loading, derivation, pruning, optimistic writes, serialization, rollback, and gate behavior.
packages/api/src/routers/api/tests/me.int.test.ts Covers persistence, replacement semantics, validation, serialization shape, authentication, and per-user scoping.

Sequence Diagram

sequenceDiagram
  participant User
  participant Modal as Labs Modal
  participant Hook as useLabs
  participant Cache as React Query /me Cache
  participant API as PATCH /me/labs
  participant Mongo as User Document
  User->>Modal: Toggle experiment
  Modal->>Hook: setLabEnabled(id, value)
  Hook->>Cache: Apply optimistic full replacement
  Hook->>API: Queue serialized PATCH
  API->>Mongo: Replace labs object
  Mongo-->>API: Persisted labs
  API-->>Hook: Updated labs
  Hook->>Cache: Refetch /me after queue settles
Loading

Reviews (2): Last reviewed commit: "fix(app): serialize lab toggles so an ol..." | Re-trigger Greptile

Comment thread packages/app/src/labs/useLabs.ts
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 280 passed • 1 skipped • 1110s

Status Count
✅ Passed 280
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

Two quick toggles each sent an independently computed full replacement, so if
the first request's write landed after the second's, Mongo kept the older
payload and the newer lab silently reverted on the next /me refetch.

The mutation now carries a react-query scope, which sends same-scope mutations
one at a time in call order. Each queued payload is built after the previous
onMutate has written the cache, so serialized sends are cumulative rather than
conflicting. Covered by a regression test that fails without the scope.

Across two tabs or devices it is still last-write-wins, since those are
separate clients with separate queues. Documented rather than fixed.

Also drops the two eslint-disable comments the escape-hatch ratchet flagged:
typing the hdxServer test double as the jest.Mock it actually is removes the
need to assert a bare { json } stub into ky's generic ResponsePromise.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant