diff --git a/modules/adkernelAdnAnalyticsAdapter.js b/modules/adkernelAdnAnalyticsAdapter.js new file mode 100644 index 00000000000..9402f51d1b6 --- /dev/null +++ b/modules/adkernelAdnAnalyticsAdapter.js @@ -0,0 +1,365 @@ +import adapter from 'src/AnalyticsAdapter'; +import CONSTANTS from 'src/constants.json'; +import adaptermanager from 'src/adaptermanager'; +import {parse} from 'src/url'; +import * as utils from 'src/utils'; +import {ajax} from 'src/ajax'; + +const ANALYTICS_VERSION = '1.0.0'; +const DEFAULT_QUEUE_TIMEOUT = 4000; +const DEFAULT_HOST = 'tag.adkernel.com'; + +const ADK_HB_EVENTS = { + AUCTION_INIT: 'auctionInit', + BID_REQUEST: 'bidRequested', + BID_RESPONSE: 'bidResponse', + BID_WON: 'bidWon', + AUCTION_END: 'auctionEnd', + TIMEOUT: 'adapterTimedOut' +}; + +function buildRequestTemplate(pubId) { + const url = utils.getTopWindowUrl(); + const ref = utils.getTopWindowReferrer(); + const topLocation = utils.getTopWindowLocation(); + + return { + ver: ANALYTICS_VERSION, + domain: topLocation.hostname, + path: topLocation.pathname, + accId: pubId, + env: { + screen: { + w: window.screen.width, + h: window.screen.height + }, + lang: navigator.language + }, + src: getUmtSource(url, ref) + } +} + +let analyticsAdapter = Object.assign(adapter({analyticsType: 'endpoint'}), + { + 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_WON: + handler = trackBidWon; + 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(); + } + } + } + }); + +analyticsAdapter.context = {}; + +analyticsAdapter.originEnableAnalytics = analyticsAdapter.enableAnalytics; + +analyticsAdapter.enableAnalytics = (config) => { + if (!config.options.pubId) { + utils.logError('PubId is not defined. Analytics won\'t work'); + return; + } + analyticsAdapter.context = { + host: config.options.host || DEFAULT_HOST, + pubId: config.options.pubId, + requestTemplate: buildRequestTemplate(config.options.pubId), + queue: new ExpiringQueue(sendAll, config.options.queueTimeout || DEFAULT_QUEUE_TIMEOUT) + }; + analyticsAdapter.originEnableAnalytics(config); +}; + +adaptermanager.registerAnalyticsAdapter({ + adapter: analyticsAdapter, + code: 'adkernelAdn' +}); + +export default analyticsAdapter; + +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}/hb-analytics`, () => { + }, JSON.stringify(req)); + } +} + +function trackAuctionInit() { + analyticsAdapter.context.auctionTimeStart = Date.now(); + const event = createHbEvent(undefined, ADK_HB_EVENTS.AUCTION_INIT); + return [event]; +} + +function trackBidRequest(args) { + return args.bids.map(bid => + createHbEvent(args.bidderCode, ADK_HB_EVENTS.BID_REQUEST, bid.placementCode)); +} + +function trackBidResponse(args) { + const event = createHbEvent(args.bidderCode, ADK_HB_EVENTS.BID_RESPONSE, + args.adUnitCode, args.cpm, args.timeToRespond / 1000); + return [event]; +} + +function trackBidWon(args) { + const event = createHbEvent(args.bidderCode, ADK_HB_EVENTS.BID_WON, args.adUnitCode, args.cpm); + return [event]; +} + +function trackAuctionEnd(args) { + const event = createHbEvent(undefined, ADK_HB_EVENTS.AUCTION_END, undefined, + undefined, (Date.now() - analyticsAdapter.context.auctionTimeStart) / 1000); + return [event]; +} + +function trackBidTimeout(args) { + return args.map(bidderName => createHbEvent(bidderName, ADK_HB_EVENTS.TIMEOUT)); +} + +function createHbEvent(adapter, event, tagid = undefined, value = 0, time = 0) { + let ev = { event: event }; + if (adapter) { + ev.adapter = adapter + } + if (tagid) { + ev.tagid = tagid; + } + if (value) { + ev.val = value; + } + if (time) { + ev.time = time; + } + return ev; +} + +const UTM_TAGS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'utm_c1', 'utm_c2', 'utm_c3', 'utm_c4', 'utm_c5']; +const ADKERNEL_PREBID_KEY = 'adk_dpt_analytics'; +const DIRECT = '(direct)'; +const REFERRAL = '(referral)'; +const ORGANIC = '(organic)'; + +export function getUmtSource(pageUrl, referrer) { + let prevUtm = getPreviousTrafficSource(); + let currUtm = getCurrentTrafficSource(pageUrl, referrer); + let [updated, actual] = chooseActualUtm(prevUtm, currUtm); + if (updated) { + storeUtm(actual); + } + return actual; + + function getPreviousTrafficSource() { + let val = localStorage.getItem(ADKERNEL_PREBID_KEY); + if (!val) { + return getDirect(); + } + return JSON.parse(val); + } + + function getCurrentTrafficSource(pageUrl, referrer) { + var source = getUTM(pageUrl); + if (source) { + return source; + } + if (referrer) { + let se = getSearchEngine(referrer); + if (se) { + return asUtm(se, ORGANIC, ORGANIC); + } + let parsedUrl = parse(pageUrl); + let [refHost, refPath] = getReferrer(referrer); + if (refHost && refHost !== parsedUrl.hostname) { + return asUtm(refHost, REFERRAL, REFERRAL, '', refPath); + } + } + return getDirect(); + } + + function getSearchEngine(pageUrl) { + let engines = { + 'google': /^https?\:\/\/(?:www\.)?(?:google\.(?:com?\.)?(?:com|cat|[a-z]{2})|g.cn)\//i, + 'yandex': /^https?\:\/\/(?:www\.)?ya(?:ndex\.(?:com|net)?\.?(?:asia|mobi|org|[a-z]{2})?|\.ru)\//i, + 'bing': /^https?\:\/\/(?:www\.)?bing\.com\//i, + 'duckduckgo': /^https?\:\/\/(?:www\.)?duckduckgo\.com\//i, + 'ask': /^https?\:\/\/(?:www\.)?ask\.com\//i, + 'yahoo': /^https?\:\/\/(?:[-a-z]+\.)?(?:search\.)?yahoo\.com\//i + }; + + for (let engine in engines) { + if (engines.hasOwnProperty(engine) && engines[engine].test(pageUrl)) { + return engine; + } + } + } + + function getReferrer(referrer) { + let ref = parse(referrer); + return [ref.hostname, ref.pathname]; + } + + function getUTM(pageUrl) { + let urlParameters = parse(pageUrl).search; + if (!urlParameters['utm_campaign'] || !urlParameters['utm_source']) { + return; + } + let utmArgs = []; + for (let utmTagName of UTM_TAGS) { + let utmValue = urlParameters[utmTagName] || ''; + utmArgs.push(utmValue); + } + return asUtm.apply(this, utmArgs); + } + + function getDirect() { + return asUtm(DIRECT, DIRECT, DIRECT); + } + + function storeUtm(utm) { + let val = JSON.stringify(utm); + localStorage.setItem(ADKERNEL_PREBID_KEY, val); + } + + function asUtm(source, medium, campaign, term = '', content = '', c1 = '', c2 = '', c3 = '', c4 = '', c5 = '') { + let result = { + source: source, + medium: medium, + campaign: campaign + }; + if (term) { + result.term = term; + } + if (content) { + result.content = content; + } + if (c1) { + result.c1 = c1; + } + if (c2) { + result.c2 = c2; + } + if (c3) { + result.c3 = c3; + } + if (c4) { + result.c4 = c4; + } + if (c5) { + result.c5 = c5; + } + return result; + } + + function chooseActualUtm(prev, curr) { + if (ord(prev) < ord(curr)) { + return [true, curr]; + } if (ord(prev) > ord(curr)) { + return [false, prev]; + } else { + if (prev.campaign === REFERRAL && prev.content !== curr.content) { + return [true, curr]; + } else if (prev.campaign === ORGANIC && prev.source !== curr.source) { + return [true, curr]; + } else if (isCampaignTraffic(prev) && (prev.campaign !== curr.campaign || prev.source !== curr.source)) { + return [true, curr]; + } + } + return [false, prev]; + } + + function ord(utm) { + switch (utm.campaign) { + case DIRECT: + return 0; + case ORGANIC: + return 1; + case REFERRAL: + return 2; + default: + return 3; + } + } + + function isCampaignTraffic(utm) { + return [DIRECT, REFERRAL, ORGANIC].indexOf(utm.campaign) === -1; + } +} + +/** + * Expiring queue implementation. Fires callback on elapsed timeout since last last update or creation. + * @param callback + * @param ttl + * @constructor + */ +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); + } +} diff --git a/modules/adkernelAdnAnalyticsAdapter.md b/modules/adkernelAdnAnalyticsAdapter.md new file mode 100644 index 00000000000..94219e5ec8d --- /dev/null +++ b/modules/adkernelAdnAnalyticsAdapter.md @@ -0,0 +1,22 @@ +# Overview +Module Name: Adkernel ADN Analytics Adapter + +Module Type: Analytics Adapter + +Maintainer: denis@adkernel.com + +# Description + +Analytics adapter for Adkernel Ad Delivery Network. Contact contact@adkernel.com for information. + +# Test Parameters + +``` +{ + provider: 'adkernelAdn', + options : { + pubId : 50357, //id provided by adkernel + host: 'dsp-staging.adkernel.com' //optional host for validation purposes + } +} +``` diff --git a/test/spec/modules/adkernelAdnAnalyticsAdapter_spec.js b/test/spec/modules/adkernelAdnAnalyticsAdapter_spec.js new file mode 100644 index 00000000000..e71689868f7 --- /dev/null +++ b/test/spec/modules/adkernelAdnAnalyticsAdapter_spec.js @@ -0,0 +1,250 @@ +import analyticsAdapter, {ExpiringQueue, getUmtSource} from 'modules/adkernelAdnAnalyticsAdapter'; +import {expect} from 'chai'; +import adaptermanager from 'src/adaptermanager'; +import * as events from 'src/events'; +import * as ajax from 'src/ajax'; +import CONSTANTS from 'src/constants.json'; + +const DIRECT = { + source: '(direct)', + medium: '(direct)', + campaign: '(direct)' +}; +const REFERRER = { + source: 'lander.com', + medium: '(referral)', + campaign: '(referral)', + content: '/lander.html' +}; +const GOOGLE_ORGANIC = { + source: 'google', + medium: '(organic)', + campaign: '(organic)' +}; +const CAMPAIGN = { + source: 'adkernel', + medium: 'email', + campaign: 'new_campaign', + c1: '1', + c2: '2', + c3: '3', + c4: '4', + c5: '5' + +}; +describe('', () => { + let sandbox; + + before(() => { + sandbox = sinon.sandbox.create(); + }); + + after(() => { + sandbox.restore(); + }); + + describe('UTM source parser', () => { + let stubSetItem; + let stubGetItem; + + before(() => { + stubSetItem = sandbox.stub(localStorage, 'setItem'); + stubGetItem = sandbox.stub(localStorage, 'getItem'); + }); + + afterEach(() => { + sandbox.reset(); + }); + + it('should parse first direct visit as (direct)', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(undefined); + stubSetItem.returns(undefined); + let source = getUmtSource('http://example.com'); + expect(source).to.be.eql(DIRECT); + }); + + it('should respect past campaign visits before direct', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(JSON.stringify(CAMPAIGN)); + stubSetItem.returns(undefined); + let source = getUmtSource('http://example.com'); + expect(source).to.be.eql(CAMPAIGN); + }); + + it('should parse visit from google as organic', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(undefined); + stubSetItem.returns(undefined); + let source = getUmtSource('http://example.com', 'https://www.google.com/search?q=pikachu'); + expect(source).to.be.eql(GOOGLE_ORGANIC); + }); + + it('should respect previous campaign visit before organic', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(JSON.stringify(CAMPAIGN)); + stubSetItem.returns(undefined); + let source = getUmtSource('http://example.com', 'https://www.google.com/search?q=pikachu'); + expect(source).to.be.eql(CAMPAIGN); + }); + + it('should parse referral visit', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(undefined); + stubSetItem.returns(undefined); + let source = getUmtSource('http://example.com', 'http://lander.com/lander.html'); + expect(source).to.be.eql(REFERRER); + }); + + it('should respect previous campaign visit before referral', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(JSON.stringify(CAMPAIGN)); + stubSetItem.returns(undefined); + let source = getUmtSource('http://example.com', 'https://www.google.com/search?q=pikachu'); + expect(source).to.be.eql(CAMPAIGN); + }); + + it('should parse referral visit from same domain as direct', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(undefined); + stubSetItem.returns(undefined); + let source = getUmtSource('http://lander.com/news.html', 'http://lander.com/lander.html'); + expect(source).to.be.eql(DIRECT); + }); + + it('should parse campaign visit', () => { + stubGetItem.withArgs('adk_dpt_analytics').returns(undefined); + stubSetItem.returns(undefined); + let source = getUmtSource('http://lander.com/index.html?utm_campaign=new_campaign&utm_source=adkernel&utm_medium=email&utm_c1=1&utm_c2=2&utm_c3=3&utm_c4=4&utm_c5=5'); + expect(source).to.be.eql(CAMPAIGN); + }); + }); + + describe('ExpiringQueue', () => { + let start; + before(() => { + start = Date.now(); + }); + it('should notify after timeout period', (done) => { + let queue = new ExpiringQueue(() => { + let elements = queue.popAll(); + expect(elements).to.be.eql([1, 2, 3, 4]); + elements = queue.popAll(); + expect(elements).to.have.lengthOf(0); + expect(Date.now() - start).to.be.at.least(200).and.below(250); + done(); + }, 100); + + queue.push(1); + setTimeout(() => { + queue.push([2, 3]); + }, 50); + setTimeout(() => { + queue.push([4]); + }, 100); + }); + }); + + const REQUEST = { + bidderCode: 'adapter', + requestId: '5018eb39-f900-4370-b71e-3bb5b48d324f', + bidderRequestId: '1a6fc81528d0f6', + bids: [{ + bidder: 'adapter', + params: {}, + placementCode: 'container-1', + transactionId: 'de90df62-7fd0-4fbc-8787-92d133a7dc06', + sizes: [[300, 250]], + bidId: '208750227436c1', + bidderRequestId: '1a6fc81528d0f6', + requestId: '5018eb39-f900-4370-b71e-3bb5b48d324f' + }], + auctionStart: 1509369418387, + timeout: 3000, + start: 1509369418389 + }; + + const RESPONSE = { + bidderCode: 'adapter', + width: 300, + height: 250, + statusMessage: 'Bid available', + adId: '208750227436c1', + mediaType: 'banner', + cpm: 0.015, + ad: '', + requestId: '5018eb39-f900-4370-b71e-3bb5b48d324f', + responseTimestamp: 1509369418832, + requestTimestamp: 1509369418389, + bidder: 'adapter', + adUnitCode: 'container-1', + timeToRespond: 443, + size: '300x250' + }; + + describe('Analytics adapter', () => { + let ajaxStub; + let timer; + + before(() => { + ajaxStub = sandbox.stub(ajax, 'ajax'); + timer = sandbox.useFakeTimers(0); + }); + + it('should be configurable', () => { + adaptermanager.registerAnalyticsAdapter({ + code: 'adkernelAdn', + adapter: analyticsAdapter + }); + + adaptermanager.enableAnalytics({ + provider: 'adkernelAdn', + options: { + pubId: 777, + queueTimeout: 1000 + } + }); + + expect(analyticsAdapter.context).to.have.property('host', 'tag.adkernel.com'); + expect(analyticsAdapter.context).to.have.property('pubId', 777); + }); + + it('should handle auction init event', () => { + events.emit(CONSTANTS.EVENTS.AUCTION_INIT, {config: {}, timeout: 3000}); + const ev = analyticsAdapter.context.queue.peekAll(); + expect(ev).to.have.length(1); + expect(ev[0]).to.be.eql({event: 'auctionInit'}); + }); + + it('should handle bid request event', () => { + events.emit(CONSTANTS.EVENTS.BID_REQUESTED, REQUEST); + const ev = analyticsAdapter.context.queue.peekAll(); + expect(ev).to.have.length(2); + expect(ev[1]).to.be.eql({event: 'bidRequested', adapter: 'adapter', tagid: 'container-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(3); + expect(ev[2]).to.be.eql({ + event: 'bidResponse', + adapter: 'adapter', + tagid: 'container-1', + val: 0.015, + time: 0.443 + }); + }); + + it('should handle auction end event', () => { + timer.tick(447); + events.emit(CONSTANTS.EVENTS.AUCTION_END, RESPONSE); + let ev = analyticsAdapter.context.queue.peekAll(); + expect(ev).to.have.length(0); + expect(ajaxStub.calledOnce).to.be.equal(true); + ev = JSON.parse(ajaxStub.firstCall.args[2]).hb_ev; + expect(ev[3]).to.be.eql({event: 'auctionEnd', time: 0.447}); + }); + + it('should handle winning bid', () => { + events.emit(CONSTANTS.EVENTS.BID_WON, RESPONSE); + timer.tick(4500); + expect(ajaxStub.calledTwice).to.be.equal(true); + let ev = JSON.parse(ajaxStub.secondCall.args[2]).hb_ev; + expect(ev[0]).to.be.eql({event: 'bidWon', adapter: 'adapter', tagid: 'container-1', val: 0.015}); + }); + }); +});