Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QortexRtdProvider: Supports new Qortex bid enrichment process #12173

Merged
merged 31 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bfeff50
creates config request step
shilohannese Aug 6, 2024
be9a8b7
gather page data and send POST
shilohannese Aug 8, 2024
4282e8d
includes player events logic
shilohannese Aug 19, 2024
40f6a05
rtd MVP
shilohannese Aug 21, 2024
6c1aac2
change function name
shilohannese Aug 21, 2024
d9f7a0c
saving before methodology change
shilohannese Aug 23, 2024
908460f
satifies coverage and information specification:wq
shilohannese Aug 26, 2024
b66c652
removes adapter
shilohannese Aug 26, 2024
eae4915
remove dependencies
shilohannese Aug 26, 2024
a1afab6
adds final MVP features
shilohannese Aug 27, 2024
37d9876
Merge remote-tracking branch 'origin/master' into prebid-pr-stage
shilohannese Aug 27, 2024
9d9b710
fixed submodules line
shilohannese Aug 27, 2024
8ff9ec4
use cryptography
shilohannese Aug 27, 2024
e5e5169
use textcontent per circleci
shilohannese Aug 27, 2024
df3c59f
spelling
shilohannese Aug 27, 2024
7e78f77
Prebid config options (#7)
shilohannese Sep 7, 2024
50d31e6
limits the type and amount of text collected on a page (#8)
shilohannese Sep 11, 2024
9ff3e38
fix lint errors
shilohannese Sep 11, 2024
b6a0d56
updates config param to be opt in
shilohannese Sep 25, 2024
223fd3d
update markdown
shilohannese Sep 25, 2024
93d36c3
Merge pull request #9 from firecatapult/qxd-4991-update-opt-in-flag
rrochwick Sep 26, 2024
98b526a
resolve circle ci issue
shilohannese Oct 9, 2024
dafaae9
Merge pull request #10 from firecatapult/qxd-5020-param-fix
rrochwick Oct 9, 2024
c87ba5f
Merge remote-tracking branch 'origin' into pr-sync-master
shilohannese Oct 22, 2024
efac0cb
Merge pull request #13 from firecatapult/pr-sync-master
rrochwick Oct 22, 2024
10c7b20
new branch from updated pr-stage
shilohannese Oct 22, 2024
ccb5bd7
resolves tests after code removal
shilohannese Oct 22, 2024
c1264f7
Merge pull request #14 from firecatapult/qxd-5006-remove-content-anal…
rrochwick Oct 22, 2024
8995b86
spelling and CICD error
shilohannese Oct 24, 2024
0e05060
spelling
shilohannese Oct 24, 2024
3d68641
reorder md to match github io page:
shilohannese Oct 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 232 additions & 36 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(['Recieved 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,45 +57,143 @@ 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 anyalitics event sent')
shilohannese marked this conversation as resolved.
Show resolved Hide resolved
})
.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';
}
}

Expand All @@ -83,14 +203,16 @@ export function getContext () {
* @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 +244,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(`Recieved invalid billable event due to ${!uid ? 'missing' : 'duplicate'} uid: qx-impression`)
shilohannese marked this conversation as resolved.
Show resolved Hide resolved
return;
} else {
logMessage('received billable event: qx-impression')
impressionIds.add(uid)
logMessage('Recieved 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(`Recieved 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 recieved 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();
Fixed Show fixed Hide fixed
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() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is thi9s controlling if you've removed the text going over the wire

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is only used for determining if we enrich the auction with contextual data about the current page (and videos known to be on the page) from our servers. This still requires a check on the prebid config and within our zone configurations to even attempt to retrieve information known about the current page.

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);
8 changes: 6 additions & 2 deletions modules/qortexRtdProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Maintainer: [email protected]

The Qortex RTD module appends contextual segments to the bidding object based on the content of a page using the Qortex API.

Upon load, the Qortex context API will analyze the bidder page (video, text, image, etc.) and will return a [Content object](https://www.iab.com/wp-content/uploads/2016/03/OpenRTB-API-Specification-Version-2-5-FINAL.pdf#page=26). The module will then merge that object into the appropriate bidders' `ortb2.site.content`, which can be used by prebid adapters that use `site.content` data.
If the `Qortex Group Id` and module parameters provided during configuration is active, the Qortex context API will attempt to generate and return a [Content object](https://www.iab.com/wp-content/uploads/2016/03/OpenRTB-API-Specification-Version-2-5-FINAL.pdf#page=26) using indexed data from provided page content. The module will then merge that object into the appropriate bidders' `ortb2.site.content`, which can be used by prebid adapters that use `site.content` data.


## Build
Expand Down Expand Up @@ -40,6 +40,7 @@ pbjs.setConfig({
params: {
groupId: 'ABC123', //required
bidders: ['qortex', 'adapter2'], //optional (see below)
enableBidEnrichment: true, //optional (see below)
tagConfig: { // optional, please reach out to your account manager for configuration reccommendation
videoContainer: 'string',
htmlContainer: 'string',
Expand All @@ -66,4 +67,7 @@ pbjs.setConfig({
#### `tagConfig` - optional
- This optional parameter is an object containing the config settings that could be usedto initialize the Qortex integration on your page. A preconfigured object for this step will be provided to you by the Qortex team.

- If this parameter is not present, the Qortex integration can still be configured and loaded manually on your page outside of prebid. The RTD module will continue to initialize and operate as normal.
- If this parameter is not present, the Qortex integration can still be configured and loaded manually on your page outside of prebid. The RTD module will continue to initialize and operate as normal.

#### `enableBidEnrichment` - optional
- This optional parameter allows a publisher to opt-in to the features of the RTD module that use our API to enrich bids with first party data for contextuality. Enabling this feature will allow this module to interact with the Qortex AI contextuality server for indexing and analysis. Please use caution when adding this module to pages that may contain personal user data or proprietary information.
Loading