From 83f36b4cbdea9fcc5b41e8e76e348232c9775ab5 Mon Sep 17 00:00:00 2001 From: Alejandro Villanueva Date: Mon, 15 Apr 2024 17:01:58 -0600 Subject: [PATCH 1/2] ADD Magnite audiences aka carbon --- modules.json | 3 +- modules/carbonAnalyticsAdapter.js | 226 ++++++++++++++++++++++++++++++ modules/carbonAnalyticsAdapter.md | 16 +++ modules/carbonRtdProvider.js | 216 ++++++++++++++++++++++++++++ modules/carbonRtdProvider.md | 36 +++++ package-lock.json | 2 +- 6 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 modules/carbonAnalyticsAdapter.js create mode 100644 modules/carbonAnalyticsAdapter.md create mode 100644 modules/carbonRtdProvider.js create mode 100644 modules/carbonRtdProvider.md diff --git a/modules.json b/modules.json index fc9bba2fb5a..835f6fdf2bc 100644 --- a/modules.json +++ b/modules.json @@ -51,5 +51,6 @@ "pairIdSystem", "connectIdSystem", "33acrossIdSystem", - "id5IdSystem" + "id5IdSystem", + "carbonRtdProvider" ] diff --git a/modules/carbonAnalyticsAdapter.js b/modules/carbonAnalyticsAdapter.js new file mode 100644 index 00000000000..d3c37e85e90 --- /dev/null +++ b/modules/carbonAnalyticsAdapter.js @@ -0,0 +1,226 @@ +import { deepAccess, generateUUID, logError } from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; +import { getStorageManager } from '../src/storageManager.js'; +import {getGlobal} from '../src/prebidGlobal.js'; +import adapterManager from '../src/adapterManager.js'; +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import CONSTANTS from '../src/constants.json'; +const CARBON_GVL_ID = 493; +const ANALYTICS_VERSION = 'v1.0'; +const PROFILE_ID_KEY = 'carbon_ccuid'; +const PROFILE_ID_COOKIE = 'ccuid'; +const SESSION_ID_COOKIE = 'ccsid'; +const MODULE_NAME = 'carbon'; +const ANALYTICS_TYPE = 'endpoint'; +const DAY_MS = 24 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; +export const storage = getStorageManager({gvlid: CARBON_GVL_ID, moduleName: 'carbon'}); +let analyticsHost = ''; +let pageViewId = ''; +let profileId = ''; +let sessionId = ''; +let parentId = ''; +let pageEngagement = {}; +let carbonAdapter = Object.assign(adapter({analyticsHost, ANALYTICS_TYPE}), { + track({eventType, args}) { + args = args ? JSON.parse(JSON.stringify(args)) : {}; + switch (eventType) { + case CONSTANTS.EVENTS.AUCTION_END: { + registerEngagement(); + let event = createBaseEngagementEvent(args); + sendEngagementEvent(event, 'auction_end'); + break; + } + case CONSTANTS.EVENTS.TCF2_ENFORCEMENT: { + // check for relevant tcf information on the event before recording + if (args.storageBlocked?.length > 0 || args.biddersBlocked?.length > 0 || args.analyticsBlocked?.length > 0) { + registerEngagement(); + let event = createBaseEngagementEvent(args); + event.tcf_events = args; + sendEngagementEvent(event, 'tcf_enforcement'); + } + break; + } + default: { + break; + } + } + } +}); +// save the base class function +carbonAdapter.originEnableAnalytics = carbonAdapter.enableAnalytics; +// override enableAnalytics so we can get access to the config passed in from the page +carbonAdapter.enableAnalytics = function (config) { + if (config?.options?.parentId) { + parentId = config.options.parentId; + } else { + logError('required config value "parentId" not provided'); + } + if (config?.options.endpoint) { + analyticsHost = config.options.endpoint; + } else { + logError('required config value "endpoint" not provided'); + } + pageViewId = generateUUID(); + profileId = getProfileId(); + sessionId = getSessionId(); + pageEngagement = { // create the initial page engagement event + ttl: 60, // unit is seconds + count: 0, + id: generateUUID(), + startTime: Date.now(), + timeLastEngage: Date.now() + }; + let event = createBaseEngagementEvent() + sendEngagementEvent(event, 'page_load'); + carbonAdapter.originEnableAnalytics(config); // call the base class function +}; +function getProfileId() { + if (storage.localStorageIsEnabled()) { + let localStorageId = storage.getDataFromLocalStorage(PROFILE_ID_KEY); + if (localStorageId && localStorageId != '') { + if (storage.cookiesAreEnabled()) { + storage.setCookie(PROFILE_ID_COOKIE, localStorageId, new Date(Date.now() + 89 * DAY_MS), 'Lax'); + } + return localStorageId; + } + } + if (storage.cookiesAreEnabled()) { + let cookieId = storage.getCookie(PROFILE_ID_COOKIE); + if (cookieId && cookieId != '') { + if (storage.localStorageIsEnabled()) { + storage.setDataInLocalStorage(PROFILE_ID_KEY, cookieId); + } + storage.setCookie(PROFILE_ID_COOKIE, cookieId, new Date(Date.now() + 89 * DAY_MS), 'Lax'); + return cookieId; + } + } + let newId = generateUUID(); + if (storage.localStorageIsEnabled()) { + storage.setDataInLocalStorage(PROFILE_ID_KEY, newId); + } + if (storage.cookiesAreEnabled()) { + storage.setCookie(PROFILE_ID_COOKIE, newId, new Date(Date.now() + 89 * DAY_MS), 'Lax'); + } + return newId; +} +function updateProfileId(userData) { + if (userData?.update && userData?.id != '') { + profileId = userData.id; + if (storage.cookiesAreEnabled()) { + storage.setCookie(PROFILE_ID_COOKIE, userData.id, new Date(Date.now() + 89 * DAY_MS), 'Lax'); + } + if (storage.localStorageIsEnabled()) { + storage.setDataInLocalStorage(PROFILE_ID_KEY, userData.id); + } + } +} +function getSessionId() { + if (storage.cookiesAreEnabled()) { + let cookieId = storage.getCookie(SESSION_ID_COOKIE); + if (cookieId && cookieId != '') { + storage.setCookie(SESSION_ID_COOKIE, cookieId, new Date(Date.now() + 5 * MINUTE_MS), 'Lax'); + return cookieId; + } + let newId = generateUUID(); + storage.setCookie(SESSION_ID_COOKIE, newId, new Date(Date.now() + 5 * MINUTE_MS), 'Lax'); + return newId; + } + return generateUUID(); +} +function registerEngagement() { + let present = Date.now(); + let timediff = (present - pageEngagement.timeLastEngage) / 1000; // convert to seconds + pageEngagement.timeLastEngage = present; + if (timediff < pageEngagement.ttl) { + return; + } + pageEngagement.count++; + pageEngagement.startTime = present; + pageEngagement.id = generateUUID(); +}; +function getConsentData(args) { // range through this and get every value + if (Array.isArray(args?.bidderRequests) && args.bidderRequests.length > 0) { + let bidderRequest = args.bidderRequests[0]; + let consentData = { + gdpr_consent: '', + ccpa_consent: '' + }; + if (bidderRequest?.gdprConsent?.consentString) { + consentData.gdpr_consent = bidderRequest.gdprConsent.consentString; + } + if (bidderRequest?.uspConsent?.consentString) { + consentData.ccpa_consent = bidderRequest.uspConsent.consentString; + } + if (consentData.gdpr_consent != '' || consentData.ccpa_consent != '') { + return consentData; + } + } +} +function getExternalIds() { + let externalIds = {}; + if (getGlobal().getUserIdsAsEids && typeof getGlobal().getUserIdsAsEids == 'function') { + let eids = getGlobal().getUserIdsAsEids(); + if (eids?.length) { + eids.forEach(eid => { + if (eid.source && eid?.uids?.length) { + externalIds[eid.source] = eid.uids.map(uid => uid.id); + } + }); + return externalIds; + } + } +} +function createBaseEngagementEvent(args) { + let event = {}; + event.profile_id = profileId; + event.session_id = sessionId; + event.pageview_id = pageViewId; + event.engagement_id = pageEngagement.id; + event.engagement_count = pageEngagement.count; + event.engagement_ttl = pageEngagement.ttl; + event.start_time = pageEngagement.startTime; + event.end_time = Date.now(); + event.script_id = window.location.host; + event.url = window.location.href; + event.referrer = document.referrer || deepAccess(args, 'bidderRequests.0.refererInfo.page') || undefined; + if (args?.bidderRequests) { + event.consent = getConsentData(args); + } + event.external_ids = getExternalIds(); // TODO check args for EIDs on subsequent auctions + return event; +} +function sendEngagementEvent(event, eventTrigger) { + if (analyticsHost != '' && parentId != '') { + let reqUrl = `${analyticsHost}/${ANALYTICS_VERSION}/parent/${parentId}/engagement/trigger/${eventTrigger}`; + ajax(reqUrl, + { + success: function (response, req) { // update the ID if we find a cross domain cookie + let userData = {}; + try { + userData = JSON.parse(response); + updateProfileId(userData); + } catch (e) { + logError('unable to parse API response'); + } + }, + error: error => { + if (error !== '') logError(error); + } + }, + JSON.stringify(event), + { + contentType: 'application/json', + method: 'POST', + withCredentials: true, + crossOrigin: true + } + ); + } +}; +adapterManager.registerAnalyticsAdapter({ + adapter: carbonAdapter, + code: MODULE_NAME, + gvlid: CARBON_GVL_ID +}); +export default carbonAdapter; diff --git a/modules/carbonAnalyticsAdapter.md b/modules/carbonAnalyticsAdapter.md new file mode 100644 index 00000000000..c0629d40ed5 --- /dev/null +++ b/modules/carbonAnalyticsAdapter.md @@ -0,0 +1,16 @@ +# Overview +Module Name: Carbon Analytics Adapter +Module Type: Analytics Adapter +Maintainer: rstevens@magnite.com +# Description +Analytics adapter for prebid by Magnite. Contact csupport@magnite.com for more information. +# Implementation +``` +pbjs.enableAnalytics({ + provider: 'carbon', + options: { + parentId: '', //Contact Magnite to recieve your unique ID + endpoint: '' //Contact magnite to recieve the analytics endpoint value + } +}); +``` diff --git a/modules/carbonRtdProvider.js b/modules/carbonRtdProvider.js new file mode 100644 index 00000000000..aac0a495696 --- /dev/null +++ b/modules/carbonRtdProvider.js @@ -0,0 +1,216 @@ +/** +* This module adds the carbon provider to the Real Time Data module (rtdModule) +* The {@link module:modules/realTimeData} module is required +* The module will add contextual and audience targeting data to bid requests +* @module modules/carbonRtdProvider +* @requires module:modules/realTimeData +*/ +import { submodule } from '../src/hook.js'; +import { ajax } from '../src/ajax.js'; +import {getGlobal} from '../src/prebidGlobal.js'; +import { logError, isGptPubadsDefined, generateUUID } from '../src/utils.js'; +import { getStorageManager } from '../src/storageManager.js'; +const SUBMODULE_NAME = 'carbon' +const CARBON_GVL_ID = 493; +const MODULE_VERSION = 'v1.0' +const STORAGE_KEY = 'carbon_data' +const PROFILE_ID_KEY = 'carbon_ccuid' +const TAXONOMY_RULE_EXPIRATION_KEY = 'carbon_ct_expiration' +let rtdHost = ''; +let parentId = ''; +let features = {}; +export const storage = getStorageManager({ gvlid: CARBON_GVL_ID, moduleName: SUBMODULE_NAME }) +export function setLocalStorage(carbonData) { + if (storage.localStorageIsEnabled()) { + let data = JSON.stringify(carbonData); + storage.setDataInLocalStorage(STORAGE_KEY, data); + } +} +export function setTaxonomyRuleExpiration(customTaxonomyTTL) { + if (storage.localStorageIsEnabled()) { + let expiration = Date.now() + customTaxonomyTTL; + storage.setDataInLocalStorage(TAXONOMY_RULE_EXPIRATION_KEY, expiration); + } +} +export function updateProfileId(carbonData) { + let identity = carbonData?.profile?.identity; + if (identity?.update && identity?.id != '' && storage.localStorageIsEnabled()) { + storage.setDataInLocalStorage(PROFILE_ID_KEY, identity.id); + } +} +export function matchCustomTaxonomyRule(rule) { + const contentText = window.top.document.body.innerText; + if (rule.MatchType == 'any') { + let words = Object.keys(rule.WordWeights).join('|'); + let regex = RegExp('\\b' + words + '\\b', 'i'); + let result = contentText.match(regex); + if (result) { + return true + } + } else if (rule.MatchType == 'minmatch') { + let score = 0; + let words = Object.keys(rule.WordWeights).join('|'); + let regex = RegExp('\\b' + words + '\\b', 'gi'); + let result = contentText.match(regex); + if (result?.length) { + for (let match of result) { + let point = rule.WordWeights[match]; + if (!isNaN(point)) { + score += point; + } + if (score >= rule.MatchValue) { + return true; + } + } + } + } + return false; +} +export function matchCustomTaxonomy(rules) { + let matchedRules = rules.filter(matchCustomTaxonomyRule); + return matchedRules.map(x => x.Id); +} +export function prepareGPTTargeting(carbonData) { + if (isGptPubadsDefined()) { + setGPTTargeting(carbonData) + } else { + window.googletag = window.googletag || {}; + window.googletag.cmd = window.googletag.cmd || []; + window.googletag.cmd.push(() => setGPTTargeting(carbonData)); + } +} +export function setGPTTargeting(carbonData) { + if (Array.isArray(carbonData?.profile?.audiences) && features?.audience?.pushGpt) { + window.googletag.pubads().setTargeting('carbon_segment', carbonData.profile.audiences); + } + if (Array.isArray(carbonData?.context?.pageContext?.contextualclassifications) && features?.context?.pushGpt) { + let contextSegments = carbonData.context.pageContext.contextualclassifications.map(x => { + if (x.type && x.type == 'iab_intent' && x.id) { + return x.id; + } + }).filter(x => x != undefined); + window.googletag.pubads().setTargeting('cc-iab-class-id', contextSegments); + } + if (Array.isArray(carbonData?.context?.customTaxonomy) && features?.customTaxonomy?.pushGpt) { + let customTaxonomyResults = matchCustomTaxonomy(carbonData.context.customTaxonomy); + window.googletag.pubads().setTargeting('cc-custom-taxonomy', customTaxonomyResults); + } +} +export function updateRealTimeDataAsync(callback, taxonomyRules) { + let doc = window.top.document; + let pageUrl = `${doc.location.protocol}//${doc.location.host}${doc.location.pathname}`; + // generate an arbitrary ID if storage is blocked so that contextual data can still be retrieved + let profileId = storage.getDataFromLocalStorage(PROFILE_ID_KEY) || generateUUID(); + let reqUrl = new URL(`${rtdHost}/${MODULE_VERSION}/realtime/${parentId}`); + reqUrl.searchParams.append('profile_id', profileId); + reqUrl.searchParams.append('url', encodeURIComponent(pageUrl)); + if (getGlobal().getUserIdsAsEids && typeof getGlobal().getUserIdsAsEids == 'function') { + let eids = getGlobal().getUserIdsAsEids(); + if (eids && eids.length) { + eids.forEach(eid => { + if (eid?.uids?.length) { + eid.uids.forEach(uid => { + reqUrl.searchParams.append('eid', `${eid.source}:${uid.id}`) + }); + } + }); + } + } + reqUrl.searchParams.append('context', (typeof features?.context?.active === 'undefined') ? true : features.context.active); + if (features?.context?.limit && features.context.limit > 0) { + reqUrl.searchParams.append('contextLimit', features.context.limit); + } + reqUrl.searchParams.append('audience', (typeof features?.audience?.active === 'undefined') ? true : features.audience.active); + if (features?.audience?.limit && features.audience.limit > 0) { + reqUrl.searchParams.append('audienceLimit', features.audience.limit); + } + reqUrl.searchParams.append('deal_ids', (typeof features?.dealId?.active === 'undefined') ? true : features.dealId.active); + if (features?.dealId?.limit && features.dealId.limit > 0) { + reqUrl.searchParams.append('dealIdLimit', features.dealId.limit); + } + // only request new taxonomy rules from server if no cached rules available + if (taxonomyRules && taxonomyRules.length) { + reqUrl.searchParams.append('custom_taxonomy', false); + } else { + reqUrl.searchParams.append('custom_taxonomy', (typeof features?.customTaxonomy?.active === 'undefined') ? true : features.customTaxonomy.active); + if (features?.customTaxonomy?.limit && features.customTaxonomy.limit > 0) { + reqUrl.searchParams.append('customTaxonomy', features.customTaxonomy.limit); + } + } + ajax(reqUrl, { + success: function (response, req) { + let carbonData = {}; + if (req.status === 200) { + try { + carbonData = JSON.parse(response); + } catch (e) { + logError('unable to parse API response'); + } + // if custom taxonomy didn't expire use the existing data + if (taxonomyRules?.length && carbonData?.context) { + carbonData.context.customTaxonomy = taxonomyRules; + } else if (carbonData.context?.customTaxonomyTTL > 0) { + setTaxonomyRuleExpiration(carbonData.context?.customTaxonomyTTL); + } + updateProfileId(carbonData); + prepareGPTTargeting(carbonData); + setLocalStorage(carbonData); + callback(); + } + }, + error: function () { + logError('failed to retrieve targeting information'); + } + }, + null, { + method: 'GET', + withCredentials: true, + crossOrigin: true + }); +} +export function bidRequestHandler(bidReqConfig, callback, config, userConsent) { + try { + const carbonData = JSON.parse(storage.getDataFromLocalStorage(STORAGE_KEY) || '{}') + if (carbonData) { + prepareGPTTargeting(carbonData); + } + // check if custom taxonomy rules have expired + let updateTaxonomyRules = true; + let taxonomyRuleExpiration = storage.getDataFromLocalStorage(TAXONOMY_RULE_EXPIRATION_KEY); + if (taxonomyRuleExpiration && !isNaN(taxonomyRuleExpiration)) { + updateTaxonomyRules = taxonomyRuleExpiration >= Date.now(); + } + // use existing cached custom taxonomy rules if not expired + if (!updateTaxonomyRules && carbonData?.context?.customTaxonomy?.length) { + updateRealTimeDataAsync(callback, carbonData.context.customTaxonomy); + } else { + updateRealTimeDataAsync(callback); + } + callback(); + } catch (err) { + logError(err); + } +} +function init(moduleConfig, userConsent) { + if (moduleConfig?.params?.parentId) { + parentId = moduleConfig.params.parentId; + } else { + logError('required config value "parentId" not provided'); + return false; + } + if (moduleConfig?.params?.endpoint) { + rtdHost = moduleConfig.params.endpoint; + } else { + logError('required config value "endpoint" not provided'); + return false; + } + features = moduleConfig?.params?.features || features; + return true; +} +/** @type {RtdSubmodule} */ +export const carbonSubmodule = { + name: SUBMODULE_NAME, + getBidRequestData: bidRequestHandler, + init: init +}; +submodule('realTimeData', carbonSubmodule); diff --git a/modules/carbonRtdProvider.md b/modules/carbonRtdProvider.md new file mode 100644 index 00000000000..9adeb8d13ee --- /dev/null +++ b/modules/carbonRtdProvider.md @@ -0,0 +1,36 @@ +# Carbon Real-Time Data Submodule +## Overview + Module Name: Carbon Rtd Provider + Module Type: Rtd Provider + Maintainer: rstevens@magnite.com +## Description +The Carbon RTD module appends contextual segments and user custom segment data to the bidding object. +## Usage +### Build +``` +gulp build --modules=carbonRtdProvider +``` +### Implementation +``` +pbjs.setConfig({ + realTimeData: { + auctionDelay: 80, + dataProviders: [ + { + name: 'carbon', + waitForIt: true, + params: { + parentId: '', //Contact Magnite for a unique ID + endpoint: '', //Contact magnite to recieve the analytics endpoint value + features: { + enableContext: true, + enableAudience: true, + enableCustomTaxonomy: true, + enableDealId: true + } + } + } + ] + } +}); +``` diff --git a/package-lock.json b/package-lock.json index 3009823ec22..86abd6bc22e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "prebid.js", - "version": "8.38.0-pre", + "version": "8.40.0", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.16.7", From c35fe40a20b9896dae82eed3cc2e867b09cd191e Mon Sep 17 00:00:00 2001 From: Alejandro Villanueva Date: Tue, 16 Apr 2024 09:58:23 -0600 Subject: [PATCH 2/2] ADD missing entry --- modules.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules.json b/modules.json index 835f6fdf2bc..214261fe291 100644 --- a/modules.json +++ b/modules.json @@ -52,5 +52,6 @@ "connectIdSystem", "33acrossIdSystem", "id5IdSystem", - "carbonRtdProvider" + "carbonRtdProvider", + "carbonAnalyticsAdapter" ]