diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index 5e1c8a77cb..38facc5652 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -2,8 +2,6 @@ Optable RTD submodule example - Prebid.js - - @@ -130,11 +128,6 @@ ]; pbjs.setConfig({ - optableRtdConfig: { // optional, check the doc for explanation - email: 'email-sha256-hash', - phone: 'phone-sha256-hash', - postal_code: 'postal_code', - }, debug: true, // use only for testing, remove in production realTimeData: { auctionDelay: 1000, // should be set lower in production use @@ -143,26 +136,23 @@ name: 'optable', waitForIt: true, params: { - // bundleUrl: "https://prebidtest.solutions.cdn.optable.co/public-assets/prebidtest-sdk.js?hello=world", - // adserverTargeting: false, - // handleRtd: async (reqBidsConfigObj, optableExtraData, mergeFn) => { - // const optableBundle = /** @type {Object} */ (window.optable); - // console.warn('Entering custom RTD handler'); - // console.warn('reqBidsConfigObj', reqBidsConfigObj); - // console.warn('optableExtraData', optableExtraData); - // console.warn('mergeFn', mergeFn); - // - // // Call Optable DCN for targeting data and return the ORTB2 object - // const targetingData = await optableBundle.instance.targeting(); - // - // if (!targetingData || !targetingData.ortb2) { - // return; - // } - // - // mergeFn( - // reqBidsConfigObj.ortb2Fragments.global, - // targetingData.ortb2, - // ); + // REQUIRED PARAMETERS: + host: 'na.edge.optable.co', // Your DCN hostname + node: 'prebidtest', // Your node identifier + site: 'prebidtest-sdk', // Your site identifier + + // OPTIONAL PARAMETERS: + cookies: false, // Cookie mode (default: true, set to false for cookieless) + // timeout: '500ms', // API timeout hint + // ids: ['i4:1.2.3.4'], // User identifiers (also auto-extracted from Prebid userId module) + // hids: ['hid1'], // Hint identifiers + + // CUSTOM HANDLER (optional): + // handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { + // console.log('Custom RTD handler'); + // console.log('Targeting data:', targetingData); + // // Perform custom logic here + // mergeFn(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2); // } } } @@ -172,12 +162,36 @@ pbjs.addAdUnits(adUnits); + // Track if we've already displayed the data (only show once) + let displayedEnrichedData = false; + pbjs.onEvent('bidRequested', function (data) { try { - window.optable.cmd.push(() => { + // Display the enriched user data from Optable RTD + if (!displayedEnrichedData && data.ortb2 && data.ortb2.user) { + console.log('Full bid request ortb2:', data.ortb2); + console.log('User data:', data.ortb2.user); + + const userData = data.ortb2.user; + const eidCount = userData.eids ? userData.eids.length : 0; + const dataCount = userData.data ? userData.data.length : 0; + + console.log(`Found ${eidCount} EIDs and ${dataCount} data segments`); + document.getElementById('enriched-optable').style.display = 'block'; - document.getElementById('enriched-optable-data').textContent = JSON.stringify(data.ortb2.user, null, 2); - }); + document.getElementById('enriched-optable-data').textContent = JSON.stringify(userData, null, 2); + displayedEnrichedData = true; + } + + // Display split test variant if present in adUnit ortb2Imp + if (data.bids && data.bids.length > 0) { + data.bids.forEach(bid => { + const splitTestAssignment = bid.ortb2Imp?.ext?.optable?.splitTestAssignment; + if (splitTestAssignment) { + console.log(`Split test variant for ${bid.adUnitCode}: ${splitTestAssignment}`); + } + }); + } } catch (e) { console.error('Exception while trying to display enriched data', e); } diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index 290c0947ee..fcc166c566 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -1,47 +1,264 @@ +/** + * Optable Real-Time Data (RTD) Provider Module for Prebid.js + * See modules/optableRtdProvider.md for full documentation + */ + import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; -import { loadExternalScript } from '../src/adloader.js'; -import { config } from '../src/config.js'; import { submodule } from '../src/hook.js'; import { deepAccess, mergeDeep, prefixLog } from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; +import { getStorageManager } from '../src/storageManager.js'; 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 }); + +// RTD module variant for debugging and tracking +const RTD_MODULE_VARIANT = 'optable-rtd-1.0.0-pr14405'; + +// localStorage key for targeting cache (direct API mode only) +const OPTABLE_CACHE_KEY = 'optable-cache:targeting'; + +// Storage key prefix for passport (visitor ID) - compatible with Web SDK format +const PASSPORT_KEY_PREFIX = 'OPTABLE_PASSPORT_'; /** - * Extracts the parameters for Optable RTD module from the config object passed at instantiation + * Parse and validate module configuration * @param {Object} moduleConfig Configuration object for the module + * @returns {Object} Parsed configuration */ export const parseConfig = (moduleConfig) => { - let bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null); + // Check for deprecated bundleUrl parameter + const bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null); + if (bundleUrl) { + logError('bundleUrl parameter is no longer supported. Please either: (1) Load Optable SDK directly in your page HTML, OR (2) Switch to Direct API mode using host/node/site parameters. See documentation for details.'); + return null; + } + + const host = deepAccess(moduleConfig, 'params.host', null); + const node = deepAccess(moduleConfig, 'params.node', null); + const site = deepAccess(moduleConfig, 'params.site', null); const adserverTargeting = deepAccess(moduleConfig, 'params.adserverTargeting', true); - const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); const instance = deepAccess(moduleConfig, 'params.instance', null); - // If present, trim the bundle URL - if (typeof bundleUrl === 'string') { - bundleUrl = bundleUrl.trim(); - } + const hasDirectApiConfig = host && node && site; - // Verify that bundleUrl is a valid URL: only secure (HTTPS) URLs are allowed - if (typeof bundleUrl === 'string' && bundleUrl.length && !bundleUrl.startsWith('https://')) { - logError('Invalid URL format for bundleUrl in moduleConfig. Only HTTPS URLs are allowed.'); - return { bundleUrl: null, adserverTargeting, handleRtd: null }; + if (host !== null && (typeof host !== 'string' || !host.trim())) { + logError('host parameter must be a non-empty string'); + return null; + } + if (node !== null && (typeof node !== 'string' || !node.trim())) { + logError('node parameter must be a non-empty string'); + return null; } + if (site !== null && (typeof site !== 'string' || !site.trim())) { + logError('site parameter must be a non-empty string'); + return null; + } + + const cookies = deepAccess(moduleConfig, 'params.cookies', true); + const timeout = deepAccess(moduleConfig, 'params.timeout', null); + const ids = deepAccess(moduleConfig, 'params.ids', []); + const hids = deepAccess(moduleConfig, 'params.hids', []); + const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); + const skipMergeIfUid2Exists = deepAccess(moduleConfig, 'params.skipMergeIfUid2Exists', false); if (handleRtd && typeof handleRtd !== 'function') { logError('handleRtd must be a function'); - return { bundleUrl, adserverTargeting, handleRtd: null }; + return null; } - const result = { bundleUrl, adserverTargeting, handleRtd }; - if (instance !== null) { - result.instance = instance; + if (!Array.isArray(ids)) { + logError('ids parameter must be an array'); + return null; } - return result; + if (!Array.isArray(hids)) { + logError('hids parameter must be an array'); + return null; + } + + return { + host: host ? host.trim() : null, + node: node ? node.trim() : null, + site: site ? site.trim() : null, + cookies, + timeout, + ids, + hids, + handleRtd, + adserverTargeting, + instance, + hasDirectApiConfig, + skipMergeIfUid2Exists + }; } +// Global session ID (generated once per page load) +let sessionID = null; + +// Track if we've made a targeting API call this session (to avoid redundant calls) +let targetingCallMade = false; + +/** + * Generates a random session ID (base64url encoded 16-byte random value) + * @returns {string} Session ID + */ +export const generateSessionID = () => { + if (sessionID) { + return sessionID; + } + + // Generate 16 random bytes + const arr = new Uint8Array(16); + crypto.getRandomValues(arr); + + // Convert to base64url (URL-safe, no padding) + sessionID = btoa(String.fromCharCode(...arr)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + + return sessionID; +}; + +/** + * Base64 encode a string using UTF-16LE encoding (compatible with SDK) + * @param {string} str String to encode + * @returns {string} Base64 encoded string + */ +const encodeBase64 = (str) => { + const codeUnits = new Uint16Array(str.length); + for (let i = 0; i < codeUnits.length; i++) { + codeUnits[i] = str.charCodeAt(i); + } + return btoa(String.fromCharCode(...new Uint8Array(codeUnits.buffer))); +}; + +/** + * Generate storage key for passport based on host/node + * @param {string} host DCN host + * @param {string} node Node identifier + * @returns {string} Storage key + */ +const generatePassportKey = (host, node) => { + const base = `${host}/${node}`; + return `${PASSPORT_KEY_PREFIX}${encodeBase64(base)}`; +}; + +/** + * Get passport from localStorage + * @param {string} host DCN host + * @param {string} node Node identifier + * @returns {string|null} Passport value or null + */ +export const getPassport = (host, node) => { + const key = generatePassportKey(host, node); + return storage.getDataFromLocalStorage(key); +}; + +/** + * Set passport in localStorage + * @param {string} host DCN host + * @param {string} node Node identifier + * @param {string} passport Passport value + */ +export const setPassport = (host, node, passport) => { + const key = generatePassportKey(host, node); + storage.setDataInLocalStorage(key, passport); +}; + +/** + * Generate SDK-compatible targeting cache key + * @param {string} host DCN host + * @param {string} node Node identifier + * @returns {string} Storage key in format OPTABLE_TARGETING_ + */ +const generateSDKTargetingKey = (host, node) => { + const base = `${host}/${node}`; + return `OPTABLE_TARGETING_${encodeBase64(base)}`; +}; + +/** + * Update SDK-compatible targeting cache in localStorage + * This ensures compatibility with SDK's targetingFromCache() method + * @param {string} host DCN host + * @param {string} node Node identifier + * @param {Object} targetingData Targeting response data + */ +const setSDKTargetingCache = (host, node, targetingData) => { + if (!targetingData) { + return; + } + // Cache if we have ortb2 data OR split_test_assignment + // Can have data witheld due to split test but still want to cache this response + if (!targetingData.ortb2 && !targetingData.split_test_assignment) { + return; + } + const key = generateSDKTargetingKey(host, node); + storage.setDataInLocalStorage(key, JSON.stringify(targetingData)); + logMessage(`Updated SDK-compatible cache: ${key}`); +}; + +/** + * Get cached targeting data from localStorage + * @returns {Object|null} Cached targeting data or null + */ +const getCachedTargeting = () => { + const cacheData = storage.getDataFromLocalStorage(OPTABLE_CACHE_KEY); + if (cacheData) { + try { + const parsedData = JSON.parse(cacheData); + const eidCount = parsedData?.ortb2?.user?.eids?.length || 0; + const splitTestAssignment = parsedData?.split_test_assignment; + + // Use cache if we have EIDs OR if we have a split_test_assignment (test or control) + if (eidCount > 0) { + logMessage(`Found cached targeting with ${eidCount} EIDs`); + return parsedData; + } else if (splitTestAssignment) { + logMessage(`Found cached targeting with split_test_assignment="${splitTestAssignment}" (no EIDs)`); + return parsedData; + } + + logMessage(`Ignoring cached targeting: ${eidCount} EIDs, split_test_assignment="${splitTestAssignment || 'none'}"`); + return null; + } catch (e) { + logWarn('Failed to parse cached targeting', e); + } + } + return null; +}; + +/** + * Set targeting data in localStorage + * @param {Object} targetingData Targeting response + */ +const setCachedTargeting = (targetingData) => { + if (!targetingData) { + return; + } + // Cache if we have ortb2 data OR split_test_assignment + if (!targetingData.ortb2 && !targetingData.split_test_assignment) { + return; + } + storage.setDataInLocalStorage(OPTABLE_CACHE_KEY, JSON.stringify(targetingData)); +}; + +/** + * Check if Optable Web SDK is available on the page + * @param {string|null} instance SDK instance name (default: 'instance') + * @returns {boolean} True if SDK is available + */ +const isSDKAvailable = (instance = null) => { + const instanceKey = instance || 'instance'; + return typeof window !== 'undefined' && + window.optable && + window.optable[instanceKey] && + typeof window.optable[instanceKey].targeting === 'function'; +}; + /** * Wait for Optable SDK event to fire with targeting data * @param {string} eventName Name of the event to listen for @@ -60,7 +277,6 @@ const waitForOptableEvent = (eventName) => { const eventListener = (event) => { logMessage(`Received ${eventName} event`); - // Extract targeting data from event detail const targetingData = event.detail; window.removeEventListener(eventName, eventListener); resolve(targetingData); @@ -72,130 +288,473 @@ const waitForOptableEvent = (eventName) => { }; /** - * Default function to handle/enrich RTD data - * @param reqBidsConfigObj Bid request configuration object - * @param optableExtraData Additional data to be used by the Optable SDK - * @param mergeFn Function to merge data + * Handle RTD data using SDK mode (event-based) + * @param {Function} handleRtdFn Custom handler function or default + * @param {Object} reqBidsConfigObj Bid request configuration + * @param {Function} mergeFn Merge function + * @param {Object} config Configuration object with skipMergeIfUid2Exists flag * @returns {Promise} */ -export const defaultHandleRtd = async (reqBidsConfigObj, optableExtraData, mergeFn) => { - // Wait for the Optable SDK to dispatch targeting data via event - let targetingData = await waitForOptableEvent('optable-targeting:change'); +const handleSDKMode = async (handleRtdFn, reqBidsConfigObj, mergeFn, config) => { + const targetingData = await waitForOptableEvent('optable-targeting:change'); if (!targetingData || !targetingData.ortb2) { - logWarn('No targeting data found'); + logWarn('No targeting data from SDK event'); return; } - mergeFn( - reqBidsConfigObj.ortb2Fragments.global, - targetingData.ortb2, - ); - logMessage('Prebid\'s global ORTB2 object after merge: ', reqBidsConfigObj.ortb2Fragments.global); + if (handleRtdFn.constructor.name === 'AsyncFunction') { + await handleRtdFn(reqBidsConfigObj, targetingData, mergeFn, config); + } else { + handleRtdFn(reqBidsConfigObj, targetingData, mergeFn, config); + } }; /** - * Get data from Optable and merge it into the global ORTB2 object - * @param {Function} handleRtdFn Function to handle RTD data - * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Object} optableExtraData Additional data to be used by the Optable SDK - * @param {Function} mergeFn Function to merge data + * Extract consent information from Prebid's userConsent parameter + * @param {Object} userConsent User consent object passed by Prebid RTD framework + * @returns {Object} Consent object with GPP, GDPR strings, and deviceAccess flag */ -export const mergeOptableData = async (handleRtdFn, reqBidsConfigObj, optableExtraData, mergeFn) => { - if (handleRtdFn.constructor.name === 'AsyncFunction') { - await handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn); +const extractConsent = (userConsent) => { + const consent = { + deviceAccess: true, + gpp: null, + gppSectionIDs: null, + gdpr: null, + gdprApplies: null + }; + + // Extract GPP consent if available + if (userConsent?.gpp) { + consent.gpp = userConsent.gpp.gppString || null; + consent.gppSectionIDs = userConsent.gpp.applicableSections || null; + } + + // Extract GDPR consent if available + if (userConsent?.gdpr) { + consent.gdprApplies = userConsent.gdpr.gdprApplies; + consent.gdpr = userConsent.gdpr.consentString || null; + + // Extract deviceAccess from TCF Purpose 1 if available + if (userConsent.gdpr.vendorData) { + const purpose1Consent = userConsent.gdpr.vendorData.purpose?.consents?.[1] || + userConsent.gdpr.vendorData.publisher?.consents?.[1]; + if (purpose1Consent !== undefined) { + consent.deviceAccess = purpose1Consent; + } + } + } + + return consent; +}; + +/** + * Extract user identifiers from config and Prebid userId module + * @param {Array} configIds IDs from module config + * @param {Array} configHids HIDs from module config + * @param {Object} reqBidsConfigObj Bid request configuration + * @returns {Object} Object with ids and hids arrays + */ +const extractIdentifiers = (configIds, configHids, reqBidsConfigObj) => { + const ids = [...configIds]; + const hids = [...configHids]; + + // Note: We don't extract IDs from Prebid userId module (ortb2.user.ext.eids) + // because those are in ORTB format, not Optable's ID format (e:hash, c:ppid, etc.) + // Optable-specific IDs should be provided via params.ids configuration + + // Add default __passport__ ID if no other IDs provided + // This allows targeting to work with just passport + IP enrichment + if (ids.length === 0) { + ids.push('__passport__'); + } + + return { ids, hids }; +}; + +/** + * Build targeting API request URL with all query parameters + * @param {Object} params Parameters object + * @returns {string} Complete URL for targeting API + */ +const buildTargetingURL = (params) => { + const { host, node, site, ids, hids, consent, sessionId, passport, cookies, timeout } = params; + + const searchParams = new URLSearchParams(); + + ids.forEach(id => searchParams.append('id', id)); + hids.forEach(hid => searchParams.append('hid', hid)); + + searchParams.set('o', site); + searchParams.set('t', node); + searchParams.set('sid', sessionId); + searchParams.set('osdk', RTD_MODULE_VARIANT); + + if (consent.gpp) { + searchParams.set('gpp', consent.gpp); + } + if (consent.gppSectionIDs && Array.isArray(consent.gppSectionIDs)) { + searchParams.set('gpp_sid', consent.gppSectionIDs.join(',')); + } + if (consent.gdpr) { + searchParams.set('gdpr_consent', consent.gdpr); + } + if (consent.gdprApplies !== null && consent.gdprApplies !== undefined) { + searchParams.set('gdpr', consent.gdprApplies ? '1' : '0'); + } + + if (cookies) { + searchParams.set('cookies', 'yes'); } else { - handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn); + searchParams.set('cookies', 'no'); + searchParams.set('passport', passport || ''); + } + + if (timeout) { + searchParams.set('timeout', timeout); + } + + const url = `https://${host}/v2/targeting?${searchParams.toString()}`; + return url; +}; + +/** + * Call the targeting API and return the response + * @param {Object} params Configuration parameters + * @returns {Promise} Targeting response or null + */ +const callTargetingAPI = (params) => { + return new Promise((resolve) => { + const url = buildTargetingURL(params); + const { host, node } = params; + + logMessage(`Calling targeting API: ${url.split('?')[0]}`); + + const ajaxOptions = { + method: 'GET', + withCredentials: params.consent.deviceAccess, + success: (responseText) => { + try { + const response = JSON.parse(responseText); + const eidCount = response?.ortb2?.user?.eids?.length || 0; + logMessage(`Targeting API returned ${eidCount} EIDs`); + + // Update passport if present in response + if (response.passport) { + logMessage('Updating passport from API response'); + setPassport(host, node, response.passport); + // Remove passport from response to prevent it being included in bid requests + delete response.passport; + } + + resolve(response); + } catch (e) { + logError('Failed to parse targeting API response', e); + resolve(null); + } + }, + error: (error) => { + logError('Targeting API call failed', error); + resolve(null); + } + }; + + ajax(url, ajaxOptions); + }); +}; + +/** + * Check if UID2 EID exists in the given EIDs array + * @param {Array} eids Array of EIDs to check + * @returns {boolean} True if UID2 EID is present + */ +export const hasUid2Eid = (eids) => { + if (!Array.isArray(eids)) { + return false; } + return eids.some(eid => eid.source === 'uidapi.com'); }; /** + * Default function to handle/enrich RTD data by merging targeting data into ortb2 * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Function} callback Called on completion - * @param {Object} moduleConfig Configuration for Optable RTD module - * @param {Object} userConsent + * @param {Object} targetingData Targeting data from API + * @param {Function} mergeFn Function to merge data + * @param {Object} config Optional configuration object with skipMergeIfUid2Exists flag + * @returns {void} */ -export const getBidRequestData = (reqBidsConfigObj, callback, moduleConfig, userConsent) => { +export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn, config = {}) => { + if (!targetingData || !targetingData.ortb2) { + logWarn('No targeting data found'); + return; + } + + const eidCount = targetingData.ortb2?.user?.eids?.length || 0; + logMessage(`defaultHandleRtd: received targeting data with ${eidCount} EIDs`); + + // Filter out UID2 EIDs if UID2 already exists and skipMergeIfUid2Exists is enabled + let filteredTargetingData = targetingData; + if (config.skipMergeIfUid2Exists && targetingData.ortb2.user?.eids) { + const existingEids = reqBidsConfigObj.ortb2Fragments?.global?.user?.eids; + if (hasUid2Eid(existingEids)) { + logMessage('UID2 EID already exists - filtering out UID2 EIDs from Optable data (skipMergeIfUid2Exists=true)'); + + // Filter out UID2 EIDs from Optable's response + const filteredEids = targetingData.ortb2.user.eids.filter(eid => eid.source !== 'uidapi.com'); + const uid2Count = eidCount - filteredEids.length; + + logMessage(`Filtered out ${uid2Count} UID2 EID(s), keeping ${filteredEids.length} non-UID2 EID(s)`); + + // Create a filtered copy of targeting data + filteredTargetingData = { + ...targetingData, + ortb2: { + ...targetingData.ortb2, + user: { + ...targetingData.ortb2.user, + eids: filteredEids + } + } + }; + } + } + + logMessage('Merging ortb2 data into global ORTB2 fragments...'); + + mergeFn( + reqBidsConfigObj.ortb2Fragments.global, + filteredTargetingData.ortb2, + ); + + const finalEidCount = filteredTargetingData.ortb2?.user?.eids?.length || 0; + logMessage(`EIDs merged into ortb2Fragments.global.user.eids (${finalEidCount} EIDs)`); + + // Also add to user.ext.eids for additional coverage + if (filteredTargetingData.ortb2.user?.eids) { + const targetORTB2 = reqBidsConfigObj.ortb2Fragments.global; + targetORTB2.user = targetORTB2.user ?? {}; + targetORTB2.user.ext = targetORTB2.user.ext ?? {}; + targetORTB2.user.ext.eids = targetORTB2.user.ext.eids ?? []; + + logMessage('Also merging Optable EIDs into ortb2.user.ext.eids...'); + + // Merge EIDs into user.ext.eids + filteredTargetingData.ortb2.user.eids.forEach(eid => { + targetORTB2.user.ext.eids.push(eid); + }); + + logMessage(`EIDs also available in ortb2.user.ext.eids (${finalEidCount} EIDs)`); + } + + // Add split_test_assignment to adUnits ortb2Imp.ext.optable if present + if (targetingData.split_test_assignment) { + logMessage(`Split test assignment detected: ${targetingData.split_test_assignment}`); + + if (reqBidsConfigObj.adUnits && Array.isArray(reqBidsConfigObj.adUnits)) { + reqBidsConfigObj.adUnits.forEach(adUnit => { + adUnit.ortb2Imp = adUnit.ortb2Imp || {}; + adUnit.ortb2Imp.ext = adUnit.ortb2Imp.ext || {}; + adUnit.ortb2Imp.ext.optable = adUnit.ortb2Imp.ext.optable || {}; + adUnit.ortb2Imp.ext.optable.splitTestAssignment = targetingData.split_test_assignment; + }); + logMessage(`Split test assignment added to ${reqBidsConfigObj.adUnits.length} ad units`); + } + } + + logMessage(`SUCCESS: ${eidCount} EIDs will be included in bid requests`); +}; + +/** + * Main function called by Prebid to get bid request data + * Automatically detects SDK mode or Direct API mode + * @param {Object} reqBidsConfigObj Bid request configuration object from Prebid + * @param {Function} callback Must be called when complete to continue auction + * @param {Object} moduleConfig RTD module configuration from pbjs.setConfig() + * @param {Object} userConsent User consent object from Prebid (GDPR/GPP/USP) + * @param {number} timeout Timeout in ms from RTD framework (derived from auctionDelay config) + */ +export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig, userConsent, timeout) => { try { - // Extract the bundle URL from the module configuration - const { bundleUrl, handleRtd } = parseConfig(moduleConfig); + const parsedConfig = parseConfig(moduleConfig); + if (!parsedConfig) { + logError('Invalid configuration, skipping Optable RTD'); + callback(); + return; + } + + const { host, node, site, cookies, timeout: configTimeout, ids: configIds, hids: configHids, handleRtd, instance, hasDirectApiConfig, skipMergeIfUid2Exists } = parsedConfig; const handleRtdFn = handleRtd || defaultHandleRtd; - const optableExtraData = config.getConfig('optableRtdConfig') || {}; - - if (bundleUrl) { - // If bundleUrl is present, load the Optable JS bundle - // by using the loadExternalScript function - logMessage('Custom bundle URL found in config: ', bundleUrl); - - // Load Optable JS bundle and merge the data - loadExternalScript(bundleUrl, MODULE_TYPE_RTD, MODULE_NAME, () => { - logMessage('Successfully loaded Optable JS bundle'); - mergeOptableData(handleRtdFn, reqBidsConfigObj, optableExtraData, mergeDeep).then(callback, callback); - }, document); - } else { - // At this point, we assume that the Optable JS bundle is already - // present on the page. If it is, we can directly merge the data - // by passing the callback to the optable.cmd.push function. - logMessage('Custom bundle URL not found in config. ' + - 'Assuming Optable JS bundle is already present on the page'); - window.optable = window.optable || { cmd: [] }; - window.optable.cmd.push(() => { - logMessage('Optable JS bundle found on the page'); - mergeOptableData(handleRtdFn, reqBidsConfigObj, optableExtraData, mergeDeep).then(callback, callback); - }); + const rtdConfig = { skipMergeIfUid2Exists }; + + // Mode 1: SDK mode - If Optable Web SDK is loaded (window.optable), use its event system + // instead of making direct API calls. SDK handles caching, consent, and provides ad server targeting. + if (isSDKAvailable(instance)) { + logMessage('Optable Web SDK detected, using SDK mode'); + logMessage('Waiting for SDK to dispatch targeting data via event'); + + await handleSDKMode(handleRtdFn, reqBidsConfigObj, mergeDeep, rtdConfig); + callback(); + return; + } + + // Mode 2: Direct API mode - Make direct HTTP calls to Optable targeting API. + // No ad server targeting support, but lighter weight (no external SDK required). + if (!hasDirectApiConfig) { + logError('Neither Web SDK nor direct API configuration found. Please configure host, node, and site parameters, or load the Optable Web SDK.'); + callback(); + return; + } + + logMessage('Using direct API mode (SDK not detected)'); + + const effectiveTimeout = (timeout && timeout > 100) ? timeout - 100 : configTimeout; + + logMessage(`Configuration: host=${host}, node=${node}, site=${site}, cookies=${cookies}`); + if (effectiveTimeout) { + logMessage(`Timeout: ${effectiveTimeout}ms${timeout ? ` (derived from auctionDelay: ${timeout}ms - 100ms)` : ' (from config)'}`); + } + + const sessionId = generateSessionID(); + const consent = extractConsent(userConsent); + + logMessage(`Session ID: ${sessionId}`); + logMessage(`Consent: GPP=${!!consent.gpp}, GDPR=${!!consent.gdpr}`); + + const { ids, hids } = extractIdentifiers(configIds, configHids, reqBidsConfigObj); + logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); + + const passport = getPassport(host, node); + logMessage(`Passport: ${passport ? 'found' : 'not found'}`); + + // Check if we have cached data - if so, use it immediately + const cachedData = getCachedTargeting(); + if (cachedData) { + logMessage('Cache found, using cached data'); + handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep, rtdConfig); + callback(); + + // Only refresh in background if we haven't made a call this session yet + if (!targetingCallMade) { + logMessage('First auction this session - refreshing cache in background'); + targetingCallMade = true; + + // Update cache in background (don't await) + callTargetingAPI({ + host, + node, + site, + ids, + hids, + consent, + sessionId, + passport, + cookies, + timeout: effectiveTimeout + }).then(data => { + if (data) { + logMessage('Background API call completed, cache updated'); + setCachedTargeting(data); + setSDKTargetingCache(host, node, data); + if (data.passport) { + setPassport(host, node, data.passport); + } + } + }).catch(error => { + logWarn('Background API call failed:', error); + }); + } else { + logMessage('Already made a targeting call this session - skipping refresh'); + } + + return; + } + + // No cache - wait for API call + logMessage('No cache found, waiting for API call'); + targetingCallMade = true; + const targetingData = await callTargetingAPI({ + host, + node, + site, + ids, + hids, + consent, + sessionId, + passport, + cookies, + timeout: effectiveTimeout + }); + + if (!targetingData) { + logWarn('No targeting data returned from API'); + callback(); + return; } + + setCachedTargeting(targetingData); + setSDKTargetingCache(host, node, targetingData); + handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep, rtdConfig); + + callback(); } catch (error) { - // If an error occurs, log it and call the callback - // to continue with the auction - logError(error); + logError('getBidRequestData error: ', error); callback(); } } /** * Get Optable targeting data and merge it into the ad units + * Only works when Optable Web SDK is present on the page + * * @param adUnits Array of ad units * @param moduleConfig Module configuration * @param userConsent User consent * @param auction Auction object - * @returns {Object} Targeting data + * @returns {Object} Targeting data (empty object if SDK not available) */ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => { - // Extract `adserverTargeting` and `instance` from the module configuration - const { adserverTargeting, instance } = parseConfig(moduleConfig); - logMessage('Ad Server targeting: ', adserverTargeting); + const parsedConfig = parseConfig(moduleConfig); + if (!parsedConfig) { + logWarn('Invalid configuration in getTargetingData'); + return {}; + } + + const { adserverTargeting, instance } = parsedConfig; + + if (!isSDKAvailable(instance)) { + logMessage('getTargetingData: Web SDK not available, ad server targeting disabled'); + logMessage('For ad server targeting, please load the Optable Web SDK'); + return {}; + } if (!adserverTargeting) { - logMessage('Ad server targeting is disabled'); + logMessage('Ad server targeting is disabled via config'); return {}; } - const targetingData = {}; // Resolve the SDK instance object based on the instance string // Default to 'instance' if not provided const instanceKey = instance || 'instance'; const sdkInstance = window?.optable?.[instanceKey]; - if (!sdkInstance) { + + if (!sdkInstance || !sdkInstance.targetingKeyValuesFromCache) { logWarn(`No Optable SDK instance found for: ${instanceKey}`); - return targetingData; + return {}; } - // Get the Optable targeting data from the cache - const optableTargetingData = sdkInstance?.targetingKeyValuesFromCache?.() || targetingData; + const optableTargetingData = sdkInstance.targetingKeyValuesFromCache() || {}; - // If no Optable targeting data is found, return an empty object if (!Object.keys(optableTargetingData).length) { - logWarn('No Optable targeting data found'); - return targetingData; + logWarn('No Optable targeting data found in SDK cache'); + return {}; } - // Merge the Optable targeting data into the ad units + const targetingData = {}; adUnits.forEach(adUnit => { targetingData[adUnit] = targetingData[adUnit] || {}; mergeDeep(targetingData[adUnit], optableTargetingData); }); - // If the key contains no data, remove it Object.keys(targetingData).forEach((adUnit) => { Object.keys(targetingData[adUnit]).forEach((key) => { if (!targetingData[adUnit][key] || !targetingData[adUnit][key].length) { @@ -203,7 +762,7 @@ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => } }); - // If the ad unit contains no data, remove it + // If the key contains no data, remove it if (!Object.keys(targetingData[adUnit]).length) { delete targetingData[adUnit]; } @@ -214,12 +773,23 @@ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => }; /** - * Dummy init function + * Init function - sets global variant tracking variables * @param {Object} config Module configuration * @param {boolean} userConsent User consent * @returns true */ const init = (config, userConsent) => { + // Set variant on window.pbjs for debugging + if (typeof window !== 'undefined' && window.pbjs) { + window.pbjs._optableRtdVariant = RTD_MODULE_VARIANT; + } + + // Set variant on window.optable for debugging + if (typeof window !== 'undefined' && window.optable) { + window.optable.optableRtdVariant = RTD_MODULE_VARIANT; + } + + logMessage('Optable RTD module loaded', RTD_MODULE_VARIANT); return true; } diff --git a/modules/optableRtdProvider.md b/modules/optableRtdProvider.md index 4ac0d4541f..ee786e3372 100644 --- a/modules/optableRtdProvider.md +++ b/modules/optableRtdProvider.md @@ -12,7 +12,47 @@ Prebid.js minimum version: 9.53.2+, or 10.2+ ## Description -Optable RTD submodule enriches the OpenRTB request by populating `user.ext.eids` and `user.data` using an identity graph and audience segmentation service hosted by Optable on behalf of the publisher. This RTD submodule primarily relies on the Optable bundle loaded on the page, which leverages the Optable-specific Visitor ID and other PPIDs to interact with the identity graph, enriching the bid request with additional user IDs and audience data. +Optable RTD submodule enriches the OpenRTB bid request by populating `user.ext.eids` and `user.data` using an identity graph and audience segmentation service hosted by Optable on behalf of the publisher. + +**This module supports TWO modes of operation with automatic detection:** + +### Mode 1: Web SDK Mode (Recommended for ad server targeting) + +Uses Optable Web SDK loaded on page via event-based integration. + +**Setup:** +- Load Optable Web SDK: `` +- Configure RTD module with optional params only + +**Features:** +- Bid request enrichment (EIDs passed to SSPs) +- Ad server targeting (key-values for GAM/other ad servers) +- Event-based (waits for 'optable-targeting:change' event) +- SDK handles API calls, consent, caching, etc. + +### Mode 2: Direct API Mode (Lightweight, SDK-less) + +Makes direct HTTP calls to Optable targeting API without any external SDK. + +**Setup:** +- No SDK required - module makes direct HTTPS GET requests +- Configure RTD module with host, node, site parameters + +**Features:** +- Bid request enrichment (EIDs passed to SSPs) +- NO ad server targeting (use SDK mode for this) +- Cache-first with fallback strategy (fast page loads) +- Consent from Prebid's userConsent parameter (no CMP calls) +- Timeout derived from auctionDelay (automatic) + +### Mode Detection + +The module automatically detects which mode to use at runtime: +1. **First:** Checks if `window.optable[instance]` is present → **SDK mode** (Direct API params ignored) +2. **Second:** If SDK absent but `host`/`node`/`site` configured → **Direct API mode** +3. **Otherwise:** Error (neither mode available) + +**Important:** If the Optable Web SDK is loaded on the page, the module will always use SDK mode, even if Direct API parameters (`host`, `node`, `site`) are configured. The Direct API parameters are only used when the SDK is not present. ## Usage @@ -26,28 +66,89 @@ gulp build --modules="rtdModule,optableRtdProvider,appnexusBidAdapter,..." > Note that Optable RTD module is dependent on the global real-time data module, `rtdModule`. -### Preloading Optable SDK bundle +### Configuration -In order to use the module you first need to register with Optable and obtain a bundle URL. The bundle URL may be specified as a `bundleUrl` parameter to the script, or otherwise it can be added directly to the page source as: +This module is configured as part of the `realTimeData.dataProviders`. -```html - -``` +**SDK Mode Configuration (with Optable Web SDK loaded):** -### Configuration +```javascript +// Load SDK first in your page: +// -This module is configured as part of the `realTimeData.dataProviders`. +pbjs.setConfig({ + debug: true, + realTimeData: { + auctionDelay: 200, + dataProviders: [ + { + name: 'optable', + waitForIt: true, + params: { + adserverTargeting: true, // Enable ad server targeting + instance: 'instance' // SDK instance name (default: 'instance') + }, + }, + ], + }, +}); +``` + +**Direct API Mode Configuration (SDK-less):** ```javascript pbjs.setConfig({ debug: true, // we recommend turning this on for testing as it adds more logging realTimeData: { + auctionDelay: 200, // recommended for real-time data dataProviders: [ { name: 'optable', + waitForIt: true, params: { - adserverTargeting: true, // optional, true by default, set to true to also set GAM targeting keywords to ad slots - instance: window.optable.rtd.instance, // optional, defaults to window.optable.rtd.instance if not specified + host: 'dcn.customer.com', // REQUIRED: Your Optable DCN hostname + node: 'prod-us', // REQUIRED: Your node identifier + site: 'my-site', // REQUIRED: Your site identifier + }, + }, + ], + }, +}); +``` + +**Advanced Configuration:** + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 200, + dataProviders: [ + { + name: 'optable', + waitForIt: true, + params: { + // REQUIRED PARAMETERS: + host: 'dcn.customer.com', // Your Optable DCN hostname + node: 'prod-us', // Your node identifier + site: 'my-site', // Your site identifier + + // OPTIONAL PARAMETERS: + cookies: false, // Set to false for cookieless mode (default: true) + timeout: '500ms', // API timeout hint + ids: ['user-id-1', 'user-id-2'], // User identifiers (also auto-extracted from userId module) + hids: ['hint-id'], // Hint identifiers + + // CUSTOM HANDLER (optional): + handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { + // Custom logic to handle targeting data + console.log('Custom RTD handler called'); + console.log('Targeting data:', targetingData); + + // Perform any custom processing here + + // Merge the data into bid requests + mergeFn(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2); + } }, }, ], @@ -57,50 +158,418 @@ pbjs.setConfig({ ### Parameters -| Name | Type | Description | Default | Notes | -|--------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|----------| -| name | String | Real time data module name | Always `optable` | | -| params | Object | | | | -| params.adserverTargeting | Boolean | If set to `true`, targeting keywords will be passed to the ad server upon auction completion | `true` | Optional | -| params.instance | Object | Optable SDK instance to use for targeting data. | `window.optable.rtd.instance` | Optional | -| params.handleRtd | Function | An optional function that uses Optable data to enrich `reqBidsConfigObj` with the real-time data. If not provided, the module will do a default call to Optable bundle. The function signature is `[async] (reqBidsConfigObj, optableExtraData, mergeFn) => {}` | `null` | Optional | +| Name | Type | Description | Default | Required | Mode | +|-------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------|------| +| name | String | Real time data module name | Always `optable` | Yes | Both | +| waitForIt | Boolean | Should be set to `true` to ensure targeting data is available before auction | `false` | Recommended | Both | +| params | Object | Configuration parameters | | Yes | Both | +| **params.adserverTargeting** | **Boolean** | **Enable ad server targeting key-values (SDK mode only)** | **`true`** | **No** | **SDK** | +| **params.instance** | **String** | **SDK instance name** | **`'instance'`** | **No** | **SDK** | +| params.host | String | Your Optable DCN hostname (e.g., `dcn.customer.com`) | None | **Yes** | Direct API | +| params.node | String | Node identifier for your DCN | None | **Yes** | Direct API | +| params.site | String | Site identifier configured in your DCN | None | **Yes** | Direct API | +| params.cookies | Boolean | Cookie mode. Set to `false` for cookieless targeting using passport | `true` | No | Direct API | +| params.timeout | String | API timeout hint (e.g., `"500ms"`) | `null` | No | Direct API | +| params.ids | Array | Array of user identifier strings in Optable format (e.g., `"e:hash"`, `"c:ppid"`, `"__passport__"`). Use this to pass Optable-specific identifiers. | `[]` | No | Direct API | +| params.hids | Array | Array of hint identifier strings | `[]` | No | Direct API | +| params.skipMergeIfUid2Exists | Boolean | If `true`, filters out UID2 EIDs from Optable's response when a UID2 EID already exists in `ortb2Fragments.global.user.eids`. Non-UID2 EIDs from Optable are still merged. This prevents duplicate UID2 identifiers and has been shown to improve lift by -0.5% to -1.5% in "1 x UID2 only" setups. | `false` | No | Both | +| params.handleRtd | Function | Custom function to handle/enrich RTD data. Function signature: `(reqBidsConfigObj, targetingData, mergeFn, config) => {}`. If not provided, the module uses a default handler that merges targeting data into ortb2Fragments.global. The `config` parameter contains `skipMergeIfUid2Exists` flag. | `null` | No | Both | + +## How It Works + +### 1. Initialization + +When Prebid's auction starts, the Optable RTD module: + +1. Validates the configuration (checks for required `host`, `node`, and `site` parameters) +2. Checks for cached targeting data in localStorage +3. If no cache is found, proceeds to make an API call + +### 2. Data Collection + +Before calling the targeting API, the module automatically: + +- Generates a session ID (once per page load) +- Extracts consent information from Prebid's consent management modules (GPP/GDPR) +- Collects user identifiers from the `ids` parameter in configuration +- Retrieves the passport (visitor ID) from localStorage for cookieless mode + +**Note:** The module does NOT extract identifiers from Prebid's userId module for the targeting API call. Those identifiers are in ORTB format and are handled separately in bid requests. + +### 3. API Call + +The module makes a GET request to `https://{host}/v2/targeting` with the following parameters: + +- `o`: Site identifier (required) +- `t`: Node identifier (required) +- `id`: User identifiers (multiple) +- `hid`: Hint identifiers (multiple) +- `osdk`: SDK version identifier +- `sid`: Session ID +- `cookies`: Cookie mode (`yes` or `no`) +- `passport`: Visitor ID for cookieless mode +- `gpp`: GPP consent string +- `gpp_sid`: GPP section IDs +- `gdpr_consent`: GDPR consent string +- `gdpr`: GDPR applies flag (`0` or `1`) +- `timeout`: Timeout hint + +### 4. Response Handling + +The targeting API returns an ORTB2 object with: + +- `ortb2.user.eids`: Extended user IDs for bid enrichment +- `ortb2.user.data`: Audience segments +- `passport`: Updated passport value (for cookieless mode) + +The module then: + +1. Updates the passport in localStorage if provided +2. Caches the response in localStorage +3. Merges the ORTB2 data into `reqBidsConfigObj.ortb2Fragments.global` +4. Also adds EIDs to `ortb2.user.ext.eids` for additional coverage + +### 5. Bid Enrichment + +The enriched ORTB2 data is automatically included in all bid requests, allowing bidders to: + +- Access extended user IDs for better user recognition +- Target based on audience segments +- Improve bid decisioning with richer user context + +## Consent Management + +The module automatically extracts consent information from Prebid's consent management configuration: + +```javascript +// Example consent management config +pbjs.setConfig({ + consentManagement: { + gpp: { + // GPP consent config + }, + gdpr: { + // GDPR consent config + } + } +}); +``` + +The consent strings are automatically passed to the targeting API. No additional configuration is needed. + +## Identifier Collection + +User identifiers are provided via the `ids` parameter in the RTD configuration: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + ids: ['e:email-hash-123', 'c:phone-hash-456'], // Optable-specific ID format + hids: ['hint-id-789'] +} +``` + +**Important:** The module does NOT automatically extract identifiers from Prebid's userId module (those are in ORTB format, not Optable's ID format). Optable-specific identifiers must be provided via the `ids` parameter. + +**Note:** If you have Prebid userId modules configured (ID5, Unified ID, etc.), those EIDs will still be included in bid requests via the standard ORTB `user.ext.eids` path, but they are not sent to the Optable targeting API. + +## Cookie vs Cookieless Mode + +### Cookie Mode (Default) + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + cookies: true // or omit this parameter +} +``` + +In cookie mode, the DCN uses first-party cookies for visitor identification. + +### Cookieless Mode + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + cookies: false +} +``` + +In cookieless mode: +- The module manages a "passport" (visitor ID) in localStorage +- The passport is sent with each targeting API call +- The API returns an updated passport, which is stored for future calls +- No cookies are set or read + +**Passport Storage Format:** +- Storage key: `OPTABLE_PASSPORT_{base64(host/node)}` +- Example: For `host="dcn.customer.com"` and `node="prod-us"`, the key is `OPTABLE_PASSPORT_` + base64 encoded `"dcn.customer.com/prod-us"` +- This format is compatible with the Optable Web SDK, allowing seamless migration between Direct API mode and SDK mode + +## Caching + +The module caches targeting responses in localStorage to improve page load performance: + +**Cache Key:** `optable-cache:targeting` + +**Cache Behavior:** +- **First page view (no cache):** API call is made synchronously, response is cached +- **Subsequent page views (cache present):** + - Cached data is used immediately (no delay) + - On first auction only: Background API call refreshes the cache + - On subsequent auctions: No additional API calls (already refreshed) +- **Cache validity:** Cache is used if it contains EIDs (`user.eids.length > 0`) OR has a `split_test_assignment` field -## Publisher Customized RTD Handler Function +**Cache Invalidation:** +- The cache is replaced (not merged) on each successful API response +- If an API call returns an empty response or fails, the existing cache is preserved +- No automatic TTL-based expiry (cache persists until next successful API response) -When there is more pre-processing or post-processing needed prior/post calling Optable bundle - a custom `handleRtd` -function can be supplied to do that. -This function will also be responsible for the `reqBidsConfigObj` enrichment. -It will also receive the `optableExtraData` object, which can contain the extra data required for the enrichment and -shouldn't be shared with other RTD providers/bidders. -`mergeFn` parameter taken by `handleRtd` is a standard Prebid.js utility function that take an object to be enriched and -an object to enrich with: the second object's fields will be merged into the first one (also see the code of an example -mentioned below): +**Empty Response Handling:** +- If the API returns 0 EIDs and no `split_test_assignment`, the cache is **not** updated +- This prevents temporary API issues from clearing valid cached data + +## Node Configuration + +The `node` parameter is required and identifies your DCN node: ```javascript -mergeFn( - reqBidsConfigObj.ortb2Fragments.global, // or other nested object as needed - rtdData, -); +params: { + host: 'dcn.customer.com', + node: 'prod-us', // Your node identifier + site: 'my-site' +} ``` -A `handleRtd` function implementation has access to its surrounding context including capturing a `pbjs` object, calling `pbjs.getConfig()` and f.e. reading off the `consentManagement` config to make the appropriate decision based on it. +The node identifier routes the API call to the specified node and maintains separate passports per node. + +## UID2 Skip Merge Feature + +The `skipMergeIfUid2Exists` parameter allows you to filter out UID2 EIDs from Optable's response when UID2 already exists in the bid request. This is useful when you have a "1 x UID2 only" setup where UID2 is the primary identifier. + +**When enabled:** +- If a UID2 EID (source: `uidapi.com`) already exists in `ortb2Fragments.global.user.eids`, Optable will filter out any UID2 EIDs from its response +- **All other non-UID2 EIDs from Optable are still merged** (e.g., ID5, LiveRamp, etc.) +- This prevents duplicate UID2 identifiers and has been shown to improve lift by -0.5% to -1.5% in "1 x UID2 only" configurations +- The filtering is performed before merging, ensuring the existing UID2 EID is not overwritten + +**Configuration:** + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 200, + dataProviders: [ + { + name: 'optable', + waitForIt: true, + params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + skipMergeIfUid2Exists: true // Skip merge if UID2 already present + }, + }, + ], + }, +}); +``` + +**Note:** This feature works in both SDK mode and Direct API mode. Custom `handleRtd` functions receive the config parameter with the `skipMergeIfUid2Exists` flag and can implement similar logic if needed. + +## Custom RTD Handler + +For advanced use cases, provide a custom `handleRtd` function: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + handleRtd: (reqBidsConfigObj, targetingData, mergeFn, config) => { + console.log('Targeting data received:', targetingData); + console.log('Config:', config); // Contains skipMergeIfUid2Exists flag + + // Custom validation + if (!targetingData || !targetingData.ortb2) { + console.warn('Invalid targeting data'); + return; + } + + // Example: Custom UID2 check (if not using built-in skipMergeIfUid2Exists) + if (config.skipMergeIfUid2Exists) { + const existingEids = reqBidsConfigObj.ortb2Fragments?.global?.user?.eids; + const hasUid2 = existingEids?.some(eid => eid.source === 'uidapi.com'); + if (hasUid2) { + console.log('UID2 already present, skipping merge'); + return; + } + } + + // Custom transformation + const customOrtb2 = { + user: { + ...targetingData.ortb2.user, + ext: { + ...targetingData.ortb2.user.ext, + customField: 'customValue' + } + } + }; + + // Merge into bid requests + mergeFn(reqBidsConfigObj.ortb2Fragments.global, customOrtb2); + + // Additional custom logic... + } +} +``` + +## Testing + +Use Prebid's debug mode to see detailed logs: + +```javascript +pbjs.setConfig({ + debug: true, + // ... rest of config +}); +``` + +Logs will show: +- Configuration validation +- API call details +- Consent extraction +- Identifier collection +- Caching behavior +- Data merging into bid requests ## Example -If you want to see an example of how the optable RTD module works, run the following command: +To see a working example: ```bash gulp serve --modules=optableRtdProvider,consentManagementGpp,consentManagementTcf,appnexusBidAdapter ``` -and then open the following URL in your browser: +Then open: [`http://localhost:9999/integrationExamples/gpt/optableRtdProvider_example.html`](http://localhost:9999/integrationExamples/gpt/optableRtdProvider_example.html) Open the browser console to see the logs. -## Maintainer contacts +## Migration from External Web SDK Approach + +If you were previously using an external Web SDK loaded via `bundleUrl` parameter: + +### Old Configuration (External Web SDK): +```javascript +params: { + bundleUrl: 'https://cdn.optable.co/bundle.js', + adserverTargeting: true +} +``` + +### New Configuration (Direct API): +```javascript +params: { + host: 'dcn.customer.com', // Your DCN hostname + node: 'prod-us', // Your node identifier + site: 'my-site' // Your site identifier +} +``` + +### Key Differences: + +1. **No External Loading**: Module no longer loads SDK from CDN, uses direct API calls instead +2. **Required Parameters**: `host`, `node`, and `site` are now required +3. **Ad Server Targeting**: Not supported. Use the Web SDK separately if you need GAM targeting keywords +4. **Custom Handler Signature**: Changed from `(reqBidsConfigObj, optableExtraData, mergeFn, skipCache)` to `(reqBidsConfigObj, targetingData, mergeFn)` +5. **Faster**: No external script loading delay +6. **Simpler**: Fewer dependencies and configuration options + +## Excluded Features + +The following Web SDK features are intentionally **not** supported in this RTD module to maintain simplicity: + +- Client-side A/B testing / traffic splitting framework (server-side split test assignment from DCN is supported via `split_test_assignment` field) +- Additional targeting signals (page URL ref) +- Ad server targeting keywords (use Web SDK for this) +- Event dispatching system +- Complex multi-storage key strategies + +**Note:** Server-side split testing is supported. If the targeting API returns a `split_test_assignment` field (e.g., "test" or "control"), it will be injected into `ortb2Imp.ext.optable.splitTestAssignment` for all ad units. + +See `modules/optableRtdProvider_EXCLUDED_FEATURES.md` for details. + +## Troubleshooting + +### "host parameter is required and must be a string" + +Ensure you've configured the `host` parameter: + +```javascript +params: { + host: 'dcn.customer.com', // Required! + node: 'prod-us', + site: 'my-site' +} +``` + +### "site parameter is required and must be a string" + +Ensure you've configured the `site` parameter: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site' // Required! +} +``` + +### "node parameter is required and must be a string" + +Ensure you've configured the `node` parameter: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', // Required! + site: 'my-site' +} +``` + +### No targeting data returned + +Check: +1. Is `waitForIt: true` set in the dataProvider config? +2. Is `auctionDelay` set appropriately (e.g., 200ms)? +3. Are there identifiers available (check `ids` param and userId module)? +4. Check browser console for API errors +5. Verify your DCN host, node and site identifier are correct + +### Consent issues + +Ensure Prebid's consent management modules are configured: + +```javascript +pbjs.setConfig({ + consentManagement: { + gdpr: { ... }, + gpp: { ... } + } +}); +``` + +## Maintainer Contacts Any suggestions or questions can be directed to [prebid@optable.co](mailto:prebid@optable.co). -Alternatively please open a new [issue](https://github.com/prebid/prebid-server-java/issues/new) or [pull request](https://github.com/prebid/prebid-server-java/pulls) in this repository. +Alternatively please open a new [issue](https://github.com/prebid/Prebid.js/issues/new) or [pull request](https://github.com/prebid/Prebid.js/pulls) in this repository. diff --git a/test/spec/modules/optableRtdProvider_spec.js b/test/spec/modules/optableRtdProvider_spec.js index b408c0b991..e249930cd6 100644 --- a/test/spec/modules/optableRtdProvider_spec.js +++ b/test/spec/modules/optableRtdProvider_spec.js @@ -1,323 +1,761 @@ import { parseConfig, + generateSessionID, + getPassport, + setPassport, defaultHandleRtd, - mergeOptableData, getBidRequestData, getTargetingData, optableSubmodule, + LOG_PREFIX, } from 'modules/optableRtdProvider'; +import { getStorageManager } from 'src/storageManager.js'; +import * as ajax from 'src/ajax.js'; describe('Optable RTD Submodule', function () { + let sandbox; + let storage; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + storage = getStorageManager({ moduleType: 'rtd', moduleName: 'optable' }); + sandbox.stub(storage, 'getDataFromLocalStorage'); + sandbox.stub(storage, 'setDataInLocalStorage'); + }); + + afterEach(() => { + sandbox.restore(); + // Clear localStorage between tests + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } + }); + describe('parseConfig', function () { - it('parses valid config correctly', function () { - const config = { + it('parses valid config with required Direct API parameters', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node' + } + }; + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.host).to.equal('dcn.customer.com'); + expect(result.site).to.equal('my-site'); + expect(result.node).to.equal('my-node'); + expect(result.cookies).to.be.true; + expect(result.ids).to.deep.equal([]); + expect(result.hids).to.deep.equal([]); + expect(result.hasDirectApiConfig).to.be.true; + }); + + it('parses SDK mode config without Direct API params', function () { + const moduleConfig = { params: { - bundleUrl: 'https://cdn.optable.co/bundle.js', adserverTargeting: true, - handleRtd: () => {} + instance: 'custom' } }; - expect(parseConfig(config)).to.deep.equal({ - bundleUrl: 'https://cdn.optable.co/bundle.js', - adserverTargeting: true, - handleRtd: config.params.handleRtd, - }); + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.adserverTargeting).to.be.true; + expect(result.instance).to.equal('custom'); + expect(result.hasDirectApiConfig).to.be.false; }); - it('trims bundleUrl if it contains extra spaces', function () { - const config = { params: { bundleUrl: ' https://cdn.optable.co/bundle.js ' } }; - expect(parseConfig(config).bundleUrl).to.equal('https://cdn.optable.co/bundle.js'); + it('trims host, site, and node values', function () { + const moduleConfig = { + params: { + host: ' dcn.customer.com ', + site: ' my-site ', + node: ' my-node ' + } + }; + const result = parseConfig(moduleConfig); + expect(result.host).to.equal('dcn.customer.com'); + expect(result.site).to.equal('my-site'); + expect(result.node).to.equal('my-node'); }); - it('returns null bundleUrl for invalid bundleUrl format', function () { - expect(parseConfig({ params: { bundleUrl: 'invalidURL' } }).bundleUrl).to.be.null; - expect(parseConfig({ params: { bundleUrl: 'www.invalid.com' } }).bundleUrl).to.be.null; + it('accepts config with null host/site/node (SDK mode)', function () { + const moduleConfig = { params: { adserverTargeting: true } }; + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.hasDirectApiConfig).to.be.false; }); - it('returns null bundleUrl for non-HTTPS bundleUrl', function () { - expect(parseConfig({ params: { bundleUrl: 'http://cdn.optable.co/bundle.js' } }).bundleUrl).to.be.null; - expect(parseConfig({ params: { bundleUrl: '//cdn.optable.co/bundle.js' } }).bundleUrl).to.be.null; - expect(parseConfig({ params: { bundleUrl: '/bundle.js' } }).bundleUrl).to.be.null; + it('returns null if host is empty string', function () { + const moduleConfig = { params: { host: '', site: 'my-site', node: 'my-node' } }; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('defaults adserverTargeting to true if missing', function () { - expect(parseConfig( - { params: { bundleUrl: 'https://cdn.optable.co/bundle.js' } } - ).adserverTargeting).to.be.true; + it('returns null if site is empty string', function () { + const moduleConfig = { params: { host: 'dcn.customer.com', site: '', node: 'my-node' } }; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('returns null handleRtd if handleRtd is not a function', function () { - expect(parseConfig({ params: { handleRtd: 'notAFunction' } }).handleRtd).to.be.null; + it('returns null if node is empty string', function () { + const moduleConfig = { params: { host: 'dcn.customer.com', site: 'my-site', node: '' } }; + expect(parseConfig(moduleConfig)).to.be.null; }); - }); - describe('defaultHandleRtd', function () { - let sandbox, reqBidsConfigObj, mergeFn; + it('parses optional parameters correctly', function () { + const handleRtdFn = () => {}; + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us', + cookies: false, + timeout: '500ms', + ids: ['id1', 'id2'], + hids: ['hid1'], + handleRtd: handleRtdFn, + adserverTargeting: false, + instance: 'custom' + } + }; + const result = parseConfig(moduleConfig); + expect(result.node).to.equal('prod-us'); + expect(result.cookies).to.be.false; + expect(result.timeout).to.equal('500ms'); + expect(result.ids).to.deep.equal(['id1', 'id2']); + expect(result.hids).to.deep.equal(['hid1']); + expect(result.handleRtd).to.equal(handleRtdFn); + expect(result.adserverTargeting).to.be.false; + expect(result.instance).to.equal('custom'); + }); - beforeEach(() => { - sandbox = sinon.createSandbox(); - reqBidsConfigObj = { ortb2Fragments: { global: {} } }; - mergeFn = sinon.spy(); - window.optable = { - instance: { - targeting: sandbox.stub(), - targetingFromCache: sandbox.stub(), - }, + it('returns null if handleRtd is not a function', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + handleRtd: 'notAFunction' + } }; + expect(parseConfig(moduleConfig)).to.be.null; }); - afterEach(() => { - sandbox.restore(); + it('returns null if ids is not an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + ids: 'notAnArray' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('merges valid targeting data into the global ORTB2 object', async function () { - const targetingData = { ortb2: { user: { ext: { optable: 'testData' } } } }; - window.optable.instance.targetingFromCache.returns(targetingData); - window.optable.instance.targeting.resolves(targetingData); + it('returns null if hids is not an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + hids: 'notAnArray' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + it('returns null and logs error for deprecated bundleUrl parameter', function () { + const moduleConfig = { + params: { + bundleUrl: 'https://example.cdn.optable.co/bundle.js' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('does nothing if targeting data is missing the ortb2 property', async function () { - window.optable.instance.targetingFromCache.returns({}); + it('parses targetingParams correctly', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + targetingParams: { + ab_test_id: 'split_test_demo', + skip_resolvers: 'Criteo' + } + } + }; + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.targetingParams).to.deep.equal({ + ab_test_id: 'split_test_demo', + skip_resolvers: 'Criteo' + }); + }); - // Dispatch event with empty ortb2 data after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {} - }); - window.dispatchEvent(event); - }, 10); + it('returns null if targetingParams is not an object', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + targetingParams: 'notAnObject' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.called).to.be.false; + it('returns null if targetingParams is an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + targetingParams: ['not', 'an', 'object'] + } + }; + expect(parseConfig(moduleConfig)).to.be.null; }); + }); - it('uses targeting data from cache if available', async function () { - const targetingData = { ortb2: { user: { ext: { optable: 'testData' } } } }; - window.optable.instance.targetingFromCache.returns(targetingData); + describe('generateSessionID', function () { + it('generates a session ID', function () { + const sid = generateSessionID(); + expect(sid).to.be.a('string'); + expect(sid.length).to.be.greaterThan(0); + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + it('returns the same session ID on subsequent calls (singleton)', function () { + const sid1 = generateSessionID(); + const sid2 = generateSessionID(); + expect(sid1).to.equal(sid2); }); - it('calls targeting function if no data is found in cache', async function () { - const targetingData = { ortb2: { user: { ext: { optable: 'testData' } } } }; - window.optable.instance.targetingFromCache.returns(null); + it('generates base64url encoded string without +/= characters', function () { + const sid = generateSessionID(); + expect(sid).to.not.match(/[+/=]/); + }); + }); - // Dispatch event with targeting data after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: targetingData - }); - window.dispatchEvent(event); - }, 10); + describe('passport storage', function () { + it('getPassport retrieves passport from localStorage', function () { + storage.getDataFromLocalStorage.returns('test-passport-value'); + const passport = getPassport('dcn.customer.com', 'node1'); + expect(passport).to.equal('test-passport-value'); + expect(storage.getDataFromLocalStorage.calledOnce).to.be.true; + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + it('setPassport stores passport in localStorage', function () { + setPassport('dcn.customer.com', 'node1', 'new-passport-value'); + expect(storage.setDataInLocalStorage.calledOnce).to.be.true; + const args = storage.setDataInLocalStorage.getCall(0).args; + expect(args[1]).to.equal('new-passport-value'); + }); + + it('generates different keys for different hosts', function () { + setPassport('dcn1.customer.com', 'node1', 'passport1'); + setPassport('dcn2.customer.com', 'node1', 'passport2'); + expect(storage.setDataInLocalStorage.calledTwice).to.be.true; + const key1 = storage.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storage.setDataInLocalStorage.getCall(1).args[0]; + expect(key1).to.not.equal(key2); + }); + + it('generates different keys for same host with different nodes', function () { + setPassport('dcn.customer.com', 'node1', 'passport1'); + setPassport('dcn.customer.com', 'node2', 'passport2'); + const key1 = storage.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storage.setDataInLocalStorage.getCall(1).args[0]; + expect(key1).to.not.equal(key2); }); }); - describe('mergeOptableData', function () { - let sandbox, mergeFn, handleRtdFn, reqBidsConfigObj; + describe('defaultHandleRtd', function () { + let reqBidsConfigObj, mergeFn; beforeEach(() => { - sandbox = sinon.createSandbox(); - mergeFn = sinon.spy(); reqBidsConfigObj = { ortb2Fragments: { global: {} } }; + mergeFn = sinon.spy(); }); - afterEach(() => { - sandbox.restore(); + it('merges valid targeting data into the global ORTB2 object', function () { + const targetingData = { + ortb2: { + user: { + eids: [{ source: 'optable.co', uids: [{ id: 'test-id' }] }] + } + } + }; + + defaultHandleRtd(reqBidsConfigObj, targetingData, mergeFn); + expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; }); - it('calls handleRtdFn synchronously if it is a regular function', async function () { - handleRtdFn = sinon.spy(); - await mergeOptableData(handleRtdFn, reqBidsConfigObj, {}, mergeFn); - expect(handleRtdFn.calledOnceWith(reqBidsConfigObj, {}, mergeFn)).to.be.true; + it('does nothing if targeting data is missing ortb2', function () { + defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); + expect(mergeFn.called).to.be.false; }); - it('calls handleRtdFn asynchronously if it is an async function', async function () { - handleRtdFn = sinon.stub().resolves(); - await mergeOptableData(handleRtdFn, reqBidsConfigObj, {}, mergeFn); - expect(handleRtdFn.calledOnceWith(reqBidsConfigObj, {}, mergeFn)).to.be.true; + it('does nothing if targeting data is null', function () { + defaultHandleRtd(reqBidsConfigObj, null, mergeFn); + expect(mergeFn.called).to.be.false; }); }); - describe('getBidRequestData', function () { - let sandbox, reqBidsConfigObj, callback, moduleConfig; + describe('getBidRequestData - Direct API Mode', function () { + let reqBidsConfigObj, callback, moduleConfig, ajaxStub; beforeEach(() => { - sandbox = sinon.createSandbox(); - reqBidsConfigObj = { ortb2Fragments: { global: {} } }; + reqBidsConfigObj = { + ortb2Fragments: { + global: {} + } + }; callback = sinon.spy(); - moduleConfig = { params: { bundleUrl: 'https://cdn.optable.co/bundle.js' } }; + moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + ids: ['id1'], + hids: [] + } + }; - sandbox.stub(window, 'optable').value({ cmd: [] }); - sandbox.stub(window.document, 'createElement'); - sandbox.stub(window.document, 'head'); + ajaxStub = sandbox.stub(ajax, 'ajax'); + // Stub window.optable to ensure Direct API mode + delete window.optable; }); - afterEach(() => { - sandbox.restore(); + it('calls targeting API with correct parameters', async function () { + storage.getDataFromLocalStorage.returns(null); // No cache + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('https://dcn.customer.com/v2/targeting'); + expect(url).to.include('o=my-site'); + expect(url).to.include('t=my-node'); + expect(url).to.include('id=id1'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(ajaxStub.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; }); - it('loads Optable JS bundle if bundleUrl is provided', function () { - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - expect(window.document.createElement.called).to.be.true; + it('uses cached data immediately and updates cache in background', async function () { + const cachedData = { + ortb2: { + user: { + eids: [{ source: 'cached.com', uids: [{ id: 'cached-id' }] }] + } + } + }; + storage.getDataFromLocalStorage.returns(JSON.stringify(cachedData)); + + let apiCallMade = false; + ajaxStub.callsFake((url, options) => { + apiCallMade = true; + // Simulate async API call + setTimeout(() => { + options.success('{"ortb2":{"user":{"eids":[{"source":"fresh.com"}]}}}'); + }, 10); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + // Callback should be called immediately with cached data + expect(callback.calledOnce).to.be.true; + // API call should still be made for background update + expect(apiCallMade).to.be.true; }); - it('uses existing Optable instance if no bundleUrl is provided', function () { - moduleConfig.params.bundleUrl = null; - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - expect(window.optable.cmd.length).to.equal(1); + it('waits for API call when no cache available', async function () { + storage.getDataFromLocalStorage.returns(null); // No cache + + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[{"source":"fresh.com"}]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(ajaxStub.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; + expect(storage.setDataInLocalStorage.called).to.be.true; // Cache updated }); - it('calls callback when assuming the bundle is present', function (done) { - moduleConfig.params.bundleUrl = null; - window.optable = { - cmd: [], - instance: { - targetingFromCache: sandbox.stub().returns(null) + it('handles API errors gracefully', async function () { + storage.getDataFromLocalStorage.returns(null); + + ajaxStub.callsFake((url, options) => { + options.error('Network error'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; + }); + + it('includes consent parameters in API call', async function () { + storage.getDataFromLocalStorage.returns(null); + + const userConsent = { + gpp: { + gppString: 'DBABMA~test', + applicableSections: [2, 6] + }, + gdpr: { + gdprApplies: true, + consentString: 'CPXxxx' } }; - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('gpp=DBABMA~test'); + expect(url).to.include('gpp_sid=2,6'); + expect(url).to.include('gdpr_consent=CPXxxx'); + expect(url).to.include('gdpr=1'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - // Check that the function is queued - expect(window.optable.cmd.length).to.equal(1); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, userConsent); + expect(ajaxStub.calledOnce).to.be.true; + }); - // Dispatch the event after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: { ortb2: { user: { ext: { optable: 'testData' } } } } - }); - window.dispatchEvent(event); - }, 10); + it('adds default __passport__ ID when no IDs configured', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.ids = []; - // Manually trigger the queued function - window.optable.cmd[0](); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('id=__passport__'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; }); - it('mergeOptableData catches error and executes callback when something goes wrong', function (done) { - moduleConfig.params.bundleUrl = null; - moduleConfig.params.handleRtd = () => { throw new Error('Test error'); }; + it('includes passport in cookieless mode', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.cookies = false; + storage.getDataFromLocalStorage.withArgs(sinon.match(/OPTABLE_PASSPORT/)).returns('test-passport'); - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('cookies=no'); + expect(url).to.include('passport=test-passport'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - expect(window.optable.cmd.length).to.equal(1); - window.optable.cmd[0](); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 50); + it('includes cookies=yes in cookie mode', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.cookies = true; + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('cookies=yes'); + expect(url).to.not.include('passport='); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; }); - it('getBidRequestData catches error and executes callback when something goes wrong', function (done) { - moduleConfig.params.bundleUrl = null; - moduleConfig.params.handleRtd = 'not a function'; - window.optable = { - cmd: [], - instance: { - targetingFromCache: sandbox.stub().returns(null) + it('updates passport from API response', async function () { + storage.getDataFromLocalStorage.returns(null); + + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[]}},"passport":"new-passport-value"}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + // Check that passport was stored + const passportCall = storage.setDataInLocalStorage.getCalls().find(call => + call.args[0].includes('OPTABLE_PASSPORT') + ); + expect(passportCall).to.exist; + expect(passportCall.args[1]).to.equal('new-passport-value'); + }); + + it('derives timeout from auctionDelay', async function () { + storage.getDataFromLocalStorage.returns(null); + const auctionTimeout = 2500; + + ajaxStub.callsFake((url, options) => { + // Should use auctionDelay - 100ms = 2400ms + expect(url).to.include('timeout=2400'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}, auctionTimeout); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('uses config timeout when auctionDelay not available', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.timeout = 1000; + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('timeout=1000'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('uses custom handleRtd function if provided', async function () { + storage.getDataFromLocalStorage.returns(null); + const customHandleRtd = sinon.spy(); + moduleConfig.params.handleRtd = customHandleRtd; + + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[{"source":"test.com"}]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(customHandleRtd.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; + }); + + it('handles invalid config gracefully', async function () { + moduleConfig.params.host = null; + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(ajaxStub.called).to.be.false; + expect(callback.calledOnce).to.be.true; + }); + + it('includes custom targetingParams in API call', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.targetingParams = { + ab_test_id: 'split_test_demo', + skip_resolvers: 'Criteo', + custom_param: 'custom_value' + }; + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('ab_test_id=split_test_demo'); + expect(url).to.include('skip_resolvers=Criteo'); + expect(url).to.include('custom_param=custom_value'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('handles null and undefined values in targetingParams', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.targetingParams = { + valid_param: 'value', + null_param: null, + undefined_param: undefined + }; + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('valid_param=value'); + expect(url).to.not.include('null_param'); + expect(url).to.not.include('undefined_param'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('updates SDK-compatible targeting cache after successful API call', async function () { + storage.getDataFromLocalStorage.returns(null); + + const targetingData = { + ortb2: { + user: { + eids: [{ source: 'test.com', uids: [{ id: 'test-id' }] }] + } } }; - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + options.success(JSON.stringify(targetingData)); + }); - expect(window.optable.cmd.length).to.equal(1); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - // Dispatch event after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: { ortb2: { user: { ext: { optable: 'testData' } } } } - }); - window.dispatchEvent(event); - }, 10); + // Verify SDK-compatible cache was updated + const sdkCacheCall = storage.setDataInLocalStorage.getCalls().find(call => + call.args[0].includes('OPTABLE_TARGETING_') + ); + expect(sdkCacheCall).to.exist; + expect(sdkCacheCall.args[1]).to.equal(JSON.stringify(targetingData)); + }); - // Execute the queued command - window.optable.cmd[0](); + it('updates SDK-compatible cache in background refresh', async function () { + const cachedData = { + ortb2: { + user: { + eids: [{ source: 'cached.com', uids: [{ id: 'cached-id' }] }] + } + } + }; + storage.getDataFromLocalStorage.returns(JSON.stringify(cachedData)); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + const freshData = { + ortb2: { + user: { + eids: [{ source: 'fresh.com', uids: [{ id: 'fresh-id' }] }] + } + } + }; + + ajaxStub.callsFake((url, options) => { + // Simulate async response + setTimeout(() => { + options.success(JSON.stringify(freshData)); + }, 10); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + // Wait for background call to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify SDK-compatible cache was updated in background + const sdkCacheCalls = storage.setDataInLocalStorage.getCalls().filter(call => + call.args[0].includes('OPTABLE_TARGETING_') + ); + expect(sdkCacheCalls.length).to.be.greaterThan(0); }); - it("doesn't fail when optable is not available", function (done) { - moduleConfig.params.bundleUrl = null; - delete window.optable; + it('uses correct SDK cache key format with base64 encoding', async function () { + storage.getDataFromLocalStorage.returns(null); - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + const targetingData = { + ortb2: { + user: { + eids: [{ source: 'test.com', uids: [{ id: 'test-id' }] }] + } + } + }; + + ajaxStub.callsFake((url, options) => { + options.success(JSON.stringify(targetingData)); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + // Verify the cache key format: OPTABLE_TARGETING_ + const sdkCacheCall = storage.setDataInLocalStorage.getCalls().find(call => + call.args[0].startsWith('OPTABLE_TARGETING_') + ); + expect(sdkCacheCall).to.exist; + expect(sdkCacheCall.args[0]).to.match(/^OPTABLE_TARGETING_.+$/); + + // Verify it's different from the regular cache key + expect(sdkCacheCall.args[0]).to.not.equal('optable-cache:targeting'); + }); + }); - // The code should have created window.optable with cmd array - expect(window.optable).to.exist; - expect(window.optable.cmd.length).to.equal(1); + describe('getBidRequestData - SDK Mode', function () { + let reqBidsConfigObj, callback, moduleConfig; - // Simulate optable bundle initializing and executing commands - window.optable.instance = { - targetingFromCache: () => null + beforeEach(() => { + reqBidsConfigObj = { ortb2Fragments: { global: {} } }; + callback = sinon.spy(); + moduleConfig = { params: { instance: 'instance', adserverTargeting: true } }; + + // Mock SDK availability + window.optable = { + instance: { + targeting: sinon.stub(), + targetingFromCache: sinon.stub().returns(null) + } }; + }); - // Dispatch event after a short delay + afterEach(() => { + delete window.optable; + }); + + it('uses SDK mode when window.optable is available', async function () { + const targetingData = { + ortb2: { + user: { eids: [{ source: 'optable.co', uids: [{ id: 'sdk-id' }] }] } + } + }; + + // Simulate SDK event setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: { ortb2: { user: { ext: { optable: 'testData' } } } } - }); + const event = new CustomEvent('optable-targeting:change', { detail: targetingData }); window.dispatchEvent(event); }, 10); - // Execute the queued command (simulating optable bundle execution) - window.optable.cmd[0](); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + expect(callback.calledOnce).to.be.true; + }); + + it('uses SDK cached data if available', async function () { + const cachedData = { + ortb2: { user: { eids: [{ source: 'cached.com' }] } } + }; + window.optable.instance.targetingFromCache.returns(cachedData); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; }); }); - describe('getTargetingData', function () { - let sandbox, moduleConfig; + describe('getBidRequestData - Mode Detection', function () { + let reqBidsConfigObj, callback; beforeEach(() => { - sandbox = sinon.createSandbox(); - moduleConfig = { params: { adserverTargeting: true } }; - window.optable = { instance: { targetingKeyValuesFromCache: sandbox.stub().returns({ key1: 'value1' }) } }; + reqBidsConfigObj = { ortb2Fragments: { global: {} } }; + callback = sinon.spy(); + delete window.optable; }); - afterEach(() => { - sandbox.restore(); + it('errors when neither SDK nor Direct API params configured', async function () { + const moduleConfig = { params: {} }; + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; + // Should log error about missing configuration }); + }); - it('returns correct targeting data when Optable data is available', function () { + describe('getTargetingData', function () { + it('returns empty object when SDK not available', function () { + delete window.optable; + const moduleConfig = { params: { host: 'test', site: 'test', node: 'test' } }; const result = getTargetingData(['adUnit1'], moduleConfig, {}, {}); - expect(result).to.deep.equal({ adUnit1: { key1: 'value1' } }); + expect(result).to.deep.equal({}); }); - it('returns empty object when no Optable data is found', function () { - window.optable.instance.targetingKeyValuesFromCache.returns({}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); - }); + it('returns targeting data when SDK available', function () { + window.optable = { + instance: { + targetingKeyValuesFromCache: sinon.stub().returns({ + 'optable_segment': ['seg1', 'seg2'] + }) + } + }; - it('returns empty object when adserverTargeting is disabled', function () { - moduleConfig.params.adserverTargeting = false; - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); - }); + const moduleConfig = { params: { adserverTargeting: true } }; + const result = getTargetingData(['adUnit1'], moduleConfig, {}, {}); - it('returns empty object when provided keys contain no data', function () { - window.optable.instance.targetingKeyValuesFromCache.returns({ key1: [] }); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + expect(result).to.have.property('adUnit1'); + expect(result.adUnit1).to.have.property('optable_segment'); - window.optable.instance.targetingKeyValuesFromCache.returns({ key1: [], key2: [], key3: [] }); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + delete window.optable; }); }); @@ -326,4 +764,13 @@ describe('Optable RTD Submodule', function () { expect(optableSubmodule.init()).to.be.true; }); }); + + describe('submodule structure', function () { + it('exports the correct submodule structure', function () { + expect(optableSubmodule.name).to.equal('optable'); + expect(optableSubmodule.init).to.be.a('function'); + expect(optableSubmodule.getBidRequestData).to.be.a('function'); + expect(optableSubmodule.getTargetingData).to.be.a('function'); + }); + }); });