Skip to content

Commit

Permalink
QortexRtdProvider: Supports new Qortex bid enrichment process (prebid…
Browse files Browse the repository at this point in the history
…#12173)

* creates config request step

* gather page data and send POST

* includes player events logic

* rtd MVP

* change function name

* saving before methodology change

* satifies coverage and information specification:wq

* removes adapter

* remove dependencies

* adds final MVP features

* fixed submodules line

* use cryptography

* use textcontent per circleci

* spelling

* Prebid config options (#7)

* rearrange logic, needs a few more tests

* updated and unit tests written

* remove logs

* limits the type and amount of text collected on a page (#8)

* fix lint errors

* updates config param to be opt in

* update markdown

* resolve circle ci issue

* new branch from updated pr-stage

* resolves tests after code removal

* spelling and CICD error

* spelling

* reorder md to match github io page:

---------

Co-authored-by: rrochwick <[email protected]>
  • Loading branch information
shilohannese and rrochwick authored Oct 25, 2024
1 parent 9073a02 commit 80fbc98
Show file tree
Hide file tree
Showing 3 changed files with 539 additions and 118 deletions.
269 changes: 232 additions & 37 deletions modules/qortexRtdProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import * as events from '../src/events.js';
import { EVENTS } from '../src/constants.js';
import { MODULE_TYPE_RTD } from '../src/activities/modules.js';

let requestUrl;
let bidderArray;
let impressionIds;
let currentSiteContext;
const DEFAULT_API_URL = 'https://demand.qortex.ai';

const qortexSessionInfo = {}

/**
* Init if module configuration is valid
Expand All @@ -22,11 +21,34 @@ function init (config) {
return false;
} else {
initializeModuleData(config);
if (config?.params?.enableBidEnrichment) {
logMessage('Requesting Qortex group configuration')
getGroupConfig()
.then(groupConfig => {
logMessage(['Received response for qortex group config', groupConfig])
if (groupConfig?.active === true && groupConfig?.prebidBidEnrichment === true) {
setGroupConfigData(groupConfig);
initializeBidEnrichment();
} else {
logWarn('Group config is not configured for qortex bid enrichment')
setGroupConfigData(groupConfig);
}
})
.catch((e) => {
const errorStatus = e.message;
logWarn('Returned error status code: ' + errorStatus);
if (errorStatus == 404) {
logWarn('No Group Config found');
}
});
} else {
logWarn('Bid Enrichment Function has been disabled in module configuration')
}
if (config?.params?.tagConfig) {
loadScriptTag(config)
}
return true;
}
if (config?.params?.tagConfig) {
loadScriptTag(config)
}
return true;
}

/**
Expand All @@ -35,62 +57,161 @@ function init (config) {
* @param {Function} callback Called on completion
*/
function getBidRequestData (reqBidsConfig, callback) {
if (reqBidsConfig?.adUnits?.length > 0) {
if (reqBidsConfig?.adUnits?.length > 0 && shouldAllowBidEnrichment()) {
getContext()
.then(contextData => {
setContextData(contextData)
addContextToRequests(reqBidsConfig)
callback();
})
.catch((e) => {
logWarn(e?.message);
.catch(e => {
logWarn('Returned error status code: ' + e.message);
callback();
});
} else {
logWarn('No adunits found on request bids configuration: ' + JSON.stringify(reqBidsConfig))
logWarn('Module function is paused due to configuration \n Module Config: ' + JSON.stringify(reqBidsConfig) + `\n Group Config: ${JSON.stringify(qortexSessionInfo.groupConfig) ?? 'NO GROUP CONFIG'}`)
callback();
}
}

/**
* Processess auction end events for Qortex reporting
* @param {Object} data Auction end object
*/
function onAuctionEndEvent (data, config, t) {
if (shouldAllowBidEnrichment()) {
sendAnalyticsEvent('AUCTION', 'AUCTION_END', attachContextAnalytics(data))
.then(result => {
logMessage('Qortex analytics event sent')
})
.catch(e => logWarn(e?.message))
}
}

/**
* determines whether to send a request to context api and does so if necessary
* @returns {Promise} ortb Content object
*/
export function getContext () {
if (!currentSiteContext) {
if (qortexSessionInfo.currentSiteContext === null) {
const pageUrlObject = { pageUrl: qortexSessionInfo.indexData?.pageUrl ?? '' }
logMessage('Requesting new context data');
return new Promise((resolve, reject) => {
const callbacks = {
success(text, data) {
const result = data.status === 200 ? JSON.parse(data.response)?.content : null;
const responseStatus = data.status;
let result = null;
if (responseStatus === 200) {
qortexSessionInfo.pageAnalysisData.contextRetrieved = true
result = JSON.parse(data.response)?.content;
}
resolve(result);
},
error(error) {
reject(new Error(error));
error(e, x) {
const responseStatus = x.status;
reject(new Error(responseStatus));
}
}
ajax(requestUrl, callbacks)
ajax(qortexSessionInfo.contextUrl, callbacks, JSON.stringify(pageUrlObject), {contentType: 'application/json'})
})
} else {
logMessage('Adding Content object from existing context data');
return new Promise(resolve => resolve(currentSiteContext));
return new Promise((resolve, reject) => resolve(qortexSessionInfo.currentSiteContext));
}
}

/**
* Requests Qortex group configuration using group id
* @returns {Promise} Qortex group configuration
*/
export function getGroupConfig () {
return new Promise((resolve, reject) => {
const callbacks = {
success(text, data) {
const result = data.status === 200 ? JSON.parse(data.response) : null;
resolve(result);
},
error(e, x) {
reject(new Error(x.status));
}
}
ajax(qortexSessionInfo.groupConfigUrl, callbacks)
})
}

/**
* Sends analytics events to Qortex
* @returns {Promise}
*/
export function sendAnalyticsEvent(eventType, subType, data) {
if (qortexSessionInfo.analyticsUrl !== null) {
if (shouldSendAnalytics()) {
const analtyicsEventObject = generateAnalyticsEventObject(eventType, subType, data)
logMessage('Sending qortex analytics event');
return new Promise((resolve, reject) => {
const callbacks = {
success() {
resolve();
},
error(error) {
reject(new Error(error));
}
}
ajax(qortexSessionInfo.analyticsUrl, callbacks, JSON.stringify(analtyicsEventObject), {contentType: 'application/json'})
})
} else {
return new Promise((resolve, reject) => reject(new Error('Current request did not meet analytics percentage threshold, cancelling sending event')));
}
} else {
return new Promise((resolve, reject) => reject(new Error('Analytics host not initialized')));
}
}

/**
* Creates analytics object for Qortex
* @returns {Object} analytics object
*/
export function generateAnalyticsEventObject(eventType, subType, data) {
return {
sessionId: qortexSessionInfo.sessionId,
groupId: qortexSessionInfo.groupId,
eventType: eventType,
subType: subType,
eventOriginSource: 'RTD',
data: data
}
}

/**
* Creates page index data for Qortex analysis
* @param qortexUrlBase api url from config or default
* @returns {string} Qortex analytics host url
*/
export function generateAnalyticsHostUrl(qortexUrlBase) {
if (qortexUrlBase === DEFAULT_API_URL) {
return 'https://events.qortex.ai/api/v1/player-event';
} else if (qortexUrlBase.includes('stg-demand')) {
return 'https://stg-events.qortex.ai/api/v1/player-event';
} else {
return 'https://dev-events.qortex.ai/api/v1/player-event';
}
}

/**
* Updates bidder configs with the response from Qortex context services
* @param {Object} reqBidsConfig Bid request configuration object
* @param {string[]} bidders Bidders specified in module's configuration
*/
export function addContextToRequests (reqBidsConfig) {
if (currentSiteContext === null) {
if (qortexSessionInfo.currentSiteContext === null) {
logWarn('No context data received at this time');
} else {
const fragment = { site: {content: currentSiteContext} }
if (bidderArray?.length > 0) {
bidderArray.forEach(bidder => mergeDeep(reqBidsConfig.ortb2Fragments.bidder, {[bidder]: fragment}))
} else if (!bidderArray) {
const fragment = { site: {content: qortexSessionInfo.currentSiteContext} }
if (qortexSessionInfo.bidderArray?.length > 0) {
qortexSessionInfo.bidderArray.forEach(bidder => mergeDeep(reqBidsConfig.ortb2Fragments.bidder, {[bidder]: fragment}))
saveContextAdded(reqBidsConfig, qortexSessionInfo.bidderArray);
} else if (!qortexSessionInfo.bidderArray) {
mergeDeep(reqBidsConfig.ortb2Fragments.global, fragment);
saveContextAdded(reqBidsConfig);
} else {
logWarn('Config contains an empty bidders array, unable to determine which bids to enrich');
}
Expand Down Expand Up @@ -122,45 +243,119 @@ export function loadScriptTag(config) {
switch (e?.detail?.type) {
case 'qx-impression':
const {uid} = e.detail;
if (!uid || impressionIds.has(uid)) {
logWarn(`received invalid billable event due to ${!uid ? 'missing' : 'duplicate'} uid: qx-impression`)
if (!uid || qortexSessionInfo.impressionIds.has(uid)) {
logWarn(`Received invalid billable event due to ${!uid ? 'missing' : 'duplicate'} uid: qx-impression`)
return;
} else {
logMessage('received billable event: qx-impression')
impressionIds.add(uid)
logMessage('Received billable event: qx-impression')
qortexSessionInfo.impressionIds.add(uid)
billableEvent.transactionId = e.detail.uid;
events.emit(EVENTS.BILLABLE_EVENT, billableEvent);
break;
}
default:
logWarn(`received invalid billable event: ${e.detail?.type}`)
logWarn(`Received invalid billable event: ${e.detail?.type}`)
}
})

loadExternalScript(src, MODULE_TYPE_RTD, code, undefined, undefined, attr);
}

export function initializeBidEnrichment() {
if (shouldAllowBidEnrichment()) {
getContext()
.then(contextData => {
if (qortexSessionInfo.pageAnalysisData.contextRetrieved) {
logMessage('Contextual record Received from Qortex API')
setContextData(contextData)
} else {
logWarn('Contexual record is not yet complete at this time')
}
})
.catch((e) => {
const errorStatus = e.message;
logWarn('Returned error status code: ' + errorStatus)
})
}
}
/**
* Helper function to set initial values when they are obtained by init
* @param {Object} config module config obtained during init
*/
export function initializeModuleData(config) {
const DEFAULT_API_URL = 'https://demand.qortex.ai';
const {apiUrl, groupId, bidders} = config.params;
requestUrl = `${apiUrl || DEFAULT_API_URL}/api/v1/analyze/${groupId}/prebid`;
bidderArray = bidders;
impressionIds = new Set();
currentSiteContext = null;
const {apiUrl, groupId, bidders, enableBidEnrichment} = config.params;
const qortexUrlBase = apiUrl || DEFAULT_API_URL;
const windowUrl = window.top.location.host;
qortexSessionInfo.bidEnrichmentDisabled = enableBidEnrichment !== null ? !enableBidEnrichment : true;
qortexSessionInfo.bidderArray = bidders;
qortexSessionInfo.impressionIds = new Set();
qortexSessionInfo.currentSiteContext = null;
qortexSessionInfo.pageAnalysisData = {
contextRetrieved: false,
contextAdded: {}
};
qortexSessionInfo.sessionId = generateSessionId();
qortexSessionInfo.groupId = groupId;
qortexSessionInfo.groupConfigUrl = `${qortexUrlBase}/api/v1/prebid/group/configs/${groupId}/${windowUrl}`;
qortexSessionInfo.contextUrl = `${qortexUrlBase}/api/v1/prebid/${groupId}/page/lookup`;
qortexSessionInfo.analyticsUrl = generateAnalyticsHostUrl(qortexUrlBase);
return qortexSessionInfo;
}

export function saveContextAdded(reqBids, bidders = null) {
const id = reqBids.auctionId;
const contextBidders = bidders ?? Array.from(new Set(reqBids.adUnits.flatMap(adunit => adunit.bids.map(bid => bid.bidder))))
qortexSessionInfo.pageAnalysisData.contextAdded[id] = contextBidders;
}

export function setContextData(value) {
currentSiteContext = value
qortexSessionInfo.currentSiteContext = value
}

export function setGroupConfigData(value) {
qortexSessionInfo.groupConfig = value
}

function generateSessionId() {
const randomInt = window.crypto.getRandomValues(new Uint32Array(1));
const currentDateTime = Math.floor(Date.now() / 1000);
return 'QX' + randomInt.toString() + 'X' + currentDateTime.toString()
}

function attachContextAnalytics (data) {
let qxData = {};
let qxDataAdded = false;
if (qortexSessionInfo?.pageAnalysisData?.contextAdded[data.auctionId]) {
qxData = qortexSessionInfo.currentSiteContext;
qxDataAdded = true;
}
data.qortexData = qxData;
data.qortexDataAdded = qxDataAdded;
return data;
}

function shouldSendAnalytics() {
const analyticsPercentage = qortexSessionInfo.groupConfig?.prebidReportingPercentage ?? 0;
const randomInt = Math.random().toFixed(5) * 100;
return analyticsPercentage > randomInt;
}

function shouldAllowBidEnrichment() {
if (qortexSessionInfo.bidEnrichmentDisabled) {
logWarn('Bid enrichment disabled at prebid config')
return false;
} else if (!qortexSessionInfo.groupConfig?.prebidBidEnrichment) {
logWarn('Bid enrichment disabled at group config')
return false;
}
return true
}

export const qortexSubmodule = {
name: 'qortex',
init,
getBidRequestData
getBidRequestData,
onAuctionEndEvent
}

submodule('realTimeData', qortexSubmodule);
Loading

0 comments on commit 80fbc98

Please sign in to comment.