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: