|
| 1 | +import buildAdapter from '../src/AnalyticsAdapter.js'; |
| 2 | +import CONSTANTS from '../src/constants.json'; |
| 3 | +import adapterManager from '../src/adapterManager.js'; |
| 4 | +import { ajax } from '../src/ajax.js'; |
| 5 | +import { logInfo, logError } from '../src/utils.js'; |
| 6 | +import events from '../src/events.js'; |
| 7 | + |
| 8 | +const { |
| 9 | + EVENTS: { |
| 10 | + AUCTION_END, |
| 11 | + TCF2_ENFORCEMENT, |
| 12 | + BID_WON, |
| 13 | + BID_VIEWABLE, |
| 14 | + AD_RENDER_FAILED |
| 15 | + } |
| 16 | +} = CONSTANTS |
| 17 | + |
| 18 | +const GVLID = 131; |
| 19 | + |
| 20 | +const STANDARD_EVENTS_TO_TRACK = [ |
| 21 | + AUCTION_END, |
| 22 | + TCF2_ENFORCEMENT, |
| 23 | + BID_WON, |
| 24 | +]; |
| 25 | + |
| 26 | +// These events cause the buffered events to be sent over |
| 27 | +const FLUSH_EVENTS = [ |
| 28 | + TCF2_ENFORCEMENT, |
| 29 | + AUCTION_END, |
| 30 | + BID_WON, |
| 31 | + BID_VIEWABLE, |
| 32 | + AD_RENDER_FAILED |
| 33 | +]; |
| 34 | + |
| 35 | +const CONFIG_URL_PREFIX = 'https://api.id5-sync.com/analytics' |
| 36 | +const TZ = new Date().getTimezoneOffset(); |
| 37 | +const PBJS_VERSION = $$PREBID_GLOBAL$$.version; |
| 38 | +const ID5_REDACTED = '__ID5_REDACTED__'; |
| 39 | +const isArray = Array.isArray; |
| 40 | + |
| 41 | +let id5Analytics = Object.assign(buildAdapter({analyticsType: 'endpoint'}), { |
| 42 | + // Keeps an array of events for each auction |
| 43 | + eventBuffer: {}, |
| 44 | + |
| 45 | + eventsToTrack: STANDARD_EVENTS_TO_TRACK, |
| 46 | + |
| 47 | + track: (event) => { |
| 48 | + const _this = id5Analytics; |
| 49 | + |
| 50 | + if (!event || !event.args) { |
| 51 | + return; |
| 52 | + } |
| 53 | + |
| 54 | + try { |
| 55 | + const auctionId = event.args.auctionId; |
| 56 | + _this.eventBuffer[auctionId] = _this.eventBuffer[auctionId] || []; |
| 57 | + |
| 58 | + // Collect events and send them in a batch when the auction ends |
| 59 | + const que = _this.eventBuffer[auctionId]; |
| 60 | + que.push(_this.makeEvent(event.eventType, event.args)); |
| 61 | + |
| 62 | + if (FLUSH_EVENTS.indexOf(event.eventType) >= 0) { |
| 63 | + // Auction ended. Send the batch of collected events |
| 64 | + _this.sendEvents(que); |
| 65 | + |
| 66 | + // From now on just send events to server side as they come |
| 67 | + que.push = (pushedEvent) => _this.sendEvents([pushedEvent]); |
| 68 | + } |
| 69 | + } catch (error) { |
| 70 | + logError('id5Analytics: ERROR', error); |
| 71 | + _this.sendErrorEvent(error); |
| 72 | + } |
| 73 | + }, |
| 74 | + |
| 75 | + sendEvents: (eventsToSend) => { |
| 76 | + const _this = id5Analytics; |
| 77 | + // By giving some content this will be automatically a POST |
| 78 | + eventsToSend.forEach((event) => |
| 79 | + ajax(_this.options.ingestUrl, null, JSON.stringify(event))); |
| 80 | + }, |
| 81 | + |
| 82 | + makeEvent: (event, payload) => { |
| 83 | + const _this = id5Analytics; |
| 84 | + const filteredPayload = deepTransformingClone(payload, |
| 85 | + transformFnFromCleanupRules(event)); |
| 86 | + return { |
| 87 | + source: 'pbjs', |
| 88 | + event, |
| 89 | + payload: filteredPayload, |
| 90 | + partnerId: _this.options.partnerId, |
| 91 | + meta: { |
| 92 | + sampling: _this.options.id5Sampling, |
| 93 | + pbjs: PBJS_VERSION, |
| 94 | + tz: TZ, |
| 95 | + } |
| 96 | + }; |
| 97 | + }, |
| 98 | + |
| 99 | + sendErrorEvent: (error) => { |
| 100 | + const _this = id5Analytics; |
| 101 | + _this.sendEvents([ |
| 102 | + _this.makeEvent('analyticsError', { |
| 103 | + message: error.message, |
| 104 | + stack: error.stack, |
| 105 | + }) |
| 106 | + ]); |
| 107 | + }, |
| 108 | + |
| 109 | + random: () => Math.random(), |
| 110 | +}); |
| 111 | + |
| 112 | +const ENABLE_FUNCTION = (config) => { |
| 113 | + const _this = id5Analytics; |
| 114 | + _this.options = (config && config.options) || {}; |
| 115 | + |
| 116 | + const partnerId = _this.options.partnerId; |
| 117 | + if (typeof partnerId !== 'number') { |
| 118 | + logError('id5Analytics: partnerId in config.options must be a number representing the id5 partner ID'); |
| 119 | + return; |
| 120 | + } |
| 121 | + |
| 122 | + ajax(`${CONFIG_URL_PREFIX}/${partnerId}/pbjs`, (result) => { |
| 123 | + logInfo('id5Analytics: Received from configuration endpoint', result); |
| 124 | + |
| 125 | + const configFromServer = JSON.parse(result); |
| 126 | + |
| 127 | + const sampling = _this.options.id5Sampling = |
| 128 | + typeof configFromServer.sampling === 'number' ? configFromServer.sampling : 0; |
| 129 | + |
| 130 | + if (typeof configFromServer.ingestUrl !== 'string') { |
| 131 | + logError('id5Analytics: cannot find ingestUrl in config endpoint response; no analytics will be available'); |
| 132 | + return; |
| 133 | + } |
| 134 | + _this.options.ingestUrl = configFromServer.ingestUrl; |
| 135 | + |
| 136 | + // 3-way fallback for which events to track: server > config > standard |
| 137 | + _this.eventsToTrack = configFromServer.eventsToTrack || _this.options.eventsToTrack || STANDARD_EVENTS_TO_TRACK; |
| 138 | + _this.eventsToTrack = isArray(_this.eventsToTrack) ? _this.eventsToTrack : STANDARD_EVENTS_TO_TRACK; |
| 139 | + |
| 140 | + logInfo('id5Analytics: Configuration is', _this.options); |
| 141 | + logInfo('id5Analytics: Tracking events', _this.eventsToTrack); |
| 142 | + if (sampling > 0 && _this.random() < (1 / sampling)) { |
| 143 | + // Init the module only if we got lucky |
| 144 | + logInfo('id5Analytics: Selected by sampling. Starting up!') |
| 145 | + |
| 146 | + // Clean start |
| 147 | + _this.eventBuffer = {}; |
| 148 | + |
| 149 | + // Replay all events until now |
| 150 | + if (!config.disablePastEventsProcessing) { |
| 151 | + events.getEvents().forEach((event) => { |
| 152 | + if (event && _this.eventsToTrack.indexOf(event.eventType) >= 0) { |
| 153 | + _this.track(event); |
| 154 | + } |
| 155 | + }); |
| 156 | + } |
| 157 | + |
| 158 | + // Merge in additional cleanup rules |
| 159 | + if (configFromServer.additionalCleanupRules) { |
| 160 | + const newRules = configFromServer.additionalCleanupRules; |
| 161 | + _this.eventsToTrack.forEach((key) => { |
| 162 | + // Some protective checks in case we mess up server side |
| 163 | + if ( |
| 164 | + isArray(newRules[key]) && |
| 165 | + newRules[key].every((eventRules) => |
| 166 | + isArray(eventRules.match) && |
| 167 | + (eventRules.apply in TRANSFORM_FUNCTIONS)) |
| 168 | + ) { |
| 169 | + logInfo('id5Analytics: merging additional cleanup rules for event ' + key); |
| 170 | + CLEANUP_RULES[key].push(...newRules[key]); |
| 171 | + } |
| 172 | + }); |
| 173 | + } |
| 174 | + |
| 175 | + // Register to the events of interest |
| 176 | + _this.handlers = {}; |
| 177 | + _this.eventsToTrack.forEach((eventType) => { |
| 178 | + const handler = _this.handlers[eventType] = (args) => |
| 179 | + _this.track({ eventType, args }); |
| 180 | + events.on(eventType, handler); |
| 181 | + }); |
| 182 | + } |
| 183 | + }); |
| 184 | + |
| 185 | + // Make only one init possible within a lifecycle |
| 186 | + _this.enableAnalytics = () => {}; |
| 187 | +}; |
| 188 | + |
| 189 | +id5Analytics.enableAnalytics = ENABLE_FUNCTION; |
| 190 | +id5Analytics.disableAnalytics = () => { |
| 191 | + const _this = id5Analytics; |
| 192 | + // Un-register to the events of interest |
| 193 | + _this.eventsToTrack.forEach((eventType) => { |
| 194 | + if (_this.handlers && _this.handlers[eventType]) { |
| 195 | + events.off(eventType, _this.handlers[eventType]); |
| 196 | + } |
| 197 | + }); |
| 198 | + |
| 199 | + // Make re-init possible. Work around the fact that past events cannot be forgotten |
| 200 | + _this.enableAnalytics = (config) => { |
| 201 | + config.disablePastEventsProcessing = true; |
| 202 | + ENABLE_FUNCTION(config); |
| 203 | + }; |
| 204 | +}; |
| 205 | + |
| 206 | +adapterManager.registerAnalyticsAdapter({ |
| 207 | + adapter: id5Analytics, |
| 208 | + code: 'id5Analytics', |
| 209 | + gvlid: GVLID |
| 210 | +}); |
| 211 | + |
| 212 | +export default id5Analytics; |
| 213 | + |
| 214 | +function redact(obj, key) { |
| 215 | + obj[key] = ID5_REDACTED; |
| 216 | +} |
| 217 | + |
| 218 | +function erase(obj, key) { |
| 219 | + delete obj[key]; |
| 220 | +} |
| 221 | + |
| 222 | +// The transform function matches against a path and applies |
| 223 | +// required transformation if match is found. |
| 224 | +function deepTransformingClone(obj, transform, currentPath = []) { |
| 225 | + const result = isArray(obj) ? [] : {}; |
| 226 | + const recursable = typeof obj === 'object' && obj !== null; |
| 227 | + if (recursable) { |
| 228 | + const keys = Object.keys(obj); |
| 229 | + if (keys.length > 0) { |
| 230 | + keys.forEach((key) => { |
| 231 | + const newPath = currentPath.concat(key); |
| 232 | + result[key] = deepTransformingClone(obj[key], transform, newPath); |
| 233 | + transform(newPath, result, key); |
| 234 | + }); |
| 235 | + return result; |
| 236 | + } |
| 237 | + } |
| 238 | + return obj; |
| 239 | +} |
| 240 | + |
| 241 | +// Every set of rules is an object where "match" is an array and |
| 242 | +// "apply" is the function to apply in case of match. The function to apply |
| 243 | +// takes (obj, prop) and transforms property "prop" in object "obj". |
| 244 | +// The "match" is an array of path parts. Each part is either a string or an array. |
| 245 | +// In case of array, it represents alternatives which all would match. |
| 246 | +// Special path part '*' matches any subproperty |
| 247 | +const CLEANUP_RULES = {}; |
| 248 | +CLEANUP_RULES[AUCTION_END] = [{ |
| 249 | + match: [['adUnits', 'bidderRequests'], '*', 'bids', '*', ['userId', 'crumbs'], '*'], |
| 250 | + apply: 'redact' |
| 251 | +}, { |
| 252 | + match: [['adUnits', 'bidderRequests'], '*', 'bids', '*', 'userIdAsEids', '*', 'uids', '*', ['id', 'ext']], |
| 253 | + apply: 'redact' |
| 254 | +}, { |
| 255 | + match: ['bidderRequests', '*', 'gdprConsent', 'vendorData'], |
| 256 | + apply: 'erase' |
| 257 | +}, { |
| 258 | + match: ['bidsReceived', '*', ['ad', 'native']], |
| 259 | + apply: 'erase' |
| 260 | +}, { |
| 261 | + match: ['noBids', '*', ['userId', 'crumbs'], '*'], |
| 262 | + apply: 'redact' |
| 263 | +}, { |
| 264 | + match: ['noBids', '*', 'userIdAsEids', '*', 'uids', '*', ['id', 'ext']], |
| 265 | + apply: 'redact' |
| 266 | +}]; |
| 267 | + |
| 268 | +CLEANUP_RULES[BID_WON] = [{ |
| 269 | + match: [['ad', 'native']], |
| 270 | + apply: 'erase' |
| 271 | +}]; |
| 272 | + |
| 273 | +const TRANSFORM_FUNCTIONS = { |
| 274 | + 'redact': redact, |
| 275 | + 'erase': erase, |
| 276 | +}; |
| 277 | + |
| 278 | +// Builds a rule function depending on the event type |
| 279 | +function transformFnFromCleanupRules(eventType) { |
| 280 | + const rules = CLEANUP_RULES[eventType] || []; |
| 281 | + return (path, obj, key) => { |
| 282 | + for (let i = 0; i < rules.length; i++) { |
| 283 | + let match = true; |
| 284 | + const ruleMatcher = rules[i].match; |
| 285 | + const transformation = rules[i].apply; |
| 286 | + if (ruleMatcher.length !== path.length) { |
| 287 | + continue; |
| 288 | + } |
| 289 | + for (let fragment = 0; fragment < ruleMatcher.length && match; fragment++) { |
| 290 | + const choices = makeSureArray(ruleMatcher[fragment]); |
| 291 | + match = !choices.every((choice) => choice !== '*' && path[fragment] !== choice); |
| 292 | + } |
| 293 | + if (match) { |
| 294 | + const transformfn = TRANSFORM_FUNCTIONS[transformation]; |
| 295 | + transformfn(obj, key); |
| 296 | + break; |
| 297 | + } |
| 298 | + } |
| 299 | + }; |
| 300 | +} |
| 301 | + |
| 302 | +function makeSureArray(object) { |
| 303 | + return isArray(object) ? object : [object]; |
| 304 | +} |
0 commit comments