From df7a9340a95fb654d3f725dd442b7ece1fda4691 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Mon, 2 Feb 2026 16:43:07 -0500 Subject: [PATCH 1/9] WIP - Optable RTD Module - targeting --- .../gpt/optableRtdProvider_example.html | 47 +- modules/optableRtdProvider.js | 646 ++++++++++++++---- modules/optableRtdProvider.md | 443 +++++++++++- test/spec/modules/optableRtdProvider_spec.js | 564 +++++++++------ 4 files changed, 1277 insertions(+), 423 deletions(-) diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index 5e1c8a77cb9..bece5f2a1a9 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -2,8 +2,6 @@ Optable RTD submodule example - Prebid.js - - @@ -130,39 +128,31 @@ ]; 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 + auctionDelay: 2500, // should be set lower in production use dataProviders: [ { 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 + site: 'js-sdk', // Your site identifier + node: 'prebidtest', // Your node 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'], // Household 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); // } } } @@ -174,6 +164,7 @@ pbjs.onEvent('bidRequested', function (data) { try { + // Display the enriched user data from Optable RTD window.optable.cmd.push(() => { document.getElementById('enriched-optable').style.display = 'block'; document.getElementById('enriched-optable-data').textContent = JSON.stringify(data.ortb2.user, null, 2); diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index 29638ba3a94..9006103d24a 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -1,87 +1,408 @@ 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}); + +// localStorage key for targeting cache +const OPTABLE_CACHE_KEY = 'optable-cache:targeting'; + +// Storage key prefix for passport (visitor ID) - matches Web SDK format +const PASSPORT_KEY_PREFIX = 'OPTABLE_PASSPORT_'; /** * Extracts the parameters for Optable RTD module from the config object passed at instantiation * @param {Object} moduleConfig Configuration object for the module + * @returns {Object} Parsed configuration */ export const parseConfig = (moduleConfig) => { - let bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null); - const adserverTargeting = deepAccess(moduleConfig, 'params.adserverTargeting', true); - const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); - const instance = deepAccess(moduleConfig, 'params.instance', null); + // Required parameters + const host = deepAccess(moduleConfig, 'params.host', null); + const site = deepAccess(moduleConfig, 'params.site', null); + const node = deepAccess(moduleConfig, 'params.node', null); - // If present, trim the bundle URL - if (typeof bundleUrl === 'string') { - bundleUrl = bundleUrl.trim(); + // Validate required parameters + if (!host || typeof host !== 'string') { + logError('host parameter is required and must be a string'); + return null; } - - // 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 (!site || typeof site !== 'string') { + logError('site parameter is required and must be a string'); + return null; + } + if (!node || typeof node !== 'string') { + logError('node parameter is required and must be a string'); + return null; } + // Optional parameters + const cookies = deepAccess(moduleConfig, 'params.cookies', true); + const timeout = deepAccess(moduleConfig, 'params.timeout', null); + const cacheFallbackTimeout = deepAccess(moduleConfig, 'params.cacheFallbackTimeout', 150); + const ids = deepAccess(moduleConfig, 'params.ids', []); + const hids = deepAccess(moduleConfig, 'params.hids', []); + const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); + + // Validate handleRtd if provided 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; + // Validate ids and hids are arrays + if (!Array.isArray(ids)) { + logError('ids parameter must be an array'); + return null; + } + if (!Array.isArray(hids)) { + logError('hids parameter must be an array'); + return null; } - return result; + + return { + host: host.trim(), + site: site.trim(), + node: node.trim(), + cookies, + timeout, + cacheFallbackTimeout, + ids, + hids, + handleRtd + }; } +// Global session ID (generated once per page load) +let sessionID = null; + /** - * Wait for Optable SDK event to fire with targeting data - * @param {string} eventName Name of the event to listen for - * @returns {Promise} Promise that resolves with targeting data or null + * Generates a random session ID (base64url encoded 16-byte random value) + * @returns {string} Session ID */ -const waitForOptableEvent = (eventName) => { - return new Promise((resolve) => { - const optableBundle = /** @type {Object} */ (window.optable); - const cachedData = optableBundle?.instance?.targetingFromCache(); +export const generateSessionID = () => { + if (sessionID) { + return sessionID; + } - if (cachedData && cachedData.ortb2) { - logMessage('Optable SDK already has cached data'); - resolve(cachedData); - return; + // 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 + * @param {string} str String to encode + * @returns {string} Base64 encoded string + */ +const encodeBase64 = (str) => { + return btoa(str); +}; + +/** + * 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); +}; + +/** + * 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; + logMessage(`Found cached targeting with ${eidCount} EIDs`); + return eidCount > 0 ? parsedData : 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 || !targetingData.ortb2) { + return; + } + storage.setDataInLocalStorage(OPTABLE_CACHE_KEY, JSON.stringify(targetingData)); +}; + +/** + * Read consent from CMP APIs directly (fallback when Prebid consent not available) + * @returns {Object} Consent object + */ +const getConsentFromCMPAPIs = () => { + const consent = { + deviceAccess: true, + gpp: null, + gppSectionIDs: null, + gdpr: null, + gdprApplies: null + }; + + // Try to read from GPP CMP API + if (typeof window.__gpp === 'function') { + try { + window.__gpp('ping', (data, success) => { + if (success && data) { + consent.gpp = data.gppString || null; + consent.gppSectionIDs = data.applicableSections || null; + } + }); + } catch (e) { + logWarn('Failed to read GPP consent from CMP API', e); + } + } + + // Try to read from TCF CMP API + if (typeof window.__tcfapi === 'function') { + try { + window.__tcfapi('getTCData', 2, (data, success) => { + if (success && data) { + consent.gdpr = data.tcString || null; + consent.gdprApplies = data.gdprApplies; + // TCF Purpose 1 = device access and storage + // Check both vendor consents and publisher consents + const purpose1Consent = data.purpose?.consents?.[1] || data.publisher?.consents?.[1]; + consent.deviceAccess = purpose1Consent !== undefined ? purpose1Consent : true; + } + }); + } catch (e) { + logWarn('Failed to read TCF consent from CMP API', e); + } + } + + return consent; +}; + +/** + * Extract consent information from Prebid config (with fallback to CMP APIs) + * @returns {Object} Consent object with GPP, GDPR strings, and deviceAccess flag + */ +const getConsentFromPrebid = () => { + const consent = { + deviceAccess: true, + gpp: null, + gppSectionIDs: null, + gdpr: null, + gdprApplies: null + }; + + // Try to get GPP consent from Prebid + const gppConsent = config.getConfig('consentManagement.gpp'); + if (gppConsent) { + consent.gpp = gppConsent.gppString || null; + consent.gppSectionIDs = gppConsent.applicableSections || null; + } + + // Try to get GDPR consent from Prebid + const gdprConsent = config.getConfig('consentManagement.gdpr'); + if (gdprConsent && gdprConsent.gdprApplies !== undefined) { + consent.gdprApplies = gdprConsent.gdprApplies; + consent.gdpr = gdprConsent.consentString || null; + + // Compute deviceAccess from TCF Purpose 1 if available + if (gdprConsent.vendorData) { + const purpose1Consent = gdprConsent.vendorData.purpose?.consents?.[1] || + gdprConsent.vendorData.publisher?.consents?.[1]; + if (purpose1Consent !== undefined) { + consent.deviceAccess = purpose1Consent; + } + } + } + + // If Prebid consent is empty, try CMP APIs directly + // This handles the case where RTD runs before consent modules initialize + if (!consent.gpp && !consent.gdpr) { + return getConsentFromCMPAPIs(); + } + + 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, site, node, ids, hids, consent, sessionId, passport, cookies, timeout} = params; + + const searchParams = new URLSearchParams(); - const eventListener = (event) => { - logMessage(`Received ${eventName} event`); - // Extract targeting data from event detail - const targetingData = event.detail; - window.removeEventListener(eventName, eventListener); - resolve(targetingData); + // Add identifiers + ids.forEach(id => searchParams.append('id', id)); + hids.forEach(hid => searchParams.append('hid', hid)); + + // Add site identifier (required) + searchParams.set('o', site); + + // Add node identifier (required) + searchParams.set('t', node); + + // Add session ID (for analytics) + searchParams.set('sid', sessionId); + + // Add SDK identifier (for server-side analytics) + searchParams.set('osdk', 'prebid-rtd-1.0.0'); + + // Add consent parameters + 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'); + } + + // Add cookie mode and passport + if (cookies) { + searchParams.set('cookies', 'yes'); + } else { + searchParams.set('cookies', 'no'); + searchParams.set('passport', passport || ''); + } + + // Add timeout hint if provided + 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 leaking into targeting + 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); + } }; - window.addEventListener(eventName, eventListener); - logMessage(`Waiting for ${eventName} event`); + ajax(url, ajaxOptions); }); }; /** - * 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 - * @returns {Promise} + * Default function to handle/enrich RTD data by merging targeting data into ortb2 + * @param {Object} reqBidsConfigObj Bid request configuration object + * @param {Object} targetingData Targeting data from API + * @param {Function} mergeFn Function to merge data + * @returns {void} */ -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'); - +export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn) => { if (!targetingData || !targetingData.ortb2) { logWarn('No targeting data found'); return; @@ -95,122 +416,165 @@ export const defaultHandleRtd = async (reqBidsConfigObj, optableExtraData, merge }; /** - * 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 - */ -export const mergeOptableData = async (handleRtdFn, reqBidsConfigObj, optableExtraData, mergeFn) => { - if (handleRtdFn.constructor.name === 'AsyncFunction') { - await handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn); - } else { - handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn); - } -}; - -/** + * Main function called by Prebid to get bid request data * @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} userConsent User consent object */ -export const getBidRequestData = (reqBidsConfigObj, callback, moduleConfig, userConsent) => { +export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig, userConsent) => { try { - // Extract the bundle URL from the module configuration - const {bundleUrl, handleRtd} = parseConfig(moduleConfig); + // Parse and validate configuration + const parsedConfig = parseConfig(moduleConfig); + if (!parsedConfig) { + logError('Invalid configuration, skipping Optable RTD'); + callback(); + return; + } + + const {host, site, node, cookies, timeout, cacheFallbackTimeout, ids: configIds, hids: configHids, handleRtd} = 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); + + logMessage(`Configuration: host=${host}, site=${site}, node=${node}, cookies=${cookies}`); + + // Generate session ID and extract consent (needed for both cache hit and miss) + const sessionId = generateSessionID(); + const consent = getConsentFromPrebid(); + + // Step 1: Check cache - if found, try API first with cache fallback + const cachedData = getCachedTargeting(); + if (cachedData) { + logMessage('Cache found, trying API-first with cache fallback'); + logMessage(`Cache fallback timeout: ${cacheFallbackTimeout}ms`); + + // Extract identifiers from config and Prebid userId module + const {ids, hids} = extractIdentifiers(configIds, configHids, reqBidsConfigObj); + logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); + + // Get passport from localStorage + const passport = getPassport(host, node); + logMessage(`Passport: ${passport ? 'found' : 'not found'}`); + + // Start API call + const apiPromise = callTargetingAPI({ + host, + site, + node, + ids, + hids, + consent, + sessionId, + passport, + cookies, + timeout }); + + // Create timeout promise + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve(null), cacheFallbackTimeout); + }); + + // Race API call against timeout + const targetingData = await Promise.race([apiPromise, timeoutPromise]); + + if (targetingData) { + // API returned in time - use fresh data + logMessage('API returned in time, using fresh targeting data'); + handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep); + + // Update cache and passport + setCachedTargeting(targetingData); + if (targetingData.passport) { + setPassport(host, node, targetingData.passport); + } + + callback(); + } else { + // Timeout - use cached data but keep waiting for API + logMessage('Timeout reached, using cached targeting data'); + handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep); + callback(); + + // Continue waiting for API to update cache in background + logMessage('Waiting for API to complete in background to update cache'); + apiPromise.then(data => { + if (data) { + logMessage('Background API call completed, cache updated'); + setCachedTargeting(data); + if (data.passport) { + setPassport(host, node, data.passport); + } + } + }).catch(error => { + logWarn('Background API call failed:', error); + }); + } + + return; + } + + // Step 2: No cache found - make targeting API call + logMessage(`Session ID: ${sessionId}`); + logMessage(`Consent: GPP=${!!consent.gpp}, GDPR=${!!consent.gdpr}`); + + // Extract identifiers from config and Prebid userId module + const {ids, hids} = extractIdentifiers(configIds, configHids, reqBidsConfigObj); + logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); + + // Get passport from localStorage + const passport = getPassport(host, node); + logMessage(`Passport: ${passport ? 'found' : 'not found'}`); + + // Call targeting API + const targetingData = await callTargetingAPI({ + host, + site, + node, + ids, + hids, + consent, + sessionId, + passport, + cookies, + timeout + }); + + if (!targetingData) { + logWarn('No targeting data returned from API'); + callback(); + return; } + + // Step 3: Cache the response + setCachedTargeting(targetingData); + + // Step 4: Merge targeting data into bid request + handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep); + + callback(); } catch (error) { - // If an error occurs, log it and call the callback - // to continue with the auction - logError(error); + // If an error occurs, log it and call the callback to continue with the auction + logError('getBidRequestData error: ', error); callback(); } } /** * Get Optable targeting data and merge it into the ad units + * Note: Ad server targeting requires the Optable Web SDK. This RTD module + * focuses on enriching bid requests with EIDs. For ad server targeting, + * please use the Optable Web SDK alongside this RTD module. + * * @param adUnits Array of ad units * @param moduleConfig Module configuration * @param userConsent User consent * @param auction Auction object - * @returns {Object} Targeting data + * @returns {Object} Empty object (ad server targeting not supported without Web SDK) */ 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); - - if (!adserverTargeting) { - logMessage('Ad server targeting is disabled'); - 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) { - logWarn(`No Optable SDK instance found for: ${instanceKey}`); - return targetingData; - } - - // Get the Optable targeting data from the cache - const optableTargetingData = sdkInstance?.targetingKeyValuesFromCache?.() || targetingData; - - // If no Optable targeting data is found, return an empty object - if (!Object.keys(optableTargetingData).length) { - logWarn('No Optable targeting data found'); - return targetingData; - } - - // Merge the Optable targeting data into the ad units - 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) { - delete targetingData[adUnit][key]; - } - }); - - // If the ad unit contains no data, remove it - if (!Object.keys(targetingData[adUnit]).length) { - delete targetingData[adUnit]; - } - }); - - logMessage('Optable targeting data: ', targetingData); - return targetingData; + logMessage('getTargetingData: Ad server targeting not supported in direct API mode'); + logMessage('For ad server targeting, please use the Optable Web SDK'); + return {}; }; /** diff --git a/modules/optableRtdProvider.md b/modules/optableRtdProvider.md index 4ac0d4541f4..2995fb90ef1 100644 --- a/modules/optableRtdProvider.md +++ b/modules/optableRtdProvider.md @@ -12,7 +12,16 @@ 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 RTD module calls the Optable targeting API directly**, without requiring the Optable Web SDK to be loaded on the page. The module handles: + +- Direct API calls to your Optable DCN for targeting data +- Automatic consent extraction from Prebid consent modules (GPP/GDPR) +- Identifier collection from both configuration and Prebid userId module +- Passport (visitor ID) management for cookieless targeting +- Response caching in localStorage for performance +- ORTB2 data enrichment for bid requests ## Usage @@ -26,28 +35,65 @@ 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 - -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: - -```html - -``` - ### Configuration This module is configured as part of the `realTimeData.dataProviders`. +**Basic Configuration:** + ```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 + site: 'my-site', // REQUIRED: Your site identifier + node: 'prod-us', // REQUIRED: Your node 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 + site: 'my-site', // Your site identifier + node: 'prod-us', // Your node 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: ['household-id'], // Household 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 +103,375 @@ 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 | +|-------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------| +| name | String | Real time data module name | Always `optable` | Yes | +| waitForIt | Boolean | Should be set to `true` to ensure targeting data is available before auction | `false` | Recommended | +| params | Object | Configuration parameters | | Yes | +| params.host | String | Your Optable DCN hostname (e.g., `dcn.customer.com`) | None | **Yes** | +| params.site | String | Site identifier configured in your DCN | None | **Yes** | +| params.node | String | Node identifier for your DCN | None | **Yes** | +| params.cookies | Boolean | Cookie mode. Set to `false` for cookieless targeting using passport | `true` | No | +| params.timeout | String | API timeout hint (e.g., `"500ms"`) | `null` | No | +| params.cacheFallbackTimeout | Number | Milliseconds to wait for fresh API data before falling back to cache. When cache exists, module tries API first; if response takes longer than this timeout, cached data is used instead. Should match or be slightly less than `auctionDelay` for best results. | `150` | No | +| params.ids | Array | Array of user identifier strings. These are combined with identifiers auto-extracted from Prebid userId module | `[]` | No | +| params.hids | Array | Array of household identifier strings | `[]` | No | +| params.handleRtd | Function | Custom function to handle/enrich RTD data. Function signature: `(reqBidsConfigObj, targetingData, mergeFn) => {}`. If not provided, the module uses a default handler that merges targeting data into ortb2Fragments.global | `null` | No | + +## How It Works + +### 1. Initialization + +When Prebid's auction starts, the Optable RTD module: + +1. Validates the configuration (checks for required `host`, `site`, and `node` 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 + - Prebid's userId module (via `ortb2.user.ext.eids`) +- Retrieves the passport (visitor ID) from localStorage for cookieless mode + +### 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`: Household 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 -## Publisher Customized RTD Handler Function +The targeting API returns an ORTB2 object with: -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): +- `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 collected from two sources: + +### 1. Configuration + +Provide identifiers directly in the RTD configuration: + +```javascript +params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us', + ids: ['email-hash-123', 'phone-hash-456'], + hids: ['household-id-789'] +} +``` + +### 2. Prebid userId Module + +The module automatically extracts identifiers from other Prebid userId modules: + +```javascript +// If you have other userId modules configured +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'id5Id', + params: { + partner: 173 + } + }, { + name: 'unifiedId', + params: { + // ... + } + }] + } +}); +``` + +All identifiers are combined and sent to the targeting API. + +## Cookie vs Cookieless Mode + +### Cookie Mode (Default) ```javascript -mergeFn( - reqBidsConfigObj.ortb2Fragments.global, // or other nested object as needed - rtdData, -); +params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us', + cookies: true // or omit this parameter +} ``` -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. +In cookie mode, the DCN uses first-party cookies for visitor identification. + +### Cookieless Mode + +```javascript +params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us', + 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 + +## Caching + +The module caches targeting responses in localStorage under the key `optable-cache:targeting` to improve performance: + +- On the first page view, an API call is made +- The response is cached in localStorage +- On subsequent page views, cached data is used immediately +- A new API call updates the cache in the background + +## Node Configuration + +The `node` parameter is required and identifies your DCN node: + +```javascript +params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'us-east' // Your node identifier +} +``` + +The node identifier routes the API call to the specified node and maintains separate passports per node. + +## Custom RTD Handler + +For advanced use cases, provide a custom `handleRtd` function: + +```javascript +params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us', + handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { + console.log('Targeting data received:', targetingData); + + // Custom validation + if (!targetingData || !targetingData.ortb2) { + console.warn('Invalid targeting data'); + 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 + site: 'my-site', // Your site identifier + node: 'prod-us' // Your node identifier +} +``` + +### Key Differences: + +1. **No External Loading**: Module no longer loads SDK from CDN, uses direct API calls instead +2. **Required Parameters**: `host`, `site`, and `node` 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: + +- A/B testing framework +- Additional targeting signals (page URL ref) +- Ad server targeting keywords (use Web SDK for this) +- Event dispatching system +- Complex multi-storage key strategies + +See `modules/optableRtdProvider_EXCLUDED_FEATURES.txt` 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! + site: 'my-site', + node: 'prod-us' +} +``` + +### "site parameter is required and must be a string" + +Ensure you've configured the `site` parameter: + +```javascript +params: { + host: 'dcn.customer.com', + site: 'my-site', // Required! + node: 'prod-us' +} +``` + +### "node parameter is required and must be a string" + +Ensure you've configured the `node` parameter: + +```javascript +params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us' // Required! +} +``` + +### 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 hostname 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 271d31d0185..c4480df5271 100644 --- a/test/spec/modules/optableRtdProvider_spec.js +++ b/test/spec/modules/optableRtdProvider_spec.js @@ -1,323 +1,442 @@ import { parseConfig, + generateSessionID, + getPassport, + setPassport, defaultHandleRtd, - mergeOptableData, getBidRequestData, getTargetingData, optableSubmodule, + LOG_PREFIX, } from 'modules/optableRtdProvider'; +import {config} from 'src/config.js'; +import * as ajax from 'src/ajax.js'; describe('Optable RTD Submodule', function () { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('parseConfig', function () { - it('parses valid config correctly', function () { - const config = { + it('parses valid config with required parameters', function () { + const moduleConfig = { params: { - bundleUrl: 'https://cdn.optable.co/bundle.js', - adserverTargeting: true, - handleRtd: () => {} + host: 'dcn.customer.com', + site: 'my-site', } }; - 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.host).to.equal('dcn.customer.com'); + expect(result.site).to.equal('my-site'); + expect(result.cookies).to.be.true; + expect(result.ids).to.deep.equal([]); + expect(result.hids).to.deep.equal([]); }); - 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 and site values', function () { + const moduleConfig = { + params: { + host: ' dcn.customer.com ', + site: ' my-site ', + } + }; + const result = parseConfig(moduleConfig); + expect(result.host).to.equal('dcn.customer.com'); + expect(result.site).to.equal('my-site'); }); - 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('returns null if host is missing', function () { + const moduleConfig = {params: {site: 'my-site'}}; + expect(parseConfig(moduleConfig)).to.be.null; }); - 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 site is missing', function () { + const moduleConfig = {params: {host: 'dcn.customer.com'}}; + 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('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 + } + }; + 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); }); - it('returns null handleRtd if handleRtd is not a function', function () { - expect(parseConfig({params: {handleRtd: 'notAFunction'}}).handleRtd).to.be.null; + it('returns null if handleRtd is not a function', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + handleRtd: 'notAFunction' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; }); - }); - describe('defaultHandleRtd', function () { - let sandbox, reqBidsConfigObj, mergeFn; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - reqBidsConfigObj = {ortb2Fragments: {global: {}}}; - mergeFn = sinon.spy(); - window.optable = { - instance: { - targeting: sandbox.stub(), - targetingFromCache: sandbox.stub(), - }, + it('returns null if ids is not an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + ids: 'notAnArray' + } }; + expect(parseConfig(moduleConfig)).to.be.null; }); - afterEach(() => { - sandbox.restore(); + it('returns null if hids is not an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + hids: '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); + 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', function () { + const sid1 = generateSessionID(); + const sid2 = generateSessionID(); + expect(sid1).to.equal(sid2); }); - it('does nothing if targeting data is missing the ortb2 property', async function () { - window.optable.instance.targetingFromCache.returns({}); + it('generates base64url encoded string', function () { + const sid = generateSessionID(); + // base64url should not contain +, /, or = + expect(sid).to.not.match(/[+/=]/); + }); + }); - // Dispatch event with empty ortb2 data after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {} - }); - window.dispatchEvent(event); - }, 10); + describe('passport storage', function () { + let storageStub; - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.called).to.be.false; + beforeEach(() => { + storageStub = { + getDataFromLocalStorage: sandbox.stub(), + setDataInLocalStorage: sandbox.stub() + }; + // Mock storage manager + sandbox.stub(require('src/storageManager'), 'getStorageManager').returns(storageStub); }); - it('uses targeting data from cache if available', async function () { - const targetingData = {ortb2: {user: {ext: {optable: 'testData'}}}}; - window.optable.instance.targetingFromCache.returns(targetingData); - - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + it('getPassport retrieves passport from localStorage', function () { + storageStub.getDataFromLocalStorage.returns('test-passport-value'); + const passport = getPassport('dcn.customer.com'); + expect(passport).to.equal('test-passport-value'); + expect(storageStub.getDataFromLocalStorage.calledOnce).to.be.true; }); - 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('setPassport stores passport in localStorage', function () { + setPassport('dcn.customer.com', null, 'new-passport-value'); + expect(storageStub.setDataInLocalStorage.calledOnce).to.be.true; + const args = storageStub.setDataInLocalStorage.getCall(0).args; + expect(args[1]).to.equal('new-passport-value'); + }); - // Dispatch event with targeting data after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: targetingData - }); - window.dispatchEvent(event); - }, 10); + it('generates different keys for different hosts', function () { + setPassport('dcn1.customer.com', null, 'passport1'); + setPassport('dcn2.customer.com', null, 'passport2'); + expect(storageStub.setDataInLocalStorage.calledTwice).to.be.true; + const key1 = storageStub.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storageStub.setDataInLocalStorage.getCall(1).args[0]; + expect(key1).to.not.equal(key2); + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + 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 = storageStub.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storageStub.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(); + }); + + 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; }); - afterEach(() => { - sandbox.restore(); + it('does nothing if targeting data is missing ortb2', function () { + defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); + expect(mergeFn.called).to.be.false; }); - 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 null', function () { + defaultHandleRtd(reqBidsConfigObj, null, 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('adds eids to user.ext.eids for additional coverage', function () { + const targetingData = { + ortb2: { + user: { + eids: [ + {source: 'optable.co', uids: [{id: 'test-id-1'}]}, + {source: 'other.com', uids: [{id: 'test-id-2'}]} + ] + } + } + }; + + defaultHandleRtd(reqBidsConfigObj, targetingData, mergeFn); + + // Check that user.ext.eids was populated + expect(reqBidsConfigObj.ortb2Fragments.global.user).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.user.ext).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.user.ext.eids).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.global.user.ext.eids.length).to.equal(2); }); }); describe('getBidRequestData', function () { - let sandbox, reqBidsConfigObj, callback, moduleConfig; + let reqBidsConfigObj, callback, moduleConfig, ajaxStub, configStub; beforeEach(() => { - sandbox = sinon.createSandbox(); - reqBidsConfigObj = {ortb2Fragments: {global: {}}}; + reqBidsConfigObj = { + ortb2Fragments: { + global: { + user: { + ext: { + eids: [] + } + } + } + } + }; callback = sinon.spy(); - moduleConfig = {params: {bundleUrl: 'https://cdn.optable.co/bundle.js'}}; + moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + ids: ['id1'], + hids: [] + } + }; - sandbox.stub(window, 'optable').value({cmd: []}); - sandbox.stub(window.document, 'createElement'); - sandbox.stub(window.document, 'head'); - }); + // Mock ajax + ajaxStub = sandbox.stub(ajax, 'ajax'); - afterEach(() => { - sandbox.restore(); + // Mock config.getConfig for consent + configStub = sandbox.stub(config, 'getConfig'); + configStub.withArgs('consentManagement.gpp').returns(null); + configStub.withArgs('consentManagement.gdpr').returns(null); }); - it('loads Optable JS bundle if bundleUrl is provided', function () { - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - expect(window.document.createElement.called).to.be.true; - }); + it('calls targeting API with correct parameters', async function () { + 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('id=id1'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - 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); + expect(ajaxStub.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; }); - 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 () { + ajaxStub.callsFake((url, options) => { + options.error('Network error'); + }); - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - // Check that the function is queued - expect(window.optable.cmd.length).to.equal(1); + expect(callback.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('uses cached data if available', async function () { + // Mock localStorage to return cached data + const storageStub = { + getDataFromLocalStorage: sandbox.stub(), + setDataInLocalStorage: sandbox.stub() + }; + storageStub.getDataFromLocalStorage.returns(JSON.stringify({ + ortb2: { + user: { + eids: [{source: 'cached.com', uids: [{id: 'cached-id'}]}] + } + } + })); + sandbox.stub(require('src/storageManager'), 'getStorageManager').returns(storageStub); - // Manually trigger the queued function - window.optable.cmd[0](); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + // Should not call ajax if cache is available + expect(ajaxStub.called).to.be.false; + expect(callback.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'); }; - - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + it('includes consent parameters in API call', async function () { + configStub.withArgs('consentManagement.gpp').returns({ + gppString: 'DBABMA~test', + applicableSections: [2, 6] + }); + configStub.withArgs('consentManagement.gdpr').returns({ + gdprApplies: true, + consentString: 'CPXxxx' + }); - expect(window.optable.cmd.length).to.equal(1); - window.optable.cmd[0](); + 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":[]}}}'); + }); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 50); + 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('extracts identifiers from Prebid userId module', async function () { + reqBidsConfigObj.ortb2Fragments.global.user.ext.eids = [ + { + source: 'id5-sync.com', + uids: [{id: 'id5-user-id'}] } - }; + ]; - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('id=id1'); // from config + expect(url).to.include('id=id5-user-id'); // from userId module + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - expect(window.optable.cmd.length).to.equal(1); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); - // Dispatch event after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {ortb2: {user: {ext: {optable: 'testData'}}}} - }); - window.dispatchEvent(event); - }, 10); + it('includes node parameter if provided', async function () { + moduleConfig.params.node = 'prod-us'; - // Execute the queued command - window.optable.cmd[0](); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('t=prod-us'); + 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("doesn't fail when optable is not available", function (done) { - moduleConfig.params.bundleUrl = null; - delete window.optable; - - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + it('includes timeout parameter if provided', async function () { + moduleConfig.params.timeout = '500ms'; - // The code should have created window.optable with cmd array - expect(window.optable).to.exist; - expect(window.optable.cmd.length).to.equal(1); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('timeout=500ms'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - // Simulate optable bundle initializing and executing commands - window.optable.instance = { - targetingFromCache: () => null - }; + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); - // Dispatch event after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {ortb2: {user: {ext: {optable: 'testData'}}}} - }); - window.dispatchEvent(event); - }, 10); + it('handles cookieless mode correctly', async function () { + moduleConfig.params.cookies = false; - // Execute the queued command (simulating optable bundle execution) - window.optable.cmd[0](); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('cookies=no'); + expect(url).to.include('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; }); - }); - describe('getTargetingData', function () { - let sandbox, moduleConfig; + it('handles invalid config gracefully', async function () { + moduleConfig.params.host = null; - beforeEach(() => { - sandbox = sinon.createSandbox(); - moduleConfig = {params: {adserverTargeting: true}}; - window.optable = {instance: {targetingKeyValuesFromCache: sandbox.stub().returns({key1: 'value1'})}}; - }); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - afterEach(() => { - sandbox.restore(); + expect(ajaxStub.called).to.be.false; + expect(callback.calledOnce).to.be.true; }); - it('returns correct targeting data when Optable data is available', function () { - const result = getTargetingData(['adUnit1'], moduleConfig, {}, {}); - expect(result).to.deep.equal({adUnit1: {key1: 'value1'}}); - }); + it('uses custom handleRtd function if provided', async function () { + const customHandleRtd = sinon.spy(); + moduleConfig.params.handleRtd = customHandleRtd; - it('returns empty object when no Optable data is found', function () { - window.optable.instance.targetingKeyValuesFromCache.returns({}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); - }); + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[{"source":"test.com"}]}}}'); + }); - it('returns empty object when adserverTargeting is disabled', function () { - moduleConfig.params.adserverTargeting = false; - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(customHandleRtd.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; }); - it('returns empty object when provided keys contain no data', function () { - window.optable.instance.targetingKeyValuesFromCache.returns({key1: []}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + it('caches targeting response after API call', async function () { + const storageStub = { + getDataFromLocalStorage: sandbox.stub().returns(null), + setDataInLocalStorage: sandbox.stub() + }; + sandbox.stub(require('src/storageManager'), 'getStorageManager').returns(storageStub); + + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[{"source":"test.com"}]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(storageStub.setDataInLocalStorage.called).to.be.true; + }); + }); - window.optable.instance.targetingKeyValuesFromCache.returns({key1: [], key2: [], key3: []}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + describe('getTargetingData', function () { + it('returns empty object (ad server targeting not supported)', function () { + const result = getTargetingData(['adUnit1'], {}, {}, {}); + expect(result).to.deep.equal({}); }); }); @@ -326,4 +445,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'); + }); + }); }); From 4132dbfba9c354a59d556064a5ad9a210f1c116b Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 4 Feb 2026 16:32:17 -0500 Subject: [PATCH 2/9] wip --- .../gpt/optableRtdProvider_example.html | 23 +- modules/optableRtdProvider.js | 405 +++++++++-------- modules/optableRtdProvider.md | 97 +++- test/spec/modules/optableRtdProvider_spec.js | 415 ++++++++++++------ 4 files changed, 596 insertions(+), 344 deletions(-) diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index bece5f2a1a9..7467268c293 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -138,8 +138,8 @@ params: { // REQUIRED PARAMETERS: host: 'na.edge.optable.co', // Your DCN hostname - site: 'js-sdk', // Your site identifier - node: 'prebidtest', // Your node identifier + site: 'mediaco-sdk', // Your site identifier + node: 'mediaco', // Your node identifier // OPTIONAL PARAMETERS: cookies: false, // Cookie mode (default: true, set to false for cookieless) @@ -162,13 +162,26 @@ pbjs.addAdUnits(adUnits); + // Track if we've already displayed the data (only show once) + let displayedEnrichedData = false; + pbjs.onEvent('bidRequested', function (data) { try { // Display the enriched user data from Optable RTD - window.optable.cmd.push(() => { + 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; + } } catch (e) { console.error('Exception while trying to display enriched data', e); } diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index 9006103d24a..a2b8902a778 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -1,5 +1,9 @@ +/** + * 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 {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'; @@ -11,52 +15,57 @@ const optableLog = prefixLog(LOG_PREFIX); const {logMessage, logWarn, logError} = optableLog; const storage = getStorageManager({moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME}); -// localStorage key for targeting cache +// localStorage key for targeting cache (direct API mode only) const OPTABLE_CACHE_KEY = 'optable-cache:targeting'; -// Storage key prefix for passport (visitor ID) - matches Web SDK format +// 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) => { - // Required parameters + // 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/site/node parameters. See migration guide: https://docs.prebid.org/dev-docs/modules/optableRtdProvider.html'); + return null; + } + const host = deepAccess(moduleConfig, 'params.host', null); const site = deepAccess(moduleConfig, 'params.site', null); const node = deepAccess(moduleConfig, 'params.node', null); + const adserverTargeting = deepAccess(moduleConfig, 'params.adserverTargeting', true); + const instance = deepAccess(moduleConfig, 'params.instance', null); - // Validate required parameters - if (!host || typeof host !== 'string') { - logError('host parameter is required and must be a string'); + const hasDirectApiConfig = host && site && node; + + if (host !== null && (typeof host !== 'string' || !host.trim())) { + logError('host parameter must be a non-empty string'); return null; } - if (!site || typeof site !== 'string') { - logError('site parameter is required and must be a string'); + if (site !== null && (typeof site !== 'string' || !site.trim())) { + logError('site parameter must be a non-empty string'); return null; } - if (!node || typeof node !== 'string') { - logError('node parameter is required and must be a string'); + if (node !== null && (typeof node !== 'string' || !node.trim())) { + logError('node parameter must be a non-empty string'); return null; } - // Optional parameters const cookies = deepAccess(moduleConfig, 'params.cookies', true); const timeout = deepAccess(moduleConfig, 'params.timeout', null); - const cacheFallbackTimeout = deepAccess(moduleConfig, 'params.cacheFallbackTimeout', 150); const ids = deepAccess(moduleConfig, 'params.ids', []); const hids = deepAccess(moduleConfig, 'params.hids', []); const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); - // Validate handleRtd if provided if (handleRtd && typeof handleRtd !== 'function') { logError('handleRtd must be a function'); return null; } - // Validate ids and hids are arrays if (!Array.isArray(ids)) { logError('ids parameter must be an array'); return null; @@ -67,15 +76,17 @@ export const parseConfig = (moduleConfig) => { } return { - host: host.trim(), - site: site.trim(), - node: node.trim(), + host: host ? host.trim() : null, + site: site ? site.trim() : null, + node: node ? node.trim() : null, cookies, timeout, - cacheFallbackTimeout, ids, hids, - handleRtd + handleRtd, + adserverTargeting, + instance, + hasDirectApiConfig }; } @@ -177,58 +188,74 @@ const setCachedTargeting = (targetingData) => { }; /** - * Read consent from CMP APIs directly (fallback when Prebid consent not available) - * @returns {Object} Consent object + * 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 getConsentFromCMPAPIs = () => { - const consent = { - deviceAccess: true, - gpp: null, - gppSectionIDs: null, - gdpr: null, - gdprApplies: null - }; +const isSDKAvailable = (instance = null) => { + const instanceKey = instance || 'instance'; + return typeof window !== 'undefined' && + window.optable && + window.optable[instanceKey] && + typeof window.optable[instanceKey].targeting === 'function'; +}; - // Try to read from GPP CMP API - if (typeof window.__gpp === 'function') { - try { - window.__gpp('ping', (data, success) => { - if (success && data) { - consent.gpp = data.gppString || null; - consent.gppSectionIDs = data.applicableSections || null; - } - }); - } catch (e) { - logWarn('Failed to read GPP consent from CMP API', e); - } - } +/** + * Wait for Optable SDK event to fire with targeting data + * @param {string} eventName Name of the event to listen for + * @returns {Promise} Promise that resolves with targeting data or null + */ +const waitForOptableEvent = (eventName) => { + return new Promise((resolve) => { + const optableBundle = /** @type {Object} */ (window.optable); + const cachedData = optableBundle?.instance?.targetingFromCache(); - // Try to read from TCF CMP API - if (typeof window.__tcfapi === 'function') { - try { - window.__tcfapi('getTCData', 2, (data, success) => { - if (success && data) { - consent.gdpr = data.tcString || null; - consent.gdprApplies = data.gdprApplies; - // TCF Purpose 1 = device access and storage - // Check both vendor consents and publisher consents - const purpose1Consent = data.purpose?.consents?.[1] || data.publisher?.consents?.[1]; - consent.deviceAccess = purpose1Consent !== undefined ? purpose1Consent : true; - } - }); - } catch (e) { - logWarn('Failed to read TCF consent from CMP API', e); + if (cachedData && cachedData.ortb2) { + logMessage('Optable SDK already has cached data'); + resolve(cachedData); + return; } + + const eventListener = (event) => { + logMessage(`Received ${eventName} event`); + const targetingData = event.detail; + window.removeEventListener(eventName, eventListener); + resolve(targetingData); + }; + + window.addEventListener(eventName, eventListener); + logMessage(`Waiting for ${eventName} event`); + }); +}; + +/** + * 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 + * @returns {Promise} + */ +const handleSDKMode = async (handleRtdFn, reqBidsConfigObj, mergeFn) => { + const targetingData = await waitForOptableEvent('optable-targeting:change'); + + if (!targetingData || !targetingData.ortb2) { + logWarn('No targeting data from SDK event'); + return; } - return consent; + if (handleRtdFn.constructor.name === 'AsyncFunction') { + await handleRtdFn(reqBidsConfigObj, targetingData, mergeFn); + } else { + handleRtdFn(reqBidsConfigObj, targetingData, mergeFn); + } }; /** - * Extract consent information from Prebid config (with fallback to CMP APIs) + * 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 */ -const getConsentFromPrebid = () => { +const extractConsent = (userConsent) => { const consent = { deviceAccess: true, gpp: null, @@ -237,35 +264,27 @@ const getConsentFromPrebid = () => { gdprApplies: null }; - // Try to get GPP consent from Prebid - const gppConsent = config.getConfig('consentManagement.gpp'); - if (gppConsent) { - consent.gpp = gppConsent.gppString || null; - consent.gppSectionIDs = gppConsent.applicableSections || null; + // Extract GPP consent if available + if (userConsent?.gpp) { + consent.gpp = userConsent.gpp.gppString || null; + consent.gppSectionIDs = userConsent.gpp.applicableSections || null; } - // Try to get GDPR consent from Prebid - const gdprConsent = config.getConfig('consentManagement.gdpr'); - if (gdprConsent && gdprConsent.gdprApplies !== undefined) { - consent.gdprApplies = gdprConsent.gdprApplies; - consent.gdpr = gdprConsent.consentString || null; + // Extract GDPR consent if available + if (userConsent?.gdpr) { + consent.gdprApplies = userConsent.gdpr.gdprApplies; + consent.gdpr = userConsent.gdpr.consentString || null; - // Compute deviceAccess from TCF Purpose 1 if available - if (gdprConsent.vendorData) { - const purpose1Consent = gdprConsent.vendorData.purpose?.consents?.[1] || - gdprConsent.vendorData.publisher?.consents?.[1]; + // 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; } } } - // If Prebid consent is empty, try CMP APIs directly - // This handles the case where RTD runs before consent modules initialize - if (!consent.gpp && !consent.gdpr) { - return getConsentFromCMPAPIs(); - } - return consent; }; @@ -303,23 +322,14 @@ const buildTargetingURL = (params) => { const searchParams = new URLSearchParams(); - // Add identifiers ids.forEach(id => searchParams.append('id', id)); hids.forEach(hid => searchParams.append('hid', hid)); - // Add site identifier (required) searchParams.set('o', site); - - // Add node identifier (required) searchParams.set('t', node); - - // Add session ID (for analytics) searchParams.set('sid', sessionId); - - // Add SDK identifier (for server-side analytics) searchParams.set('osdk', 'prebid-rtd-1.0.0'); - // Add consent parameters if (consent.gpp) { searchParams.set('gpp', consent.gpp); } @@ -333,7 +343,6 @@ const buildTargetingURL = (params) => { searchParams.set('gdpr', consent.gdprApplies ? '1' : '0'); } - // Add cookie mode and passport if (cookies) { searchParams.set('cookies', 'yes'); } else { @@ -341,7 +350,6 @@ const buildTargetingURL = (params) => { searchParams.set('passport', passport || ''); } - // Add timeout hint if provided if (timeout) { searchParams.set('timeout', timeout); } @@ -375,7 +383,7 @@ const callTargetingAPI = (params) => { if (response.passport) { logMessage('Updating passport from API response'); setPassport(host, node, response.passport); - // Remove passport from response to prevent it leaking into targeting + // Remove passport from response to prevent it being included in bid requests delete response.passport; } @@ -417,14 +425,15 @@ export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn) => { /** * Main function called by Prebid to get bid request data - * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Function} callback Called on completion - * @param {Object} moduleConfig Configuration for Optable RTD module - * @param {Object} userConsent User consent object + * 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) => { +export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig, userConsent, timeout) => { try { - // Parse and validate configuration const parsedConfig = parseConfig(moduleConfig); if (!parsedConfig) { logError('Invalid configuration, skipping Optable RTD'); @@ -432,31 +441,58 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig return; } - const {host, site, node, cookies, timeout, cacheFallbackTimeout, ids: configIds, hids: configHids, handleRtd} = parsedConfig; + const {host, site, node, cookies, timeout: configTimeout, ids: configIds, hids: configHids, handleRtd, instance, hasDirectApiConfig} = parsedConfig; const handleRtdFn = handleRtd || defaultHandleRtd; + // 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); + 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, site, and node 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}, site=${site}, node=${node}, cookies=${cookies}`); + if (effectiveTimeout) { + logMessage(`Timeout: ${effectiveTimeout}ms${timeout ? ` (derived from auctionDelay: ${timeout}ms - 100ms)` : ' (from config)'}`); + } - // Generate session ID and extract consent (needed for both cache hit and miss) const sessionId = generateSessionID(); - const consent = getConsentFromPrebid(); + const consent = extractConsent(userConsent); - // Step 1: Check cache - if found, try API first with cache fallback - const cachedData = getCachedTargeting(); - if (cachedData) { - logMessage('Cache found, trying API-first with cache fallback'); - logMessage(`Cache fallback timeout: ${cacheFallbackTimeout}ms`); + logMessage(`Session ID: ${sessionId}`); + logMessage(`Consent: GPP=${!!consent.gpp}, GDPR=${!!consent.gdpr}`); - // Extract identifiers from config and Prebid userId module - const {ids, hids} = extractIdentifiers(configIds, configHids, reqBidsConfigObj); - logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); + const {ids, hids} = extractIdentifiers(configIds, configHids, reqBidsConfigObj); + logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); - // Get passport from localStorage - const passport = getPassport(host, node); - logMessage(`Passport: ${passport ? 'found' : 'not found'}`); + const passport = getPassport(host, node); + logMessage(`Passport: ${passport ? 'found' : 'not found'}`); - // Start API call - const apiPromise = callTargetingAPI({ + // Check if we have cached data - if so, use it immediately and update in background + const cachedData = getCachedTargeting(); + if (cachedData) { + logMessage('Cache found, using cached data and updating in background'); + handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep); + callback(); + + // Update cache in background (don't await) + callTargetingAPI({ host, site, node, @@ -466,66 +502,24 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig sessionId, passport, cookies, - timeout - }); - - // Create timeout promise - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(null), cacheFallbackTimeout); - }); - - // Race API call against timeout - const targetingData = await Promise.race([apiPromise, timeoutPromise]); - - if (targetingData) { - // API returned in time - use fresh data - logMessage('API returned in time, using fresh targeting data'); - handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep); - - // Update cache and passport - setCachedTargeting(targetingData); - if (targetingData.passport) { - setPassport(host, node, targetingData.passport); - } - - callback(); - } else { - // Timeout - use cached data but keep waiting for API - logMessage('Timeout reached, using cached targeting data'); - handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep); - callback(); - - // Continue waiting for API to update cache in background - logMessage('Waiting for API to complete in background to update cache'); - apiPromise.then(data => { - if (data) { - logMessage('Background API call completed, cache updated'); - setCachedTargeting(data); - if (data.passport) { - setPassport(host, node, data.passport); - } + timeout: effectiveTimeout + }).then(data => { + if (data) { + logMessage('Background API call completed, cache updated'); + setCachedTargeting(data); + if (data.passport) { + setPassport(host, node, data.passport); } - }).catch(error => { - logWarn('Background API call failed:', error); - }); - } + } + }).catch(error => { + logWarn('Background API call failed:', error); + }); return; } - // Step 2: No cache found - make targeting API call - logMessage(`Session ID: ${sessionId}`); - logMessage(`Consent: GPP=${!!consent.gpp}, GDPR=${!!consent.gdpr}`); - - // Extract identifiers from config and Prebid userId module - const {ids, hids} = extractIdentifiers(configIds, configHids, reqBidsConfigObj); - logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); - - // Get passport from localStorage - const passport = getPassport(host, node); - logMessage(`Passport: ${passport ? 'found' : 'not found'}`); - - // Call targeting API + // No cache - wait for API call + logMessage('No cache found, waiting for API call'); const targetingData = await callTargetingAPI({ host, site, @@ -536,7 +530,7 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig sessionId, passport, cookies, - timeout + timeout: effectiveTimeout }); if (!targetingData) { @@ -545,15 +539,11 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig return; } - // Step 3: Cache the response setCachedTargeting(targetingData); - - // Step 4: Merge targeting data into bid request handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep); callback(); } catch (error) { - // If an error occurs, log it and call the callback to continue with the auction logError('getBidRequestData error: ', error); callback(); } @@ -561,20 +551,69 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig /** * Get Optable targeting data and merge it into the ad units - * Note: Ad server targeting requires the Optable Web SDK. This RTD module - * focuses on enriching bid requests with EIDs. For ad server targeting, - * please use the Optable Web SDK alongside this RTD module. + * 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} Empty object (ad server targeting not supported without Web SDK) + * @returns {Object} Targeting data (empty object if SDK not available) */ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => { - logMessage('getTargetingData: Ad server targeting not supported in direct API mode'); - logMessage('For ad server targeting, please use the Optable Web SDK'); - return {}; + 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 via config'); + return {}; + } + + const instanceKey = instance || 'instance'; + const sdkInstance = window?.optable?.[instanceKey]; + + if (!sdkInstance || !sdkInstance.targetingKeyValuesFromCache) { + logWarn(`No Optable SDK instance found for: ${instanceKey}`); + return {}; + } + + const optableTargetingData = sdkInstance.targetingKeyValuesFromCache() || {}; + + if (!Object.keys(optableTargetingData).length) { + logWarn('No Optable targeting data found in SDK cache'); + return {}; + } + + const targetingData = {}; + adUnits.forEach(adUnit => { + targetingData[adUnit] = targetingData[adUnit] || {}; + mergeDeep(targetingData[adUnit], optableTargetingData); + }); + + Object.keys(targetingData).forEach((adUnit) => { + Object.keys(targetingData[adUnit]).forEach((key) => { + if (!targetingData[adUnit][key] || !targetingData[adUnit][key].length) { + delete targetingData[adUnit][key]; + } + }); + + if (!Object.keys(targetingData[adUnit]).length) { + delete targetingData[adUnit]; + } + }); + + logMessage('Ad server targeting data:', targetingData); + return targetingData; }; /** diff --git a/modules/optableRtdProvider.md b/modules/optableRtdProvider.md index 2995fb90ef1..fc2eb1ef51e 100644 --- a/modules/optableRtdProvider.md +++ b/modules/optableRtdProvider.md @@ -14,14 +14,43 @@ Prebid.js minimum version: 9.53.2+, or 10.2+ 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 RTD module calls the Optable targeting API directly**, without requiring the Optable Web SDK to be loaded on the page. The module handles: +**This module supports TWO modes of operation with automatic detection:** -- Direct API calls to your Optable DCN for targeting data -- Automatic consent extraction from Prebid consent modules (GPP/GDPR) -- Identifier collection from both configuration and Prebid userId module -- Passport (visitor ID) management for cookieless targeting -- Response caching in localStorage for performance -- ORTB2 data enrichment for bid requests +### 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, site, node 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: +1. If `window.optable` is present → SDK mode +2. If SDK absent but host/site/node configured → Direct API mode +3. If neither → Error ## Usage @@ -39,7 +68,30 @@ gulp build --modules="rtdModule,optableRtdProvider,appnexusBidAdapter,..." This module is configured as part of the `realTimeData.dataProviders`. -**Basic Configuration:** +**SDK Mode Configuration (with Optable Web SDK loaded):** + +```javascript +// Load SDK first in your page: +// + +pbjs.setConfig({ + debug: true, + realTimeData: { + 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({ @@ -103,20 +155,21 @@ pbjs.setConfig({ ### Parameters -| Name | Type | Description | Default | Required | -|-------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------| -| name | String | Real time data module name | Always `optable` | Yes | -| waitForIt | Boolean | Should be set to `true` to ensure targeting data is available before auction | `false` | Recommended | -| params | Object | Configuration parameters | | Yes | -| params.host | String | Your Optable DCN hostname (e.g., `dcn.customer.com`) | None | **Yes** | -| params.site | String | Site identifier configured in your DCN | None | **Yes** | -| params.node | String | Node identifier for your DCN | None | **Yes** | -| params.cookies | Boolean | Cookie mode. Set to `false` for cookieless targeting using passport | `true` | No | -| params.timeout | String | API timeout hint (e.g., `"500ms"`) | `null` | No | -| params.cacheFallbackTimeout | Number | Milliseconds to wait for fresh API data before falling back to cache. When cache exists, module tries API first; if response takes longer than this timeout, cached data is used instead. Should match or be slightly less than `auctionDelay` for best results. | `150` | No | -| params.ids | Array | Array of user identifier strings. These are combined with identifiers auto-extracted from Prebid userId module | `[]` | No | -| params.hids | Array | Array of household identifier strings | `[]` | No | -| params.handleRtd | Function | Custom function to handle/enrich RTD data. Function signature: `(reqBidsConfigObj, targetingData, mergeFn) => {}`. If not provided, the module uses a default handler that merges targeting data into ortb2Fragments.global | `null` | No | +| 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.site | String | Site identifier configured in your DCN | None | **Yes** | Direct API | +| params.node | String | Node identifier for 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. These are combined with identifiers auto-extracted from Prebid userId module | `[]` | No | Direct API | +| params.hids | Array | Array of household identifier strings | `[]` | No | Direct API | +| params.handleRtd | Function | Custom function to handle/enrich RTD data. Function signature: `(reqBidsConfigObj, targetingData, mergeFn) => {}`. If not provided, the module uses a default handler that merges targeting data into ortb2Fragments.global | `null` | No | Both | ## How It Works diff --git a/test/spec/modules/optableRtdProvider_spec.js b/test/spec/modules/optableRtdProvider_spec.js index c4480df5271..060ae72e85a 100644 --- a/test/spec/modules/optableRtdProvider_spec.js +++ b/test/spec/modules/optableRtdProvider_spec.js @@ -9,56 +9,95 @@ import { optableSubmodule, LOG_PREFIX, } from 'modules/optableRtdProvider'; -import {config} from 'src/config.js'; +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 with required parameters', function () { + 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('trims host and site values', function () { + it('parses SDK mode config without Direct API params', function () { + const moduleConfig = { + params: { + adserverTargeting: true, + instance: 'custom' + } + }; + 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 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('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 if host is empty string', function () { + const moduleConfig = {params: {host: '', site: 'my-site', node: 'my-node'}}; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('returns null if host is missing', function () { - const moduleConfig = {params: {site: 'my-site'}}; + 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 if site is missing', function () { - const moduleConfig = {params: {host: 'dcn.customer.com'}}; + 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; }); @@ -73,7 +112,9 @@ describe('Optable RTD Submodule', function () { timeout: '500ms', ids: ['id1', 'id2'], hids: ['hid1'], - handleRtd: handleRtdFn + handleRtd: handleRtdFn, + adserverTargeting: false, + instance: 'custom' } }; const result = parseConfig(moduleConfig); @@ -83,6 +124,8 @@ describe('Optable RTD Submodule', function () { 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'); }); it('returns null if handleRtd is not a function', function () { @@ -90,6 +133,7 @@ describe('Optable RTD Submodule', function () { params: { host: 'dcn.customer.com', site: 'my-site', + node: 'my-node', handleRtd: 'notAFunction' } }; @@ -101,6 +145,7 @@ describe('Optable RTD Submodule', function () { params: { host: 'dcn.customer.com', site: 'my-site', + node: 'my-node', ids: 'notAnArray' } }; @@ -112,11 +157,21 @@ describe('Optable RTD Submodule', function () { params: { host: 'dcn.customer.com', site: 'my-site', + node: 'my-node', hids: 'notAnArray' } }; expect(parseConfig(moduleConfig)).to.be.null; }); + + 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; + }); }); describe('generateSessionID', function () { @@ -126,59 +181,47 @@ describe('Optable RTD Submodule', function () { expect(sid.length).to.be.greaterThan(0); }); - it('returns the same session ID on subsequent calls', function () { + it('returns the same session ID on subsequent calls (singleton)', function () { const sid1 = generateSessionID(); const sid2 = generateSessionID(); expect(sid1).to.equal(sid2); }); - it('generates base64url encoded string', function () { + it('generates base64url encoded string without +/= characters', function () { const sid = generateSessionID(); - // base64url should not contain +, /, or = expect(sid).to.not.match(/[+/=]/); }); }); describe('passport storage', function () { - let storageStub; - - beforeEach(() => { - storageStub = { - getDataFromLocalStorage: sandbox.stub(), - setDataInLocalStorage: sandbox.stub() - }; - // Mock storage manager - sandbox.stub(require('src/storageManager'), 'getStorageManager').returns(storageStub); - }); - it('getPassport retrieves passport from localStorage', function () { - storageStub.getDataFromLocalStorage.returns('test-passport-value'); - const passport = getPassport('dcn.customer.com'); + storage.getDataFromLocalStorage.returns('test-passport-value'); + const passport = getPassport('dcn.customer.com', 'node1'); expect(passport).to.equal('test-passport-value'); - expect(storageStub.getDataFromLocalStorage.calledOnce).to.be.true; + expect(storage.getDataFromLocalStorage.calledOnce).to.be.true; }); it('setPassport stores passport in localStorage', function () { - setPassport('dcn.customer.com', null, 'new-passport-value'); - expect(storageStub.setDataInLocalStorage.calledOnce).to.be.true; - const args = storageStub.setDataInLocalStorage.getCall(0).args; + 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', null, 'passport1'); - setPassport('dcn2.customer.com', null, 'passport2'); - expect(storageStub.setDataInLocalStorage.calledTwice).to.be.true; - const key1 = storageStub.setDataInLocalStorage.getCall(0).args[0]; - const key2 = storageStub.setDataInLocalStorage.getCall(1).args[0]; + 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 = storageStub.setDataInLocalStorage.getCall(0).args[0]; - const key2 = storageStub.setDataInLocalStorage.getCall(1).args[0]; + const key1 = storage.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storage.setDataInLocalStorage.getCall(1).args[0]; expect(key1).to.not.equal(key2); }); }); @@ -213,42 +256,15 @@ describe('Optable RTD Submodule', function () { defaultHandleRtd(reqBidsConfigObj, null, mergeFn); expect(mergeFn.called).to.be.false; }); - - it('adds eids to user.ext.eids for additional coverage', function () { - const targetingData = { - ortb2: { - user: { - eids: [ - {source: 'optable.co', uids: [{id: 'test-id-1'}]}, - {source: 'other.com', uids: [{id: 'test-id-2'}]} - ] - } - } - }; - - defaultHandleRtd(reqBidsConfigObj, targetingData, mergeFn); - - // Check that user.ext.eids was populated - expect(reqBidsConfigObj.ortb2Fragments.global.user).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.global.user.ext).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.global.user.ext.eids).to.exist; - expect(reqBidsConfigObj.ortb2Fragments.global.user.ext.eids.length).to.equal(2); - }); }); - describe('getBidRequestData', function () { - let reqBidsConfigObj, callback, moduleConfig, ajaxStub, configStub; + describe('getBidRequestData - Direct API Mode', function () { + let reqBidsConfigObj, callback, moduleConfig, ajaxStub; beforeEach(() => { reqBidsConfigObj = { ortb2Fragments: { - global: { - user: { - ext: { - eids: [] - } - } - } + global: {} } }; callback = sinon.spy(); @@ -256,24 +272,24 @@ describe('Optable RTD Submodule', function () { params: { host: 'dcn.customer.com', site: 'my-site', + node: 'my-node', ids: ['id1'], hids: [] } }; - // Mock ajax ajaxStub = sandbox.stub(ajax, 'ajax'); - - // Mock config.getConfig for consent - configStub = sandbox.stub(config, 'getConfig'); - configStub.withArgs('consentManagement.gpp').returns(null); - configStub.withArgs('consentManagement.gdpr').returns(null); + // Stub window.optable to ensure Direct API mode + delete window.optable; }); 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":[]}}}'); }); @@ -284,48 +300,73 @@ describe('Optable RTD Submodule', function () { expect(callback.calledOnce).to.be.true; }); - it('handles API errors gracefully', async function () { + 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) => { - options.error('Network error'); + 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 cached data if available', async function () { - // Mock localStorage to return cached data - const storageStub = { - getDataFromLocalStorage: sandbox.stub(), - setDataInLocalStorage: sandbox.stub() - }; - storageStub.getDataFromLocalStorage.returns(JSON.stringify({ - ortb2: { - user: { - eids: [{source: 'cached.com', uids: [{id: 'cached-id'}]}] - } - } - })); - sandbox.stub(require('src/storageManager'), 'getStorageManager').returns(storageStub); + 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, {}); - // Should not call ajax if cache is available - expect(ajaxStub.called).to.be.false; + expect(ajaxStub.calledOnce).to.be.true; expect(callback.calledOnce).to.be.true; + expect(storage.setDataInLocalStorage.called).to.be.true; // Cache updated }); - it('includes consent parameters in API call', async function () { - configStub.withArgs('consentManagement.gpp').returns({ - gppString: 'DBABMA~test', - applicableSections: [2, 6] - }); - configStub.withArgs('consentManagement.gdpr').returns({ - gdprApplies: true, - consentString: 'CPXxxx' + 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' + } + }; + ajaxStub.callsFake((url, options) => { expect(url).to.include('gpp=DBABMA~test'); expect(url).to.include('gpp_sid=2,6'); @@ -334,21 +375,16 @@ describe('Optable RTD Submodule', function () { options.success('{"ortb2":{"user":{"eids":[]}}}'); }); - await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, userConsent); expect(ajaxStub.calledOnce).to.be.true; }); - it('extracts identifiers from Prebid userId module', async function () { - reqBidsConfigObj.ortb2Fragments.global.user.ext.eids = [ - { - source: 'id5-sync.com', - uids: [{id: 'id5-user-id'}] - } - ]; + it('adds default __passport__ ID when no IDs configured', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.ids = []; ajaxStub.callsFake((url, options) => { - expect(url).to.include('id=id1'); // from config - expect(url).to.include('id=id5-user-id'); // from userId module + expect(url).to.include('id=__passport__'); options.success('{"ortb2":{"user":{"eids":[]}}}'); }); @@ -356,11 +392,14 @@ describe('Optable RTD Submodule', function () { expect(ajaxStub.calledOnce).to.be.true; }); - it('includes node parameter if provided', async function () { - moduleConfig.params.node = 'prod-us'; + 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'); ajaxStub.callsFake((url, options) => { - expect(url).to.include('t=prod-us'); + expect(url).to.include('cookies=no'); + expect(url).to.include('passport=test-passport'); options.success('{"ortb2":{"user":{"eids":[]}}}'); }); @@ -368,11 +407,13 @@ describe('Optable RTD Submodule', function () { expect(ajaxStub.calledOnce).to.be.true; }); - it('includes timeout parameter if provided', async function () { - moduleConfig.params.timeout = '500ms'; + 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('timeout=500ms'); + expect(url).to.include('cookies=yes'); + expect(url).to.not.include('passport='); options.success('{"ortb2":{"user":{"eids":[]}}}'); }); @@ -380,29 +421,52 @@ describe('Optable RTD Submodule', function () { expect(ajaxStub.calledOnce).to.be.true; }); - it('handles cookieless mode correctly', async function () { - moduleConfig.params.cookies = false; + it('updates passport from API response', async function () { + storage.getDataFromLocalStorage.returns(null); ajaxStub.callsFake((url, options) => { - expect(url).to.include('cookies=no'); - expect(url).to.include('passport='); - options.success('{"ortb2":{"user":{"eids":[]}}}'); + 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('handles invalid config gracefully', async function () { - moduleConfig.params.host = null; + it('uses config timeout when auctionDelay not available', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.timeout = 1000; - await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('timeout=1000'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - expect(ajaxStub.called).to.be.false; - expect(callback.calledOnce).to.be.true; + 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; @@ -416,28 +480,111 @@ describe('Optable RTD Submodule', function () { expect(callback.calledOnce).to.be.true; }); - it('caches targeting response after API call', async function () { - const storageStub = { - getDataFromLocalStorage: sandbox.stub().returns(null), - setDataInLocalStorage: sandbox.stub() + 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; + }); + }); + + describe('getBidRequestData - SDK Mode', function () { + let reqBidsConfigObj, callback, moduleConfig; + + 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) + } + }; + }); + + 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: targetingData}); + window.dispatchEvent(event); + }, 10); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; + }); + + it('uses SDK cached data if available', async function () { + const cachedData = { + ortb2: {user: {eids: [{source: 'cached.com'}]}} }; - sandbox.stub(require('src/storageManager'), 'getStorageManager').returns(storageStub); + window.optable.instance.targetingFromCache.returns(cachedData); - ajaxStub.callsFake((url, options) => { - options.success('{"ortb2":{"user":{"eids":[{"source":"test.com"}]}}}'); - }); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; + }); + }); + + describe('getBidRequestData - Mode Detection', function () { + let reqBidsConfigObj, callback; + + beforeEach(() => { + reqBidsConfigObj = {ortb2Fragments: {global: {}}}; + callback = sinon.spy(); + delete window.optable; + }); + + it('errors when neither SDK nor Direct API params configured', async function () { + const moduleConfig = {params: {}}; await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - expect(storageStub.setDataInLocalStorage.called).to.be.true; + expect(callback.calledOnce).to.be.true; + // Should log error about missing configuration }); }); describe('getTargetingData', function () { - it('returns empty object (ad server targeting not supported)', function () { - const result = getTargetingData(['adUnit1'], {}, {}, {}); + 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({}); }); + + it('returns targeting data when SDK available', function () { + window.optable = { + instance: { + targetingKeyValuesFromCache: sinon.stub().returns({ + 'optable_segment': ['seg1', 'seg2'] + }) + } + }; + + const moduleConfig = {params: {adserverTargeting: true}}; + const result = getTargetingData(['adUnit1'], moduleConfig, {}, {}); + + expect(result).to.have.property('adUnit1'); + expect(result.adUnit1).to.have.property('optable_segment'); + + delete window.optable; + }); }); describe('init', function () { From 224f84adaa375e692abd121322d0e350cf7c12c5 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 5 Feb 2026 11:36:01 -0500 Subject: [PATCH 3/9] example --- integrationExamples/gpt/optableRtdProvider_example.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index 7467268c293..3007d02958e 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -130,7 +130,7 @@ pbjs.setConfig({ debug: true, // use only for testing, remove in production realTimeData: { - auctionDelay: 2500, // should be set lower in production use + auctionDelay: 1000, // should be set lower in production use dataProviders: [ { name: 'optable', @@ -138,8 +138,8 @@ params: { // REQUIRED PARAMETERS: host: 'na.edge.optable.co', // Your DCN hostname - site: 'mediaco-sdk', // Your site identifier - node: 'mediaco', // Your node identifier + site: 'prebidtest-sdk', // Your site identifier + node: 'prebidtest', // Your node identifier // OPTIONAL PARAMETERS: cookies: false, // Cookie mode (default: true, set to false for cookieless) From 861b8754b18739562016eec4f8308a05acee8cf5 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 5 Feb 2026 11:39:49 -0500 Subject: [PATCH 4/9] clean --- modules/optableRtdProvider.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index a2b8902a778..cc71671953f 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -579,6 +579,8 @@ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => return {}; } + // 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]; @@ -607,12 +609,13 @@ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => } }); + // If the key contains no data, remove it if (!Object.keys(targetingData[adUnit]).length) { delete targetingData[adUnit]; } }); - logMessage('Ad server targeting data:', targetingData); + logMessage('Optable targeting data: ', targetingData); return targetingData; }; From 525043ccfd09fbd53bbdc530e14a1935b5fc9063 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 5 Feb 2026 11:54:40 -0500 Subject: [PATCH 5/9] ext field --- modules/optableRtdProvider.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index cc71671953f..9bcb546cc98 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -30,7 +30,7 @@ export const parseConfig = (moduleConfig) => { // 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/site/node parameters. See migration guide: https://docs.prebid.org/dev-docs/modules/optableRtdProvider.html'); + 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/site/node parameters. See documentation for details.'); return null; } @@ -416,11 +416,35 @@ export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn) => { return; } + const eidCount = targetingData.ortb2?.user?.eids?.length || 0; + logMessage(`defaultHandleRtd: received targeting data with ${eidCount} EIDs`); + logMessage('Merging ortb2 data into global ORTB2 fragments...'); + mergeFn( reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2, ); - logMessage('Prebid\'s global ORTB2 object after merge: ', reqBidsConfigObj.ortb2Fragments.global); + + logMessage(`EIDs merged into ortb2Fragments.global.user.eids (${eidCount} EIDs)`); + + // Also add to user.ext.eids for additional coverage + if (targetingData.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 + targetingData.ortb2.user.eids.forEach(eid => { + targetORTB2.user.ext.eids.push(eid); + }); + + logMessage(`EIDs also available in ortb2.user.ext.eids (${eidCount} EIDs)`); + } + + logMessage(`SUCCESS: ${eidCount} EIDs will be included in bid requests`); }; /** From d8fecd5b3ca9f16923b81d756496e26d219961e8 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 6 Feb 2026 09:29:53 -0500 Subject: [PATCH 6/9] comments --- .../gpt/optableRtdProvider_example.html | 6 +- modules/optableRtdProvider.js | 28 ++++----- modules/optableRtdProvider.md | 59 ++++++++++--------- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index 3007d02958e..571acce6b8e 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -138,14 +138,14 @@ params: { // REQUIRED PARAMETERS: host: 'na.edge.optable.co', // Your DCN hostname - site: 'prebidtest-sdk', // Your site identifier 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'], // Household identifiers + // 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) => { diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index 9bcb546cc98..dc583588f83 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -30,30 +30,30 @@ export const parseConfig = (moduleConfig) => { // 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/site/node parameters. See documentation for details.'); + 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 site = deepAccess(moduleConfig, 'params.site', null); const node = deepAccess(moduleConfig, 'params.node', null); + const site = deepAccess(moduleConfig, 'params.site', null); const adserverTargeting = deepAccess(moduleConfig, 'params.adserverTargeting', true); const instance = deepAccess(moduleConfig, 'params.instance', null); - const hasDirectApiConfig = host && site && node; + const hasDirectApiConfig = host && node && site; if (host !== null && (typeof host !== 'string' || !host.trim())) { logError('host 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; - } 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); @@ -77,8 +77,8 @@ export const parseConfig = (moduleConfig) => { return { host: host ? host.trim() : null, - site: site ? site.trim() : null, node: node ? node.trim() : null, + site: site ? site.trim() : null, cookies, timeout, ids, @@ -318,7 +318,7 @@ const extractIdentifiers = (configIds, configHids, reqBidsConfigObj) => { * @returns {string} Complete URL for targeting API */ const buildTargetingURL = (params) => { - const {host, site, node, ids, hids, consent, sessionId, passport, cookies, timeout} = params; + const {host, node, site, ids, hids, consent, sessionId, passport, cookies, timeout} = params; const searchParams = new URLSearchParams(); @@ -465,7 +465,7 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig return; } - const {host, site, node, cookies, timeout: configTimeout, ids: configIds, hids: configHids, handleRtd, instance, hasDirectApiConfig} = parsedConfig; + const {host, node, site, cookies, timeout: configTimeout, ids: configIds, hids: configHids, handleRtd, instance, hasDirectApiConfig} = parsedConfig; const handleRtdFn = handleRtd || defaultHandleRtd; // Mode 1: SDK mode - If Optable Web SDK is loaded (window.optable), use its event system @@ -482,7 +482,7 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig // 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, site, and node parameters, or load the Optable Web SDK.'); + logError('Neither Web SDK nor direct API configuration found. Please configure host, node, and site parameters, or load the Optable Web SDK.'); callback(); return; } @@ -491,7 +491,7 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig const effectiveTimeout = (timeout && timeout > 100) ? timeout - 100 : configTimeout; - logMessage(`Configuration: host=${host}, site=${site}, node=${node}, cookies=${cookies}`); + 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)'}`); } @@ -518,8 +518,8 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig // Update cache in background (don't await) callTargetingAPI({ host, - site, node, + site, ids, hids, consent, @@ -546,8 +546,8 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig logMessage('No cache found, waiting for API call'); const targetingData = await callTargetingAPI({ host, - site, node, + site, ids, hids, consent, diff --git a/modules/optableRtdProvider.md b/modules/optableRtdProvider.md index fc2eb1ef51e..adc47672e4b 100644 --- a/modules/optableRtdProvider.md +++ b/modules/optableRtdProvider.md @@ -21,7 +21,7 @@ Optable RTD submodule enriches the OpenRTB bid request by populating `user.ext.e Uses Optable Web SDK loaded on page via event-based integration. **Setup:** -- Load Optable Web SDK: `` +- Load Optable Web SDK: `` - Configure RTD module with optional params only **Features:** @@ -36,7 +36,7 @@ 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, site, node parameters +- Configure RTD module with host, node, site parameters **Features:** - Bid request enrichment (EIDs passed to SSPs) @@ -49,7 +49,7 @@ Makes direct HTTP calls to Optable targeting API without any external SDK. The module automatically detects which mode to use: 1. If `window.optable` is present → SDK mode -2. If SDK absent but host/site/node configured → Direct API mode +2. If SDK absent but host/node/site configured → Direct API mode 3. If neither → Error ## Usage @@ -72,11 +72,12 @@ This module is configured as part of the `realTimeData.dataProviders`. ```javascript // Load SDK first in your page: -// +// pbjs.setConfig({ debug: true, realTimeData: { + auctionDelay: 200, dataProviders: [ { name: 'optable', @@ -104,8 +105,8 @@ pbjs.setConfig({ waitForIt: true, params: { host: 'dcn.customer.com', // REQUIRED: Your Optable DCN hostname - site: 'my-site', // REQUIRED: Your site identifier node: 'prod-us', // REQUIRED: Your node identifier + site: 'my-site', // REQUIRED: Your site identifier }, }, ], @@ -126,14 +127,14 @@ pbjs.setConfig({ params: { // REQUIRED PARAMETERS: host: 'dcn.customer.com', // Your Optable DCN hostname - site: 'my-site', // Your site identifier 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: ['household-id'], // Household identifiers + hids: ['hint-id'], // Hint identifiers // CUSTOM HANDLER (optional): handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { @@ -163,12 +164,12 @@ pbjs.setConfig({ | **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.site | String | Site identifier configured in your DCN | 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. These are combined with identifiers auto-extracted from Prebid userId module | `[]` | No | Direct API | -| params.hids | Array | Array of household identifier strings | `[]` | No | Direct API | +| params.hids | Array | Array of hint identifier strings | `[]` | No | Direct API | | params.handleRtd | Function | Custom function to handle/enrich RTD data. Function signature: `(reqBidsConfigObj, targetingData, mergeFn) => {}`. If not provided, the module uses a default handler that merges targeting data into ortb2Fragments.global | `null` | No | Both | ## How It Works @@ -177,7 +178,7 @@ pbjs.setConfig({ When Prebid's auction starts, the Optable RTD module: -1. Validates the configuration (checks for required `host`, `site`, and `node` parameters) +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 @@ -199,7 +200,7 @@ The module makes a GET request to `https://{host}/v2/targeting` with the followi - `o`: Site identifier (required) - `t`: Node identifier (required) - `id`: User identifiers (multiple) -- `hid`: Household identifiers (multiple) +- `hid`: Hint identifiers (multiple) - `osdk`: SDK version identifier - `sid`: Session ID - `cookies`: Cookie mode (`yes` or `no`) @@ -264,10 +265,10 @@ Provide identifiers directly in the RTD configuration: ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', node: 'prod-us', + site: 'my-site', ids: ['email-hash-123', 'phone-hash-456'], - hids: ['household-id-789'] + hids: ['hint-id-789'] } ``` @@ -303,8 +304,8 @@ All identifiers are combined and sent to the targeting API. ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', node: 'prod-us', + site: 'my-site', cookies: true // or omit this parameter } ``` @@ -316,8 +317,8 @@ In cookie mode, the DCN uses first-party cookies for visitor identification. ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', node: 'prod-us', + site: 'my-site', cookies: false } ``` @@ -344,8 +345,8 @@ The `node` parameter is required and identifies your DCN node: ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', - node: 'us-east' // Your node identifier + node: 'prod-us', // Your node identifier + site: 'my-site' } ``` @@ -358,8 +359,8 @@ For advanced use cases, provide a custom `handleRtd` function: ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', node: 'prod-us', + site: 'my-site', handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { console.log('Targeting data received:', targetingData); @@ -437,15 +438,15 @@ params: { ```javascript params: { host: 'dcn.customer.com', // Your DCN hostname - site: 'my-site', // Your site identifier - node: 'prod-us' // Your node identifier + 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`, `site`, and `node` are now required +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 @@ -461,7 +462,7 @@ The following Web SDK features are intentionally **not** supported in this RTD m - Event dispatching system - Complex multi-storage key strategies -See `modules/optableRtdProvider_EXCLUDED_FEATURES.txt` for details. +See `modules/optableRtdProvider_EXCLUDED_FEATURES.md` for details. ## Troubleshooting @@ -472,8 +473,8 @@ Ensure you've configured the `host` parameter: ```javascript params: { host: 'dcn.customer.com', // Required! - site: 'my-site', - node: 'prod-us' + node: 'prod-us', + site: 'my-site' } ``` @@ -484,8 +485,8 @@ Ensure you've configured the `site` parameter: ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', // Required! - node: 'prod-us' + node: 'prod-us', + site: 'my-site' // Required! } ``` @@ -496,8 +497,8 @@ Ensure you've configured the `node` parameter: ```javascript params: { host: 'dcn.customer.com', - site: 'my-site', - node: 'prod-us' // Required! + node: 'prod-us', // Required! + site: 'my-site' } ``` @@ -508,7 +509,7 @@ Check: 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 hostname and site identifier are correct +5. Verify your DCN host, node and site identifier are correct ### Consent issues From 016c8ceffb79acb13d6329769157b34931d87216 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 20 Feb 2026 12:39:43 -0500 Subject: [PATCH 7/9] one targeting call --- modules/optableRtdProvider.js | 60 +++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index dc583588f83..e523fda56b1 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -93,6 +93,9 @@ export const parseConfig = (moduleConfig) => { // 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 @@ -508,42 +511,51 @@ export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig const passport = getPassport(host, node); logMessage(`Passport: ${passport ? 'found' : 'not found'}`); - // Check if we have cached data - if so, use it immediately and update in background + // Check if we have cached data - if so, use it immediately const cachedData = getCachedTargeting(); if (cachedData) { - logMessage('Cache found, using cached data and updating in background'); + logMessage('Cache found, using cached data'); handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep); callback(); - // 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); - if (data.passport) { - setPassport(host, node, data.passport); + // 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); + if (data.passport) { + setPassport(host, node, data.passport); + } } - } - }).catch(error => { - logWarn('Background API call failed:', error); - }); + }).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, From dfba873b742c67000a1dfd5f5b1d4d651fe3d5e9 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 20 Feb 2026 12:58:08 -0500 Subject: [PATCH 8/9] split test --- .../gpt/optableRtdProvider_example.html | 10 ++++++++++ modules/optableRtdProvider.js | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index 571acce6b8e..e803175869e 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -182,6 +182,16 @@ 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 splitTestVariant = bid.ortb2Imp?.ext?.optable?.splitTestVariant; + if (splitTestVariant) { + console.log(`Split test variant for ${bid.adUnitCode}: ${splitTestVariant}`); + } + }); + } } catch (e) { console.error('Exception while trying to display enriched data', e); } diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index e523fda56b1..80767c70b62 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -447,6 +447,21 @@ export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn) => { logMessage(`EIDs also available in ortb2.user.ext.eids (${eidCount} EIDs)`); } + // Add split_test_variant to adUnits ortb2Imp.ext.optable if present + if (targetingData.split_test_variant) { + logMessage(`Split test variant detected: ${targetingData.split_test_variant}`); + + 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.splitTestVariant = targetingData.split_test_variant; + }); + logMessage(`Split test variant added to ${reqBidsConfigObj.adUnits.length} ad units`); + } + } + logMessage(`SUCCESS: ${eidCount} EIDs will be included in bid requests`); }; From 6d1a0d7833e4991c579b8eea143c02ef213b5d96 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 20 Feb 2026 16:15:52 -0500 Subject: [PATCH 9/9] rename --- modules/optableRtdProvider.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index 80767c70b62..1ea70891476 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -447,18 +447,18 @@ export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn) => { logMessage(`EIDs also available in ortb2.user.ext.eids (${eidCount} EIDs)`); } - // Add split_test_variant to adUnits ortb2Imp.ext.optable if present - if (targetingData.split_test_variant) { - logMessage(`Split test variant detected: ${targetingData.split_test_variant}`); + // 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.splitTestVariant = targetingData.split_test_variant; + adUnit.ortb2Imp.ext.optable.splitTestAssignment = targetingData.split_test_assignment; }); - logMessage(`Split test variant added to ${reqBidsConfigObj.adUnits.length} ad units`); + logMessage(`Split test assignment added to ${reqBidsConfigObj.adUnits.length} ad units`); } }