Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 187 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 10 additions & 16 deletions docs/rl_project_desc.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ <h3>Installation</h3>
<h4>Verify rl.php Access</h4>
<p>The RL module includes a <code>.htaccess</code> file that allows direct access to <code>rl.php</code> (following the same pattern as Drupal 11's contrib statistics module). Test that it's working:</p>

<pre><code>curl -X POST -d "action=turns&experiment_id=test&arm_ids=1" http://example.com/modules/contrib/rl/rl.php</code></pre>
<pre><code>curl -X POST -d "action=ping" http://example.com/modules/contrib/rl/rl.php</code></pre>

<p><strong>If the test fails:</strong></p>
<ul>
Expand Down Expand Up @@ -147,24 +147,18 @@ <h3>API</h3>
$cache_manager->overridePageCacheIfShorter(60); // 60 seconds
</code></pre>

<h3>HTTP Endpoints</h3>
<h3>JavaScript API</h3>

<h4>rl.php - Direct Endpoint (Recommended)</h4>
<p><strong>Use the direct rl.php endpoint for optimal performance:</strong></p>
<p>Attach the <code>rl/api</code> library to get <code>Drupal.rl</code> on the page:</p>

<pre><code>// 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);
<pre><code>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);</code></pre>
Drupal.rl.decide('hero_cta', ['v0', 'v1', 'v2']).then(function (armId) {
showVariant(armId);
});</code></pre>

<p>All three methods feed a shared 500 ms batch window, so every experiment on the page rides one POST to <code>rl.php</code>. See the README for the HTTP wire format and server-side integration patterns.</p>

<h3>Cache Management</h3>

Expand Down
Loading
Loading