Feat/wb ab testing#5349
Conversation
Add whole-page A/B testing across the API, the Next.js SDK, and the admin data layer. API (@webiny/api-website-builder): - New private CMS models wbyWbExperiment and wbyWbVariant (tenant/locale scoped via the CMS layer like wbyWbPage). - Experiment CRUD + lifecycle: create/update/get/list, start/stop with one-active-per-revision enforcement, and winner graduation that creates a new page revision (N+1) from a variant snapshot via CreateEntryRevisionFrom. - Variant CRUD; a variant begins as a full content snapshot of the baseline revision and never appears in the revision timeline. - PageAfterPublish handler structurally ends an active experiment when a different revision of the page is published. - Public, SDK-facing reads: getPageExperiment(path) and getVariantContent(id), gated by the Website Builder API key (now granted wb.experiment/wb.variant r). - New wb.experiment and wb.variant permission entities. SDK (@webiny/website-builder-sdk): - Deterministic, tenant-isolated bucketing (hash of visitorId + experimentId), geo/device targeting, traffic percentage, and a forced-variant override. - Canonical, provider-agnostic ExposureEvent + AnalyticsProvider seam with a PostHog adapter (no PostHog dependency bundled; client is injected). The seam is GA4-ready — no provider field names reach the data model or render path. - getPageWithExperiment helper: resolves the live revision, buckets server-side, serves control or a variant with no client swap, emits one exposure, and keeps the cache key on (path, bucket) only — never the raw visitor id. - Bucketing unit tests. Admin (@webiny/app-website-builder): - ExperimentsGateway (headless feature) + useExperiments hook covering the full experiment/variant lifecycle, registered in the admin Extension. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the experiments management UI to the Website Builder admin, wired onto the ExperimentsGateway. - ExperimentsPresenter (MobX) loading a page's experiments and their variants, with create / add-variant / start / stop / graduate actions and an even traffic-split rebalance persisted on variant changes. - An "A/B testing" action on each page row opens an experiments dialog (create experiment, manage variants, start/stop, graduate a winner). Creating an experiment is gated to published pages (live-revision-only) and an informational notice warns that publishing a new revision ends a running experiment. - New wb.experiment / wb.variant entities added to the admin permissions schema. - ExperimentsPresentationFeature and ExperimentsConfig registered in the admin Extension. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esting Two bugs surfaced while testing the admin UI: - "No experiment found" when adding a variant / starting an experiment: the admin passed the experiment's base entryId to operations whose server side resolves entries via GetEntryById, which matches only the exact revision id. Use the revision id consistently as the experiment/variant identifier — for the variant→experiment foreign key, the public read's variant lookup, the SDK-facing variantId (so getVariantContent resolves), the traffic-split keys, and the graduation ownership check. - Forms stayed disabled after creating an experiment until the dialog was reopened: the child form components read presenter.vm.busy but were not MobX observers, and the parent dialog observer never reads busy, so flipping busy back to false triggered no re-render. Wrap CreateExperimentForm, AddVariantForm, ExperimentRow, StartControls and VariantRow in observer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the Website Builder editor to a variant's content so editors can make a variant differ from the control. - New route /website-builder/experiments/variant/:id/editor and a VariantEditor that mirrors the page editor (same editor scope + DefaultEditorConfig) but loads the variant's content snapshot and autosaves via updateVariant. - VariantAutoSave debounces document changes into updateVariant (properties, metadata, bindings, elements, extensions) — the same fields page autosave uses. - VariantEditorConfig adds a back button, a "Variant: <name>" title, and the variant autosave to the editor top bar (no page publish controls). - An "Edit" button on each variant row opens the editor (hidden once graduated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he iframe The variant editor reused the page editor, whose preview iframe addresses the document as a page (wb.type=page, wb.id=<id>). For a variant this broke two ways: the in-editor SDK keys its document store by the iframe's wb.id while the canvas keys by properties.id, and the app's preview fetch resolved the id via the page model. - Pin the variant editor document's properties.id to the variant id so it matches wb.id — this aligns the editor's document store with the canvas store so live edits render. Keep metadata.documentType "page" so the in-editor SDK streams the document to the iframe. The Next.js app side (separate repo) falls back to the published page by path when the preview-by-id fetch misses, giving the editing canvas a valid base while the in-editor SDK streams the actual variant document. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the governance hole where a running experiment served draft variants with no approval. Data model is unchanged; only the business logic and serving. - Serve only PUBLISHED experiment/variant content, matched by page (not a frozen revision). Draft edits never serve. - Page publish cascades: the PageAfterPublish handler now publishes the page's running experiment + ready variants. Since only page publish runs the approval workflow, approving + publishing the page is what takes them live. - Public read (getPageExperiment) resolves the published running experiment by pageEntryId and derives participating variants from the traffic split (keyed by variant entryId); getVariantContent serves the published variant revision by entryId. - Instant kill-switch: pause/resume an experiment via a runtime flag (KeyValueStore), no publish/approval — it only ever reverts to the approved control. Serving honours it via an uncached check. - Admin: pause/resume controls + paused state; traffic split keyed by variant entryId. The Next.js app (separate repo) adds the uncached pause check and serves the published variant content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ep 1) Remove the previous experiments/variants admin UI and start the UI fresh, screen by screen. - Add an "Experiments" button (flask icon + chevron) to the page editor top bar via the page editor config. - Clicking it opens a right-side Experiments drawer showing the empty state: an A/B illustration, "No experiments on this page", a description, a "Create experiment" button (wired to a no-op for now), and a "Learn about A/B testing" link. The create-experiment flow is the next screen. API business logic and the Next.js SDK serving are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Anchor the top-bar Experiments action with `before="buttonPublish"` so it sits immediately to the left of the Publish button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Experiments drawer now navigates from the empty state to a "New experiment" form (back arrow returns to the overview): - Experiment name; experiment key auto-generated from the name (slugified) and editable, with the analytics-key info note. - Variants & traffic split: Control (current page) + Variant B by default, with per-variant sliders that auto-balance to 100%, a live Total%, and Add variant / remove controls (variants labelled B, C, D…). - Cancel + Create experiment footer. Create currently closes the drawer; persisting to the API is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the New experiment form, variant names are now editable inputs (Control stays fixed), and every row (Control + variants) has an optional description field. The create payload carries each variant's name + description and the control description. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each control/variant row now shows labelled fields — Name, Key, Description, and a labelled Traffic slider — for a cleaner layout. Every variant carries its own key, auto-generated from its name (slugified) and editable; Control's name is fixed while its key/description remain editable. The create payload includes each variant's key and the control key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once experiments exist, the top-bar button becomes a switcher: - Orange flask button showing the current experiment name + chevron. - Dropdown with a "Now viewing" section (Original page — no experiment applied), an "Experiments" section listing each experiment with a status dot + Active/ Inactive badge and a check on the current selection, and a "Manage experiments…" action. Experiments are held in local editor state for now (creating one in the drawer adds it and flips the button to the switcher). Applying a variant to the canvas preview is intentionally out of scope for this step; persistence/real data comes later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hook the experiments UI up to the API: - Re-add the admin ExperimentsGateway (list/get/create/update experiment, list/create/update/delete variant) over the Website Builder GraphQL, plus a useExperiments hook that orchestrates create (experiment → variants with copied baseline content, marked ready → traffic split written with the real variant ids; experiment key stored in the analytics config). - The top-bar button loads the page's experiments from the API on mount and becomes the switcher when any exist. - "Manage experiments…" opens the drawer's list view (experiment name, status, variant count) with New experiment and per-row Edit. - Edit reuses the experiment form (prefilled from the persisted experiment + its variants) to update name, key, traffic split and variant names; variant add/remove is locked in edit for now. Variant key/description aren't persisted yet (no model fields) — on edit the key is re-derived from the name and description starts empty. pageEntryId / baselineRevisionId come from the edited document. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a toolbar between the address bar and the canvas that appears once an experiment is selected in the top-bar switcher: - Shows the experiment name, its status (Active/Inactive), and the bucket currently being edited (Control or a variant, with its traffic weight). - "Switch variant" opens a menu listing the control and every variant with weights and a tick on the current one, plus an "Edit experiment" shortcut that opens the drawer straight into edit mode. Selection is shared between the top-bar switcher and this toolbar through a new editor-wide ExperimentsEditorProvider (installed via an Ui.Layout decorator), which now owns experiment loading, the current experiment/variant selection and the experiments drawer. ExperimentsButton and the drawer consume it; the drawer gained an initialEdit prop for the toolbar's direct-edit path. Switching a variant updates the toolbar's indicator; driving the canvas to load the selected variant's content is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the drawer's experiment list as cards and wire the lifecycle actions. Admin: - Each experiment renders as a card: name, status, a variant preview per bucket (control + variants) with traffic weights, a proportional traffic bar, a legend, and Activate/Deactivate, Edit and Delete actions. - A header explains that only one experiment can run at a time, with a New button. Activating an experiment stops the page's currently running one first; deactivating stops it; deleting removes the experiment and its variants (with a confirmation dialog). Actions report through snackbars and reload the list, so the top-bar switcher and in-preview toolbar stay in sync. - ExperimentsGateway gains startExperiment/stopExperiment/deleteExperiment. API: - Add a DeleteExperiment feature (use case + repository) and a deleteExperiment(id) mutation. - Allow StartExperiment to start a "stopped" experiment (not just "draft") so it can be re-activated after being deactivated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bels The New, Activate and Delete buttons on the experiment cards used raw SVGs that defaulted to black; set fill to currentColor so each icon inherits its button's text colour (white on New/Activate, red on Delete). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a variant in the in-preview toolbar now loads that variant into the editor so it can be viewed and edited like the page; selecting Control returns to the page. - Lift ExperimentsEditorProvider above the DocumentEditor (in PageEditor) so the selected bucket can drive which document the editor mounts. The provider now takes the page revision id as a prop and also exposes the selected variant. - PageEditorSurface mounts the editor for the selected bucket: the page (control) with the usual page config, or a variant mapped onto an equivalent editor document (id / properties.id = variant revision, presented as a "page" on the page's path, so the site's editing SDK streams and renders it live) with a VariantPageEditorConfig that swaps page chrome for a variant autosave. Content is (re)fetched on each switch so a bucket always shows its latest saved state. - VariantAutoSave persists edits back to the variant via updateVariant; gateway gains getVariant (full content) and updateVariant accepts content fields. No API or Next.js site changes: the editing canvas already renders whatever the editor streams, and getVariant/updateVariant already exist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing a variant streamed wb.id = the variant revision id, so the site's preview resolved the server-side base via getPageById(variantId) — which fails, since a variant isn't a page (and the published-by-path fallback fails when the page is only a draft), leaving the canvas blank. The variant's editor document now keeps the page's identity (id / properties.id / path) so the preview base resolves to the page draft exactly like the control does, while its content (elements, bindings, properties) is the variant's. The editor streams that content over the base on connect, and autosave still targets the actual variant via context. Control and variant therefore share wb.id; the switch is driven by re-mounting the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The address-bar "preview in a new tab" / copy-link buttons previewed the page even while editing a variant, because a variant's editor document borrows the page's identity (id/path) so the preview base resolves. The variant document now carries an editor-only marker (metadata wbVariantPreviewId, stripped before persisting) with the variant's revision id. usePreviewLink detects it and builds a link to the page path with `?wb-variant-draft=<revisionId>` instead of the page preview params, so the new tab renders the variant's current draft content (the site fetches it by revision id via getVariant, which the public API key can read). The Next.js site side (resolveVariantDraftDocument + a page branch for wb-variant-draft) lives in the demo app repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…permission Experiments and variants are content that belongs to a page, so they no longer have their own permission entities — every experiment/variant read, write, publish and delete now checks the corresponding wb.page action. This means the read-only frontend API key (which already has wb.page:r / $wb.readonly) can serve and preview draft variants, exactly as it already reads draft pages; no separate Experiments/Variants toggles and no extra grants on the key. - Route all experiment/variant use-case permission checks to "page". - Drop the experiment/variant entities from the API and admin permission schemas. - Drop the now-obsolete wb.experiment/wb.variant grants from the API key installer. Fixes the variant "preview in a new tab" 404: getVariant was failing authorization (masked as "not found") because the key had no variant read permission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leware
Move the A/B serving algorithm fully behind the SDK so apps don't reimplement
bucketing, overlay, caching and the kill-switch.
- SDK: getPageWithExperiment already lived here; add the missing kill-switch —
IDataProvider/ContentSdk gain getExperimentPaused (uncached via a new no-store
path on ApiClient), and getPageWithExperiment now reverts to the control for a
non-running or paused experiment. It also accepts `searchParams` and derives
the forced-variant id itself.
- react: re-export the experiment surface (getPageWithExperiment,
resolveVisitorContext, assignVariant, forcedAssignment, analytics-provider
registration, the constants and all experiment types) so it's reachable from
@webiny/website-builder-nextjs.
- nextjs: ship a middleware helper (createWebsiteBuilderMiddleware + default
middleware/config) handling preview/draft mode and the stable A/B visitor
cookie, exported at @webiny/website-builder-nextjs/middleware.
End-developer DX becomes: `contentSdk.getPageWithExperiment(path, { searchParams })`
in the page, and a one-line middleware re-export.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…colours - Right-align the experiment form's Cancel / Save buttons (were left/stretched). - Introduce a shared variant colour palette (variantColors.ts) and use it in the form, the list cards, and the in-preview toolbar so each variant shows the same colour everywhere. The toolbar's "Switch variant" list and current-bucket pill no longer render every variant orange — they follow the assigned colour by index. - Make the switcher's flask/chevron icons inherit the button's colour (currentColor) so they're orange with the label instead of black. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utton Only the switcher (shown once experiments exist) has a chevron; with no experiments the button is a plain "Experiments" action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a published (read-only) page the top bar now shows the running experiment as an indicator — flask + name + Active/Inactive — with a Pause/Resume button that toggles the kill-switch (reverts serving to control instantly, no republish). The in-preview toolbar drops the experiment identity (now in the top bar) and reads "Previewing" instead of "You're editing", with no "Edit experiment" entry. - Admin gateway gains pauseExperiment/resumeExperiment/getExperimentPaused (keyed on the experiment entryId, matching the serving/getPageExperiment id). - The editor context tracks the selected experiment's paused state and exposes pause/resume; the read-only top-bar auto-selects the running experiment so the toolbar tracks it. - Draft pages keep the switcher (before Publish); the read-only indicator is registered before the version menu. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pavel910
left a comment
There was a problem hiding this comment.
I've reviewed the api-* part of the PR. In general the only important thing to change is that we never return null from use cases. It's a general rule - use cases either return a record, or throw an error. The errors are nicely typed, and can be easily used for flow control. Adding more edge cases is easy, you simply add more error types.
…ases Apply the review feedback on the api-* layer: - Use cases and repositories never return `null`. GetActiveExperimentForPath and GetActiveExperimentForRevision now fail with typed errors: NoActiveExperimentError when there's no running experiment, and ExperimentPausedError when the kill-switch is on. The getPageExperiment/getActiveExperiment resolvers map those (and page not-found) back to a null response so the SDK still reads "serve the control", and StartExperiment treats NoActiveExperiment as free-to-start. - Rename the repository's single public method to `execute`. - Split ExperimentPause into one file per use case (Pause/Resume/IsPaused). - Register a single top-level ExperimentFeature and VariantFeature (in features/experiments/feature.ts and features/variants/feature.ts) instead of wiring each sub-feature individually in index.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
(c/p from my Claude Code :) )
One thing to flag: the serving query getPageExperiment legitimately has a "no experiment on this page" outcome. To keep that from being an error the SDK logs on every plain page, the resolver maps NoActiveExperiment/Paused to a null data response (still typed failures inside the use case, just translated at the GraphQL boundary). Happy to adjust if you'd prefer a different convention there. |
brunozoric
left a comment
There was a problem hiding this comment.
I wrote few comments, but these stand out:
- Most abstractions.ts have same problem - more than one abstraction defined.
- Too much useEffect in your components. Move to presenters.
- Drop the WEBINY_API_LEGACY_MODELS conditional from the experiment/variant models; these are new models, so use "wbyWbExperiment" / "wbyWbVariant" directly. - Use the repo's slugify library for experiment/variant keys instead of a local helper (in NewExperimentForm and ExperimentsDrawer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Bruno's convention, split every experiment/variant feature's abstractions.ts into an abstractions/ folder with one abstraction per file (feature/Something/abstractions/Something.ts), matching api-opensearch. Shared, non-abstraction exports (e.g. experimentPauseKey) get their own file; each feature's index.ts stays the public barrel, re-exporting from the new files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…view) Rewrite the editor-embedded experiments UI to the webhooks presentation pattern (MobX Presenter + ViewModel + DataSource + feature + observer views), removing the React context and the useEffect-based data loading Bruno flagged. - ExperimentsEditor: the shared hub presenter (experiments, selection, variant options, kill-switch state) + its data source; the top-bar switcher/indicator and the in-preview toolbar are observer views of it. Replaces ExperimentsEditorContext + useExperiments. - ExperimentsManager: drawer/list presenter owning drawer view state and the per-experiment variant summaries for the cards (loaded via a reaction, so the card no longer fetches in a useEffect); lifecycle actions delegate to the hub. - ExperimentForm: create/edit form presenter + observer view. - shared/ (variantColors, variantDocument) and config/ (ExperimentsEditorConfig, VariantAutoSave). PageEditor inits the hub presenter; PageEditorSurface reads the selected variant from it. Features registered in Extension.tsx. Editor selectors (isReadOnly) and the autosave subscription remain thin effects, mirroring the framework's own PageAutoSave. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…conventions Follow-ups from checking the rewrite against webiny-admin-architect: - Drop split callback props: ExperimentCard now takes the manager presenter and drives activate/deactivate/edit/delete directly, owning its own snackbar + delete confirmation (like WebhookListContent). ExperimentsListView takes only the presenter; the drawer no longer threads onActivate/onDelete/etc. - Move view derivation into the hub ViewModel: variant options now carry their colour, and the vm exposes currentBucket, runningExperiment and switcherItems, so the toolbar and switcher read display-ready data instead of computing it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @brunozoric — all addressed. Rundown: Models & keys — 783ace2
One abstraction per file — d547cbc
Presenters (webhooks pattern) — ff6d7ed, refined in cf58418
One intentional deviation — presenter scoping. The ExperimentsManager (drawer) and ExperimentForm presenters use the standard child-container + DiContainerProvider scoping. The ExperimentsEditor hub presenter, though, is an app-wide singleton (registered in Extension.tsx). Unlike a webhooks page — where one WebhookListView hosts the whole feature under a single provider — the experiments UI mounts in disjoint page-editor slots with no common React ancestor: the switcher/indicator in a top-bar Ui.TopBar.Action, the toolbar in a Ui.Content.Element, and the surface swap in PageEditor. They all have to observe the same selection/paused state, so a shared singleton is the only way to bridge them. It's initialised per page via presenter.init(page.id), and only one editor is open at a time, so it's effectively page-scoped. Happy to wrap the whole editor in an editor-level DiContainerProvider instead if you'd rather keep it uniform. |
Resolve PageEditor.tsx conflict: adopt release's GetSettingsFeature-based settings loading; keep the experiments PageEditorSurface/presenter/drawer render (release's inline DocumentEditor + Default*Config already live in PageEditorSurface). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds end-to-end A/B testing to the Website Builder: editors define an experiment on a page, create full-page variants, set a traffic split, activate/deactivate, and edit each variant on the normal editor canvas. Variants are served server-side by the SDK with deterministic bucketing, a stable split, an instant kill-switch, and a provider seam for an exposure event (PostHog wiring is a follow-up). Built into core (not an extension).
Data model — two private Headless CMS models (never in the page's revision timeline):
Approval / governance — variants never serve unpublished. Serving reads published experiment/variant content by entryId; a PageAfterPublish handler cascades a publish to the page's running experiment + ready variants, so the page's existing approval workflow is the single gate.
How a variant is chosen (per request, in the SDK) — getPageWithExperiment(path):
Persistence & stickiness — no assignment is stored. Only a wb_ab_vid cookie (random UUID, 1-year, no PII, set in middleware) is persisted; the same visitor id always hashes to the same bucket, so assignment is sticky and stateless.
Split correctness — cyrb53 is uniform across visitor ids, so weight configured split; inclusion and bucket selection use independenthash seeds.
Caching — reads cache and key only on path / variant id, never on visitor id, so the CDN cache stays shareable; only the kill-switch read is no-store.
Permissions — experiments/variants have no separate permission entities; every read/write/publish/delete checks the corresponding wb.page action. The read-only frontend key (wb.page:r / $wb.readonly) can serve and preview them
---
2. SDK changes (website-builder-sdk / -react / -nextjs)
▎ These packages are unpublished on the branch, so link/build them n link the four @webiny/website-builder-* packages, or point the appat the built dist). The demo app in this workspace is already refactored to the shape below.
middleware.ts
export { middleware, config } from "@webiny/website-builder-nextjs/
app/[[...slug]]/page.tsx — the whole A/B path is one call:
import { contentSdk } from "@webiny/website-builder-nextjs";
import { initializeContentSdk, getTenant } from "@/src/contentSdk";
const { isEnabled: isPreview } = await draftMode();
initializeContentSdk({ preview: isPreview, tenantId: await getTenant() });
const { page } = await contentSdk.getPageWithExperiment(path, { searchParams: search });
return ;
Verify:
Known limitations / follow-ups