Skip to content

Add Drupal.rl thin JS API with request batching #42

@jjroelofs

Description

@jjroelofs

Problem

Modules that use RL currently roll their own JavaScript for talking to rl.php. When multiple RL-powered features appear on the same page (e.g. DXPR Builder hero buttons + rl_page_title + 4 rl_menu_link entries), each module fires its own HTTP requests independently, producing ~12 round trips per page view (6 decides + 6 turns) with no awareness of each other.

The rl.php endpoint already supports batching within a single action (action=decide takes parallel experiment_ids/arm_counts, action=turns takes arm_ids CSV), but there is no client-side coordinator, so cross-module batching never happens. There is also a stale action=scores call in modules/rl_example_frontend/js/frontend-ab-testing.js:90 that rl.php does not handle.

Goal

Ship a thin, shared Drupal.rl JS API that every RL consumer module uses as a transport proxy. The core stays mechanism, not policy: modules decide when to record impressions, conversions, and fetch decisions. The shared API handles only how those calls reach rl.php, merging them into as few HTTP requests as possible.

Modeled on Drupal.history in Drupal core, which does exactly this for node-view tracking.

API surface

Drupal.rl.decide(experimentId, armCount)  // => Promise<armId>
Drupal.rl.turn(experimentId, armId)       // fire-and-forget
Drupal.rl.reward(experimentId, armId)     // fire-and-forget
Drupal.rl.flush()                         // optional manual flush

Internally:

  • visibilitychange / pagehide listeners flush buffered events via navigator.sendBeacon so rewards survive navigation.
  • No IntersectionObserver helpers, no click-handler wiring, no DOM scanning, no drupalSettings registration convention, no retry/dedupe beyond the batch window. Those concerns stay in the consumer modules.

Batching strategy

One unified queue, one endpoint, two dispatch delays:

  • Decide enqueued -> schedule flush at setTimeout(0). All decide calls land during Drupal.behaviors.attach at DOMContentLoaded, so a next-tick flush catches every module's decides in one POST with no added latency. Waiting 500ms would just lengthen the flash-of-default-content for client-side variants without collecting anything new.
  • Turn / reward enqueued -> schedule flush at setTimeout(500). These trickle in from user interaction (scroll, click, hover), so a 500ms window does real collection work.
  • Either rule only schedules a timer if no sooner flush is already pending.

Expected reduction

For the multi-module example page above: ~12 requests -> 2 requests per page view (one decide batch at next tick, one track batch 500ms later), with each consumer module's JS shrinking to a thin caller.

rl.php refactor

Collapse turn / turns / reward / decide into a single action=batch that accepts a JSON body read from php://input:

Request:

{
  "decides": [
    {"id": "hero_cta", "arms": 2},
    {"id": "page_title_123", "arms": 3}
  ],
  "turns":   [{"id": "menu_main_5", "arm": "v1"}],
  "rewards": [{"id": "hero_cta", "arm": "v0"}]
}

Response:

{
  "decisions": {
    "hero_cta": {"armId": "v1"},
    "page_title_123": {"armId": "v0"}
  }
}

Server loop: validate every id against ExperimentRegistry, call ExperimentManager::getThompsonScores() for decides, ExperimentDataStorage::recordTurn() / recordReward() for the rest. One Drupal kernel bootstrap per batch, not per event.

The legacy single-action handlers (turn, turns, reward, decide) can be deleted once all consumer JS is migrated. ping stays.

Drupal core conventions to follow

Pattern borrowed from Drupal.history (core/modules/history/js/history.js) and Drupal.announce (core/misc/announce.js):

  • Split libraries in rl.libraries.yml: one rl/api library containing just the transport (Drupal.rl definition), plus consumer-facing behavior libraries that depend on rl/api.
  • Expose Drupal.rl as a plain object on the namespace. No Drupal.behaviors entry in the core file itself.
  • IIFE signature: (function (Drupal, drupalSettings, once) { ... })(Drupal, drupalSettings, once); (no jQuery).
  • Dependencies: core/drupal, core/drupalSettings, core/drupal.debounce if useful.
  • Standard @file / @namespace JSDoc header.

Consumer module migration

Each existing RL consumer needs its JS replaced with calls into Drupal.rl:

  • modules/rl_example/js/rl-example-tracking.js
  • modules/rl_example_frontend/js/frontend-ab-testing.js (also fixes the broken action=scores call at line 90)
  • modules/rl_menu_link/js/menu-tracking.js
  • modules/rl_page_title/js/title-tracking.js

Each module's *.libraries.yml entry gains a dependency on rl/api.

Out of scope

  • Client-side caching of decisions across page loads (history module uses localStorage for timestamps; we can add later if needed).
  • Dedupe of duplicate turns within a session.
  • Retries on failed flushes beyond the implicit pagehide sendBeacon safety net.
  • Any change to the PHP service layer (ExperimentManager, ExperimentDataStorage, ExperimentRegistry).

Acceptance criteria

  • Drupal.rl API exists with decide / turn / reward / flush and is callable from any RL consumer module.
  • rl.php accepts action=batch with the JSON payload shape above and returns the decisions map.
  • Legacy single-action handlers are removed from rl.php; ping remains.
  • All four consumer modules listed above are migrated off direct fetch / XHR / sendBeacon calls.
  • Broken action=scores call in rl_example_frontend is fixed.
  • Manual e2e verification on a page hosting at least three RL experiments shows exactly two network requests to rl.php under normal load (one decide batch, one track batch).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions