Skip to content

Commit

Permalink
Generic Analytics Adapter: initial release (prebid#9134)
Browse files Browse the repository at this point in the history
* New module: generic analytics adapter

* Use special gvlid value instead of `isVendorless` flag for vendorless consent checks

* Mark generic analytics as vendorless for gdpr enforcement

* Allow analytics adapters to define dynamic gvlids

* Add gvlid option

* Gdpr enforcement softVendorExceptions
  • Loading branch information
dgirardi authored and JacobKlein26 committed Feb 8, 2023
1 parent 720525c commit 5a53df0
Show file tree
Hide file tree
Showing 10 changed files with 605 additions and 98 deletions.
50 changes: 26 additions & 24 deletions modules/gdprEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This module gives publishers extra set of features to enforce individual purposes of TCF v2
*/

import {deepAccess, hasDeviceAccess, isArray, logWarn} from '../src/utils.js';
import {deepAccess, hasDeviceAccess, isArray, logError, logWarn} from '../src/utils.js';
import {config} from '../src/config.js';
import adapterManager, {gdprDataHandler} from '../src/adapterManager.js';
import {find, includes} from '../src/polyfill.js';
Expand All @@ -11,13 +11,7 @@ import {getHook} from '../src/hook.js';
import {validateStorageEnforcement} from '../src/storageManager.js';
import * as events from '../src/events.js';
import CONSTANTS from '../src/constants.json';

// modules for which vendor consent is not needed (see https://github.com/prebid/Prebid.js/issues/8161)
const VENDORLESS_MODULES = new Set([
'sharedId',
'pubCommonId',
'pubProvidedId',
]);
import {VENDORLESS_GVLID} from '../src/consentHandler.js';

export const STRICT_STORAGE_ENFORCEMENT = 'strictStorageEnforcement';

Expand Down Expand Up @@ -71,7 +65,7 @@ export const internal = {
* @param {{string|Object}} - module
* @return {number} - GVL ID
*/
export function getGvlid(module) {
export function getGvlid(module, ...args) {
let gvlid = null;
if (module) {
// Check user defined GVL Mapping in pbjs.setConfig()
Expand All @@ -86,7 +80,7 @@ export function getGvlid(module) {
return gvlid;
}

gvlid = internal.getGvlidForBidAdapter(moduleCode) || internal.getGvlidForUserIdModule(module) || internal.getGvlidForAnalyticsAdapter(moduleCode);
gvlid = internal.getGvlidForBidAdapter(moduleCode) || internal.getGvlidForUserIdModule(module) || internal.getGvlidForAnalyticsAdapter(moduleCode, ...args);
}
return gvlid;
}
Expand Down Expand Up @@ -120,10 +114,19 @@ function getGvlidForUserIdModule(userIdModule) {
/**
* Returns GVL ID for an analytics adapter. If an analytics adapter does not have an associated GVL ID, it returns 'null'.
* @param {string} code - 'provider' property on the analytics adapter config
* @param {{}} config - analytics configuration object
* @return {number} GVL ID
*/
function getGvlidForAnalyticsAdapter(code) {
return adapterManager.getAnalyticsAdapter(code) && (adapterManager.getAnalyticsAdapter(code).gvlid || null);
function getGvlidForAnalyticsAdapter(code, config) {
const adapter = adapterManager.getAnalyticsAdapter(code);
return adapter?.gvlid || ((gvlid) => {
if (typeof gvlid !== 'function') return gvlid;
try {
return gvlid.call(adapter.adapter, config);
} catch (e) {
logError(`Error invoking ${code} adapter.gvlid()`, e)
}
})(adapter?.adapter?.gvlid)
}

export function shouldEnforce(consentData, purpose, name) {
Expand All @@ -145,28 +148,28 @@ export function shouldEnforce(consentData, purpose, name) {
* @param {Object} consentData - gdpr consent data
* @param {string=} currentModule - Bidder code of the current module
* @param {number=} gvlId - GVL ID for the module
* @param vendorlessModule a predicate function that takes a module name, and returns true if the module does not need vendor consent
* @returns {boolean}
*/
export function validateRules(rule, consentData, currentModule, gvlId, vendorlessModule = VENDORLESS_MODULES.has.bind(VENDORLESS_MODULES)) {
export function validateRules(rule, consentData, currentModule, gvlId) {
const purposeId = TCF2[Object.keys(TCF2).filter(purposeName => TCF2[purposeName].name === rule.purpose)[0]].id;

// return 'true' if vendor present in 'vendorExceptions'
if (includes(rule.vendorExceptions || [], currentModule)) {
if ((rule.vendorExceptions || []).includes(currentModule)) {
return true;
}
const vendorConsentRequred = !((gvlId === VENDORLESS_GVLID || (rule.softVendorExceptions || []).includes(currentModule)))

// get data from the consent string
const purposeConsent = deepAccess(consentData, `vendorData.purpose.consents.${purposeId}`);
const vendorConsent = deepAccess(consentData, `vendorData.vendor.consents.${gvlId}`);
const vendorConsent = vendorConsentRequred ? deepAccess(consentData, `vendorData.vendor.consents.${gvlId}`) : true;
const liTransparency = deepAccess(consentData, `vendorData.purpose.legitimateInterests.${purposeId}`);

/*
Since vendor exceptions have already been handled, the purpose as a whole is allowed if it's not being enforced
or the user has consented. Similar with vendors.
*/
const purposeAllowed = rule.enforcePurpose === false || purposeConsent === true;
const vendorAllowed = vendorlessModule(currentModule) || rule.enforceVendor === false || vendorConsent === true;
const vendorAllowed = rule.enforceVendor === false || vendorConsent === true;

/*
Few if any vendors should be declaring Legitimate Interest for Device Access (Purpose 1), but some are claiming
Expand All @@ -183,33 +186,32 @@ export function validateRules(rule, consentData, currentModule, gvlId, vendorles
/**
* This hook checks whether module has permission to access device or not. Device access include cookie and local storage
* @param {Function} fn reference to original function (used by hook logic)
* @param isVendorless if true, do not require vendor consent (for e.g. core modules)
* @param {Number=} gvlid gvlid of the module
* @param {string=} moduleName name of the module
* @param result
*/
export function deviceAccessHook(fn, isVendorless, gvlid, moduleName, result, {validate = validateRules} = {}) {
export function deviceAccessHook(fn, gvlid, moduleName, result, {validate = validateRules} = {}) {
result = Object.assign({}, {
hasEnforcementHook: true
});
if (!hasDeviceAccess()) {
logWarn('Device access is disabled by Publisher');
result.valid = false;
} else if (isVendorless && !strictStorageEnforcement) {
} else if (gvlid === VENDORLESS_GVLID && !strictStorageEnforcement) {
// for vendorless (core) storage, do not enforce rules unless strictStorageEnforcement is set
result.valid = true;
} else {
const consentData = gdprDataHandler.getConsentData();
if (shouldEnforce(consentData, 1, moduleName)) {
const curBidder = config.getCurrentBidder();
// Bidders have a copy of storage object with bidder code binded. Aliases will also pass the same bidder code when invoking storage functions and hence if alias tries to access device we will try to grab the gvl id for alias instead of original bidder
if (curBidder && (curBidder != moduleName) && adapterManager.aliasRegistry[curBidder] === moduleName) {
if (curBidder && (curBidder !== moduleName) && adapterManager.aliasRegistry[curBidder] === moduleName) {
gvlid = getGvlid(curBidder);
} else {
gvlid = getGvlid(moduleName) || gvlid;
}
const curModule = moduleName || curBidder;
let isAllowed = validate(purpose1Rule, consentData, curModule, gvlid, isVendorless ? () => true : undefined);
let isAllowed = validate(purpose1Rule, consentData, curModule, gvlid,);
if (isAllowed) {
result.valid = true;
} else {
Expand All @@ -221,7 +223,7 @@ export function deviceAccessHook(fn, isVendorless, gvlid, moduleName, result, {v
result.valid = true;
}
}
fn.call(this, isVendorless, gvlid, moduleName, result);
fn.call(this, gvlid, moduleName, result);
}

/**
Expand Down Expand Up @@ -314,7 +316,7 @@ export function enableAnalyticsHook(fn, config) {
}
config = config.filter(conf => {
const analyticsAdapterCode = conf.provider;
const gvlid = getGvlid(analyticsAdapterCode);
const gvlid = getGvlid(analyticsAdapterCode, conf);
const isAllowed = !!validateRules(purpose7Rule, consentData, analyticsAdapterCode, gvlid);
if (!isAllowed) {
analyticsBlocked.push(analyticsAdapterCode);
Expand Down
157 changes: 157 additions & 0 deletions modules/genericAnalyticsAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import AnalyticsAdapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js';
import {prefixLog, isPlainObject} from '../src/utils.js';
import * as CONSTANTS from '../src/constants.json';
import adapterManager from '../src/adapterManager.js';
import {ajaxBuilder} from '../src/ajax.js';

const DEFAULTS = {
batchSize: 1,
batchDelay: 100,
method: 'POST'
}

const TYPES = {
handler: 'function',
batchSize: 'number',
batchDelay: 'number',
gvlid: 'number',
}

const MAX_CALL_DEPTH = 20;

export function GenericAnalytics() {
const parent = AnalyticsAdapter({analyticsType: 'endpoint'});
const {logError, logWarn} = prefixLog('Generic analytics:');
let batch = [];
let callDepth = 0;
let options, handler, timer, translate;

function optionsAreValid(options) {
if (!options.url && !options.handler) {
logError('options must specify either `url` or `handler`')
return false;
}
if (options.hasOwnProperty('method') && !['GET', 'POST'].includes(options.method)) {
logError('options.method must be GET or POST');
return false;
}
for (const [field, type] of Object.entries(TYPES)) {
// eslint-disable-next-line valid-typeof
if (options.hasOwnProperty(field) && typeof options[field] !== type) {
logError(`options.${field} must be a ${type}`);
return false;
}
}
if (options.hasOwnProperty('events')) {
if (!isPlainObject(options.events)) {
logError('options.events must be an object');
return false;
}
for (const [event, handler] of Object.entries(options.events)) {
if (!CONSTANTS.EVENTS.hasOwnProperty(event)) {
logWarn(`options.events.${event} does not match any known Prebid event`);
if (typeof handler !== 'function') {
logError(`options.events.${event} must be a function`);
return false;
}
}
}
}
return true;
}

function processBatch() {
const currentBatch = batch;
batch = [];
callDepth++;
try {
// the pub-provided handler may inadvertently cause an infinite chain of events;
// even just logging an exception from it may cause an AUCTION_DEBUG event, that
// gets back to the handler, that throws another exception etc.
// to avoid the issue, put a cap on recursion
if (callDepth === MAX_CALL_DEPTH) {
logError('detected probable infinite recursion, discarding events', currentBatch);
}
if (callDepth >= MAX_CALL_DEPTH) {
return;
}
try {
handler(currentBatch);
} catch (e) {
logError('error executing options.handler', e);
}
} finally {
callDepth--;
}
}

function translator(eventHandlers) {
if (!eventHandlers) {
return (data) => data;
}
return function ({eventType, args}) {
if (eventHandlers.hasOwnProperty(eventType)) {
try {
return eventHandlers[eventType](args);
} catch (e) {
logError(`error executing options.events.${eventType}`, e);
}
}
}
}

return Object.assign(
Object.create(parent),
{
gvlid(config) {
return config?.options?.gvlid
},
enableAnalytics(config) {
if (optionsAreValid(config?.options || {})) {
options = Object.assign({}, DEFAULTS, config.options);
handler = options.handler || defaultHandler(options);
translate = translator(options.events);
parent.enableAnalytics.call(this, config);
}
},
track(event) {
if (event.eventType === CONSTANTS.EVENTS.AUCTION_INIT && event.args.hasOwnProperty('config')) {
// clean up auctionInit event
// TODO: remove this special case in v8
delete event.args.config;
}
const datum = translate(event);
if (datum != null) {
batch.push(datum);
if (timer != null) {
clearTimeout(timer);
timer = null;
}
if (batch.length >= options.batchSize) {
processBatch();
} else {
timer = setTimeout(processBatch, options.batchDelay);
}
}
}
}
)
}

export function defaultHandler({url, method, batchSize, ajax = ajaxBuilder()}) {
const callbacks = {
success() {},
error() {}
}
const extract = batchSize > 1 ? (events) => events : (events) => events[0];
const serialize = method === 'GET' ? (data) => ({data: JSON.stringify(data)}) : (data) => JSON.stringify(data);

return function (events) {
ajax(url, callbacks, serialize(extract(events)), {method})
}
}

adapterManager.registerAnalyticsAdapter({
adapter: GenericAnalytics(),
code: 'generic',
});
3 changes: 2 additions & 1 deletion modules/pubCommonId.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import * as events from '../src/events.js';
import CONSTANTS from '../src/constants.json';
import { getStorageManager } from '../src/storageManager.js';
import {timedAuctionHook} from '../src/utils/perfMetrics.js';
import {VENDORLESS_GVLID} from '../src/consentHandler.js';

const storage = getStorageManager({moduleName: 'pubCommonId'});
const storage = getStorageManager({moduleName: 'pubCommonId', gvlid: VENDORLESS_GVLID});

const ID_NAME = '_pubcid';
const OPTOUT_NAME = '_pubcid_optout';
Expand Down
2 changes: 2 additions & 0 deletions modules/pubProvidedIdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import {submodule} from '../src/hook.js';
import { logInfo, isArray } from '../src/utils.js';
import {VENDORLESS_GVLID} from '../src/consentHandler.js';

const MODULE_NAME = 'pubProvidedId';

Expand All @@ -18,6 +19,7 @@ export const pubProvidedIdSubmodule = {
* @type {string}
*/
name: MODULE_NAME,
gvlid: VENDORLESS_GVLID,

/**
* decode the stored id value for passing to bid request
Expand Down
4 changes: 3 additions & 1 deletion modules/sharedIdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { parseUrl, buildUrl, triggerPixel, logInfo, hasDeviceAccess, generateUUI
import {submodule} from '../src/hook.js';
import { coppaDataHandler } from '../src/adapterManager.js';
import {getStorageManager} from '../src/storageManager.js';
import {VENDORLESS_GVLID} from '../src/consentHandler.js';

export const storage = getStorageManager({moduleName: 'pubCommonId'});
export const storage = getStorageManager({moduleName: 'pubCommonId', gvlid: VENDORLESS_GVLID});
const COOKIE = 'cookie';
const LOCAL_STORAGE = 'html5';
const OPTOUT_NAME = '_pubcid_optout';
Expand Down Expand Up @@ -73,6 +74,7 @@ export const sharedIdSystemSubmodule = {
*/
name: 'sharedId',
aliasName: 'pubCommonId',
gvlid: VENDORLESS_GVLID,

/**
* decode the stored id value for passing to bid requests
Expand Down
8 changes: 8 additions & 0 deletions src/consentHandler.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import {isStr, timestamp} from './utils.js';
import {defer, GreedyPromise} from './utils/promise.js';

/**
* Placeholder gvlid for when vendor consent is not required. When this value is used as gvlid, the gdpr
* enforcement module will take it to mean "vendor consent was given".
*
* see https://github.com/prebid/Prebid.js/issues/8161
*/
export const VENDORLESS_GVLID = Object.freeze({});

export class ConsentHandler {
#enabled;
#data;
Expand Down
Loading

0 comments on commit 5a53df0

Please sign in to comment.