prebid/Prebid.js

View on GitHub
modules/permutiveRtdProvider.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * This module adds permutive provider to the real time data module
 * The {@link module:modules/realTimeData} module is required
 * The module will add custom segment targeting to ad units of specific bidders
 * @module modules/permutiveRtdProvider
 * @requires module:modules/realTimeData
 */
import {getGlobal} from '../src/prebidGlobal.js';
import {submodule} from '../src/hook.js';
import {getStorageManager} from '../src/storageManager.js';
import {deepAccess, deepSetValue, isFn, logError, mergeDeep, isPlainObject, safeJSONParse, prefixLog} from '../src/utils.js';
import {includes} from '../src/polyfill.js';
import {MODULE_TYPE_RTD} from '../src/activities/modules.js';

/**
 * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule
 */

const MODULE_NAME = 'permutive'

const logger = prefixLog('[PermutiveRTD]')

export const PERMUTIVE_SUBMODULE_CONFIG_KEY = 'permutive-prebid-rtd'
export const PERMUTIVE_STANDARD_KEYWORD = 'p_standard'
export const PERMUTIVE_CUSTOM_COHORTS_KEYWORD = 'permutive'
export const PERMUTIVE_STANDARD_AUD_KEYWORD = 'p_standard_aud'

export const storage = getStorageManager({moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME})

function init(moduleConfig, userConsent) {
  readPermutiveModuleConfigFromCache()

  return true
}

function liftIntoParams(params) {
  return isPlainObject(params) ? { params } : {}
}

let cachedPermutiveModuleConfig = {}

/**
 * Access the submodules RTD params that are cached to LocalStorage by the Permutive SDK. This lets the RTD submodule
 * apply publisher defined params set in the Permutive platform, so they may still be applied if the Permutive SDK has
 * not initialised before this submodule is initialised.
 */
function readPermutiveModuleConfigFromCache() {
  const params = safeJSONParse(storage.getDataFromLocalStorage(PERMUTIVE_SUBMODULE_CONFIG_KEY))
  return cachedPermutiveModuleConfig = liftIntoParams(params)
}

/**
 * Access the submodules RTD params attached to the Permutive SDK.
 *
 * @return The Permutive config available by the Permutive SDK or null if the operation errors.
 */
function getParamsFromPermutive() {
  try {
    return liftIntoParams(window.permutive.addons.prebid.getPermutiveRtdConfig())
  } catch (e) {
    return null
  }
}

/**
 * Merges segments into existing bidder config in reverse priority order. The highest priority is 1.
 *
 *   1. customModuleConfig <- set by publisher with pbjs.setConfig
 *   2. permutiveRtdConfig <- set by the publisher using the Permutive platform
 *   3. defaultConfig
 *
 * As items with a higher priority will be deeply merged into the previous config, deep merges are performed by
 * reversing the priority order.
 *
 * @param {Object} customModuleConfig - Publisher config for module
 * @return {Object} Deep merges of the default, Permutive and custom config.
 */
export function getModuleConfig(customModuleConfig) {
  // Use the params from Permutive if available, otherwise fallback to the cached value set by Permutive.
  const permutiveModuleConfig = getParamsFromPermutive() || cachedPermutiveModuleConfig

  return mergeDeep({
    waitForIt: false,
    params: {
      maxSegs: 500,
      acBidders: [],
      overwrites: {},
    },
  },
  permutiveModuleConfig,
  customModuleConfig,
  )
}

/**
 * Sets ortb2 config for ac bidders
 * @param {Object} bidderOrtb2 - The ortb2 object for the all bidders
 * @param {Object} customModuleConfig - Publisher config for module
 */
