Repository navigation
Add MkDocs documentation with GitLab Pages CI #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
54d5aa6
b3295a0
7e19ce6
6e5d00c
614aa08
63718e5
c1b4b69
489445e
ee49f98
3a34531
0d3fcb1
aa60486
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| ################ | ||
| # DrupalCI GitLab CI/CD template. | ||
| # | ||
| # Project: https://project.pages.drupalcode.org/gitlab_templates/ | ||
| # Source: https://git.drupalcode.org/project/gitlab_templates/ | ||
| ################ | ||
|
|
||
| variables: | ||
| SKIP_COMPOSER_LINT: '1' | ||
| SKIP_PHPCS: '1' | ||
| SKIP_PHPSTAN: '1' | ||
| SKIP_STYLELINT: '1' | ||
| SKIP_ESLINT: '1' | ||
| SKIP_CSPELL: '1' | ||
|
|
||
| include: | ||
| - project: $_GITLAB_TEMPLATES_REPO | ||
| ref: $_GITLAB_TEMPLATES_REF | ||
| file: | ||
| - '/includes/include.drupalci.main.yml' | ||
| - '/includes/include.drupalci.variables.yml' | ||
| - '/includes/include.drupalci.workflows.yml' | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,8 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # RL: A/B & Multivariate Testing for Drupal (Reinforcement Learning) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **[Full documentation](https://project.pages.drupalcode.org/rl/)** | This README covers the essentials; the docs site has guides, API reference, and worked examples. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| A/B and multivariate testing for Drupal using reinforcement learning. Each page | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| view is a trial, each conversion is a reward, and the algorithm continuously | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| shifts traffic to whichever variant is winning. RLHF-style feedback loop, no | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -70,26 +72,6 @@ composer require drupal/rl | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| drush en rl | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Plotly.js Library (Required for Charts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The RL module uses Plotly.js for experiment charts. Install it via Composer using | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [Asset Packagist](https://asset-packagist.org): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Add Asset Packagist repository (if not already configured) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| composer config repositories.asset-packagist composer https://asset-packagist.org | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Install the Composer plugin for npm assets (if not already installed) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| composer require oomphinc/composer-installers-extender | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| composer config extra.installer-types --json '["npm-asset"]' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| composer config extra.installer-paths.web/libraries/\{\$name\} --json '["type:npm-asset"]' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Install Plotly.js | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| composer require npm-asset/plotly.js-dist-min:^2.35 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| This installs the library to `web/libraries/plotly.js-dist-min/`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Post-Installation: Verify rl.php Access | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The RL module includes a `.htaccess` file that allows direct access to | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -98,7 +80,7 @@ module). Test that it's working: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Test if rl.php is accessible | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| curl -X POST -d "action=turns&experiment_id=test&arm_ids=1" \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| curl -X POST -d "action=ping" \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| http://example.com/modules/contrib/rl/rl.php | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -134,8 +116,8 @@ location ~ ^/modules/.*\.php$ { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Socket: `unix:/var/run/php/php8.1-fpm.sock` (or your PHP version) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - TCP: `127.0.0.1:9000` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| If server policies prevent direct access to `rl.php`, use the Drupal | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Routes API instead. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| If server policies prevent direct access to `rl.php`, your consumer | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| module must implement its own Drupal route as an alternative endpoint. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Drush Command Reference | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -234,7 +216,7 @@ $experiment_manager->recordTurn('my-experiment', 'variant-a'); | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $experiment_manager->recordReward('my-experiment', 'variant-a'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Get Thompson Sampling scores | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $scores = $experiment_manager->getThompsonScores('my-experiment'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $scores = $experiment_manager->getThompsonScores('my-experiment', NULL, ['variant-a', 'variant-b']); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Select best option | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $ts_calculator = \Drupal::service('rl.ts_calculator'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -410,8 +392,8 @@ Content-Type: application/json | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rejected individually without poisoning the rest of the batch. A | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| successful response looks like: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -431,6 +413,27 @@ Sampling lookup. Missing keys mean "use the default variant". When | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `ranking[0]` for backwards compatibility. Turns and rewards are | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fire-and-forget writes with no per-event response. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| When some entries are rejected (invalid IDs, unregistered experiments, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| malformed entries), the response includes an `errors` array: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "ok": true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "decisions": { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "hero_cta": {"armId": "v1"} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "errors": [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {"kind": "turn", "id": "bad!", "reason": "invalid_id"} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+416
to
+429
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tiny carry-over: this section got the pre-fix wording, so the example pairs
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Each error includes the `kind` (decide, turn, or reward), the `id` that | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failed, and a machine-readable `reason`. Partial successes return HTTP 200 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with `ok: true` alongside both `decisions` and `errors`; the response is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 422 with `ok: false` only when every entry in the batch was rejected. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Curl examples | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -449,6 +452,7 @@ curl -X POST 'https://example.com/modules/contrib/rl/rl.php' -d 'action=ping' | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | Status | When | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | --- | --- | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | `400` | Missing/invalid `action`, malformed JSON, or missing `experiment_id` on a legacy action. | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | `422` | Batch request where every entry was rejected (all invalid or unregistered). | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | `500` | Drupal kernel failed to boot. Error logged to the PHP error log. | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Performance notes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -499,31 +503,29 @@ Implement the `ExperimentDecoratorInterface`: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| namespace Drupal\my_module\Decorator; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Drupal\Core\Entity\EntityTypeManagerInterface; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Drupal\rl\Decorator\ExperimentDecoratorInterface; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class MyExperimentDecorator implements ExperimentDecoratorInterface { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * {@inheritdoc} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| protected EntityTypeManagerInterface $entityTypeManager; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public function __construct(EntityTypeManagerInterface $entity_type_manager) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $this->entityTypeManager = $entity_type_manager; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public function decorateExperiment(string $experiment_id): ?array { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Return NULL to skip, or a render array for custom display. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!str_starts_with($experiment_id, 'my_module-')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NULL; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ['#markup' => 'My Custom Experiment Name']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * {@inheritdoc} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public function decorateArm(string $experiment_id, string $arm_id): ?array { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Return NULL to skip, or a render array for custom display. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!str_starts_with($experiment_id, 'my_module-')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NULL; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Example: Load entity and return its label. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $entity = \Drupal::entityTypeManager()->getStorage('node')->load($arm_id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $entity = $this->entityTypeManager->getStorage('node')->load($arm_id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($entity) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '#markup' => htmlspecialchars($entity->label()) . | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| # 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 described in the | ||
| [PHP API](php.md). | ||
|
|
||
| ## Supported actions | ||
|
|
||
| All actions are additive; adding `batch` did not deprecate the legacy | ||
| form actions, and `rl_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' | ||
| ``` | ||
|
|
||
| ## Batch action | ||
|
|
||
| ``` | ||
| POST /modules/contrib/rl/rl.php?action=batch | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "decides": [ | ||
| {"id": "hero_cta", "arms": ["v0", "v1", "v2"]}, | ||
| {"id": "faq_sort", "arms": ["t0", "t1", "t2", "t3"], "rank": true} | ||
| ], | ||
| "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 | ||
| rejected individually without poisoning the rest of the batch. A | ||
| successful response looks like: | ||
|
|
||
| ```json | ||
| { | ||
| "ok": true, | ||
| "decisions": { | ||
| "hero_cta": {"armId": "v1"}, | ||
| "faq_sort": {"armId": "t2", "ranking": ["t2", "t0", "t3", "t1"]} | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| `decisions` contains only entries that had a successful Thompson | ||
| Sampling lookup. Missing keys mean "use the default variant". When | ||
| `"rank": true` is set on a decide entry, the response includes a | ||
| `ranking` array with all arm IDs sorted by Thompson Sampling score | ||
| (best first). The `armId` field is always present and equals | ||
| `ranking[0]` for backwards compatibility. Turns and rewards are | ||
| fire-and-forget writes with no per-event response. | ||
|
|
||
| When some entries are rejected (invalid IDs, unregistered experiments, | ||
| malformed entries), the response includes an `errors` array alongside | ||
| any successful results. A partial success returns HTTP 200 with | ||
| `ok: true`: | ||
|
|
||
| ```json | ||
| { | ||
| "ok": true, | ||
| "decisions": { | ||
| "hero_cta": {"armId": "v1"} | ||
| }, | ||
| "errors": [ | ||
| {"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"}, | ||
| {"kind": "turn", "id": "bad!", "reason": "invalid_id"} | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| Each error includes the `kind` (decide, turn, or reward), the `id` that | ||
| failed, and a machine-readable `reason`. When *every* entry in the batch | ||
| is rejected, the response is HTTP 422 with `ok: false` and no | ||
| `decisions`. | ||
|
|
||
| ## Error responses | ||
|
|
||
| | Status | When | | ||
| | --- | --- | | ||
| | `400` | Missing/invalid `action`, malformed JSON, or missing `experiment_id` on a legacy action. | | ||
| | `422` | Batch request where every entry was rejected (all invalid or unregistered). | | ||
| | `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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| # JavaScript API | ||
|
|
||
| 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. | ||
|
|
||
| ## Methods | ||
|
|
||
| ### `Drupal.rl.decide(experimentId, armIds)` | ||
|
|
||
| Ask for a decision when the variant needs to be chosen client-side. | ||
| The arm list **must** be read from the DOM; see the discipline section below. | ||
|
|
||
| ```javascript | ||
| 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); | ||
| }); | ||
| ``` | ||
|
|
||
| ### `Drupal.rl.rank(experimentId, armIds)` | ||
|
|
||
| Ask for a full ranking when you need to sort, not just pick a winner. | ||
| Use case: reordering accordion items, FAQ lists, or any sortable | ||
| content by visitor engagement. | ||
|
|
||
| ```javascript | ||
| var items = document.querySelector('[data-rl-experiment="faq_sort"]'); | ||
| var faqArms = items.dataset.rlArms.split(','); | ||
| Drupal.rl.rank('faq_sort', faqArms).then(function (sorted) { | ||
| // sorted = ['t3', 't0', 't1', 't2'], all arms, best first | ||
| sorted.forEach(function (armId) { | ||
| items.appendChild(items.querySelector('[data-rl-arm="' + armId + '"]')); | ||
| }); | ||
| }); | ||
| ``` | ||
|
|
||
| ### `Drupal.rl.turn(experimentId, armId)` | ||
|
|
||
| Record an impression when the variant becomes visible. | ||
|
|
||
| ```javascript | ||
| Drupal.rl.turn('hero_cta', 'v0'); | ||
| ``` | ||
|
|
||
| ### `Drupal.rl.reward(experimentId, armId)` | ||
|
|
||
| Record a conversion when the user clicks, submits, or converts. | ||
|
|
||
| ```javascript | ||
| Drupal.rl.reward('hero_cta', 'v0'); | ||
| ``` | ||
|
|
||
| ### `Drupal.rl.flush()` | ||
|
|
||
| Force an immediate flush of the event queue. | ||
|
|
||
| ```javascript | ||
| Drupal.rl.flush(); | ||
| ``` | ||
|
|
||
| ## Batching behaviour | ||
|
|
||
| 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()` and `rank()` | ||
|
|
||
| > **Never hardcode arm IDs in JS. Always read them from a DOM | ||
| > attribute that the server-side renderer emitted.** | ||
|
|
||
| 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. This keeps `Drupal.rl.decide()` and | ||
| `Drupal.rl.rank()` 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 `rl_sorting`, which batches turns on its | ||
| own 100 ms window and posts them as form data) keep working untouched. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Opts into the full DrupalCI matrix, not just Pages: the
pages/mkdocs job does live insideinclude.drupalci.main.yml(and gitlab_templates offers no pages-only include), butSKIP_COMPOSER_LINT,SKIP_PHPCS,SKIP_PHPSTAN,SKIP_STYLELINT,SKIP_ESLINT, andSKIP_CSPELLall default to '0', so every drupalcode push/MR now also runs those jobs with the templates' own configs. This repo has no cspell wordlist and no phpstan config, and standardizes lint on docker compose plus GitHub Actions, so the first pipeline will show unrelated red/orange jobs and any pages-job failure gets lost in the noise. (PHPUnit is safe: that job is gated by a tests-exist rule and this repo has onlyscripts/e2e/.) Consider setting the documentedSKIP_*or allow-failure variables in this same commit, or note explicitly that adopting full DrupalCI is intentional and was verified against a test pipeline. Also worth knowing: the pages job deploys only on pushes to drupalcode's default branch that touchdocs/**ormkdocs.yml; nothing deploys from GitHub, MRs, or feature branches.