diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html
index 5e1c8a77cb..e803175869 100644
--- a/integrationExamples/gpt/optableRtdProvider_example.html
+++ b/integrationExamples/gpt/optableRtdProvider_example.html
@@ -2,8 +2,6 @@
Optable RTD submodule example - Prebid.js
-
-
@@ -130,11 +128,6 @@
];
pbjs.setConfig({
- optableRtdConfig: { // optional, check the doc for explanation
- email: 'email-sha256-hash',
- phone: 'phone-sha256-hash',
- postal_code: 'postal_code',
- },
debug: true, // use only for testing, remove in production
realTimeData: {
auctionDelay: 1000, // should be set lower in production use
@@ -143,26 +136,23 @@
name: 'optable',
waitForIt: true,
params: {
- // bundleUrl: "https://prebidtest.solutions.cdn.optable.co/public-assets/prebidtest-sdk.js?hello=world",
- // adserverTargeting: false,
- // handleRtd: async (reqBidsConfigObj, optableExtraData, mergeFn) => {
- // const optableBundle = /** @type {Object} */ (window.optable);
- // console.warn('Entering custom RTD handler');
- // console.warn('reqBidsConfigObj', reqBidsConfigObj);
- // console.warn('optableExtraData', optableExtraData);
- // console.warn('mergeFn', mergeFn);
- //
- // // Call Optable DCN for targeting data and return the ORTB2 object
- // const targetingData = await optableBundle.instance.targeting();
- //
- // if (!targetingData || !targetingData.ortb2) {
- // return;
- // }
- //
- // mergeFn(
- // reqBidsConfigObj.ortb2Fragments.global,
- // targetingData.ortb2,
- // );
+ // REQUIRED PARAMETERS:
+ host: 'na.edge.optable.co', // Your DCN hostname
+ node: 'prebidtest', // Your node identifier
+ site: 'prebidtest-sdk', // Your site identifier
+
+ // OPTIONAL PARAMETERS:
+ cookies: false, // Cookie mode (default: true, set to false for cookieless)
+ // timeout: '500ms', // API timeout hint
+ // ids: ['i4:1.2.3.4'], // User identifiers (also auto-extracted from Prebid userId module)
+ // hids: ['hid1'], // Hint identifiers
+
+ // CUSTOM HANDLER (optional):
+ // handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => {
+ // console.log('Custom RTD handler');
+ // console.log('Targeting data:', targetingData);
+ // // Perform custom logic here
+ // mergeFn(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2);
// }
}
}
@@ -172,12 +162,36 @@
pbjs.addAdUnits(adUnits);
+ // Track if we've already displayed the data (only show once)
+ let displayedEnrichedData = false;
+
pbjs.onEvent('bidRequested', function (data) {
try {
- window.optable.cmd.push(() => {
+ // Display the enriched user data from Optable RTD
+ if (!displayedEnrichedData && data.ortb2 && data.ortb2.user) {
+ console.log('Full bid request ortb2:', data.ortb2);
+ console.log('User data:', data.ortb2.user);
+
+ const userData = data.ortb2.user;
+ const eidCount = userData.eids ? userData.eids.length : 0;
+ const dataCount = userData.data ? userData.data.length : 0;
+
+ console.log(`Found ${eidCount} EIDs and ${dataCount} data segments`);
+
document.getElementById('enriched-optable').style.display = 'block';
- document.getElementById('enriched-optable-data').textContent = JSON.stringify(data.ortb2.user, null, 2);
- });
+ document.getElementById('enriched-optable-data').textContent = JSON.stringify(userData, null, 2);
+ displayedEnrichedData = true;
+ }
+
+ // Display split test variant if present in adUnit ortb2Imp
+ if (data.bids && data.bids.length > 0) {
+ data.bids.forEach(bid => {
+ const 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 29638ba3a9..1ea7089147 100644
--- a/modules/optableRtdProvider.js
+++ b/modules/optableRtdProvider.js
@@ -1,47 +1,208 @@
+/**
+ * Optable Real-Time Data (RTD) Provider Module for Prebid.js
+ * See modules/optableRtdProvider.md for full documentation
+ */
+
import {MODULE_TYPE_RTD} from '../src/activities/modules.js';
-import {loadExternalScript} from '../src/adloader.js';
-import {config} from '../src/config.js';
import {submodule} from '../src/hook.js';
import {deepAccess, mergeDeep, prefixLog} from '../src/utils.js';
+import {ajax} from '../src/ajax.js';
+import {getStorageManager} from '../src/storageManager.js';
const MODULE_NAME = 'optable';
export const LOG_PREFIX = `[${MODULE_NAME} RTD]:`;
const optableLog = prefixLog(LOG_PREFIX);
const {logMessage, logWarn, logError} = optableLog;
+const storage = getStorageManager({moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME});
+
+// localStorage key for targeting cache (direct API mode only)
+const OPTABLE_CACHE_KEY = 'optable-cache:targeting';
+
+// Storage key prefix for passport (visitor ID) - compatible with Web SDK format
+const PASSPORT_KEY_PREFIX = 'OPTABLE_PASSPORT_';
/**
- * Extracts the parameters for Optable RTD module from the config object passed at instantiation
+ * Parse and validate module configuration
* @param {Object} moduleConfig Configuration object for the module
+ * @returns {Object} Parsed configuration
*/
export const parseConfig = (moduleConfig) => {
- let bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null);
+ // Check for deprecated bundleUrl parameter
+ const bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null);
+ if (bundleUrl) {
+ logError('bundleUrl parameter is no longer supported. Please either: (1) Load Optable SDK directly in your page HTML, OR (2) Switch to Direct API mode using host/node/site parameters. See documentation for details.');
+ return null;
+ }
+
+ const host = deepAccess(moduleConfig, 'params.host', null);
+ const node = deepAccess(moduleConfig, 'params.node', null);
+ const site = deepAccess(moduleConfig, 'params.site', null);
const adserverTargeting = deepAccess(moduleConfig, 'params.adserverTargeting', true);
- const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null);
const instance = deepAccess(moduleConfig, 'params.instance', null);
- // If present, trim the bundle URL
- if (typeof bundleUrl === 'string') {
- bundleUrl = bundleUrl.trim();
- }
+ const hasDirectApiConfig = host && node && site;
- // Verify that bundleUrl is a valid URL: only secure (HTTPS) URLs are allowed
- if (typeof bundleUrl === 'string' && bundleUrl.length && !bundleUrl.startsWith('https://')) {
- logError('Invalid URL format for bundleUrl in moduleConfig. Only HTTPS URLs are allowed.');
- return {bundleUrl: null, adserverTargeting, handleRtd: null};
+ if (host !== null && (typeof host !== 'string' || !host.trim())) {
+ logError('host parameter must be a non-empty string');
+ return null;
}
+ if (node !== null && (typeof node !== 'string' || !node.trim())) {
+ logError('node parameter must be a non-empty string');
+ return null;
+ }
+ if (site !== null && (typeof site !== 'string' || !site.trim())) {
+ logError('site parameter must be a non-empty string');
+ return null;
+ }
+
+ const cookies = deepAccess(moduleConfig, 'params.cookies', true);
+ const timeout = deepAccess(moduleConfig, 'params.timeout', null);
+ const ids = deepAccess(moduleConfig, 'params.ids', []);
+ const hids = deepAccess(moduleConfig, 'params.hids', []);
+ const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null);
if (handleRtd && typeof handleRtd !== 'function') {
logError('handleRtd must be a function');
- return {bundleUrl, adserverTargeting, handleRtd: null};
+ return null;
}
- const result = {bundleUrl, adserverTargeting, handleRtd};
- if (instance !== null) {
- result.instance = instance;
+ if (!Array.isArray(ids)) {
+ logError('ids parameter must be an array');
+ return null;
+ }
+ if (!Array.isArray(hids)) {
+ logError('hids parameter must be an array');
+ return null;
}
- return result;
+
+ return {
+ host: host ? host.trim() : null,
+ node: node ? node.trim() : null,
+ site: site ? site.trim() : null,
+ cookies,
+ timeout,
+ ids,
+ hids,
+ handleRtd,
+ adserverTargeting,
+ instance,
+ hasDirectApiConfig
+ };
}
+// Global session ID (generated once per page load)
+let sessionID = null;
+
+// Track if we've made a targeting API call this session (to avoid redundant calls)
+let targetingCallMade = false;
+
+/**
+ * Generates a random session ID (base64url encoded 16-byte random value)
+ * @returns {string} Session ID
+ */
+export const generateSessionID = () => {
+ if (sessionID) {
+ return sessionID;
+ }
+
+ // Generate 16 random bytes
+ const arr = new Uint8Array(16);
+ crypto.getRandomValues(arr);
+
+ // Convert to base64url (URL-safe, no padding)
+ sessionID = btoa(String.fromCharCode(...arr))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+
+ return sessionID;
+};
+
+/**
+ * Base64 encode a string
+ * @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));
+};
+
+/**
+ * Check if Optable Web SDK is available on the page
+ * @param {string|null} instance SDK instance name (default: 'instance')
+ * @returns {boolean} True if SDK is available
+ */
+const isSDKAvailable = (instance = null) => {
+ const instanceKey = instance || 'instance';
+ return typeof window !== 'undefined' &&
+ window.optable &&
+ window.optable[instanceKey] &&
+ typeof window.optable[instanceKey].targeting === 'function';
+};
+
/**
* Wait for Optable SDK event to fire with targeting data
* @param {string} eventName Name of the event to listen for
@@ -60,7 +221,6 @@ const waitForOptableEvent = (eventName) => {
const eventListener = (event) => {
logMessage(`Received ${eventName} event`);
- // Extract targeting data from event detail
const targetingData = event.detail;
window.removeEventListener(eventName, eventListener);
resolve(targetingData);
@@ -72,130 +232,427 @@ const waitForOptableEvent = (eventName) => {
};
/**
- * Default function to handle/enrich RTD data
- * @param reqBidsConfigObj Bid request configuration object
- * @param optableExtraData Additional data to be used by the Optable SDK
- * @param mergeFn Function to merge data
+ * Handle RTD data using SDK mode (event-based)
+ * @param {Function} handleRtdFn Custom handler function or default
+ * @param {Object} reqBidsConfigObj Bid request configuration
+ * @param {Function} mergeFn Merge function
* @returns {Promise}
*/
-export const defaultHandleRtd = async (reqBidsConfigObj, optableExtraData, mergeFn) => {
- // Wait for the Optable SDK to dispatch targeting data via event
- let targetingData = await waitForOptableEvent('optable-targeting:change');
+const handleSDKMode = async (handleRtdFn, reqBidsConfigObj, mergeFn) => {
+ const targetingData = await waitForOptableEvent('optable-targeting:change');
if (!targetingData || !targetingData.ortb2) {
- logWarn('No targeting data found');
+ logWarn('No targeting data from SDK event');
return;
}
- mergeFn(
- reqBidsConfigObj.ortb2Fragments.global,
- targetingData.ortb2,
- );
- logMessage('Prebid\'s global ORTB2 object after merge: ', reqBidsConfigObj.ortb2Fragments.global);
+ if (handleRtdFn.constructor.name === 'AsyncFunction') {
+ await handleRtdFn(reqBidsConfigObj, targetingData, mergeFn);
+ } else {
+ handleRtdFn(reqBidsConfigObj, targetingData, mergeFn);
+ }
};
/**
- * Get data from Optable and merge it into the global ORTB2 object
- * @param {Function} handleRtdFn Function to handle RTD data
- * @param {Object} reqBidsConfigObj Bid request configuration object
- * @param {Object} optableExtraData Additional data to be used by the Optable SDK
- * @param {Function} mergeFn Function to merge data
+ * Extract consent information from Prebid's userConsent parameter
+ * @param {Object} userConsent User consent object passed by Prebid RTD framework
+ * @returns {Object} Consent object with GPP, GDPR strings, and deviceAccess flag
*/
-export const mergeOptableData = async (handleRtdFn, reqBidsConfigObj, optableExtraData, mergeFn) => {
- if (handleRtdFn.constructor.name === 'AsyncFunction') {
- await handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn);
+const extractConsent = (userConsent) => {
+ const consent = {
+ deviceAccess: true,
+ gpp: null,
+ gppSectionIDs: null,
+ gdpr: null,
+ gdprApplies: null
+ };
+
+ // Extract GPP consent if available
+ if (userConsent?.gpp) {
+ consent.gpp = userConsent.gpp.gppString || null;
+ consent.gppSectionIDs = userConsent.gpp.applicableSections || null;
+ }
+
+ // Extract GDPR consent if available
+ if (userConsent?.gdpr) {
+ consent.gdprApplies = userConsent.gdpr.gdprApplies;
+ consent.gdpr = userConsent.gdpr.consentString || null;
+
+ // Extract deviceAccess from TCF Purpose 1 if available
+ if (userConsent.gdpr.vendorData) {
+ const purpose1Consent = userConsent.gdpr.vendorData.purpose?.consents?.[1] ||
+ userConsent.gdpr.vendorData.publisher?.consents?.[1];
+ if (purpose1Consent !== undefined) {
+ consent.deviceAccess = purpose1Consent;
+ }
+ }
+ }
+
+ return consent;
+};
+
+/**
+ * Extract user identifiers from config and Prebid userId module
+ * @param {Array} configIds IDs from module config
+ * @param {Array} configHids HIDs from module config
+ * @param {Object} reqBidsConfigObj Bid request configuration
+ * @returns {Object} Object with ids and hids arrays
+ */
+const extractIdentifiers = (configIds, configHids, reqBidsConfigObj) => {
+ const ids = [...configIds];
+ const hids = [...configHids];
+
+ // Note: We don't extract IDs from Prebid userId module (ortb2.user.ext.eids)
+ // because those are in ORTB format, not Optable's ID format (e:hash, c:ppid, etc.)
+ // Optable-specific IDs should be provided via params.ids configuration
+
+ // Add default __passport__ ID if no other IDs provided
+ // This allows targeting to work with just passport + IP enrichment
+ if (ids.length === 0) {
+ ids.push('__passport__');
+ }
+
+ return { ids, hids };
+};
+
+/**
+ * Build targeting API request URL with all query parameters
+ * @param {Object} params Parameters object
+ * @returns {string} Complete URL for targeting API
+ */
+const buildTargetingURL = (params) => {
+ const {host, node, site, ids, hids, consent, sessionId, passport, cookies, timeout} = params;
+
+ const searchParams = new URLSearchParams();
+
+ ids.forEach(id => searchParams.append('id', id));
+ hids.forEach(hid => searchParams.append('hid', hid));
+
+ searchParams.set('o', site);
+ searchParams.set('t', node);
+ searchParams.set('sid', sessionId);
+ searchParams.set('osdk', 'prebid-rtd-1.0.0');
+
+ if (consent.gpp) {
+ searchParams.set('gpp', consent.gpp);
+ }
+ if (consent.gppSectionIDs && Array.isArray(consent.gppSectionIDs)) {
+ searchParams.set('gpp_sid', consent.gppSectionIDs.join(','));
+ }
+ if (consent.gdpr) {
+ searchParams.set('gdpr_consent', consent.gdpr);
+ }
+ if (consent.gdprApplies !== null && consent.gdprApplies !== undefined) {
+ searchParams.set('gdpr', consent.gdprApplies ? '1' : '0');
+ }
+
+ if (cookies) {
+ searchParams.set('cookies', 'yes');
} else {
- handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn);
+ searchParams.set('cookies', 'no');
+ searchParams.set('passport', passport || '');
+ }
+
+ if (timeout) {
+ searchParams.set('timeout', timeout);
}
+
+ const url = `https://${host}/v2/targeting?${searchParams.toString()}`;
+ return url;
+};
+
+/**
+ * Call the targeting API and return the response
+ * @param {Object} params Configuration parameters
+ * @returns {Promise