From 56eede123103fcde54c4ea0887cc7eef06d59d1b Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Thu, 12 Nov 2026 17:31:53 -0500 Subject: [PATCH] prebid: read the RTD auction delay from the configured prebid global, not always window.pbjs --- lib/core/prebid/rtd.md | 3 +- lib/core/prebid/rtd.test.ts | 60 +++++++++++++++++++++++++++++++++++-- lib/core/prebid/rtd.ts | 16 +++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/core/prebid/rtd.md b/lib/core/prebid/rtd.md index 3743bfb8..15ea6ae8 100644 --- a/lib/core/prebid/rtd.md +++ b/lib/core/prebid/rtd.md @@ -19,7 +19,7 @@ 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. +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. The delay is read from `window[prebidGlobal]`, so a page running several Prebid instances has to name the one whose `realTimeData` config carries it, or no wait happens and the auction is served unenriched. 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 @@ -27,6 +27,7 @@ On each auction, `handleRtd`: | Option | Default | Description | | ----------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `waitForTargeting` | `false` | Wait (bounded by Prebid's `auctionDelay`) for a targeting response when the cache has no EIDs. | +| `prebidGlobal` | `"pbjs"` | Name of the Prebid global whose `realTimeData.auctionDelay` bounds that wait. | | `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. | diff --git a/lib/core/prebid/rtd.test.ts b/lib/core/prebid/rtd.test.ts index 974e4f37..7913c06f 100644 --- a/lib/core/prebid/rtd.test.ts +++ b/lib/core/prebid/rtd.test.ts @@ -4,7 +4,8 @@ import type { ReqBidsConfigObj } from "./rtd"; const EIDS = [{ source: "uidapi.com", uids: [{ id: "uid2-token" }] }]; -const w = window as unknown as { pbjs?: { getConfig: () => unknown } }; +type PrebidStub = { getConfig: () => unknown }; +const w = window as unknown as { pbjs?: PrebidStub; asc?: PrebidStub }; function bidsConfig(): ReqBidsConfigObj { return { ortb2Fragments: { global: {}, bidder: {} } }; @@ -18,10 +19,15 @@ function seedCache(eids: unknown[]) { localStorage.setItem("OPTABLE_RESOLVED", JSON.stringify({ ortb2: { user: { eids } } })); } +function withAuctionDelay(ms: number): PrebidStub { + return { getConfig: () => ({ realTimeData: { auctionDelay: ms } }) }; +} + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); - w.pbjs = { getConfig: () => ({ realTimeData: { auctionDelay: 500 } }) }; + w.pbjs = withAuctionDelay(500); + delete w.asc; }); describe("buildRTD - waitForTargeting", () => { @@ -75,6 +81,56 @@ describe("buildRTD - waitForTargeting", () => { }); }); +describe("buildRTD - prebidGlobal", () => { + it("takes the auction delay from the named global", async () => { + delete w.pbjs; + w.asc = withAuctionDelay(500); + const req = bidsConfig(); + + const pending = buildRTD({ waitForTargeting: true, prebidGlobal: "asc" }).handleRtd(req); + seedCache(EIDS); + window.dispatchEvent(new CustomEvent(targetingEventName)); + await pending; + + expect(globalEids(req)).toHaveLength(1); + }); + + // The bug this option exists for: the auction runs on another instance, the + // delay lookup misses, and the wait never happens. + it("does not wait when only another instance declares the delay", async () => { + delete w.pbjs; + w.asc = withAuctionDelay(500); + const req = bidsConfig(); + + await buildRTD({ waitForTargeting: true }).handleRtd(req); + + expect(globalEids(req)).toHaveLength(0); + }); + + it("still defaults to pbjs", async () => { + w.asc = withAuctionDelay(500); + 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("warns when the wait is asked for but no delay bounds it", async () => { + delete w.pbjs; + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const req = bidsConfig(); + + await buildRTD({ waitForTargeting: true, enableLogging: true }).handleRtd(req); + + expect(warn.mock.calls.flat().join(" ")).toContain("no realTimeData.auctionDelay"); + warn.mockRestore(); + }); +}); + describe("buildRTD - isControlGroup", () => { it("serves no EIDs while the gate returns true", async () => { seedCache(EIDS); diff --git a/lib/core/prebid/rtd.ts b/lib/core/prebid/rtd.ts index 50e54e54..e48b42d3 100644 --- a/lib/core/prebid/rtd.ts +++ b/lib/core/prebid/rtd.ts @@ -68,6 +68,7 @@ interface RTDConfig { targetingFromCache: (config?: RTDConfig) => TargetingData | null; handleRtd: (reqBidsConfigObj: ReqBidsConfigObj, optableExtraData?: any, mergeFn?: any) => Promise; instance: string; + prebidGlobal: string; waitForTargeting: boolean; isControlGroup: () => boolean; } @@ -83,6 +84,9 @@ interface RTDOptions { forceGlobalRouting?: boolean; mergeStrategy?: MergeStrategy; instance?: string; + // Name of the publisher's Prebid global, the one whose realTimeData + // auctionDelay bounds the waitForTargeting wait. + prebidGlobal?: 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). @@ -168,7 +172,16 @@ async function readTargetingData(config: RTDConfig): Promise { const cacheHasEids = (cachedData?.ortb2?.user?.eids?.length ?? 0) > 0; // Get auction delay from pbjs config - const delay = (window as any)?.pbjs?.getConfig?.()?.realTimeData?.auctionDelay; + const delay = (window as any)?.[config.prebidGlobal]?.getConfig?.()?.realTimeData?.auctionDelay; + + // An unbounded wait would hold the auction indefinitely, so a missing delay + // still serves. Say so: it reads as waitForTargeting silently doing nothing. + if (config.waitForTargeting && !cacheHasEids && !delay) { + config.log( + "warn", + `waitForTargeting is set but window.${config.prebidGlobal} reports no realTimeData.auctionDelay; serving this auction unenriched` + ); + } // Return immediately when waitForTargeting is off, the cache already has // EIDs to serve, or no auction delay bounds a wait. A cache entry without @@ -389,6 +402,7 @@ function buildRTD(options: RTDOptions = {}): RTDConfig { appendNewMergeStrategy, targetingFromCache, instance: options.instance ?? "instance", + prebidGlobal: options.prebidGlobal ?? "pbjs", waitForTargeting: options.waitForTargeting ?? false, isControlGroup: options.isControlGroup ?? (() => false), async handleRtd(reqBidsConfigObj: ReqBidsConfigObj, optableExtraData?: any, mergeFn?: any): Promise {