From c4d0a3f0546d39f614b2d41c5f40d62462293ad7 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Thu, 10 Sep 2026 14:29:43 -0400 Subject: [PATCH 1/2] rtd: fix waitForTargeting to listen for the optable-targeting:change event --- lib/core/events/cache-refresh.ts | 2 +- lib/core/prebid/rtd.test.ts | 64 ++++++++++++++++++++++++++++++++ lib/core/prebid/rtd.ts | 26 ++++--------- 3 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 lib/core/prebid/rtd.test.ts diff --git a/lib/core/events/cache-refresh.ts b/lib/core/events/cache-refresh.ts index d0abc200..c563c614 100644 --- a/lib/core/events/cache-refresh.ts +++ b/lib/core/events/cache-refresh.ts @@ -20,4 +20,4 @@ function sendTargetingUpdateEvent(config: ResolvedConfig, response: TargetingRes ); } -export { sendTargetingUpdateEvent }; +export { sendTargetingUpdateEvent, targetingEventName }; diff --git a/lib/core/prebid/rtd.test.ts b/lib/core/prebid/rtd.test.ts new file mode 100644 index 00000000..39c51da9 --- /dev/null +++ b/lib/core/prebid/rtd.test.ts @@ -0,0 +1,64 @@ +import { targetingEventName } from "../events/cache-refresh"; +import { buildRTD } from "./rtd"; +import type { ReqBidsConfigObj } from "./rtd"; + +const EIDS = [{ source: "uidapi.com", uids: [{ id: "uid2-token" }] }]; + +const w = window as unknown as { pbjs?: { getConfig: () => unknown } }; + +function bidsConfig(): ReqBidsConfigObj { + return { ortb2Fragments: { global: {}, bidder: {} } }; +} + +function globalEids(req: ReqBidsConfigObj) { + return req.ortb2Fragments.global.user?.ext?.eids ?? []; +} + +function seedCache(eids: unknown[]) { + localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify({ ortb2: { user: { eids } } })); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + w.pbjs = { getConfig: () => ({ realTimeData: { auctionDelay: 500 } }) }; +}); + +describe("buildRTD - waitForTargeting", () => { + it("serves immediately when the cache already has EIDs", async () => { + seedCache(EIDS); + const req = bidsConfig(); + + await buildRTD({ waitForTargeting: true }).handleRtd(req); + + expect(globalEids(req)).toHaveLength(1); + }); + + it("waits for the targeting update event and merges the cache it announces", async () => { + const req = bidsConfig(); + const pending = buildRTD({ waitForTargeting: true }).handleRtd(req); + + seedCache(EIDS); + window.dispatchEvent(new CustomEvent(targetingEventName)); + await pending; + + expect(globalEids(req)).toHaveLength(1); + }); + + it("gives up after the auction delay when no event arrives", async () => { + w.pbjs = { getConfig: () => ({ realTimeData: { auctionDelay: 20 } }) }; + const req = bidsConfig(); + + await buildRTD({ waitForTargeting: true }).handleRtd(req); + + expect(globalEids(req)).toHaveLength(0); + }); + + it("does not wait when waitForTargeting is off", async () => { + const req = bidsConfig(); + + await buildRTD().handleRtd(req); + + expect(globalEids(req)).toHaveLength(0); + }); +}); diff --git a/lib/core/prebid/rtd.ts b/lib/core/prebid/rtd.ts index a0cd78ef..3696efcb 100644 --- a/lib/core/prebid/rtd.ts +++ b/lib/core/prebid/rtd.ts @@ -1,4 +1,5 @@ // RTD (Real-Time Data) module for Prebid.js integration +import { targetingEventName } from "../events/cache-refresh"; import { flagEnabled } from "../flags"; import { consoleLog } from "../log"; @@ -188,30 +189,19 @@ async function readTargetingData(config: RTDConfig): Promise { config.log("info", `Waiting for targeting data (max ${delay}ms)`); const targetingData = await new Promise((resolve) => { - let resolved = false; - const eventHandler = () => { - if (!resolved) { - resolved = true; - config.log("info", "Received optableResolved event"); - const data = targetingFromCache(config); - resolve(data); - } + clearTimeout(timeoutId); + config.log("info", "Received targeting update event"); + resolve(targetingFromCache(config)); }; const timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - config.log("warn", `Auction delay timeout (${delay}ms) - no targeting data available`); - window.removeEventListener("optableResolved", eventHandler); - resolve(null); - } + window.removeEventListener(targetingEventName, eventHandler); + config.log("warn", `Auction delay timeout (${delay}ms) - no targeting data available`); + resolve(null); }, delay); - window.addEventListener("optableResolved", eventHandler, { once: true }); - - // Clean up timeout if event fires first - window.addEventListener("optableResolved", () => clearTimeout(timeoutId), { once: true }); + window.addEventListener(targetingEventName, eventHandler, { once: true }); }); if (!targetingData) { From 07618a7812f5869137269468b8159f113b97f8f3 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Thu, 10 Sep 2026 14:30:45 -0400 Subject: [PATCH 2/2] rtd: add control-group gating and wait when the cache has no EIDs --- README.md | 12 ++++++++++ lib/core/prebid/rtd.md | 44 +++++++++++++++++++++++++++++++++++++ lib/core/prebid/rtd.test.ts | 39 ++++++++++++++++++++++++++++++++ lib/core/prebid/rtd.ts | 16 ++++++++++++-- 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 lib/core/prebid/rtd.md diff --git a/README.md b/README.md index cc4e72d5..ded50fb9 100644 --- a/README.md +++ b/README.md @@ -1066,6 +1066,18 @@ For bidder adapters that do not support SDA, but that do support targeting priva ``` +### RTD module + +`buildRTD()` builds the config behind the Optable RTD provider, which merges cached EIDs into the auction's `ortb2Fragments` with per-source bidder routing and merge strategies. With `waitForTargeting: true` it waits — bounded by Prebid's `auctionDelay` — for the targeting response when the cache has no EIDs yet, and an `isControlGroup` callback gates serving for split tests: + +```typescript +import { buildRTD } from "@optable/web-sdk/lib/dist/core/prebid/rtd"; + +const rtd = buildRTD({ waitForTargeting: true, isControlGroup: () => isControlGroup }); +``` + +For the auction flow and the full option list, see the [RTD module README](lib/core/prebid/rtd.md). + ## Identifying visitors arriving from Email newsletters If you send Email newsletters that contain links to your website, then you may want to automatically _identify_ visitors that have clicked on any such links via their Email address. diff --git a/lib/core/prebid/rtd.md b/lib/core/prebid/rtd.md new file mode 100644 index 00000000..3743bfb8 --- /dev/null +++ b/lib/core/prebid/rtd.md @@ -0,0 +1,44 @@ +# Prebid RTD Module + +`buildRTD(options)` builds the config object behind the Optable RTD provider: its `handleRtd` merges cached EIDs into a Prebid auction's `ortb2Fragments`, routing each EID to its bidder (or to `global`) and applying a merge strategy per source. + +## Usage + +```js +import { buildRTD } from "@optable/web-sdk/lib/dist/core/prebid/rtd"; + +const rtd = buildRTD({ + waitForTargeting: true, + isControlGroup: () => isControlGroup, +}); +``` + +## Auction flow + +On each auction, `handleRtd`: + +1. Returns null while `isControlGroup()` is true — no EIDs reach bids. +2. Serves from the cache immediately when it holds EIDs. +3. Otherwise, with `waitForTargeting` on and a Prebid `auctionDelay` configured, waits for the `optable-targeting:change` event (sent whenever the SDK writes the targeting cache) up to the auction delay, then serves whatever the cache holds. A cache entry without EIDs does not skip the wait: targeting may still be in flight. +4. Routes each EID to the bidders configured for its source, falling back to `global` for unknown sources or bidders absent from the auction, and merges per the source's strategy. + +## Options + +| Option | Default | Description | +| ----------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `waitForTargeting` | `false` | Wait (bounded by Prebid's `auctionDelay`) for a targeting response when the cache has no EIDs. | +| `isControlGroup` | `() => false` | Split-test gate, evaluated per auction; while true, `handleRtd` serves no EIDs. | +| `eidSources` | built-in routing | Per-source bidder routes and merge strategy. Pass `{}` to route everything to `global`. | +| `mergeStrategy` | append | Global merge strategy; `appendMergeStrategy`, `prependMergeStrategy`, `replaceMergeStrategy` and `appendNewMergeStrategy` are exported. | +| `skipMerge` | `() => false` | Per-source veto called at merge time. | +| `matcherFilter` | `[]` | Only EIDs whose `matcher` is listed are served. | +| `matcherExclude` | `[]` | EIDs whose `matcher` is listed are dropped. | +| `optableCacheTargeting` | `OPTABLE_RESOLVED` | localStorage key of the targeting cache. | +| `targetingData` | read from cache | Explicit targeting data, bypassing the cache and the wait. | +| `forceGlobalRouting` | `false` | Route every EID to `global` instead of per-bidder. | +| `enableLogging` | `false` | Verbose logging. Also enabled by the `optableDebug` flag. | +| `instance` | `"instance"` | Name of the SDK instance on `window.optable`. | + +`buildRTD` also honors the `optableForceGlobalRouting` and `optableForceSkipMerge` [QA flags](../flags.md). + +Cache-only metadata (`_ref` UID2 refresh material) is stripped from EIDs before they reach bid requests. diff --git a/lib/core/prebid/rtd.test.ts b/lib/core/prebid/rtd.test.ts index 39c51da9..974e4f37 100644 --- a/lib/core/prebid/rtd.test.ts +++ b/lib/core/prebid/rtd.test.ts @@ -61,4 +61,43 @@ describe("buildRTD - waitForTargeting", () => { expect(globalEids(req)).toHaveLength(0); }); + + it("waits when the cache exists but has no EIDs", async () => { + seedCache([]); + const req = bidsConfig(); + const pending = buildRTD({ waitForTargeting: true }).handleRtd(req); + + seedCache(EIDS); + window.dispatchEvent(new CustomEvent(targetingEventName)); + await pending; + + expect(globalEids(req)).toHaveLength(1); + }); +}); + +describe("buildRTD - isControlGroup", () => { + it("serves no EIDs while the gate returns true", async () => { + seedCache(EIDS); + const req = bidsConfig(); + + const result = await buildRTD({ isControlGroup: () => true }).handleRtd(req); + + expect(result).toBeNull(); + expect(globalEids(req)).toHaveLength(0); + }); + + it("is re-evaluated per auction", async () => { + seedCache(EIDS); + let control = true; + const rtd = buildRTD({ isControlGroup: () => control }); + + const first = bidsConfig(); + await rtd.handleRtd(first); + expect(globalEids(first)).toHaveLength(0); + + control = false; + const second = bidsConfig(); + await rtd.handleRtd(second); + expect(globalEids(second)).toHaveLength(1); + }); }); diff --git a/lib/core/prebid/rtd.ts b/lib/core/prebid/rtd.ts index 3696efcb..50e54e54 100644 --- a/lib/core/prebid/rtd.ts +++ b/lib/core/prebid/rtd.ts @@ -69,6 +69,7 @@ interface RTDConfig { handleRtd: (reqBidsConfigObj: ReqBidsConfigObj, optableExtraData?: any, mergeFn?: any) => Promise; instance: string; waitForTargeting: boolean; + isControlGroup: () => boolean; } interface RTDOptions { @@ -83,6 +84,9 @@ interface RTDOptions { mergeStrategy?: MergeStrategy; instance?: string; waitForTargeting?: boolean; + // Split-test gate: while it returns true, handleRtd serves no EIDs. Wired + // to the wrapper's assignment (for example setupAB's result). + isControlGroup?: () => boolean; } // Merge strategies for EIDs @@ -161,12 +165,15 @@ function targetingFromCache(config: RTDConfig = {} as RTDConfig): TargetingData // Get targeting data from cache, if available async function readTargetingData(config: RTDConfig): Promise { const cachedData = targetingFromCache(config); + const cacheHasEids = (cachedData?.ortb2?.user?.eids?.length ?? 0) > 0; // Get auction delay from pbjs config const delay = (window as any)?.pbjs?.getConfig?.()?.realTimeData?.auctionDelay; - // If waitForTargeting is disabled, cache is not empty, or no delay configured, return immediately - if (!config.waitForTargeting || cachedData || !delay) { + // Return immediately when waitForTargeting is off, the cache already has + // EIDs to serve, or no auction delay bounds a wait. A cache entry without + // EIDs does not short-circuit: targeting may still be in flight. + if (!config.waitForTargeting || cacheHasEids || !delay) { if (!cachedData) { config.log("info", "No cached targeting data found"); return {}; @@ -383,7 +390,12 @@ function buildRTD(options: RTDOptions = {}): RTDConfig { targetingFromCache, instance: options.instance ?? "instance", waitForTargeting: options.waitForTargeting ?? false, + isControlGroup: options.isControlGroup ?? (() => false), async handleRtd(reqBidsConfigObj: ReqBidsConfigObj, optableExtraData?: any, mergeFn?: any): Promise { + if (this.isControlGroup()) { + this.log("info", "Control group - serving no EIDs"); + return null; + } const targetingData = options.targetingData ?? (await readTargetingData(this)); try { return handleRtd(this, reqBidsConfigObj, targetingData, optableExtraData, mergeFn);