Skip to content

Commit 3aeab29

Browse files
cosenmarcosmenzer
andauthored
ID5 analytics adapter: initial release (#6871)
* Adding Markdown for the new id5 analytics module * #24 Adding a first untested implementation * #24 Adding some unit tess and refactoring * #24 Adding cleanup transformations, improvements and tests * #24 Improving on specs and implementation of cleanup * #24 Adding standard tracking of bidWon and cleanup of native creative * #24 More cleanup rules * #24 Using real URL instad of mock * #24 Typo * #24 Code review improvements Co-authored-by: Marco Cosentino <[email protected]> Co-authored-by: Scott Menzer <[email protected]>
1 parent 287f07c commit 3aeab29

File tree

3 files changed

+798
-0
lines changed

3 files changed

+798
-0
lines changed

modules/id5AnalyticsAdapter.js

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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+
}

modules/id5AnalyticsAdapter.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Overview
2+
Module Name: ID5 Analytics Adapter
3+
4+
Module Type: Analytics Adapter
5+
6+
Maintainer: [id5.io](https://id5.io)
7+
8+
# ID5 Universal ID
9+
10+
The ID5 Universal ID is a shared, neutral identifier that publishers and ad tech platforms can use to recognise users even in environments where 3rd party cookies are not available. The ID5 Universal ID is designed to respect users' privacy choices and publishers’ preferences throughout the advertising value chain. For more information about the ID5 Universal ID and detailed integration docs, please visit [our documentation](https://support.id5.io/portal/en/kb/articles/prebid-js-user-id-module).
11+
12+
# ID5 Analytics Registration
13+
14+
The ID5 Analytics Adapter is free to use during our Beta period, but requires a simple registration with ID5. Please visit [id5.io/universal-id](https://id5.io/universal-id) to sign up and request your ID5 Partner Number to get started. If you're already using the ID5 Universal ID, you may use your existing Partner Number with the analytics adapter.
15+
16+
The ID5 privacy policy is at [https://www.id5.io/platform-privacy-policy](https://www.id5.io/platform-privacy-policy).
17+
18+
## ID5 Analytics Configuration
19+
20+
First, make sure to add the ID5 Analytics submodule to your Prebid.js package with:
21+
22+
```
23+
gulp build --modules=...,id5AnalyticsAdapter
24+
```
25+
26+
The following configuration parameters are available:
27+
28+
```javascript
29+
pbjs.enableAnalytics({
30+
provider: 'id5Analytics',
31+
options: {
32+
partnerId: 1234, // change to the Partner Number you received from ID5
33+
eventsToTrack: ['auctionEnd','bidWon']
34+
}
35+
});
36+
```
37+
38+
| Parameter | Scope | Type | Description | Example |
39+
| --- | --- | --- | --- | --- |
40+
| provider | Required | String | The name of this module: `id5Analytics` | `id5Analytics` |
41+
| options.partnerId | Required | Number | This is the ID5 Partner Number obtained from registering with ID5. | `1234` |
42+
| options.eventsToTrack | Optional | Array of strings | Overrides the set of tracked events | `['auctionEnd','bidWon']` |

0 commit comments

Comments
 (0)