Problem
Modules that use RL currently roll their own JavaScript for talking to rl.php. When multiple RL-powered features appear on the same page (e.g. DXPR Builder hero buttons + rl_page_title + 4 rl_menu_link entries), each module fires its own HTTP requests independently, producing ~12 round trips per page view (6 decides + 6 turns) with no awareness of each other.
The rl.php endpoint already supports batching within a single action (action=decide takes parallel experiment_ids/arm_counts, action=turns takes arm_ids CSV), but there is no client-side coordinator, so cross-module batching never happens. There is also a stale action=scores call in modules/rl_example_frontend/js/frontend-ab-testing.js:90 that rl.php does not handle.
Goal
Ship a thin, shared Drupal.rl JS API that every RL consumer module uses as a transport proxy. The core stays mechanism, not policy: modules decide when to record impressions, conversions, and fetch decisions. The shared API handles only how those calls reach rl.php, merging them into as few HTTP requests as possible.
Modeled on Drupal.history in Drupal core, which does exactly this for node-view tracking.
API surface
Drupal.rl.decide(experimentId, armCount) // => Promise<armId>
Drupal.rl.turn(experimentId, armId) // fire-and-forget
Drupal.rl.reward(experimentId, armId) // fire-and-forget
Drupal.rl.flush() // optional manual flush
Internally:
visibilitychange / pagehide listeners flush buffered events via navigator.sendBeacon so rewards survive navigation.
- No IntersectionObserver helpers, no click-handler wiring, no DOM scanning, no
drupalSettings registration convention, no retry/dedupe beyond the batch window. Those concerns stay in the consumer modules.
Batching strategy
One unified queue, one endpoint, two dispatch delays:
- Decide enqueued -> schedule flush at
setTimeout(0). All decide calls land during Drupal.behaviors.attach at DOMContentLoaded, so a next-tick flush catches every module's decides in one POST with no added latency. Waiting 500ms would just lengthen the flash-of-default-content for client-side variants without collecting anything new.
- Turn / reward enqueued -> schedule flush at
setTimeout(500). These trickle in from user interaction (scroll, click, hover), so a 500ms window does real collection work.
- Either rule only schedules a timer if no sooner flush is already pending.
Expected reduction
For the multi-module example page above: ~12 requests -> 2 requests per page view (one decide batch at next tick, one track batch 500ms later), with each consumer module's JS shrinking to a thin caller.
rl.php refactor
Collapse turn / turns / reward / decide into a single action=batch that accepts a JSON body read from php://input:
Request:
{
"decides": [
{"id": "hero_cta", "arms": 2},
{"id": "page_title_123", "arms": 3}
],
"turns": [{"id": "menu_main_5", "arm": "v1"}],
"rewards": [{"id": "hero_cta", "arm": "v0"}]
}
Response:
{
"decisions": {
"hero_cta": {"armId": "v1"},
"page_title_123": {"armId": "v0"}
}
}
Server loop: validate every id against ExperimentRegistry, call ExperimentManager::getThompsonScores() for decides, ExperimentDataStorage::recordTurn() / recordReward() for the rest. One Drupal kernel bootstrap per batch, not per event.
The legacy single-action handlers (turn, turns, reward, decide) can be deleted once all consumer JS is migrated. ping stays.
Drupal core conventions to follow
Pattern borrowed from Drupal.history (core/modules/history/js/history.js) and Drupal.announce (core/misc/announce.js):
- Split libraries in
rl.libraries.yml: one rl/api library containing just the transport (Drupal.rl definition), plus consumer-facing behavior libraries that depend on rl/api.
- Expose
Drupal.rl as a plain object on the namespace. No Drupal.behaviors entry in the core file itself.
- IIFE signature:
(function (Drupal, drupalSettings, once) { ... })(Drupal, drupalSettings, once); (no jQuery).
- Dependencies:
core/drupal, core/drupalSettings, core/drupal.debounce if useful.
- Standard
@file / @namespace JSDoc header.
Consumer module migration
Each existing RL consumer needs its JS replaced with calls into Drupal.rl:
modules/rl_example/js/rl-example-tracking.js
modules/rl_example_frontend/js/frontend-ab-testing.js (also fixes the broken action=scores call at line 90)
modules/rl_menu_link/js/menu-tracking.js
modules/rl_page_title/js/title-tracking.js
Each module's *.libraries.yml entry gains a dependency on rl/api.
Out of scope
- Client-side caching of decisions across page loads (history module uses localStorage for timestamps; we can add later if needed).
- Dedupe of duplicate turns within a session.
- Retries on failed flushes beyond the implicit
pagehide sendBeacon safety net.
- Any change to the PHP service layer (
ExperimentManager, ExperimentDataStorage, ExperimentRegistry).
Acceptance criteria
Drupal.rl API exists with decide / turn / reward / flush and is callable from any RL consumer module.
rl.php accepts action=batch with the JSON payload shape above and returns the decisions map.
- Legacy single-action handlers are removed from
rl.php; ping remains.
- All four consumer modules listed above are migrated off direct
fetch / XHR / sendBeacon calls.
- Broken
action=scores call in rl_example_frontend is fixed.
- Manual e2e verification on a page hosting at least three RL experiments shows exactly two network requests to
rl.php under normal load (one decide batch, one track batch).
Problem
Modules that use RL currently roll their own JavaScript for talking to
rl.php. When multiple RL-powered features appear on the same page (e.g. DXPR Builder hero buttons +rl_page_title+ 4rl_menu_linkentries), each module fires its own HTTP requests independently, producing ~12 round trips per page view (6 decides + 6 turns) with no awareness of each other.The
rl.phpendpoint already supports batching within a single action (action=decidetakes parallelexperiment_ids/arm_counts,action=turnstakesarm_idsCSV), but there is no client-side coordinator, so cross-module batching never happens. There is also a staleaction=scorescall inmodules/rl_example_frontend/js/frontend-ab-testing.js:90thatrl.phpdoes not handle.Goal
Ship a thin, shared
Drupal.rlJS API that every RL consumer module uses as a transport proxy. The core stays mechanism, not policy: modules decide when to record impressions, conversions, and fetch decisions. The shared API handles only how those calls reachrl.php, merging them into as few HTTP requests as possible.Modeled on
Drupal.historyin Drupal core, which does exactly this for node-view tracking.API surface
Internally:
visibilitychange/pagehidelisteners flush buffered events vianavigator.sendBeaconso rewards survive navigation.drupalSettingsregistration convention, no retry/dedupe beyond the batch window. Those concerns stay in the consumer modules.Batching strategy
One unified queue, one endpoint, two dispatch delays:
setTimeout(0). All decide calls land duringDrupal.behaviors.attachat DOMContentLoaded, so a next-tick flush catches every module's decides in one POST with no added latency. Waiting 500ms would just lengthen the flash-of-default-content for client-side variants without collecting anything new.setTimeout(500). These trickle in from user interaction (scroll, click, hover), so a 500ms window does real collection work.Expected reduction
For the multi-module example page above: ~12 requests -> 2 requests per page view (one decide batch at next tick, one track batch 500ms later), with each consumer module's JS shrinking to a thin caller.
rl.php refactor
Collapse
turn/turns/reward/decideinto a singleaction=batchthat accepts a JSON body read fromphp://input:Request:
{ "decides": [ {"id": "hero_cta", "arms": 2}, {"id": "page_title_123", "arms": 3} ], "turns": [{"id": "menu_main_5", "arm": "v1"}], "rewards": [{"id": "hero_cta", "arm": "v0"}] }Response:
{ "decisions": { "hero_cta": {"armId": "v1"}, "page_title_123": {"armId": "v0"} } }Server loop: validate every id against
ExperimentRegistry, callExperimentManager::getThompsonScores()for decides,ExperimentDataStorage::recordTurn()/recordReward()for the rest. One Drupal kernel bootstrap per batch, not per event.The legacy single-action handlers (
turn,turns,reward,decide) can be deleted once all consumer JS is migrated.pingstays.Drupal core conventions to follow
Pattern borrowed from
Drupal.history(core/modules/history/js/history.js) andDrupal.announce(core/misc/announce.js):rl.libraries.yml: onerl/apilibrary containing just the transport (Drupal.rldefinition), plus consumer-facing behavior libraries that depend onrl/api.Drupal.rlas a plain object on the namespace. NoDrupal.behaviorsentry in the core file itself.(function (Drupal, drupalSettings, once) { ... })(Drupal, drupalSettings, once);(no jQuery).core/drupal,core/drupalSettings,core/drupal.debounceif useful.@file/@namespaceJSDoc header.Consumer module migration
Each existing RL consumer needs its JS replaced with calls into
Drupal.rl:modules/rl_example/js/rl-example-tracking.jsmodules/rl_example_frontend/js/frontend-ab-testing.js(also fixes the brokenaction=scorescall at line 90)modules/rl_menu_link/js/menu-tracking.jsmodules/rl_page_title/js/title-tracking.jsEach module's
*.libraries.ymlentry gains a dependency onrl/api.Out of scope
pagehidesendBeacon safety net.ExperimentManager,ExperimentDataStorage,ExperimentRegistry).Acceptance criteria
Drupal.rlAPI exists withdecide/turn/reward/flushand is callable from any RL consumer module.rl.phpacceptsaction=batchwith the JSON payload shape above and returns the decisions map.rl.php;pingremains.fetch/XHR/sendBeaconcalls.action=scorescall inrl_example_frontendis fixed.rl.phpunder normal load (one decide batch, one track batch).