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

Generic Analytics Adapter: initial release #9134

Merged
merged 7 commits into from
Nov 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Copy link
Collaborator

Choose a reason for hiding this comment

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

these infinite loops have occurred before, eg https://github.com/prebid/Prebid.js/pull/7805/files ; should we more generally defend against them?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There is a more general prevention mechanism in #9113. That also allows the pub to set which events should be forwarded to any analytics adapter. However, adapters can choose to ignore some or circumvent the default event tracking system; so in general, the only way to find out which events are tracked is reading code. For this particular adapter though control is entirely on the publisher.

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

is it straightforward to see which events a particular analytics adapter subscribes to?

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