Repository navigation
Conversation
|
This PR introduces changes that may not work on all browsers. According to Babel, the following polyfills may be needed, and they are not automatically included:
The best way to address this is to provide good test coverage, as normal PR checks run unit tests on older browsers. |
| // ortb2Fragments are wrapped in Proxies that intercept eids; read and write the property directly | ||
| const eidsDesc = Object.getOwnPropertyDescriptor(targetORTB2.user.ext, 'eids'); | ||
| const currentEids = Array.isArray(eidsDesc?.value) ? eidsDesc.value : []; | ||
|
|
||
| Object.defineProperty(targetORTB2.user.ext, 'eids', { | ||
| value: [...currentEids, ...filteredTargetingData.ortb2.user.eids], | ||
| writable: true, | ||
| enumerable: true, | ||
| configurable: true, | ||
| }); |
There was a problem hiding this comment.
Object.defineProperty skips the guardOrtb2Fragments guard on the second targetingHandleRtd write. The Prebid guard defines get, set and deleteProperty, but not defineProperty. That means that if the user withholds consent and Prebid denies Optable permission to add user IDs, the Optable EID still get into the bid request sent to the bidders. Also if permission is granted, the EID is sent twice. We should just delete the block and merge to one path through the guard. So replace the mergeFn call with
const { eids, ...user } = filteredTargetingData.ortb2.user ?? {};
mergeFn(reqBidsConfigObj.ortb2Fragments.global, {
...filteredTargetingData.ortb2,
user: eids ? { ...user, ext: { ...user.ext, eids } } : user,
});
| } | ||
| }; | ||
|
|
||
| ajaxStub = sandbox.stub(ajax, 'ajax'); |
There was a problem hiding this comment.
This wont intercept anything. The Babel plugin rewrites the module import import { ajax } into const ajax = __ajax0.withCallers(...). Likely why the test are failing.
|
|
||
| beforeEach(() => { | ||
| sandbox = sinon.createSandbox(); | ||
| storage = getStorageManager({ moduleType: 'rtd', moduleName: 'optable' }); |
There was a problem hiding this comment.
This returns a new object on every call so it'll just stub its own copy, not the actual module private storage
| expect(result.cookies).to.be.true; | ||
| expect(result.ids).to.deep.equal([]); | ||
| expect(result.hids).to.deep.equal([]); | ||
| expect(result.hasDirectApiConfig).to.be.true; |
There was a problem hiding this comment.
This is suppose to return my-site so true will never be valid. to.be.true expects the exact boolean so truthy wont work
| expect(result).to.not.be.null; | ||
| expect(result.adserverTargeting).to.be.true; | ||
| expect(result.instance).to.equal('custom'); | ||
| expect(result.hasDirectApiConfig).to.be.false; |
There was a problem hiding this comment.
Same as above. This returns null
| const moduleConfig = { params: { adserverTargeting: true } }; | ||
| const result = parseTargetingConfig(moduleConfig); | ||
| expect(result).to.not.be.null; | ||
| expect(result.hasDirectApiConfig).to.be.false; |
There was a problem hiding this comment.
Same as above. This returns null as well
| logMessage(`targetingHandleRtd: received targeting data with ${eidCount} EIDs`); | ||
|
|
||
| let filteredTargetingData = targetingData; | ||
| if (options.skipMergeIfUid2Exists && targetingData.ortb2.user?.eids) { |
There was a problem hiding this comment.
skipMergeIfUid2Exists only looks at user.eids, but by the time RTD runs existing EIDs are in user.ext.eids. normalizeFPD already has deleted user.eids as well
| // Serve the cache immediately and refresh it in the background, once per page load | ||
| const cachedData = getCachedTargeting(); | ||
| if (cachedData) { | ||
| handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep, options); |
There was a problem hiding this comment.
Should be await handleRtdFn. As per the md file
| params.handleRtd | Function | Custom handler: `[async] (reqBidsConfigObj, targetingData, mergeFn, options) => {}`. `options` carries `skipMergeIfUid2Exists` | `null` |
"callback() releases the auction before an async handleRtd merge anything
|
|
||
| setCachedTargeting(targetingData); | ||
| setSDKTargetingCache(host, node, targetingData); | ||
| handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep, options); |
| const purpose1Consent = userConsent.gdpr.vendorData.purpose?.consents?.[1] || | ||
| userConsent.gdpr.vendorData.publisher?.consents?.[1]; | ||
| if (purpose1Consent !== undefined) { | ||
| consent.deviceAccess = purpose1Consent; | ||
| } |
There was a problem hiding this comment.
purpose.consents[1] || publisher.consents[1] becomes false || undefined which is undefined so a refused Purpose 1 leaves deviceAccess true and the request goes out withCredentials
|
The repo as a 80% test coverage rule as per the |
| * @param {Object} targetingData Targeting response | ||
| * @returns {boolean} True when the response is worth caching | ||
| */ | ||
| const isCacheable = (targetingData) => !!(targetingData && (targetingData.ortb2 || targetingData.split_test_assignment)); |
There was a problem hiding this comment.
isCacheable and getCachedTargeting disagree. A response with only data is stored but never served so every auction waits on the API. Split-test only responses are served and then dropped by targetingHandleRtd.
| * @typedef {import('./optableRtdProvider.d.ts').OptableMergeFn} OptableMergeFn | ||
| */ | ||
|
|
||
| const MODULE_NAME = 'optable'; | ||
| export const LOG_PREFIX = `[${MODULE_NAME} RTD]:`; | ||
| const optableLog = prefixLog(LOG_PREFIX); | ||
| const { logMessage, logWarn, logError } = optableLog; | ||
| const storage = getStorageManager({ moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME }); |
There was a problem hiding this comment.
Repo has a review rule for metadata disclosure which means optable-cache:targeting, OPTABLE_PASSPORT_* and OPTABLE_TARGETING_* (see below) requires adding disclosureURL: 'local://modules/optableRtdProvider.json' with a matching metadata/disclosures/modules/. See what 51Degrees and chromeAi does
|
|
||
| if (filteredTargetingData.ortb2.user?.eids) { | ||
| const targetORTB2 = reqBidsConfigObj.ortb2Fragments.global; | ||
| targetORTB2.user = targetORTB2.user ?? {}; | ||
| targetORTB2.user.ext = targetORTB2.user.ext ?? {}; | ||
|
|
||
| // ortb2Fragments are wrapped in Proxies that intercept eids; read and write the property directly | ||
| const eidsDesc = Object.getOwnPropertyDescriptor(targetORTB2.user.ext, 'eids'); | ||
| const currentEids = Array.isArray(eidsDesc?.value) ? eidsDesc.value : []; | ||
|
|
||
| Object.defineProperty(targetORTB2.user.ext, 'eids', { | ||
| value: [...currentEids, ...filteredTargetingData.ortb2.user.eids], | ||
| writable: true, | ||
| enumerable: true, | ||
| configurable: true, | ||
| }); | ||
|
|
||
| logMessage(`EIDs also available in ortb2.user.ext.eids (${finalEidCount} EIDs)`); | ||
| } |
There was a problem hiding this comment.
This block uses Object.defineProperty to get past the ortb2Fragments Proxy. That Proxy is Prebid's ortb2Guard (libraries/objectGuard/ortbGuard.js), which blocks writes to user.eids / user.ext.eids when the publisher denies the enrichEids activity. objectGuard only traps get, set and deleteProperty, so defineProperty goes straight to the target and the publisher's activity controls are skipped. It also sends every EID to bidders twice.
The mergeDeep above already writes user.eids through the guard, and Prebid moves user.eids to user.ext.eids for ORTB 2.5 bidders. Suggest removing the block (and the ortb2.user.ext.eids test):
| if (filteredTargetingData.ortb2.user?.eids) { | |
| const targetORTB2 = reqBidsConfigObj.ortb2Fragments.global; | |
| targetORTB2.user = targetORTB2.user ?? {}; | |
| targetORTB2.user.ext = targetORTB2.user.ext ?? {}; | |
| // ortb2Fragments are wrapped in Proxies that intercept eids; read and write the property directly | |
| const eidsDesc = Object.getOwnPropertyDescriptor(targetORTB2.user.ext, 'eids'); | |
| const currentEids = Array.isArray(eidsDesc?.value) ? eidsDesc.value : []; | |
| Object.defineProperty(targetORTB2.user.ext, 'eids', { | |
| value: [...currentEids, ...filteredTargetingData.ortb2.user.eids], | |
| writable: true, | |
| enumerable: true, | |
| configurable: true, | |
| }); | |
| logMessage(`EIDs also available in ortb2.user.ext.eids (${finalEidCount} EIDs)`); | |
| } |
| // deviceAccess follows TCF Purpose 1 | ||
| if (userConsent.gdpr.vendorData) { | ||
| const purpose1Consent = userConsent.gdpr.vendorData.purpose?.consents?.[1] || | ||
| userConsent.gdpr.vendorData.publisher?.consents?.[1]; | ||
| if (purpose1Consent !== undefined) { | ||
| consent.deviceAccess = purpose1Consent; | ||
| } | ||
| } |
There was a problem hiding this comment.
When the user denies Purpose 1, purpose.consents[1] is false, so the || falls through to publisher.consents[1], which is usually undefined. The !== undefined check then fails and deviceAccess stays true, so the call goes out with withCredentials: true. publisher.consents is the publisher's own purpose consent, not consent for vendors, so it shouldn't count here. When GDPR applies and there is no TCF data, denying is the safer default.
| // deviceAccess follows TCF Purpose 1 | |
| if (userConsent.gdpr.vendorData) { | |
| const purpose1Consent = userConsent.gdpr.vendorData.purpose?.consents?.[1] || | |
| userConsent.gdpr.vendorData.publisher?.consents?.[1]; | |
| if (purpose1Consent !== undefined) { | |
| consent.deviceAccess = purpose1Consent; | |
| } | |
| } | |
| // deviceAccess follows TCF Purpose 1 when GDPR applies | |
| if (userConsent.gdpr.gdprApplies) { | |
| consent.deviceAccess = userConsent.gdpr.vendorData?.purpose?.consents?.[1] === true; | |
| } |
| /** | ||
| * @param {Object} targetingData Targeting response | ||
| * @returns {boolean} True when the response is worth caching | ||
| */ | ||
| const isCacheable = (targetingData) => !!(targetingData && (targetingData.ortb2 || targetingData.split_test_assignment)); |
There was a problem hiding this comment.
With this guard, a successful response with no EIDs and no split test assignment never replaces the cache. Once the DCN stops returning EIDs for a user (opt-out, identity cleared), the old ones keep being served on every page indefinitely. A successful response, empty or not, should replace the cache. Only a failed call should leave it alone. callTargetingAPI already resolves null on an HTTP error or an unparseable body, so the guard only needs to check for that:
| /** | |
| * @param {Object} targetingData Targeting response | |
| * @returns {boolean} True when the response is worth caching | |
| */ | |
| const isCacheable = (targetingData) => !!(targetingData && (targetingData.ortb2 || targetingData.split_test_assignment)); | |
| /** | |
| * @param {Object|null} targetingData Targeting response, or null when the call failed | |
| * @returns {boolean} True when the response should replace the cache: any successful | |
| * response does, even an empty one; a failed call keeps the previous entry | |
| */ | |
| const isCacheable = (targetingData) => !!targetingData; |
| } | ||
|
|
||
| // Leave 100ms of the auction delay for the merge | ||
| const effectiveTimeout = (timeout && timeout > 100) ? timeout - 100 : configTimeout; |
There was a problem hiding this comment.
Two things here:
- Whenever
auctionDelay > 100, this silently overridesparams.timeout. An explicit config value should win:
| const effectiveTimeout = (timeout && timeout > 100) ? timeout - 100 : configTimeout; | |
| const effectiveTimeout = configTimeout ?? ((timeout && timeout > 100) ? timeout - 100 : null); |
- It is sent to the DCN as a bare number (
timeout=2400). Can you confirm/v2/targetingexpects milliseconds in that format?
Also, there's no client-side timeout. If the API answers after auctionDelay, the RTD framework has already released the auction, but handleRtdFn still runs and changes ortb2Fragments for an auction that was already sent. Suggest caching the late response for the next auction and not merging it:
const startedAt = Date.now();
const targetingData = await fetchTargeting(apiParams); // see the comment on the background refresh
if (!targetingData) {
logWarn('No targeting data returned from API');
callback();
return;
}
// The RTD framework stops waiting after auctionDelay
if (timeout && Date.now() - startedAt >= timeout) {
logWarn('Targeting API answered after auctionDelay, cached for the next auction');
callback();
return;
}
await handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep, options);
callback();await handleRtdFn(...) also covers async custom handlers. On this path and the cached one they currently run without being awaited, so callback() fires before they finish.
| const { logMessage, logWarn, logError } = optableLog; | ||
| const storage = getStorageManager({ moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME }); | ||
|
|
||
| const RTD_MODULE_VARIANT = 'optable-rtd-1.0.0-pr14405-v1'; |
There was a problem hiding this comment.
This variant still references the superseded #14405. It's sent as osdk and stamped on every ad unit, so it'll show up in DCN and bidder logs:
| const RTD_MODULE_VARIANT = 'optable-rtd-1.0.0-pr14405-v1'; | |
| const RTD_MODULE_VARIANT = 'optable-rtd-1.0.0'; |
(If DCN-side reporting already keys on the current string, change both together.)
| if (window.pbjs) { | ||
| window.pbjs._optableRtdVariant = RTD_MODULE_VARIANT; | ||
| } |
There was a problem hiding this comment.
The Prebid global name is configurable (globalVarName), so window.pbjs misses builds that rename it. Use getGlobal() (import { getGlobal } from '../src/prebidGlobal.js';). It always exists, so the guard can go:
| if (window.pbjs) { | |
| window.pbjs._optableRtdVariant = RTD_MODULE_VARIANT; | |
| } | |
| getGlobal()._optableRtdVariant = RTD_MODULE_VARIANT; |
| return new Promise((resolve) => { | ||
| const optableBundle = /** @type {Object} */ (window.optable); | ||
| const cachedData = optableBundle?.instance?.targetingFromCache(); | ||
| const cachedData = optableBundle?.[instance || 'instance']?.targetingFromCache(); |
There was a problem hiding this comment.
isSDKAvailable checks for .targeting, but this calls .targetingFromCache() without optional chaining. An instance without that method (an older SDK, or a custom instance) throws inside the Promise executor, and the auction goes out without Optable data instead of waiting for the event:
| const cachedData = optableBundle?.[instance || 'instance']?.targetingFromCache(); | |
| const cachedData = optableBundle?.[instance || 'instance']?.targetingFromCache?.(); |
| if (!targetingCallMade) { | ||
| targetingCallMade = true; | ||
| callTargetingAPI(apiParams).then(data => { | ||
| if (data) { | ||
| logMessage('Background API call completed, cache updated'); | ||
| setCachedTargeting(data); | ||
| setSDKTargetingCache(host, node, data); | ||
| } | ||
| }).catch(error => { | ||
| logWarn('Background API call failed:', error); | ||
| }); | ||
| } |
There was a problem hiding this comment.
targetingCallMade only covers this background refresh. When nothing is cached, every concurrent auction (common with several requestBids calls at page load) makes its own blocking /v2/targeting call. Suggest sharing one in-flight request per page load, replacing targetingCallMade (line 203) and handling the cache writes in one place:
let targetingRequest = null;
/**
* Call the targeting API once per page load and update both caches. Concurrent
* auctions share the request; a failed call is retried by the next auction.
* @param {Object} apiParams Targeting API parameters
* @returns {Promise<Object|null>} Targeting response or null
*/
const fetchTargeting = (apiParams) => {
if (!targetingRequest) {
targetingRequest = callTargetingAPI(apiParams).then(data => {
if (data) {
setCachedTargeting(data);
setSDKTargetingCache(apiParams.host, apiParams.node, data);
} else {
targetingRequest = null;
}
return data;
});
}
return targetingRequest;
};Then this block becomes fetchTargeting(apiParams);, and the uncached path becomes const targetingData = await fetchTargeting(apiParams); without its own setCachedTargeting / setSDKTargetingCache calls.
| - **UID2 refresh data:** `ext.optable.ref` pointers are stripped from EIDs before they reach bid requests, and `refs` are stored keyed by EID source, matching the Web SDK cache format. | ||
| - **Passport:** Stored under the Web SDK's `OPTABLE_PASSPORT_*` key, and removed from the response before merging. | ||
| - **Consent:** GPP and TCF strings come from Prebid's consent modules. Credentials are only sent when TCF Purpose 1 is granted. | ||
| - **Ad server targeting:** `adserverTargeting` still requires the Web SDK on the page. |
There was a problem hiding this comment.
The docs are right, but in direct-API mode without the SDK, getTargetingData still logs No Optable SDK instance found as a warning on every auction, which is expected in that setup. Suggest lowering it to a message when targeting mode is on (in getTargetingData, outside this diff):
if (!sdkInstance) {
const log = isTargetingEnabled(moduleConfig) ? logMessage : logWarn;
log(`No Optable SDK instance found for: ${instanceKey}, skipping ad server targeting`);
return targetingData;
}|
@mosherBT The cache bullet in the description doesn't match the code: only - Responses are cached in localStorage under `optable-cache:targeting`, which the module reads back, and also under the Web SDK's `OPTABLE_TARGETING_<base64(host/node)>` key so the SDK's `targetingFromCache()` can use them. `optable-cache:targeting` is not scoped by host or node. The passport is stored under the Web SDK's `OPTABLE_PASSPORT_<base64(host/node)>` key.
- A cached response is used immediately and refreshed in the background once per page load. A successful response replaces the cache, even when empty; a failed call leaves it untouched.The last sentence assumes the |
Type of change
Description of change
Adds an opt-in targeting mode to the Optable RTD module, enabled with
params.enableTargeting: true. Without it, the module behaves exactly as before.With
enableTargeting:optable-targeting:changeevent, or uses its cached targeting./v2/targeting) directly, usinghost,nodeandsite, with consent taken from Prebid's consent modules.skipMergeIfUid2Existsoptionally skips Optable's UID2 EIDs when one is already present.ortb2Imp.ext.optable.variant, and withsplitTestAssignmentwhen the DCN returns one.New params are documented in
modules/optableRtdProvider.mdand typed inmodules/optableRtdProvider.d.ts.Other information
Supersedes #14405.