diff --git a/README.md b/README.md
index 8fbce54..2843545 100644
--- a/README.md
+++ b/README.md
@@ -213,27 +213,198 @@ $cache_manager = \Drupal::service('rl.cache_manager');
$cache_manager->overridePageCacheIfShorter(60); // 60 seconds
```
-## HTTP Endpoints
+## Deciding which variant to show
-### rl.php - Direct Endpoint (Recommended)
-**Use the direct rl.php endpoint for optimal performance:**
+### Server-side (preferred)
+
+Pick the winning variant in PHP at render time whenever you can. Your
+consumer already has the full arm set in hand (entity fields, view
+filters, plugin config), so it can call the RL service directly:
+
+```php
+$scores = $experiment_manager->getThompsonScores(
+ 'my_experiment',
+ NULL,
+ ['v0', 'v1', 'v2'] // arm ids owned by your domain
+);
+arsort($scores);
+$best_arm = key($scores);
+```
+
+Deciding server-side keeps the arm list where it belongs (with the
+experiment owner) and avoids a network round trip on every page load.
+See `ai_sorting`'s Views sort plugin for the canonical pattern and
+`VariantSelectorBase` in this module for a reusable base class.
+
+### Client-side (cache-friendly path)
+
+Some consumers have to decide in JS: full-page-cached builders that
+render all variants into the HTML and swap them on the client so they
+can keep Varnish/Fastly caching. For that case there is
+`Drupal.rl.decide()`, documented below.
+
+## JavaScript API (`Drupal.rl`)
+
+Attach the `rl/api` library to make `Drupal.rl` available. It is a thin
+transport proxy that coalesces every decide, impression, and conversion
+fired on the page into a single batched POST to `rl.php`, so N
+experiments on the same page produce one or two requests instead of one
+set per experiment.
```javascript
-// Record turns (trials) - when content is viewed
-const formData = new FormData();
-formData.append('action', 'turns');
-formData.append('experiment_id', 'abc123');
-formData.append('arm_ids', '1,2,3');
-navigator.sendBeacon('/modules/contrib/rl/rl.php', formData);
-
-// Record reward (success) - when user clicks/converts
-const rewardData = new FormData();
-rewardData.append('action', 'rewards');
-rewardData.append('experiment_id', 'abc123');
-rewardData.append('arm_id', '1');
-navigator.sendBeacon('/modules/contrib/rl/rl.php', rewardData);
+// Record an impression when the variant becomes visible.
+Drupal.rl.turn('hero_cta', 'v0');
+
+// Record a conversion when the user clicks / submits / converts.
+Drupal.rl.reward('hero_cta', 'v0');
+
+// Ask for a decision when the variant needs to be chosen client-side.
+// The arm list MUST be read from the DOM - see discipline below.
+var container = document.querySelector('[data-rl-experiment="hero_cta"]');
+var armIds = container.dataset.rlArms.split(',');
+Drupal.rl.decide('hero_cta', armIds).then(function (armId) {
+ showVariant(armId);
+});
+
+// Optional: force an immediate flush.
+Drupal.rl.flush();
+```
+
+Events accumulate for 500 ms and then flush in one POST. Decide,
+turn, and reward events share the same queue and the same request, so
+a page with a DXPR Builder variant block plus tracking on other
+elements ends up making a single round trip. Buffered tracking events
+are also flushed via `navigator.sendBeacon` on `visibilitychange` and
+`pagehide` so they survive navigation.
+
+### Discipline for `decide()`
+
+> **Never hardcode arm ids in JS. Always read them from a DOM
+> attribute that the server-side renderer emitted.**
+
+Rationale: the DOM is downstream of the same server-render pipeline
+that produced the decide's context. When the experiment manager adds
+or removes a variant, the consumer's page cache is invalidated, the
+next render emits the new attribute, and JS picks it up. JS never
+asserts what the arm set is - it just echoes whatever the current
+cached HTML says, mirroring ai_sorting's PHP pattern of recomputing
+`$arm_ids` from a fresh view query on every render. This keeps
+`Drupal.rl.decide()` drift-free without requiring the rl core to
+store arm lists.
+
+The convention your builder uses internally (numeric `v0..vN`, UUIDs,
+node ids, anything matching `^[a-zA-Z0-9_-]+$`) is whatever you emit
+into the attribute. The rl core is arm-agnostic.
+
+If the server returns no decision for an experiment (not registered,
+no data, network error), the returned promise resolves to `armIds[0]`
+so callers never need a `.catch()` for the common path.
+
+`Drupal.rl` is one transport among several. Modules that already ship
+their own tracking JS (like `ai_sorting`, which batches turns on its
+own 100 ms window and posts them as form data) keep working untouched.
+
+## HTTP API (`rl.php`)
+
+`rl.php` is the low-level endpoint. It is reachable directly from any
+client that can make an HTTP POST - browser pages, native mobile apps,
+server-side workers, other CMSes, edge functions. Deciding is *not*
+exposed here: it happens in PHP at render time as shown above.
+
+Four actions are supported. All are additive - adding `batch` did not
+deprecate the legacy form actions, and `ai_sorting` and other production
+consumers keep using them unchanged.
+
+| Action | Encoding | Purpose |
+| --- | --- | --- |
+| `ping` | form POST | Liveness check. Returns `pong`. |
+| `turn` | form POST | Record one impression. |
+| `turns` | form POST | Record impressions for many arms in one experiment. |
+| `reward` | form POST | Record one conversion. |
+| `batch` | JSON POST | Record turns and rewards across many experiments in one request. Used by `Drupal.rl`. |
+
+Experiment IDs and arm IDs must match `^[a-zA-Z0-9_-]+$`. Experiments
+must already be registered via `ExperimentRegistryInterface::register()`;
+unknown IDs are silently dropped so garbage writes cannot create
+registry entries.
+
+### Legacy form actions
+
+```bash
+# Turn
+curl -X POST https://example.com/modules/contrib/rl/rl.php \
+ -d 'action=turn&experiment_id=hero_cta&arm_id=v0'
+
+# Multiple arms in one experiment
+curl -X POST https://example.com/modules/contrib/rl/rl.php \
+ -d 'action=turns&experiment_id=hero_cta&arm_ids=v0,v1'
+
+# Reward
+curl -X POST https://example.com/modules/contrib/rl/rl.php \
+ -d 'action=reward&experiment_id=hero_cta&arm_id=v0'
+```
+
+### `action=batch`
+
+```
+POST /modules/contrib/rl/rl.php?action=batch
+Content-Type: application/json
+
+{
+ "decides": [
+ {"id": "hero_cta", "arms": ["v0", "v1", "v2"]}
+ ],
+ "turns": [
+ {"id": "hero_cta", "arm": "v0"},
+ {"id": "menu_main_5", "arm": "v1"}
+ ],
+ "rewards": [
+ {"id": "hero_cta", "arm": "v0"}
+ ]
+}
+```
+
+All three sections are optional. Invalid or unregistered entries are
+dropped silently so one bad event does not poison the rest of the
+batch. The response is
+
+```json
+{"ok":true,"decisions":{"hero_cta":{"armId":"v1"}}}
+```
+
+`decisions` contains only entries that had a successful Thompson
+Sampling lookup. Missing keys mean "use the default variant". Turns
+and rewards are fire-and-forget writes with no per-event response.
+
+### Curl examples
+
+```bash
+# Batch tracking from a server-side job.
+curl -X POST 'https://example.com/modules/contrib/rl/rl.php?action=batch' \
+ -H 'Content-Type: application/json' \
+ -d '{"turns":[{"id":"hero_cta","arm":"v1"}],"rewards":[{"id":"hero_cta","arm":"v1"}]}'
+
+# Liveness probe.
+curl -X POST 'https://example.com/modules/contrib/rl/rl.php' -d 'action=ping'
+# => pong
```
+### Error responses
+
+| Status | When |
+| --- | --- |
+| `400` | Missing/invalid `action`, malformed JSON, or missing `experiment_id` on a legacy action. |
+| `500` | Drupal kernel failed to boot. Error logged to the PHP error log. |
+
+### Performance notes
+
+`rl.php` bootstraps a minimal Drupal kernel per request (same pattern as
+core's `statistics.php`), not the full stack that would run behind a
+normal route. One kernel boot processes the whole batch, so the cheapest
+way to use this endpoint is to send as many events as possible in one
+request. `Drupal.rl` already does this on the browser side; non-browser
+callers should coalesce events similarly when they can.
+
## Cache Management
RL provides optional cache management for web components:
diff --git a/docs/rl_project_desc.html b/docs/rl_project_desc.html
index 39b6804..e3f8714 100644
--- a/docs/rl_project_desc.html
+++ b/docs/rl_project_desc.html
@@ -62,7 +62,7 @@
Installation
Verify rl.php Access
The RL module includes a .htaccess file that allows direct access to rl.php (following the same pattern as Drupal 11's contrib statistics module). Test that it's working:
-curl -X POST -d "action=turns&experiment_id=test&arm_ids=1" http://example.com/modules/contrib/rl/rl.php
+curl -X POST -d "action=ping" http://example.com/modules/contrib/rl/rl.php
If the test fails:
@@ -147,24 +147,18 @@ API
$cache_manager->overridePageCacheIfShorter(60); // 60 seconds
-HTTP Endpoints
+JavaScript API
-rl.php - Direct Endpoint (Recommended)
-Use the direct rl.php endpoint for optimal performance:
+Attach the rl/api library to get Drupal.rl on the page:
-
// Record turns (trials) - when content is viewed
-const formData = new FormData();
-formData.append('action', 'turns');
-formData.append('experiment_uuid', 'abc123');
-formData.append('arm_ids', '1,2,3');
-navigator.sendBeacon('/modules/contrib/rl/rl.php', formData);
+Drupal.rl.turn('hero_cta', 'v0');
+Drupal.rl.reward('hero_cta', 'v0');
-// Record reward (success) - when user clicks/converts
-const rewardData = new FormData();
-rewardData.append('action', 'rewards');
-rewardData.append('experiment_uuid', 'abc123');
-rewardData.append('arm_id', '1');
-navigator.sendBeacon('/modules/contrib/rl/rl.php', rewardData);
+Drupal.rl.decide('hero_cta', ['v0', 'v1', 'v2']).then(function (armId) {
+ showVariant(armId);
+});
+
+All three methods feed a shared 500 ms batch window, so every experiment on the page rides one POST to rl.php. See the README for the HTTP wire format and server-side integration patterns.
Cache Management
diff --git a/js/rl.js b/js/rl.js
new file mode 100644
index 0000000..393043e
--- /dev/null
+++ b/js/rl.js
@@ -0,0 +1,291 @@
+/**
+ * @file
+ * Thin RL transport proxy with request batching.
+ *
+ * Exposes Drupal.rl.decide(), Drupal.rl.turn(), Drupal.rl.reward(), and
+ * Drupal.rl.flush(). Batches calls into a single POST to rl.php so that
+ * multiple RL-powered features on the same page share one request
+ * instead of each making its own.
+ *
+ * Batching strategy: events accumulate for 500 ms and then flush in one
+ * POST that can carry decides, turns, and rewards simultaneously.
+ * visibilitychange / pagehide flush tracking writes immediately via
+ * navigator.sendBeacon so buffered events survive navigation.
+ *
+ * About decide(): client-side decide exists to serve consumers that
+ * render variants in JS (DXPR Builder's runtime, etc.) where shifting
+ * the decision to PHP would burn full-page cache. Consumers that can
+ * decide server-side (ai_sorting, rl_page_title, rl_menu_link,
+ * rl_example, rl_example_frontend) should keep doing so; decide() is
+ * for the client-rendered path only.
+ *
+ * Important discipline for callers:
+ * Read the arm id list from the DOM at call time. Do not hardcode
+ * arm ids in JS. The server-side renderer that produced the page's
+ * cached HTML should emit the arm list as a data attribute on the
+ * variant container; decide() just echoes that list back to the
+ * server so Thompson Sampling can seed cold-start arms. This mirrors
+ * ai_sorting's PHP pattern of recomputing the arm list from the
+ * current view query on every render.
+ *
+ * Modelled on Drupal.history in Drupal core, which batches node-view
+ * tracking the same way.
+ *
+ * @namespace
+ */
+
+(function (Drupal, drupalSettings) {
+
+ 'use strict';
+
+ var queue = emptyQueue();
+ var timer = null;
+
+ function emptyQueue() {
+ return {
+ // experimentId -> { arms: [...], resolvers: [fn] }
+ decides: Object.create(null),
+ turns: [],
+ rewards: [],
+ };
+ }
+
+ function endpoint() {
+ if (drupalSettings.rl && drupalSettings.rl.endpointUrl) {
+ return drupalSettings.rl.endpointUrl;
+ }
+ return null;
+ }
+
+ function hasPending() {
+ for (var id in queue.decides) {
+ if (Object.prototype.hasOwnProperty.call(queue.decides, id)) {
+ return true;
+ }
+ }
+ return queue.turns.length > 0 || queue.rewards.length > 0;
+ }
+
+ function schedule() {
+ if (timer !== null) {
+ return;
+ }
+ timer = setTimeout(function () {
+ timer = null;
+ flush();
+ }, 500);
+ }
+
+ function takeQueue() {
+ var snapshot = queue;
+ queue = emptyQueue();
+ return snapshot;
+ }
+
+ function batchUrl(url) {
+ return url + (url.indexOf('?') === -1 ? '?' : '&') + 'action=batch';
+ }
+
+ function buildPayload(snapshot) {
+ var decides = [];
+ for (var id in snapshot.decides) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.decides, id)) {
+ decides.push({ id: id, arms: snapshot.decides[id].arms });
+ }
+ }
+ return {
+ decides: decides,
+ turns: snapshot.turns,
+ rewards: snapshot.rewards,
+ };
+ }
+
+ function resolveDecides(snapshot, decisions) {
+ for (var id in snapshot.decides) {
+ if (!Object.prototype.hasOwnProperty.call(snapshot.decides, id)) {
+ continue;
+ }
+ var entry = snapshot.decides[id];
+ var armId = null;
+ if (decisions && decisions[id] && decisions[id].armId) {
+ armId = decisions[id].armId;
+ }
+ // Fallback: first arm in the caller-provided list. This keeps the
+ // promise contract "always resolves to a usable arm" so consumers
+ // do not need a .catch() for the common failure modes (unknown
+ // experiment, empty data, network error).
+ if (!armId) {
+ armId = entry.arms[0];
+ }
+ entry.resolvers.forEach(function (resolve) {
+ resolve(armId);
+ });
+ }
+ }
+
+ function fallbackDecides(snapshot) {
+ for (var id in snapshot.decides) {
+ if (!Object.prototype.hasOwnProperty.call(snapshot.decides, id)) {
+ continue;
+ }
+ var entry = snapshot.decides[id];
+ var fallback = entry.arms[0];
+ entry.resolvers.forEach(function (resolve) {
+ resolve(fallback);
+ });
+ }
+ }
+
+ function flush() {
+ if (!hasPending()) {
+ return;
+ }
+ var url = endpoint();
+ var snapshot = takeQueue();
+ if (!url) {
+ fallbackDecides(snapshot);
+ return;
+ }
+ var body = JSON.stringify(buildPayload(snapshot));
+
+ fetch(batchUrl(url), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: body,
+ credentials: 'same-origin',
+ keepalive: true,
+ }).then(function (response) {
+ if (!response.ok) {
+ fallbackDecides(snapshot);
+ return null;
+ }
+ return response.json();
+ }).then(function (json) {
+ if (json) {
+ resolveDecides(snapshot, json.decisions || {});
+ }
+ }).catch(function () {
+ fallbackDecides(snapshot);
+ });
+ }
+
+ function flushBeacon() {
+ if (!hasPending()) {
+ return;
+ }
+ var url = endpoint();
+ if (!url) {
+ return;
+ }
+ var snapshot = takeQueue();
+
+ // sendBeacon is fire-and-forget: we cannot read the response, so
+ // pending decides cannot be fulfilled from this path. Resolve them
+ // with the default fallback. The page is navigating away anyway.
+ fallbackDecides(snapshot);
+
+ if (snapshot.turns.length === 0 && snapshot.rewards.length === 0) {
+ return;
+ }
+ var body = JSON.stringify({
+ decides: [],
+ turns: snapshot.turns,
+ rewards: snapshot.rewards,
+ });
+
+ if ('sendBeacon' in navigator) {
+ var blob = new Blob([body], { type: 'application/json' });
+ navigator.sendBeacon(batchUrl(url), blob);
+ }
+ }
+
+ Drupal.rl = {
+
+ /**
+ * Request a Thompson Sampling decision for an experiment.
+ *
+ * The armIds list must be read from the DOM (data attribute emitted
+ * by the server-side renderer that produced the cached HTML) at
+ * call time, never hardcoded. This mirrors ai_sorting's PHP pattern
+ * of recomputing the arm list from the current view query on every
+ * render, keeping JS and the cached HTML downstream of the same
+ * source of truth.
+ *
+ * @param {string} experimentId
+ * The pre-registered experiment id.
+ * @param {Array} armIds
+ * The arm ids in play. Minimum 2. Must match ^[a-zA-Z0-9_-]+$
+ * server-side (UUIDs without braces satisfy this). The winning
+ * arm id is returned unchanged so the caller can map it back to
+ * its own variant table.
+ *
+ * @return {Promise}
+ * Resolves to the winning arm id. Falls back to armIds[0] on any
+ * server failure (unknown experiment, no data, network error) so
+ * callers do not need a .catch() for the common path.
+ */
+ decide: function (experimentId, armIds) {
+ if (!Array.isArray(armIds) || armIds.length < 2) {
+ return Promise.reject(new Error('Drupal.rl.decide requires an array of at least 2 arm ids'));
+ }
+ return new Promise(function (resolve) {
+ var entry = queue.decides[experimentId];
+ if (!entry) {
+ entry = queue.decides[experimentId] = {
+ arms: armIds.slice(),
+ resolvers: [],
+ };
+ }
+ entry.resolvers.push(resolve);
+ schedule();
+ });
+ },
+
+ /**
+ * Record an impression for a variant.
+ *
+ * @param {string} experimentId
+ * @param {string} armId
+ */
+ turn: function (experimentId, armId) {
+ queue.turns.push({ id: experimentId, arm: armId });
+ schedule();
+ },
+
+ /**
+ * Record a conversion for a variant.
+ *
+ * @param {string} experimentId
+ * @param {string} armId
+ */
+ reward: function (experimentId, armId) {
+ queue.rewards.push({ id: experimentId, arm: armId });
+ schedule();
+ },
+
+ /**
+ * Force an immediate flush of the buffered queue.
+ */
+ flush: function () {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ flush();
+ },
+
+ };
+
+ // Flush buffered tracking events on navigation so turns and rewards
+ // are not lost. visibilitychange covers tab switches and mobile
+ // navigation; pagehide covers desktop back/forward cache restoration.
+ // Pending decides are resolved with the fallback arm - the page is
+ // going away so the answer no longer matters.
+ document.addEventListener('visibilitychange', function () {
+ if (document.visibilityState === 'hidden') {
+ flushBeacon();
+ }
+ });
+ window.addEventListener('pagehide', flushBeacon);
+
+})(Drupal, drupalSettings);
diff --git a/modules/rl_example/js/rl-example-tracking.js b/modules/rl_example/js/rl-example-tracking.js
index d0a1ff9..5809d7a 100644
--- a/modules/rl_example/js/rl-example-tracking.js
+++ b/modules/rl_example/js/rl-example-tracking.js
@@ -1,63 +1,35 @@
+/**
+ * @file
+ * Records a turn for the newsletter form when it enters the viewport.
+ *
+ * Critical for forms in footers that are not immediately visible. The
+ * reward is recorded server-side in the AJAX submit callback; this file
+ * only needs to register the impression.
+ */
+
(function (Drupal, drupalSettings, once) {
+
'use strict';
- /**
- * Tracks when the newsletter form becomes visible in the viewport.
- * This is critical for forms in footers that aren't immediately visible.
- */
Drupal.behaviors.rlExampleViewportTracking = {
- attach: function (context, settings) {
- // Only proceed if we have the necessary settings
- if (!settings.rlExample || !settings.rlExample.tracking) {
+ attach: function (context) {
+ var tracking = drupalSettings.rlExample && drupalSettings.rlExample.tracking;
+ if (!tracking) {
return;
}
- const trackingData = settings.rlExample.tracking;
-
- // Use once() to ensure we only process each form once
- once('rl-example-viewport', '.rl-example-newsletter-form', context).forEach(function(form) {
- // Create an IntersectionObserver to detect when form enters viewport
- const observer = new IntersectionObserver(function(entries) {
- entries.forEach(function(entry) {
+ once('rl-example-viewport', '.rl-example-newsletter-form', context).forEach(function (form) {
+ var observer = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
if (entry.isIntersecting) {
- // Form is now visible - record the turn
- const formData = new FormData();
- formData.append('action', 'turns');
- formData.append('experiment_id', trackingData.experimentId);
- formData.append('arm_ids', trackingData.armId);
-
- // Send the turn signal
- navigator.sendBeacon(trackingData.rlEndpointUrl, formData);
-
- // Disconnect observer after recording turn
+ Drupal.rl.turn(tracking.experimentId, tracking.armId);
observer.disconnect();
}
});
- }, {
- // Trigger when 100% of the form is visible
- threshold: 1.0
- });
-
- // Start observing the form
+ }, { threshold: 1.0 });
observer.observe(form);
});
-
- // Example: Reward tracking via JavaScript (commented out)
- // In this module, we handle rewards in the PHP submitCallback instead.
- // Uncomment and modify this if you need client-side reward tracking:
- //
- // once('rl-example-reward', '.rl-example-newsletter-form button[type="submit"]', context).forEach(function(button) {
- // button.addEventListener('click', function(e) {
- // // Send reward signal to RL
- // const rewardData = new FormData();
- // rewardData.append('action', 'reward');
- // rewardData.append('experiment_id', trackingData.experimentId);
- // rewardData.append('arm_id', trackingData.armId);
- //
- // navigator.sendBeacon(trackingData.rlEndpointUrl, rewardData);
- // });
- // });
- }
+ },
};
-})(Drupal, drupalSettings, once);
\ No newline at end of file
+})(Drupal, drupalSettings, once);
diff --git a/modules/rl_example/rl_example.libraries.yml b/modules/rl_example/rl_example.libraries.yml
index 576685c..c59d6af 100644
--- a/modules/rl_example/rl_example.libraries.yml
+++ b/modules/rl_example/rl_example.libraries.yml
@@ -3,4 +3,6 @@ tracking:
js/rl-example-tracking.js: {}
dependencies:
- core/drupal
+ - core/drupalSettings
- core/once
+ - rl/api
diff --git a/modules/rl_example/src/Plugin/Block/NewsletterBlock.php b/modules/rl_example/src/Plugin/Block/NewsletterBlock.php
index ffa0b94..7b910ae 100644
--- a/modules/rl_example/src/Plugin/Block/NewsletterBlock.php
+++ b/modules/rl_example/src/Plugin/Block/NewsletterBlock.php
@@ -2,17 +2,15 @@
namespace Drupal\rl_example\Plugin\Block;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Block\BlockBase;
-use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\rl\Registry\ExperimentRegistryInterface;
use Drupal\rl\Service\CacheManager;
use Drupal\rl\Service\ExperimentManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\HttpFoundation\RequestStack;
-use Drupal\Core\Ajax\AjaxResponse;
-use Drupal\Core\Ajax\MessageCommand;
/**
* Provides a newsletter signup block with A/B tested button text.
@@ -52,20 +50,6 @@ class NewsletterBlock extends BlockBase implements ContainerFactoryPluginInterfa
*/
protected $cacheManager;
- /**
- * The module extension list service.
- *
- * @var \Drupal\Core\Extension\ModuleExtensionList
- */
- protected $moduleExtensionList;
-
- /**
- * The request stack service.
- *
- * @var \Symfony\Component\HttpFoundation\RequestStack
- */
- protected $requestStack;
-
/**
* The experiment ID.
*
@@ -85,25 +69,6 @@ class NewsletterBlock extends BlockBase implements ContainerFactoryPluginInterfa
/**
* Constructs a NewsletterBlock object.
- *
- * @param array $configuration
- * A configuration array containing information about the plugin instance.
- * @param string $plugin_id
- * The plugin ID for the plugin instance.
- * @param mixed $plugin_definition
- * The plugin implementation definition.
- * @param \Drupal\rl\Service\ExperimentManagerInterface $experiment_manager
- * The RL experiment manager.
- * @param \Drupal\rl\Registry\ExperimentRegistryInterface $experiment_registry
- * The RL experiment registry.
- * @param \Drupal\Core\Messenger\MessengerInterface $messenger
- * The messenger service.
- * @param \Drupal\rl\Service\CacheManager $cache_manager
- * The RL cache manager.
- * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
- * The module extension list service.
- * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
- * The request stack service.
*/
public function __construct(
array $configuration,
@@ -113,16 +78,12 @@ public function __construct(
ExperimentRegistryInterface $experiment_registry,
MessengerInterface $messenger,
CacheManager $cache_manager,
- ModuleExtensionList $module_extension_list,
- RequestStack $request_stack,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->experimentManager = $experiment_manager;
$this->experimentRegistry = $experiment_registry;
$this->messenger = $messenger;
$this->cacheManager = $cache_manager;
- $this->moduleExtensionList = $module_extension_list;
- $this->requestStack = $request_stack;
// Use deterministic ID for this specific experiment.
$this->experimentId = 'rl_example-newsletter_button';
@@ -148,8 +109,6 @@ public static function create(ContainerInterface $container, array $configuratio
$container->get('rl.experiment_registry'),
$container->get('messenger'),
$container->get('rl.cache_manager'),
- $container->get('extension.list.module'),
- $container->get('request_stack')
);
}
@@ -194,16 +153,13 @@ public function build() {
],
];
- // Build correct endpoint URL for rl.php.
- $rl_path = $this->moduleExtensionList->getPath('rl');
- $base_path = $this->requestStack->getCurrentRequest()->getBasePath();
-
- // Attach JavaScript library for viewport tracking.
+ // Attach JavaScript library for viewport tracking. The rl.php endpoint
+ // URL comes from drupalSettings.rl.endpointUrl, published by
+ // rl_page_attachments() whenever the rl/api library is loaded.
$form['#attached']['library'][] = 'rl_example/tracking';
$form['#attached']['drupalSettings']['rlExample']['tracking'] = [
'experimentId' => $this->experimentId,
'armId' => $best_id,
- 'rlEndpointUrl' => "{$base_path}/{$rl_path}/rl.php",
];
// Override page cache if block cache is shorter than site cache.
diff --git a/modules/rl_example_frontend/js/frontend-ab-testing.js b/modules/rl_example_frontend/js/frontend-ab-testing.js
index c57f7cb..1ee1c7e 100644
--- a/modules/rl_example_frontend/js/frontend-ab-testing.js
+++ b/modules/rl_example_frontend/js/frontend-ab-testing.js
@@ -1,146 +1,45 @@
+/**
+ * @file
+ * Tracking for the rl_example_frontend newsletter block.
+ *
+ * The winning variant is picked server-side in NewsletterBlock::build()
+ * and the button is rendered with the winning text already in place. This
+ * file only needs to report the impression when the form enters the
+ * viewport and the conversion when the user clicks the submit button.
+ * Both events go through Drupal.rl, which batches them with any other RL
+ * calls on the page.
+ */
+
(function (Drupal, drupalSettings, once) {
+
'use strict';
- /**
- * Frontend A/B testing for newsletter signup button.
- *
- * This approach eliminates cache complexity by:
- * 1. Fetching Thompson sampling scores via AJAX on page load
- * 2. Overriding button text based on best performing arm
- * 3. Tracking turns via IntersectionObserver
- * 4. Tracking rewards via click handlers
- */
- Drupal.behaviors.rlExampleFrontendABTesting = {
- attach: function (context, settings) {
- // Only proceed if we have the necessary settings
- if (!settings.rlExampleFrontend) {
+ Drupal.behaviors.rlExampleFrontendTracking = {
+ attach: function (context) {
+ var config = drupalSettings.rlExampleFrontend;
+ if (!config || !config.experimentId || !config.armId) {
return;
}
- const config = settings.rlExampleFrontend;
- let selectedArmId = null;
-
- // Use once() to ensure we only process each form once
- once('rl-frontend-ab', '.rl-example-frontend-newsletter-form', context).forEach(function(form) {
-
- // Step 1: Get Thompson sampling scores and select best arm
- fetchThompsonScores(config.experimentId, Object.keys(config.buttonTexts))
- .then(function(scores) {
- // Find the arm with highest score
- let bestScore = -1;
- let bestArmId = null;
-
- for (const armId in scores) {
- if (scores[armId] > bestScore) {
- bestScore = scores[armId];
- bestArmId = armId;
- }
- }
-
- if (bestArmId && config.buttonTexts[bestArmId]) {
- selectedArmId = bestArmId;
-
- // Step 2: Override button text with selected variation
- const submitButton = form.querySelector('input[type="submit"]');
- if (submitButton) {
- submitButton.value = config.buttonTexts[bestArmId];
- }
-
- // Step 3: Set up intersection observer for turn tracking
- setupTurnTracking(form, config, selectedArmId);
-
- // Step 4: Set up click handler for reward tracking
- setupRewardTracking(form, config, selectedArmId);
- }
- });
- });
-
- /**
- * Fetch Thompson sampling scores from RL system
- */
- function fetchThompsonScores(experimentId, armIds) {
- return new Promise(function(resolve, reject) {
- // Make AJAX request to get Thompson sampling scores
- const xhr = new XMLHttpRequest();
- xhr.open('POST', config.rlEndpointUrl);
- xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
-
- xhr.onload = function() {
- if (xhr.status === 200) {
- try {
- const response = JSON.parse(xhr.responseText);
- if (response.scores) {
- resolve(response.scores);
- } else {
- reject('No scores in response');
- }
- } catch (e) {
- reject('Invalid JSON response');
- }
- } else {
- reject('HTTP ' + xhr.status);
- }
- };
-
- xhr.onerror = function() {
- reject('Network error');
- };
-
- // Send request for Thompson scores
- const params = 'action=scores&experiment_id=' + encodeURIComponent(experimentId) +
- '&arm_ids=' + encodeURIComponent(armIds.join(','));
- xhr.send(params);
- });
- }
-
- /**
- * Set up intersection observer for turn tracking
- */
- function setupTurnTracking(form, config, armId) {
- const observer = new IntersectionObserver(function(entries) {
- entries.forEach(function(entry) {
+ once('rl-frontend-ab', '.rl-example-frontend-newsletter-form', context).forEach(function (form) {
+ var observer = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
if (entry.isIntersecting) {
- // Form is now visible - record the turn
- const formData = new FormData();
- formData.append('action', 'turns');
- formData.append('experiment_id', config.experimentId);
- formData.append('arm_ids', armId);
-
- // Send the turn signal
- navigator.sendBeacon(config.rlEndpointUrl, formData);
-
- // Disconnect observer after recording turn
+ Drupal.rl.turn(config.experimentId, config.armId);
observer.disconnect();
}
});
- }, {
- // Trigger when 50% of the form is visible
- threshold: 0.5
- });
-
- // Start observing the form
+ }, { threshold: 0.5 });
observer.observe(form);
- }
- /**
- * Set up click handler for reward tracking
- */
- function setupRewardTracking(form, config, armId) {
- const submitButton = form.querySelector('input[type="submit"]');
+ var submitButton = form.querySelector('input[type="submit"]');
if (submitButton) {
- submitButton.addEventListener('click', function(e) {
- // Send reward signal to RL - user clicked the button
- const formData = new FormData();
- formData.append('action', 'rewards');
- formData.append('experiment_id', config.experimentId);
- formData.append('arm_id', armId);
-
- // Use sendBeacon for non-blocking send
- navigator.sendBeacon(config.rlEndpointUrl, formData);
+ submitButton.addEventListener('click', function () {
+ Drupal.rl.reward(config.experimentId, config.armId);
});
}
- }
- }
+ });
+ },
};
-})(Drupal, drupalSettings, once);
\ No newline at end of file
+})(Drupal, drupalSettings, once);
diff --git a/modules/rl_example_frontend/rl_example_frontend.libraries.yml b/modules/rl_example_frontend/rl_example_frontend.libraries.yml
index d328355..4bbb76c 100644
--- a/modules/rl_example_frontend/rl_example_frontend.libraries.yml
+++ b/modules/rl_example_frontend/rl_example_frontend.libraries.yml
@@ -3,4 +3,6 @@ frontend_ab_testing:
js/frontend-ab-testing.js: {}
dependencies:
- core/drupal
+ - core/drupalSettings
- core/once
+ - rl/api
diff --git a/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php b/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php
index cefcd33..57c9886 100644
--- a/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php
+++ b/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php
@@ -2,18 +2,27 @@
namespace Drupal\rl_example_frontend\Plugin\Block;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Block\BlockBase;
-use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\rl\Registry\ExperimentRegistryInterface;
+use Drupal\rl\Service\CacheManager;
+use Drupal\rl\Service\ExperimentManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\HttpFoundation\RequestStack;
-use Drupal\Core\Ajax\AjaxResponse;
-use Drupal\Core\Ajax\MessageCommand;
/**
- * Provides a newsletter signup block with frontend A/B tested button text.
+ * Provides a newsletter signup block with A/B tested button text.
+ *
+ * Companion to the rl_example block. Both decide the winning variant
+ * server-side and track turns / rewards through the Drupal.rl JS API.
+ * The distinction is how conversions are recorded:
+ * - rl_example records the reward in a Drupal AJAX submit callback,
+ * which round-trips through a full Drupal request.
+ * - rl_example_frontend records the reward with Drupal.rl.reward() from
+ * a click handler, which goes through the thin rl.php endpoint and
+ * does not block the submit flow.
*
* @Block(
* id = "rl_example_frontend_newsletter",
@@ -22,6 +31,13 @@
*/
class NewsletterBlock extends BlockBase implements ContainerFactoryPluginInterface {
+ /**
+ * The RL experiment manager.
+ *
+ * @var \Drupal\rl\Service\ExperimentManagerInterface
+ */
+ protected $experimentManager;
+
/**
* The RL experiment registry.
*
@@ -37,18 +53,11 @@ class NewsletterBlock extends BlockBase implements ContainerFactoryPluginInterfa
protected $messenger;
/**
- * The module extension list service.
- *
- * @var \Drupal\Core\Extension\ModuleExtensionList
- */
- protected $moduleExtensionList;
-
- /**
- * The request stack service.
+ * The RL cache manager.
*
- * @var \Symfony\Component\HttpFoundation\RequestStack
+ * @var \Drupal\rl\Service\CacheManager
*/
- protected $requestStack;
+ protected $cacheManager;
/**
* The experiment ID.
@@ -70,36 +79,21 @@ class NewsletterBlock extends BlockBase implements ContainerFactoryPluginInterfa
/**
* Constructs a NewsletterBlock object.
- *
- * @param array $configuration
- * A configuration array containing information about the plugin instance.
- * @param string $plugin_id
- * The plugin ID for the plugin instance.
- * @param mixed $plugin_definition
- * The plugin implementation definition.
- * @param \Drupal\rl\Registry\ExperimentRegistryInterface $experiment_registry
- * The RL experiment registry.
- * @param \Drupal\Core\Messenger\MessengerInterface $messenger
- * The messenger service.
- * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
- * The module extension list service.
- * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
- * The request stack service.
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
+ ExperimentManagerInterface $experiment_manager,
ExperimentRegistryInterface $experiment_registry,
MessengerInterface $messenger,
- ModuleExtensionList $module_extension_list,
- RequestStack $request_stack,
+ CacheManager $cache_manager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->experimentManager = $experiment_manager;
$this->experimentRegistry = $experiment_registry;
$this->messenger = $messenger;
- $this->moduleExtensionList = $module_extension_list;
- $this->requestStack = $request_stack;
+ $this->cacheManager = $cache_manager;
// Use deterministic ID for this specific experiment.
$this->experimentId = 'rl_example_frontend-newsletter_button';
@@ -121,10 +115,10 @@ public static function create(ContainerInterface $container, array $configuratio
$configuration,
$plugin_id,
$plugin_definition,
+ $container->get('rl.experiment_manager'),
$container->get('rl.experiment_registry'),
$container->get('messenger'),
- $container->get('extension.list.module'),
- $container->get('request_stack')
+ $container->get('rl.cache_manager'),
);
}
@@ -132,7 +126,17 @@ public static function create(ContainerInterface $container, array $configuratio
* {@inheritdoc}
*/
public function build() {
- // Build minimal form with fixed button text - JavaScript will override it.
+ // Decide which variant to render in PHP. The arm list is owned right
+ // here in the block config so the rl core never needs to know it.
+ $scores = $this->experimentManager->getThompsonScores(
+ $this->experimentId,
+ NULL,
+ array_keys($this->buttonTexts)
+ );
+ arsort($scores);
+ $best_id = key($scores);
+ $button_text = $this->buttonTexts[$best_id];
+
$form = [
'#type' => 'form',
'#attributes' => ['class' => ['rl-example-frontend-newsletter-form']],
@@ -144,28 +148,25 @@ public function build() {
'#required' => TRUE,
];
- // Fixed button text that JavaScript will replace.
$form['submit'] = [
'#type' => 'submit',
- // Default fallback text.
- '#value' => 'Subscribe',
+ '#value' => $button_text,
'#ajax' => [
'callback' => [$this, 'submitCallback'],
],
];
- // Build correct endpoint URL for rl.php.
- $rl_path = $this->moduleExtensionList->getPath('rl');
- $base_path = $this->requestStack->getCurrentRequest()->getBasePath();
-
- // Attach JavaScript library and settings for frontend A/B testing.
+ // Tell the tracking JS which arm was picked. The rl.php endpoint URL
+ // comes from drupalSettings.rl.endpointUrl, published by
+ // rl_page_attachments() whenever the rl/api library is loaded.
$form['#attached']['library'][] = 'rl_example_frontend/frontend_ab_testing';
$form['#attached']['drupalSettings']['rlExampleFrontend'] = [
'experimentId' => $this->experimentId,
- 'buttonTexts' => $this->buttonTexts,
- 'rlEndpointUrl' => "{$base_path}/{$rl_path}/rl.php",
+ 'armId' => $best_id,
];
+ $this->cacheManager->overridePageCacheIfShorter($this->getCacheMaxAge());
+
return $form;
}
@@ -173,13 +174,22 @@ public function build() {
* AJAX callback for form submission.
*/
public function submitCallback(array &$form, $form_state) {
- // Note: Reward tracking is handled by JavaScript in this frontend example.
- // The JavaScript sends the reward signal directly to rl.php.
- // Create AJAX response with success message.
+ // Reward tracking is handled by Drupal.rl.reward() from the JS click
+ // handler, not here.
$response = new AjaxResponse();
$response->addCommand(new MessageCommand($this->t('Thanks for subscribing!')));
return $response;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheMaxAge() {
+ // Short cache lifetime so the server-rendered button text can change
+ // as Thompson Sampling scores evolve. 60 seconds trades off learning
+ // speed against server load.
+ return 60;
+ }
+
}
diff --git a/modules/rl_menu_link/README.md b/modules/rl_menu_link/README.md
index 4513702..ba6cfc4 100644
--- a/modules/rl_menu_link/README.md
+++ b/modules/rl_menu_link/README.md
@@ -58,7 +58,9 @@ Same model as the Redirect module.
listener to record a reward when the user clicks. Each visit and each
click is its own event - there is no per-session cap, so repeat
visitors do not depress the conversion signal.
-4. Both events are POSTed via `navigator.sendBeacon()` to `rl.php`.
+4. Both events are dispatched through `Drupal.rl` (the shared transport
+ proxy in `rl/api`), which batches them with any other RL calls on the
+ page before POSTing to `rl.php`.
### UX flows
diff --git a/modules/rl_menu_link/js/menu-tracking.js b/modules/rl_menu_link/js/menu-tracking.js
index c7d12a3..62873ce 100644
--- a/modules/rl_menu_link/js/menu-tracking.js
+++ b/modules/rl_menu_link/js/menu-tracking.js
@@ -2,13 +2,13 @@
* @file
* Client-side tracking for RL Menu Link experiments.
*
- * Records a turn (impression) when a tracked menu link enters the viewport,
- * and a reward when the user clicks the link. Tracked anchors are identified
- * by data-rl-ml-experiment-id and data-rl-ml-arm-id attributes injected by
- * the preprocess_menu hook.
+ * Records a turn when a tracked menu link enters the viewport and a reward
+ * when the user clicks the link. Tracked anchors are identified by
+ * data-rl-ml-experiment-id and data-rl-ml-arm-id attributes injected by the
+ * preprocess_menu hook.
*
* Each click on a tracked link records a reward. There is no per-session
- * cap, so repeated clicks during the same session each count - this keeps
+ * cap so repeated clicks during the same session each count - this keeps
* the conversion signal Thompson Sampling sees unbiased across visits.
*
* Per-page-load dedupe is provided by once() and a per-anchor data attribute
@@ -22,11 +22,6 @@
Drupal.behaviors.rlMenuLinkTracking = {
attach: function (context) {
- if (!drupalSettings.rlMenuLink || !drupalSettings.rlMenuLink.rlEndpointUrl) {
- return;
- }
-
- var endpointUrl = drupalSettings.rlMenuLink.rlEndpointUrl;
var anchors = once('rl-menu-link-tracking', 'a[data-rl-ml-experiment-id]', context);
anchors.forEach(function (anchor) {
@@ -43,14 +38,14 @@
entries.forEach(function (entry) {
if (entry.isIntersecting && !entry.target.dataset.rlMlTracked) {
entry.target.dataset.rlMlTracked = '1';
- _send('turn', experimentId, armId);
+ Drupal.rl.turn(experimentId, armId);
}
});
}, { threshold: 0.1 });
observer.observe(anchor);
}
else {
- _send('turn', experimentId, armId);
+ Drupal.rl.turn(experimentId, armId);
}
// Reward tracking on click. No sessionStorage cap; each click is a
@@ -61,7 +56,7 @@
return;
}
anchor.dataset.rlMlClicked = '1';
- _send('reward', experimentId, armId);
+ Drupal.rl.reward(experimentId, armId);
// Clear the flag after the click event finishes propagating, so a
// second click later in the same page load can also be recorded.
window.setTimeout(function () {
@@ -69,15 +64,7 @@
}, 0);
});
});
-
- function _send(action, experimentId, armId) {
- var data = new FormData();
- data.append('action', action);
- data.append('experiment_id', experimentId);
- data.append('arm_id', armId);
- navigator.sendBeacon(endpointUrl, data);
- }
- }
+ },
};
})(Drupal, drupalSettings, once);
diff --git a/modules/rl_menu_link/rl_menu_link.libraries.yml b/modules/rl_menu_link/rl_menu_link.libraries.yml
index 91c14e2..37f07a1 100644
--- a/modules/rl_menu_link/rl_menu_link.libraries.yml
+++ b/modules/rl_menu_link/rl_menu_link.libraries.yml
@@ -5,3 +5,4 @@ tracking:
- core/drupal
- core/drupalSettings
- core/once
+ - rl/api
diff --git a/modules/rl_menu_link/rl_menu_link.module b/modules/rl_menu_link/rl_menu_link.module
index d3949e3..c185fb3 100644
--- a/modules/rl_menu_link/rl_menu_link.module
+++ b/modules/rl_menu_link/rl_menu_link.module
@@ -50,12 +50,10 @@ function rl_menu_link_preprocess_menu(array &$variables) {
$variables['#cache']['tags'][] = 'rl_menu_link:all';
if (!empty($touched_plugin_ids)) {
- $rl_path = \Drupal::service('extension.list.module')->getPath('rl');
- $base_path = \Drupal::request()->getBasePath();
+ // The rl.php endpoint URL comes from drupalSettings.rl.endpointUrl,
+ // published by rl_page_attachments() whenever the rl/api library is
+ // loaded.
$variables['#attached']['library'][] = 'rl_menu_link/tracking';
- $variables['#attached']['drupalSettings']['rlMenuLink'] = [
- 'rlEndpointUrl' => $base_path . '/' . $rl_path . '/rl.php',
- ];
// Add per-plugin-id tags so each experiment can invalidate the menus
// that contain its specific link.
foreach ($touched_plugin_ids as $plugin_id) {
diff --git a/modules/rl_page_title/README.md b/modules/rl_page_title/README.md
index 0a95a31..7302a13 100644
--- a/modules/rl_page_title/README.md
+++ b/modules/rl_page_title/README.md
@@ -64,8 +64,9 @@ analytics. This matches how Redirect handles multilingual.
4. `js/title-tracking.js` records a turn (impression) on page load and a
reward 10 seconds later (a bounce-rate proxy: if the user is still on the
page after 10 seconds, the variant kept them).
-5. Both events are POSTed via `navigator.sendBeacon()` to `rl.php`, the
- parent RL module's tracking endpoint.
+5. Both events are dispatched through `Drupal.rl` (the shared transport
+ proxy in `rl/api`), which batches them with any other RL calls on the
+ page before POSTing to `rl.php`.
### UX flows
diff --git a/modules/rl_page_title/js/title-tracking.js b/modules/rl_page_title/js/title-tracking.js
index 8af4d30..fa3e84a 100644
--- a/modules/rl_page_title/js/title-tracking.js
+++ b/modules/rl_page_title/js/title-tracking.js
@@ -2,13 +2,13 @@
* @file
* Client-side tracking for RL Page Title experiments.
*
- * Records a turn (impression) on page load and a reward after the user has
- * stayed on the page for 10 seconds (a bounce-rate proxy).
+ * Records a turn on page load and a reward after the user has stayed on the
+ * page for 10 seconds (a bounce-rate proxy).
*
- * Each page load records exactly one turn and (if the user stays long enough)
- * one reward. There is no per-session dedupe - every visit is its own event.
- * That keeps the conversion rate observed by Thompson Sampling unbiased
- * across repeated visits.
+ * Each page load records exactly one turn and (if the user stays long
+ * enough) one reward. There is no per-session dedupe: every visit is its
+ * own event. That keeps the conversion rate observed by Thompson Sampling
+ * unbiased across repeated visits.
*
* Per-page-load dedupe is provided by once() and a window-scoped flag so we
* cannot record more than one event for the same arm on the same page load
@@ -29,34 +29,24 @@
var settings = drupalSettings.rlPageTitle;
var experimentId = settings.experimentId;
var armId = settings.armId;
- var endpointUrl = settings.rlEndpointUrl;
- // Record turn (impression).
- var turnData = new FormData();
- turnData.append('action', 'turn');
- turnData.append('experiment_id', experimentId);
- turnData.append('arm_id', armId);
- navigator.sendBeacon(endpointUrl, turnData);
+ Drupal.rl.turn(experimentId, armId);
// Record reward after 10 seconds (bounce-rate proxy). No
// sessionStorage gate: every page load that crosses the threshold
// emits a reward, which is the correct signal for Thompson Sampling.
- // The window-scoped flag below only prevents duplicate rewards from
- // the same page load (e.g., if attachBehaviors fires twice).
+ // The window-scoped flag only prevents duplicate rewards from the
+ // same page load (e.g., if attachBehaviors fires twice).
var pageLoadFlag = '__rl_pt_rewarded_' + experimentId + '_' + armId;
setTimeout(function () {
if (window[pageLoadFlag]) {
return;
}
window[pageLoadFlag] = true;
- var rewardData = new FormData();
- rewardData.append('action', 'reward');
- rewardData.append('experiment_id', experimentId);
- rewardData.append('arm_id', armId);
- navigator.sendBeacon(endpointUrl, rewardData);
+ Drupal.rl.reward(experimentId, armId);
}, 10000);
});
- }
+ },
};
})(Drupal, drupalSettings, once);
diff --git a/modules/rl_page_title/rl_page_title.libraries.yml b/modules/rl_page_title/rl_page_title.libraries.yml
index e78c6bd..16d0ce3 100644
--- a/modules/rl_page_title/rl_page_title.libraries.yml
+++ b/modules/rl_page_title/rl_page_title.libraries.yml
@@ -5,3 +5,4 @@ tracking:
- core/drupal
- core/drupalSettings
- core/once
+ - rl/api
diff --git a/modules/rl_page_title/rl_page_title.module b/modules/rl_page_title/rl_page_title.module
index 9cfdba1..06ef91e 100644
--- a/modules/rl_page_title/rl_page_title.module
+++ b/modules/rl_page_title/rl_page_title.module
@@ -200,14 +200,12 @@ function rl_page_title_page_attachments(array &$attachments) {
return;
}
- $rl_path = \Drupal::service('extension.list.module')->getPath('rl');
- $base_path = \Drupal::request()->getBasePath();
-
+ // The rl.php endpoint URL comes from drupalSettings.rl.endpointUrl,
+ // published by rl_page_attachments() whenever the rl/api library is loaded.
$attachments['#attached']['library'][] = 'rl_page_title/tracking';
$attachments['#attached']['drupalSettings']['rlPageTitle'] = [
'experimentId' => $result['experiment_id'],
'armId' => $result['arm_id'],
- 'rlEndpointUrl' => $base_path . '/' . $rl_path . '/rl.php',
];
}
diff --git a/rl.libraries.yml b/rl.libraries.yml
index c545d49..d254009 100644
--- a/rl.libraries.yml
+++ b/rl.libraries.yml
@@ -1,3 +1,11 @@
+api:
+ version: 1.0
+ js:
+ js/rl.js: {}
+ dependencies:
+ - core/drupal
+ - core/drupalSettings
+
experiment-highlight:
version: 1.0
css:
diff --git a/rl.module b/rl.module
index 97a350d..937ce1f 100644
--- a/rl.module
+++ b/rl.module
@@ -48,6 +48,13 @@ function rl_page_attachments(array &$attachments) {
if (\Drupal::currentUser()->hasPermission('view rl reports')) {
$attachments['#attached']['library'][] = 'rl/experiment-highlight';
}
+
+ // Publish the rl.php endpoint URL to drupalSettings so any page that
+ // attaches the rl/api library can use Drupal.rl without each consumer
+ // module recomputing the URL.
+ $rl_path = \Drupal::service('extension.list.module')->getPath('rl');
+ $base_path = \Drupal::request()->getBasePath();
+ $attachments['#attached']['drupalSettings']['rl']['endpointUrl'] = $base_path . '/' . $rl_path . '/rl.php';
}
/**
diff --git a/rl.php b/rl.php
index 2b49102..199bb53 100644
--- a/rl.php
+++ b/rl.php
@@ -2,34 +2,72 @@
/**
* @file
- * Handles RL experiment tracking via AJAX with minimal bootstrap.
+ * Handles RL experiment tracking via a minimal Drupal bootstrap.
*
* Following the statistics.php architecture for optimal performance.
- * Updated for Drupal 10/11 compatibility.
+ *
+ * Four actions are supported:
+ * - ping: liveness check, no experiment touched.
+ * - turn / turns / reward: legacy form-POST tracking used by
+ * production consumers (ai_sorting, and any third-party JS that was
+ * written before Drupal.rl shipped). These remain fully supported.
+ * - batch: JSON POST body used by Drupal.rl on the client side. Carries
+ * multiple turn and reward events for potentially several experiments
+ * in a single request. See the docblock on handle_batch_request()
+ * below for the payload shape.
+ *
+ * Deciding which variant to show is an application concern that belongs in
+ * PHP at render time (see ai_sorting's Views sort plugin, or
+ * VariantSelectorBase in this module). rl.php intentionally does not
+ * expose a client-side decide endpoint.
*/
use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;
-$action = filter_input(INPUT_POST, 'action', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
-$experiment_id = filter_input(INPUT_POST, 'experiment_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
-$arm_id = filter_input(INPUT_POST, 'arm_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
+// The action can arrive in the query string (Drupal.rl batch requests) or
+// as a form field (legacy consumers + ping).
+$action = filter_input(INPUT_GET, 'action', FILTER_SANITIZE_FULL_SPECIAL_CHARS)
+ ?: filter_input(INPUT_POST, 'action', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
-// Ping action is read-only and doesn't require experiment_id.
+// Ping is a cheap liveness check used by hook_requirements() to verify
+// the web server serves rl.php directly. No Drupal bootstrap needed.
if ($action === 'ping') {
http_response_code(200);
exit('pong');
}
-if (!$action || !$experiment_id || !in_array($action, ['turn', 'turns', 'reward'])) {
- http_response_code(400);
- exit('Invalid request parameters');
-}
+$experiment_id = NULL;
+$arm_id = NULL;
+$payload = NULL;
-// Validate experiment ID format (alphanumeric, hyphens, underscores).
-if (!preg_match('/^[a-zA-Z0-9_-]+$/', $experiment_id)) {
+if ($action === 'batch') {
+ // Read and decode the JSON body before bootstrapping Drupal so malformed
+ // requests cost nothing.
+ $raw_body = file_get_contents('php://input');
+ if ($raw_body === FALSE || $raw_body === '') {
+ http_response_code(400);
+ header('Content-Type: application/json');
+ exit('{"error":"empty body"}');
+ }
+ $payload = json_decode($raw_body, TRUE);
+ if (!is_array($payload)) {
+ http_response_code(400);
+ header('Content-Type: application/json');
+ exit('{"error":"invalid json"}');
+ }
+}
+elseif (in_array($action, ['turn', 'turns', 'reward'], TRUE)) {
+ $experiment_id = filter_input(INPUT_POST, 'experiment_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
+ $arm_id = filter_input(INPUT_POST, 'arm_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
+ if (!$experiment_id || !preg_match('/^[a-zA-Z0-9_-]+$/', $experiment_id)) {
+ http_response_code(400);
+ exit('Invalid experiment_id');
+ }
+}
+else {
http_response_code(400);
- exit('Invalid experiment_id format');
+ exit('Invalid action');
}
try {
@@ -63,13 +101,22 @@
$container = $kernel->getContainer();
$registry = $container->get('rl.experiment_registry');
+ $storage = $container->get('rl.experiment_data_storage');
+ $manager = $container->has('rl.experiment_manager') ? $container->get('rl.experiment_manager') : NULL;
+
+ if ($action === 'batch') {
+ $decisions = handle_batch_request($payload, $registry, $storage, $manager);
+ http_response_code(200);
+ header('Content-Type: application/json');
+ header('Cache-Control: no-store, private, max-age=0');
+ echo json_encode(['ok' => TRUE, 'decisions' => $decisions]);
+ exit;
+ }
+
if (!$registry->isRegistered($experiment_id)) {
exit();
}
- $storage = $container->get('rl.experiment_data_storage');
-
-
switch ($action) {
case 'turn':
if ($arm_id && preg_match('/^[a-zA-Z0-9_-]+$/', $arm_id)) {
@@ -80,8 +127,7 @@
case 'turns':
$arm_ids = filter_input(INPUT_POST, 'arm_ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($arm_ids) {
- $arm_ids_array = explode(',', $arm_ids);
- $arm_ids_array = array_map('trim', $arm_ids_array);
+ $arm_ids_array = array_map('trim', explode(',', $arm_ids));
$valid_arm_ids = [];
foreach ($arm_ids_array as $aid) {
@@ -111,3 +157,135 @@
http_response_code(500);
exit('Server error');
}
+
+/**
+ * Process a Drupal.rl batch payload.
+ *
+ * Expected JSON shape (all three sections are optional):
+ * @code
+ * {
+ * "decides": [
+ * {"id": "", "arms": ["", "", ...]},
+ * ...
+ * ],
+ * "turns": [{"id": "", "arm": ""}, ...],
+ * "rewards": [{"id": "", "arm": ""}, ...]
+ * }
+ * @endcode
+ *
+ * Decides resolve to Thompson Sampling winners and are returned keyed
+ * by experiment id:
+ * @code
+ * {"decisions": {"": {"armId": ""}, ...}}
+ * @endcode
+ *
+ * Unknown or malformed entries are silently skipped so one bad event
+ * does not poison the rest of the batch. Callers that receive no
+ * decision for a given experiment should fall back to the first arm
+ * they passed in.
+ *
+ * @param array $payload
+ * The decoded JSON body.
+ * @param \Drupal\rl\Registry\ExperimentRegistryInterface $registry
+ * The experiment registry used to validate experiment ids.
+ * @param \Drupal\rl\Storage\ExperimentDataStorageInterface $storage
+ * The experiment data storage used to persist turns and rewards.
+ * @param \Drupal\rl\Service\ExperimentManagerInterface|null $manager
+ * The experiment manager used to compute Thompson Sampling scores.
+ * May be NULL if the service is unavailable, in which case no
+ * decisions are produced.
+ *
+ * @return object
+ * A stdClass keyed by experiment id. Empty object if no decides were
+ * requested or resolved. Uses stdClass so json_encode emits `{}`
+ * instead of `[]` when the map is empty.
+ */
+function handle_batch_request(array $payload, $registry, $storage, $manager = NULL): \stdClass {
+ $id_pattern = '/^[a-zA-Z0-9_-]+$/';
+ $decisions = new \stdClass();
+
+ if ($manager !== NULL && isset($payload['decides']) && is_array($payload['decides'])) {
+ foreach ($payload['decides'] as $decide) {
+ if (!is_array($decide)) {
+ continue;
+ }
+ $eid = isset($decide['id']) ? (string) $decide['id'] : '';
+ if ($eid === '' || !preg_match($id_pattern, $eid)) {
+ continue;
+ }
+ if (!$registry->isRegistered($eid)) {
+ continue;
+ }
+ $arms = $decide['arms'] ?? [];
+ if (!is_array($arms) || count($arms) < 2) {
+ continue;
+ }
+ $arm_ids = [];
+ $valid = TRUE;
+ foreach ($arms as $arm) {
+ $arm_id = (string) $arm;
+ if ($arm_id === '' || !preg_match($id_pattern, $arm_id)) {
+ $valid = FALSE;
+ break;
+ }
+ $arm_ids[] = $arm_id;
+ }
+ if (!$valid) {
+ continue;
+ }
+ try {
+ $scores = $manager->getThompsonScores($eid, NULL, $arm_ids);
+ }
+ catch (\Throwable $e) {
+ continue;
+ }
+ if (!is_array($scores) || !$scores) {
+ continue;
+ }
+ arsort($scores);
+ $decisions->{$eid} = ['armId' => (string) key($scores)];
+ }
+ }
+
+ if (isset($payload['turns']) && is_array($payload['turns'])) {
+ foreach ($payload['turns'] as $turn) {
+ if (!is_array($turn)) {
+ continue;
+ }
+ $eid = isset($turn['id']) ? (string) $turn['id'] : '';
+ $aid = isset($turn['arm']) ? (string) $turn['arm'] : '';
+ if ($eid === '' || $aid === '') {
+ continue;
+ }
+ if (!preg_match($id_pattern, $eid) || !preg_match($id_pattern, $aid)) {
+ continue;
+ }
+ if (!$registry->isRegistered($eid)) {
+ continue;
+ }
+ $storage->recordTurn($eid, $aid);
+ }
+ }
+
+ if (isset($payload['rewards']) && is_array($payload['rewards'])) {
+ foreach ($payload['rewards'] as $reward) {
+ if (!is_array($reward)) {
+ continue;
+ }
+ $eid = isset($reward['id']) ? (string) $reward['id'] : '';
+ $aid = isset($reward['arm']) ? (string) $reward['arm'] : '';
+ if ($eid === '' || $aid === '') {
+ continue;
+ }
+ if (!preg_match($id_pattern, $eid) || !preg_match($id_pattern, $aid)) {
+ continue;
+ }
+ if (!$registry->isRegistered($eid)) {
+ continue;
+ }
+ $storage->recordReward($eid, $aid);
+ }
+ }
+
+ return $decisions;
+}