export function setBidderRtb (bidderOrtb2, moduleConfig, segmentData) {
  const acBidders = deepAccess(moduleConfig, 'params.acBidders')
  const maxSegs = deepAccess(moduleConfig, 'params.maxSegs')
  const transformationConfigs = deepAccess(moduleConfig, 'params.transformations') || []

  const ssps = segmentData?.ssp?.ssps ?? []
  const sspCohorts = segmentData?.ssp?.cohorts ?? []
  const topics = segmentData?.topics ?? {}

  const bidders = new Set([...acBidders, ...ssps])
  bidders.forEach(function (bidder) {
    const currConfig = { ortb2: bidderOrtb2[bidder] || {} }

    let cohorts = []

    const isAcBidder = acBidders.indexOf(bidder) > -1
    if (isAcBidder) {
      cohorts = segmentData.ac
    }

    const isSspBidder = ssps.indexOf(bidder) > -1
    if (isSspBidder) {
      cohorts = [...new Set([...cohorts, ...sspCohorts])].slice(0, maxSegs)
    }

    const nextConfig = updateOrtbConfig(bidder, currConfig, cohorts, sspCohorts, topics, transformationConfigs, segmentData)
    bidderOrtb2[bidder] = nextConfig.ortb2
  })
}

/**
 * Updates `user.data` object in existing bidder config with Permutive segments
 * @param string bidder - The bidder
 * @param {Object} currConfig - Current bidder config
 * @param {Object[]} transformationConfigs - array of objects with `id` and `config` properties, used to determine
 *                                           the transformations on user data to include the ORTB2 object
 * @param {string[]} segmentIDs - Permutive segment IDs
 * @param {string[]} sspSegmentIDs - Permutive SSP segment IDs
 * @param {Object} topics - Privacy Sandbox Topics, keyed by IAB taxonomy version (600, 601, etc.)
 * @param {Object} segmentData - The segments available for targeting
 * @return {Object} Merged ortb2 object
 */
function updateOrtbConfig(bidder, currConfig, segmentIDs, sspSegmentIDs, topics, transformationConfigs, segmentData) {
  logger.logInfo(`Current ortb2 config`, { bidder, config: currConfig })

  const customCohortsData = deepAccess(segmentData, bidder) || []

  const name = 'permutive.com'

  const permutiveUserData = {
    name,
    segment: segmentIDs.map(segmentId => ({ id: segmentId })),
  }

  const transformedUserData = transformationConfigs
    .filter(({ id }) => ortb2UserDataTransformations.hasOwnProperty(id))
    .map(({ id, config }) => ortb2UserDataTransformations[id](permutiveUserData, config))

  const customCohortsUserData = {
    name: PERMUTIVE_CUSTOM_COHORTS_KEYWORD,
    segment: customCohortsData.map(cohortID => ({ id: cohortID })),
  }

  const ortbConfig = mergeDeep({}, currConfig)
  const currentUserData = deepAccess(ortbConfig, 'ortb2.user.data') || []

  let topicsUserData = []
  for (const [k, value] of Object.entries(topics)) {
    topicsUserData.push({
      name,
      ext: {
        segtax: Number(k)
      },
      segment: value.map(topic => ({ id: topic.toString() })),
    })
  }

  const updatedUserData = currentUserData
    .filter(el => el.name !== permutiveUserData.name && el.name !== customCohortsUserData.name)
    .concat(permutiveUserData, transformedUserData, customCohortsUserData)
    .concat(topicsUserData)

  logger.logInfo(`Updating ortb2.user.data`, { bidder, user_data: updatedUserData })
  deepSetValue(ortbConfig, 'ortb2.user.data', updatedUserData)

  // Set ortb2.user.keywords
  const currentKeywords = deepAccess(ortbConfig, 'ortb2.user.keywords')
  const keywordGroups = {
    [PERMUTIVE_STANDARD_KEYWORD]: segmentIDs,
    [PERMUTIVE_STANDARD_AUD_KEYWORD]: sspSegmentIDs,
    [PERMUTIVE_CUSTOM_COHORTS_KEYWORD]: customCohortsData,
  }

  // Transform groups of key-values into a single array of strings
  // i.e { permutive: ['1', '2'], p_standard: ['3', '4'] } => ['permutive=1', 'permutive=2', 'p_standard=3',' p_standard=4']
  const transformedKeywordGroups = Object.entries(keywordGroups)
    .flatMap(([keyword, ids]) => ids.map(id => `${keyword}=${id}`))

  const keywords = [
    currentKeywords,
    ...transformedKeywordGroups,
  ]
    .filter(Boolean)
    .join(',')

  logger.logInfo(`Updating ortb2.user.keywords`, {
    bidder,
    keywords,
  })
  deepSetValue(ortbConfig, 'ortb2.user.keywords', keywords)

  // Set user extensions
  if (segmentIDs.length > 0) {
    deepSetValue(ortbConfig, `ortb2.user.ext.data.${PERMUTIVE_STANDARD_KEYWORD}`, segmentIDs)
    logger.logInfo(`Extending ortb2.user.ext.data with "${PERMUTIVE_STANDARD_KEYWORD}"`, segmentIDs)
  }

  if (customCohortsData.length > 0) {
    deepSetValue(ortbConfig, `ortb2.user.ext.data.${PERMUTIVE_CUSTOM_COHORTS_KEYWORD}`, customCohortsData.map(String))
    logger.logInfo(`Extending ortb2.user.ext.data with "${PERMUTIVE_CUSTOM_COHORTS_KEYWORD}"`, customCohortsData)
  }

  // Set site extensions
  if (segmentIDs.length > 0) {
    deepSetValue(ortbConfig, `ortb2.site.ext.permutive.${PERMUTIVE_STANDARD_KEYWORD}`, segmentIDs)
    logger.logInfo(`Extending ortb2.site.ext.permutive with "${PERMUTIVE_STANDARD_KEYWORD}"`, segmentIDs)
  }

  logger.logInfo(`Updated ortb2 config`, { bidder, config: ortbConfig })
  return ortbConfig
}

