Skip to content

Commit

Permalink
Merge pull request #20 from guardian/kc-analytics
Browse files Browse the repository at this point in the history
Create analytics adapter to send data to data lake (eventually)
  • Loading branch information
kelvin-chappell authored Mar 27, 2018
2 parents 288d4de + 2e17ca7 commit 9934ec1
Show file tree
Hide file tree
Showing 6 changed files with 370 additions and 10 deletions.
15 changes: 8 additions & 7 deletions build/dist/prebid.js

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion modifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ These are the ways in which the Guardian optimised build differs from the [gener
* The [Sonobi bid adapter](https://github.com/guardian/Prebid.js/blob/master/modules/sonobiBidAdapter.js):
* has an extra request parameter, `gmgt`, holding AppNexus targeting key-values
* has a customised `pv` parameter, holding the Ophan-generated pageview ID
* We have built a simple console-logging [analytics adapter](https://github.com/guardian/Prebid.js/blob/master/modules/consoleLoggingAnalyticsAdapter.js).
* We have built two analytics adapters:
* an [adapter](https://github.com/guardian/Prebid.js/blob/master/modules/guAnalyticsAdapter.js) to send analytics to the data lake
* a simple console-logging [adapter](https://github.com/guardian/Prebid.js/blob/master/modules/consoleLoggingAnalyticsAdapter.js)
3 changes: 2 additions & 1 deletion modules.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"guIndexExchangeBidAdapter",
"trustxBidAdapter",
"improvedigitalBidAdapter",
"appnexusBidAdapter"
"appnexusBidAdapter",
"guAnalyticsAdapter"
]
188 changes: 188 additions & 0 deletions modules/guAnalyticsAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// see http://prebid.org/dev-docs/integrate-with-the-prebid-analytics-api.html
import adapter from 'src/AnalyticsAdapter';
import CONSTANTS from 'src/constants.json';
import * as adaptermanager from 'src/adaptermanager';
import {ajax} from 'src/ajax';

const analyticsType = 'endpoint';
const QUEUE_TIMEOUT = 4000;

let analyticsAdapter = Object.assign(adapter({analyticsType}),
{
track({eventType, args}) {
if (!analyticsAdapter.context) {
return;
}
let handler = null;
switch (eventType) {
case CONSTANTS.EVENTS.AUCTION_INIT:
if (analyticsAdapter.context.queue) {
analyticsAdapter.context.queue.init();
}
handler = trackAuctionInit;
break;
case CONSTANTS.EVENTS.BID_REQUESTED:
handler = trackBidRequest;
break;
case CONSTANTS.EVENTS.BID_RESPONSE:
handler = trackBidResponse;
break;
case CONSTANTS.EVENTS.BID_TIMEOUT:
handler = trackBidTimeout;
break;
case CONSTANTS.EVENTS.AUCTION_END:
handler = trackAuctionEnd;
break;
}
if (handler) {
let events = handler(args);
if (analyticsAdapter.context.queue) {
analyticsAdapter.context.queue.push(events);
}
if (eventType === CONSTANTS.EVENTS.AUCTION_END) {
sendAll();
}
}
}
});

function buildRequestTemplate(options) {
return {
pv: options.pv
}
}

function sendAll() {
let events = analyticsAdapter.context.queue.popAll();
if (events.length !== 0) {
let req = Object.assign({}, analyticsAdapter.context.requestTemplate, {hb_ev: events});
ajax(
`//${analyticsAdapter.context.host}/commercial/api/hb`,
() => {
},
JSON.stringify(req),
{
method: 'PUT',
contentType: 'application/json; charset=utf-8'
}
);
}
}

function trackAuctionInit(args) {
analyticsAdapter.context.auctionTimeStart = Date.now();
const event = createHbEvent(undefined, 'init', undefined, args.auctionId);
return [event];
}

function trackBidRequest(args) {
return args.bids.map(bid =>
createHbEvent(args.bidderCode, 'request', bid.adUnitCode));
}

function trackBidResponse(args) {
const event = createHbEvent(args.bidderCode, 'response', args.adUnitCode, undefined, args.timeToRespond);
return [event];
}

function trackBidTimeout(args) {
const timeToRespond = Date.now() - analyticsAdapter.context.auctionTimeStart;
return args.map(bid => createHbEvent(bid.bidder, 'timeout', bid.adUnitCode, undefined, timeToRespond));
}

function trackAuctionEnd(args) {
const duration = Date.now() - analyticsAdapter.context.auctionTimeStart;
const event = createHbEvent(undefined, 'end', undefined, args.auctionId, duration);
return [event];
}

function createHbEvent(bidder, event, slotId, auctionId, timeToRespond, args) {
let ev = {ev: event};
if (bidder) {
ev.n = bidder
}
if (slotId) {
ev.sid = slotId;
}
if (auctionId) {
ev.aid = auctionId;
}
if (timeToRespond) {
ev.ttr = timeToRespond;
}
if (args) {
ev.args = args;
}
return ev;
}

export function ExpiringQueue(callback, ttl) {
let queue = [];
let timeoutId;

this.push = (event) => {
if (event instanceof Array) {
queue.push.apply(queue, event);
} else {
queue.push(event);
}
reset();
};

this.popAll = () => {
let result = queue;
queue = [];
reset();
return result;
};

/**
* For test/debug purposes only
* @return {Array}
*/
this.peekAll = () => {
return queue;
};

this.init = reset;

function reset() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
if (queue.length) {
callback();
}
}, ttl);
}
}

analyticsAdapter.context = {};

analyticsAdapter.originEnableAnalytics = analyticsAdapter.enableAnalytics;

analyticsAdapter.enableAnalytics = (config) => {
if (!config.options.host) {
utils.logError('host is not defined. Analytics won\'t work');
return;
}
if (!config.options.pv) {
utils.logError('pv is not defined. Analytics won\'t work');
return;
}
analyticsAdapter.context = {
host: config.options.host,
pv: config.options.pv,
requestTemplate: buildRequestTemplate(config.options),
queue: new ExpiringQueue(sendAll, QUEUE_TIMEOUT)
};
analyticsAdapter.originEnableAnalytics(config);
};

adaptermanager.registerAnalyticsAdapter({
adapter: analyticsAdapter,
code: 'gu'
});

export default analyticsAdapter;
2 changes: 1 addition & 1 deletion src/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function ajaxBuilder(timeout = 3000) {
}
x.setRequestHeader('Content-Type', options.contentType || 'text/plain');
}
if (method === 'POST' && data) {
if ((method === 'PUT' || method === 'POST') && data) {
x.send(data);
} else {
x.send();
Expand Down
168 changes: 168 additions & 0 deletions test/spec/modules/guAnalyticsAdapter_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import analyticsAdapter from 'modules/guAnalyticsAdapter';
import {expect} from 'chai';
import adaptermanager from 'src/adaptermanager';
import * as ajax from 'src/ajax';
import CONSTANTS from 'src/constants.json';

const events = require('../../../src/events');

describe('Gu analytics adapter', () => {
let sandbox;
let timer;

const REQUEST1 = {
bidderCode: 'b1',
auctionId: '5018eb39-f900-4370-b71e-3bb5b48d324f',
bidderRequestId: '1a6fc81528d0f7',
bids: [{
bidder: 'b1',
params: {},
adUnitCode: 'slot-1',
transactionId: 'de90df62-7fd0-4fbc-8787-92d133a7dc06',
sizes: [[300, 250]],
bidId: '208750227436c1',
bidderRequestId: '1a6fc81528d0f7',
auctionId: '5018eb39-f900-4370-b71e-3bb5b48d324f'
}],
auctionStart: 1509369418387,
timeout: 3000,
start: 1509369418389
};

const REQUEST2 = {
bidderCode: 'b2',
auctionId: '5018eb39-f900-4370-b71e-3bb5b48d324f',
bidderRequestId: '1a6fc81528d0f6',
bids: [{
bidder: 'b2',
params: {},
adUnitCode: 'slot-1',
transactionId: 'de90df62-7fd0-4fbc-8787-92d133a7dc06',
sizes: [[300, 250]],
bidId: '208750227436c2',
bidderRequestId: '1a6fc81528d0f6',
auctionId: '5018eb39-f900-4370-b71e-3bb5b48d324f'
}],
auctionStart: 1509369418387,
timeout: 3000,
start: 1509369418389
};

const RESPONSE = {
bidderCode: 'b1',
width: 300,
height: 250,
statusMessage: 'Bid available',
adId: '208750227436c1',
mediaType: 'banner',
cpm: 0.015,
ad: '<!-- tag goes here -->',
auctionId: '5018eb39-f900-4370-b71e-3bb5b48d324f',
responseTimestamp: 1509369418832,
requestTimestamp: 1509369418389,
bidder: 'adapter',
adUnitCode: 'slot-1',
timeToRespond: 443,
size: '300x250'
};

before(() => {
sandbox = sinon.sandbox.create();
timer = sandbox.useFakeTimers(0);
});

after(() => {
timer.restore();
sandbox.restore();
analyticsAdapter.disableAnalytics();
});

beforeEach(() => {
sandbox.stub(events, 'getEvents').callsFake(() => {
return []
});
});

afterEach(() => {
events.getEvents.restore();
});

it('should be configurable', () => {
adaptermanager.registerAnalyticsAdapter({
code: 'gu',
adapter: analyticsAdapter
});

adaptermanager.enableAnalytics({
provider: 'gu',
options: {
host: 'localhost:9000',
pv: 'pv1234567'
}
});

expect(analyticsAdapter.context).to.have.property('host', 'localhost:9000');
expect(analyticsAdapter.context).to.have.property('pv', 'pv1234567');
});

it('should handle auction init event', () => {
events.emit(CONSTANTS.EVENTS.AUCTION_INIT, {
auctionId: '5018eb39-f900-4370-b71e-3bb5b48d324f',
config: {},
timeout: 3000
});
const ev = analyticsAdapter.context.queue.peekAll();
expect(ev).to.have.length(1);
expect(ev[0]).to.be.eql({ev: 'init', aid: '5018eb39-f900-4370-b71e-3bb5b48d324f'});
});

it('should handle bid request events', () => {
events.emit(CONSTANTS.EVENTS.BID_REQUESTED, REQUEST1);
events.emit(CONSTANTS.EVENTS.BID_REQUESTED, REQUEST2);
const ev = analyticsAdapter.context.queue.peekAll();
expect(ev).to.have.length(3);
expect(ev[1]).to.be.eql({ev: 'request', n: 'b1', sid: 'slot-1'});
expect(ev[2]).to.be.eql({ev: 'request', n: 'b2', sid: 'slot-1'});
});

it('should handle bid response event', () => {
events.emit(CONSTANTS.EVENTS.BID_RESPONSE, RESPONSE);
const ev = analyticsAdapter.context.queue.peekAll();
expect(ev).to.have.length(4);
expect(ev[3]).to.be.eql({
ev: 'response',
n: 'b1',
sid: 'slot-1',
ttr: 443
});
});

it('should handle bid timeout event', () => {
timer.tick(444);
events.emit(CONSTANTS.EVENTS.BID_TIMEOUT, [{
bidId: "208750227436c1",
bidder: "b2",
adUnitCode: "slot-1",
auctionId: "5018eb39-f900-4370-b71e-3bb5b48d324f"
}]);
const ev = analyticsAdapter.context.queue.peekAll();
expect(ev).to.have.length(5);
expect(ev[4]).to.be.eql({
ev: 'timeout',
n: 'b2',
sid: 'slot-1',
ttr: 444
});
});

it('should handle auction end event', () => {
timer.tick(3);
const ajaxStub = sandbox.stub(ajax, 'ajax');
events.emit(CONSTANTS.EVENTS.AUCTION_END, RESPONSE);
let ev = analyticsAdapter.context.queue.peekAll();
expect(ev).to.have.length(0);
expect(ajaxStub.called).to.be.equal(true);
ev = JSON.parse(ajaxStub.secondCall.args[2]).hb_ev;
expect(ev[5]).to.be.eql({ev: 'end', aid: '5018eb39-f900-4370-b71e-3bb5b48d324f', ttr: 447});
});
});

0 comments on commit 9934ec1

Please sign in to comment.