diff --git a/modules/oxxionAnalyticsAdapter.js b/modules/oxxionAnalyticsAdapter.js new file mode 100644 index 00000000000..73160b5bebf --- /dev/null +++ b/modules/oxxionAnalyticsAdapter.js @@ -0,0 +1,210 @@ +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import adapterManager from '../src/adapterManager.js'; +import CONSTANTS from '../src/constants.json'; +import { ajax } from '../src/ajax.js'; + +const analyticsType = 'endpoint'; +const url = 'URL_TO_SERVER_ENDPOINT'; + +const { + EVENTS: { + AUCTION_END, + BID_WON, + BID_RESPONSE, + BID_REQUESTED, + BID_TIMEOUT, + } +} = CONSTANTS; + +let saveEvents = {} +let allEvents = {} +let auctionEnd = {} +let initOptions = {} +let endpoint = 'https://default' +let requestsAttributes = ['adUnitCode', 'auctionId', 'bidder', 'bidderCode', 'bidId', 'cpm', 'creativeId', 'currency', 'width', 'height', 'mediaType', 'netRevenue', 'originalCpm', 'originalCurrency', 'requestId', 'size', 'source', 'status', 'timeToRespond', 'transactionId', 'ttl', 'sizes', 'mediaTypes', 'src', 'params', 'userId', 'labelAny', 'bids', 'adId']; + +function getAdapterNameForAlias(aliasName) { + return adapterManager.aliasRegistry[aliasName] || aliasName; +} + +function filterAttributes(arg, removead) { + let response = {}; + if (typeof arg == 'object') { + if (typeof arg['bidderCode'] == 'string') { + response['originalBidder'] = getAdapterNameForAlias(arg['bidderCode']); + } else if (typeof arg['bidder'] == 'string') { + response['originalBidder'] = getAdapterNameForAlias(arg['bidder']); + } + if (!removead && typeof arg['ad'] != 'undefined') { + response['ad'] = arg['ad']; + } + if (typeof arg['gdprConsent'] != 'undefined') { + response['gdprConsent'] = {}; + if (typeof arg['gdprConsent']['consentString'] != 'undefined') { response['gdprConsent']['consentString'] = arg['gdprConsent']['consentString']; } + } + if (typeof arg['meta'] == 'object' && typeof arg['meta']['advertiserDomains'] != 'undefined') { + response['meta'] = {'advertiserDomains': arg['meta']['advertiserDomains']}; + } + requestsAttributes.forEach((attr) => { + if (typeof arg[attr] != 'undefined') { response[attr] = arg[attr]; } + }); + if (typeof response['creativeId'] == 'number') { response['creativeId'] = response['creativeId'].toString(); } + } + return response; +} + +function cleanAuctionEnd(args) { + let response = {}; + let filteredObj; + let objects = ['bidderRequests', 'bidsReceived', 'noBids', 'adUnits']; + objects.forEach((attr) => { + if (Array.isArray(args[attr])) { + response[attr] = []; + args[attr].forEach((obj) => { + filteredObj = filterAttributes(obj, true); + if (typeof obj['bids'] == 'object') { + filteredObj['bids'] = []; + obj['bids'].forEach((bid) => { + filteredObj['bids'].push(filterAttributes(bid, true)); + }); + } + response[attr].push(filteredObj); + }); + } + }); + return response; +} + +function cleanCreatives(args) { + return filterAttributes(args, false); +} + +function enhanceMediaType(arg) { + saveEvents['bidRequested'].forEach((bidRequested) => { + if (bidRequested['auctionId'] == arg['auctionId'] && Array.isArray(bidRequested['bids'])) { + bidRequested['bids'].forEach((bid) => { + if (bid['transactionId'] == arg['transactionId'] && bid['bidId'] == arg['requestId']) { arg['mediaTypes'] = bid['mediaTypes']; } + }); + } + }); + return arg; +} + +function addBidResponse(args) { + let eventType = BID_RESPONSE; + let argsCleaned = cleanCreatives(JSON.parse(JSON.stringify(args))); ; + if (allEvents[eventType] == undefined) { allEvents[eventType] = [] } + allEvents[eventType].push(argsCleaned); +} + +function addBidRequested(args) { + let eventType = BID_REQUESTED; + let argsCleaned = filterAttributes(args, true); + if (saveEvents[eventType] == undefined) { saveEvents[eventType] = [] } + saveEvents[eventType].push(argsCleaned); +} + +function addTimeout(args) { + let eventType = BID_TIMEOUT; + if (saveEvents[eventType] == undefined) { saveEvents[eventType] = [] } + saveEvents[eventType].push(args); + let argsCleaned = []; + let argsDereferenced = JSON.parse(JSON.stringify(args)); + argsDereferenced.forEach((attr) => { + argsCleaned.push(filterAttributes(JSON.parse(JSON.stringify(attr)), false)); + }); + if (auctionEnd[eventType] == undefined) { auctionEnd[eventType] = [] } + auctionEnd[eventType].push(argsCleaned); +} + +function addAuctionEnd(args) { + let eventType = AUCTION_END; + if (saveEvents[eventType] == undefined) { saveEvents[eventType] = [] } + saveEvents[eventType].push(args); + let argsCleaned = cleanAuctionEnd(JSON.parse(JSON.stringify(args))); + if (auctionEnd[eventType] == undefined) { auctionEnd[eventType] = [] } + auctionEnd[eventType].push(argsCleaned); +} + +function handleBidWon(args) { + args = enhanceMediaType(filterAttributes(JSON.parse(JSON.stringify(args)), true)); + let increment = args['cpm']; + if (typeof saveEvents['auctionEnd'] == 'object') { + saveEvents['auctionEnd'].forEach((auction) => { + if (auction['auctionId'] == args['auctionId'] && typeof auction['bidsReceived'] == 'object') { + auction['bidsReceived'].forEach((bid) => { + if (bid['transactionId'] == args['transactionId'] && bid['adId'] != args['adId']) { + if (args['cpm'] < bid['cpm']) { + increment = 0; + } else if (increment > args['cpm'] - bid['cpm']) { + increment = args['cpm'] - bid['cpm']; + } + } + }); + } + }); + } + args['cpmIncrement'] = increment; + if (typeof saveEvents.bidRequested == 'object' && saveEvents.bidRequested.length > 0 && saveEvents.bidRequested[0].gdprConsent) { args.gdpr = saveEvents.bidRequested[0].gdprConsent; } + ajax(endpoint + '.oxxion.io/analytics/bid_won', null, JSON.stringify(args), {method: 'POST', withCredentials: true}); +} + +function handleAuctionEnd() { + ajax(endpoint + '.oxxion.io/analytics/auctions', function (data) { + let list = JSON.parse(data); + if (Array.isArray(list) && typeof allEvents['bidResponse'] != 'undefined') { + let alreadyCalled = []; + allEvents['bidResponse'].forEach((bidResponse) => { + let tmpId = bidResponse['originalBidder'] + '_' + bidResponse['creativeId']; + if (list.includes(tmpId) && !alreadyCalled.includes(tmpId)) { + alreadyCalled.push(tmpId); + ajax(endpoint + '.oxxion.io/analytics/creatives', null, JSON.stringify(bidResponse), {method: 'POST', withCredentials: true}); + } + }); + } + allEvents = {}; + }, JSON.stringify(auctionEnd), {method: 'POST', withCredentials: true}); + auctionEnd = {}; +} + +let oxxionAnalytics = Object.assign(adapter({url, analyticsType}), { + track({ + eventType, + args + }) { + switch (eventType) { + case AUCTION_END: + addAuctionEnd(args); + handleAuctionEnd(); + break; + case BID_WON: + handleBidWon(args); + break; + case BID_RESPONSE: + addBidResponse(args); + break; + case BID_REQUESTED: + addBidRequested(args); + break; + case BID_TIMEOUT: + addTimeout(args); + break; + } + }}); + +// save the base class function +oxxionAnalytics.originEnableAnalytics = oxxionAnalytics.enableAnalytics; + +// override enableAnalytics so we can get access to the config passed in from the page +oxxionAnalytics.enableAnalytics = function (config) { + oxxionAnalytics.originEnableAnalytics(config); // call the base class function + initOptions = config.options; + if (initOptions.domain) { endpoint = 'https://' + initOptions.domain; } +}; + +adapterManager.registerAnalyticsAdapter({ + adapter: oxxionAnalytics, + code: 'oxxion' +}); + +export default oxxionAnalytics; diff --git a/modules/oxxionAnalyticsAdapter.md b/modules/oxxionAnalyticsAdapter.md new file mode 100644 index 00000000000..506f013eb37 --- /dev/null +++ b/modules/oxxionAnalyticsAdapter.md @@ -0,0 +1,33 @@ +# Overview +Module Name: oxxion Analytics Adapter + +Module Type: Analytics Adapter + +Maintainer: tech@oxxion.io + +# Oxxion Analytics Adapter + +Oxxion helps you to understand how your prebid stack performs. + +# Integration + +Add the oxxion analytics adapter module to your prebid configuration : +``` +pbjs.enableAnalytics( + ... + { + provider: 'oxxion', + options : { + domain: 'test.endpoint' + } + } + ... +) +``` + +# Parameters + +| Name | Type | Description | +|:-------------------------------|:---------|:------------------------------------------------------------------------------------------------------------| +| domain | String | This string identifies yourself in Oxxion's systems and is provided to you by your Oxxion representative. | + diff --git a/test/spec/modules/oxxionAnalyticsAdapter_spec.js b/test/spec/modules/oxxionAnalyticsAdapter_spec.js new file mode 100644 index 00000000000..5f449eb882b --- /dev/null +++ b/test/spec/modules/oxxionAnalyticsAdapter_spec.js @@ -0,0 +1,324 @@ +import oxxionAnalytics from 'modules/oxxionAnalyticsAdapter.js'; +import { expect } from 'chai'; +import { server } from 'test/mocks/xhr.js'; +let adapterManager = require('src/adapterManager').default; +let events = require('src/events'); +let constants = require('src/constants.json'); + +describe('Oxxion Analytics', function () { + let timestamp = new Date() - 256; + let auctionId = '5018eb39-f900-4370-b71e-3bb5b48d324f'; + let timeout = 1500; + + let bidTimeout = [ + { + 'bidId': '5fe418f2d70364', + 'bidder': 'appnexusAst', + 'adUnitCode': 'tag_200124_banner', + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b' + } + ]; + + const auctionEnd = { + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b', + 'timestamp': 1647424261187, + 'auctionEnd': 1647424261714, + 'auctionStatus': 'completed', + 'adUnits': [ + { + 'code': 'tag_200124_banner', + 'mediaTypes': { + 'banner': { + 'sizes': [ + [ + 300, + 600 + ] + ] + } + }, + 'bids': [ + { + 'bidder': 'appnexus', + 'params': { + 'placementId': 123456 + } + }, + { + 'bidder': 'appnexusAst', + 'params': { + 'placementId': 234567 + } + } + ], + 'sizes': [ + [ + 300, + 600 + ] + ], + 'transactionId': 'de664ccb-e18b-4436-aeb0-362382eb1b40' + } + ], + 'adUnitCodes': [ + 'tag_200124_banner' + ], + 'bidderRequests': [ + { + 'bidderCode': 'appnexus', + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b', + 'bidderRequestId': '11dc6ff6378de7', + 'bids': [ + { + 'bidder': 'appnexus', + 'params': { + 'placementId': 123456 + }, + 'mediaTypes': { + 'banner': { + 'sizes': [ + [ + 300, + 600 + ] + ] + } + }, + 'adUnitCode': 'tag_200124_banner', + 'transactionId': 'de664ccb-e18b-4436-aeb0-362382eb1b40', + 'sizes': [ + [ + 300, + 600 + ] + ], + 'bidId': '34a63e5d5378a3', + 'bidderRequestId': '11dc6ff6378de7', + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b', + 'src': 'client', + 'bidRequestsCount': 1, + 'bidderRequestsCount': 1, + 'bidderWinsCount': 0 + } + ], + 'auctionStart': 1647424261187, + 'timeout': 1000, + 'gdprConsent': { + 'consentString': 'CONSENT', + 'gdprApplies': true, + 'apiVersion': 2, + 'vendorData': 'a lot of borring stuff', + }, + 'start': 1647424261189 + }, + ], + 'noBids': [ + { + 'bidder': 'appnexusAst', + 'params': { + 'placementId': 10471298 + }, + 'mediaTypes': { + 'banner': { + 'sizes': [ + [ + 300, + 600 + ] + ] + } + }, + 'adUnitCode': 'tag_200124_banner', + 'transactionId': 'de664ccb-e18b-4436-aeb0-362382eb1b40', + 'sizes': [ + [ + 300, + 600 + ] + ], + 'bidId': '5fe418f2d70364', + 'bidderRequestId': '4229a45ab8ea87', + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b', + 'src': 'client', + 'bidRequestsCount': 1, + 'bidderRequestsCount': 1, + 'bidderWinsCount': 0 + } + ], + 'bidsReceived': [ + { + 'bidderCode': 'appnexus', + 'width': 300, + 'height': 600, + 'statusMessage': 'Bid available', + 'adId': '7a4ced80f33d33', + 'requestId': '34a63e5d5378a3', + 'transactionId': 'de664ccb-e18b-4436-aeb0-362382eb1b40', + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b', + 'mediaType': 'banner', + 'source': 'client', + 'cpm': 27.4276, + 'creativeId': '158534630', + 'currency': 'USD', + 'netRevenue': true, + 'ttl': 2000, + 'ad': 'some html', + 'meta': { + 'advertiserDomains': [ + 'example.com' + ] + }, + 'originalCpm': 25.02521, + 'originalCurrency': 'EUR', + 'responseTimestamp': 1647424261559, + 'requestTimestamp': 1647424261189, + 'bidder': 'appnexus', + 'adUnitCode': 'tag_200124_banner', + 'timeToRespond': 370, + 'pbLg': '5.00', + 'pbMg': '20.00', + 'pbHg': '20.00', + 'pbAg': '20.00', + 'pbDg': '20.00', + 'pbCg': '20.000000', + 'size': '300x600', + 'adserverTargeting': { + 'hb_bidder': 'appnexus', + 'hb_adid': '7a4ced80f33d33', + 'hb_pb': '20.000000', + 'hb_size': '300x600', + 'hb_source': 'client', + 'hb_format': 'banner', + 'hb_adomain': 'example.com' + } + } + ], + 'winningBids': [ + + ], + 'timeout': 1000 + }; + + let bidWon = { + 'bidderCode': 'appnexus', + 'width': 970, + 'height': 250, + 'statusMessage': 'Bid available', + 'adId': '65d16ef039a97a', + 'requestId': '2bd3e8ff8a113f', + 'transactionId': '8b2a8629-d1ea-4bb1-aff0-e335b96dd002', + 'auctionId': '1e8b993d-8f0a-4232-83eb-3639ddf3a44b', + 'mediaType': 'banner', + 'source': 'client', + 'cpm': 27.4276, + 'creativeId': '158533702', + 'currency': 'USD', + 'netRevenue': true, + 'ttl': 2000, + 'ad': 'some html', + 'meta': { + 'advertiserDomains': [ + 'example.com' + ] + }, + 'originalCpm': 25.02521, + 'originalCurrency': 'EUR', + 'responseTimestamp': 1647424261558, + 'requestTimestamp': 1647424261189, + 'bidder': 'appnexus', + 'adUnitCode': 'tag_200123_banner', + 'timeToRespond': 369, + 'originalBidder': 'appnexus', + 'pbLg': '5.00', + 'pbMg': '20.00', + 'pbHg': '20.00', + 'pbAg': '20.00', + 'pbDg': '20.00', + 'pbCg': '20.000000', + 'size': '970x250', + 'adserverTargeting': { + 'hb_bidder': 'appnexus', + 'hb_adid': '65d16ef039a97a', + 'hb_pb': '20.000000', + 'hb_size': '970x250', + 'hb_source': 'client', + 'hb_format': 'banner', + 'hb_adomain': 'example.com' + }, + 'status': 'rendered', + 'params': [ + { + 'placementId': 123456 + } + ] + }; + + after(function () { + oxxionAnalytics.disableAnalytics(); + }); + + describe('main test flow', function () { + beforeEach(function () { + sinon.stub(events, 'getEvents').returns([]); + sinon.spy(oxxionAnalytics, 'track'); + }); + afterEach(function () { + events.getEvents.restore(); + oxxionAnalytics.disableAnalytics(); + oxxionAnalytics.track.restore(); + }); + + it('test auctionEnd', function () { + adapterManager.registerAnalyticsAdapter({ + code: 'oxxion', + adapter: oxxionAnalytics + }); + + adapterManager.enableAnalytics({ + provider: 'oxxion', + options: { + domain: 'test' + } + }); + + events.emit(constants.EVENTS.BID_REQUESTED, auctionEnd['bidderRequests'][0]); + events.emit(constants.EVENTS.BID_RESPONSE, auctionEnd['bidsReceived'][0]); + events.emit(constants.EVENTS.BID_TIMEOUT, bidTimeout); + events.emit(constants.EVENTS.AUCTION_END, auctionEnd); + expect(server.requests.length).to.equal(1); + let message = JSON.parse(server.requests[0].requestBody); + expect(message).to.have.property('auctionEnd').exist; + expect(message.auctionEnd).to.have.lengthOf(1); + expect(message.auctionEnd[0]).to.have.property('bidsReceived').and.to.have.lengthOf(1); + expect(message.auctionEnd[0].bidsReceived[0]).not.to.have.property('ad'); + expect(message.auctionEnd[0].bidsReceived[0]).to.have.property('meta'); + expect(message.auctionEnd[0].bidsReceived[0].meta).to.have.property('advertiserDomains'); + expect(message.auctionEnd[0].bidsReceived[0]).to.have.property('adId'); + expect(message.auctionEnd[0]).to.have.property('bidderRequests').and.to.have.lengthOf(1); + expect(message.auctionEnd[0].bidderRequests[0]).to.have.property('gdprConsent'); + expect(message.auctionEnd[0].bidderRequests[0].gdprConsent).not.to.have.property('vendorData'); + sinon.assert.callCount(oxxionAnalytics.track, 4); + }); + + it('test bidWon', function() { + adapterManager.registerAnalyticsAdapter({ + code: 'oxxion', + adapter: oxxionAnalytics + }); + + adapterManager.enableAnalytics({ + provider: 'oxxion', + options: { + domain: 'test' + } + }); + events.emit(constants.EVENTS.BID_WON, bidWon); + expect(server.requests.length).to.equal(1); + let message = JSON.parse(server.requests[0].requestBody); + expect(message).not.to.have.property('ad'); + expect(message).to.have.property('adId') + expect(message).to.have.property('cpmIncrement').and.to.equal(27.4276); + // sinon.assert.callCount(oxxionAnalytics.track, 1); + }); + }); +});