/**
 * Set segments on bid request object
 * @param {Object} reqBidsConfigObj - Bid request object
 * @param {Object} moduleConfig - Module configuration
 * @param {Object} segmentData - Segment object
 */
function setSegments (reqBidsConfigObj, moduleConfig, segmentData) {
  const adUnits = (reqBidsConfigObj && reqBidsConfigObj.adUnits) || getGlobal().adUnits
  const utils = { deepSetValue, deepAccess, isFn, mergeDeep }
  const aliasMap = {
    appnexusAst: 'appnexus'
  }

  if (!adUnits) {
    return
  }

  adUnits.forEach(adUnit => {
    adUnit.bids.forEach(bid => {
      let { bidder } = bid
      if (typeof aliasMap[bidder] !== 'undefined') {
        bidder = aliasMap[bidder]
      }
      const acEnabled = isAcEnabled(moduleConfig, bidder)
      const customFn = getCustomBidderFn(moduleConfig, bidder)

      if (customFn) {
        // For backwards compatibility we pass an identity function to any custom bidder function set by a publisher
        const bidIdentity = (bid) => bid
        customFn(bid, segmentData, acEnabled, utils, bidIdentity)
      }
    })
  })
}

/**
 * Catch and log errors
 * @param {function} fn - Function to safely evaluate
 */
function makeSafe (fn) {
  try {
    fn()
  } catch (e) {
    logError(e)
  }
}

function getCustomBidderFn (moduleConfig, bidder) {
  const overwriteFn = deepAccess(moduleConfig, `params.overwrites.${bidder}`)

  if (overwriteFn && isFn(overwriteFn)) {
    return overwriteFn
  } else {
    return null
  }
}

/**
 * Check whether ac is enabled for bidder
 * @param {Object} moduleConfig - Module configuration
 * @param {string} bidder - Bidder name
 * @return {boolean}
 */
export function isAcEnabled (moduleConfig, bidder) {
  const acBidders = deepAccess(moduleConfig, 'params.acBidders') || []
  return includes(acBidders, bidder)
}

/**
 * Check whether Permutive is on page
 * @return {boolean}
 */
export function isPermutiveOnPage () {
  return typeof window.permutive !== 'undefined' && typeof window.permutive.ready === 'function'
}

/**
 * Get all relevant segment IDs in an object
 * @param {number} maxSegs - Maximum number of segments to be included
 * @return {Object}
 */
