Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,18 @@ For bidder adapters that do not support SDA, but that do support targeting priva
</script>
```

### 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.
Expand Down
2 changes: 1 addition & 1 deletion lib/core/events/cache-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ function sendTargetingUpdateEvent(config: ResolvedConfig, response: TargetingRes
);
}

export { sendTargetingUpdateEvent };
export { sendTargetingUpdateEvent, targetingEventName };
44 changes: 44 additions & 0 deletions lib/core/prebid/rtd.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 103 additions & 0 deletions lib/core/prebid/rtd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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);
});

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);
});
});
42 changes: 22 additions & 20 deletions lib/core/prebid/rtd.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -68,6 +69,7 @@ interface RTDConfig {
handleRtd: (reqBidsConfigObj: ReqBidsConfigObj, optableExtraData?: any, mergeFn?: any) => Promise<void | null>;
instance: string;
waitForTargeting: boolean;
isControlGroup: () => boolean;
}

interface RTDOptions {
Expand All @@ -82,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
Expand Down Expand Up @@ -160,12 +165,15 @@ function targetingFromCache(config: RTDConfig = {} as RTDConfig): TargetingData
// Get targeting data from cache, if available
async function readTargetingData(config: RTDConfig): Promise<TargetingData> {
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 {};
Expand All @@ -188,30 +196,19 @@ async function readTargetingData(config: RTDConfig): Promise<TargetingData> {
config.log("info", `Waiting for targeting data (max ${delay}ms)`);

const targetingData = await new Promise<TargetingData | null>((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) {
Expand Down Expand Up @@ -393,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<void | null> {
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);
Expand Down