export function getSegments (maxSegs) {
  const legacySegs = readSegments('_psegs', []).map(Number).filter(seg => seg >= 1000000).map(String)
  const _ppam = readSegments('_ppam', [])
  const _pcrprs = readSegments('_pcrprs', [])

  const segments = {
    ac: [..._pcrprs, ..._ppam, ...legacySegs],
    ix: readSegments('_pindexs', []),
    rubicon: readSegments('_prubicons', []),
    appnexus: readSegments('_papns', []),
    gam: readSegments('_pdfps', []),
    ssp: readSegments('_pssps', {
      cohorts: [],
      ssps: []
    }),
    topics: readSegments('_ppsts', {}),
  }

  for (const bidder in segments) {
    if (bidder === 'ssp') {
      if (segments[bidder].cohorts && Array.isArray(segments[bidder].cohorts)) {
        segments[bidder].cohorts = segments[bidder].cohorts.slice(0, maxSegs)
      }
    } else if (bidder === 'topics') {
      for (const taxonomy in segments[bidder]) {
        segments[bidder][taxonomy] = segments[bidder][taxonomy].slice(0, maxSegs)
      }
    } else {
      segments[bidder] = segments[bidder].slice(0, maxSegs)
    }
  }

  return segments
}

/**
 * Gets an array of segment IDs from LocalStorage
 * or return the default value provided.
 * @template A
 * @param {string} key
 * @param {A} defaultValue
 * @return {A}
 */
function readSegments (key, defaultValue) {
  try {
    return JSON.parse(storage.getDataFromLocalStorage(key)) || defaultValue
  } catch (e) {
    return defaultValue
  }
}

const unknownIabSegmentId = '_unknown_'

/**
 * Functions to apply to ORT2B2 `user.data` objects.
 * Each function should return an a new object containing a `name`, (optional) `ext` and `segment`
 * properties. The result of the each transformation defined here will be appended to the array
 * under `user.data` in the bid request.
 */
const ortb2UserDataTransformations = {
  iab: (userData, config) => ({
    name: userData.name,
    ext: { segtax: config.segtax },
    segment: (userData.segment || [])
      .map(segment => ({ id: iabSegmentId(segment.id, config.iabIds) }))
      .filter(segment => segment.id !== unknownIabSegmentId)
  })
}

/**
 * Transform a Permutive segment ID into an IAB audience taxonomy ID.
 * @param {string} permutiveSegmentId
 * @param {Object} iabIds object of mappings between Permutive and IAB segment IDs (key: permutive ID, value: IAB ID)
 * @return {string} IAB audience taxonomy ID associated with the Permutive segment ID
 */
function iabSegmentId(permutiveSegmentId, iabIds) {
  return iabIds[permutiveSegmentId] || unknownIabSegmentId
}

/**
 * Pull the latest configuration and cohort information and update accordingly.
 *
 * @param reqBidsConfigObj - Bidder provided config for request
 * @param customModuleConfig - Publisher provide config
 */
export function readAndSetCohorts(reqBidsConfigObj, moduleConfig) {
  const segmentData = getSegments(deepAccess(moduleConfig, 'params.maxSegs'))

  makeSafe(function () {
    // Legacy route with custom parameters
    // ACK policy violation, in process of removing
    setSegments(reqBidsConfigObj, moduleConfig, segmentData)
  });

  makeSafe(function () {
    // Route for bidders supporting ORTB2
    setBidderRtb(reqBidsConfigObj.ortb2Fragments?.bidder, moduleConfig, segmentData)
  })
}

let permutiveSDKInRealTime = false

/** @type {RtdSubmodule} */
export const permutiveSubmodule = {
  name: MODULE_NAME,
  getBidRequestData: function (reqBidsConfigObj, callback, customModuleConfig) {
    const completeBidRequestData = () => {
      logger.logInfo(`Request data updated`)
      callback()
    }

    const moduleConfig = getModuleConfig(customModuleConfig)

    readAndSetCohorts(reqBidsConfigObj, moduleConfig)

    makeSafe(function () {
      if (permutiveSDKInRealTime || !(moduleConfig.waitForIt && isPermutiveOnPage())) {
        return completeBidRequestData()
      }

      window.permutive.ready(function () {
        logger.logInfo(`SDK is realtime, updating cohorts`)
        permutiveSDKInRealTime = true
        readAndSetCohorts(reqBidsConfigObj, getModuleConfig(customModuleConfig))
        completeBidRequestData()
      }, 'realtime')

      logger.logInfo(`Registered cohort update when SDK is realtime`)
    })
  },
  init: init
}

submodule('realTimeData', permutiveSubmodule)