diff --git a/modules/malltvAnalyticsAdapter.js b/modules/malltvAnalyticsAdapter.js new file mode 100644 index 00000000000..3431681ef2f --- /dev/null +++ b/modules/malltvAnalyticsAdapter.js @@ -0,0 +1,188 @@ +import {ajax} from '../src/ajax.js' +import adapter from '../src/AnalyticsAdapter.js' +import CONSTANTS from '../src/constants.json' +import adapterManager from '../src/adapterManager.js' +import {getGlobal} from '../src/prebidGlobal.js' +import {logInfo, logError, deepClone} from '../src/utils.js' + +const analyticsType = 'endpoint' +export const ANALYTICS_VERSION = '1.0.0' +export const DEFAULT_SERVER = 'https://central.mall.tv/analytics' + +const { + EVENTS: { + AUCTION_END, + BID_TIMEOUT + } +} = CONSTANTS + +export const BIDDER_STATUS = { + BID: 1, + NO_BID: 2, + BID_WON: 3, + TIMEOUT: 4 +} + +export const getCpmInEur = function (bid) { + if (bid.currency !== 'EUR' && typeof bid.getCpmInNewCurrency === 'function') { + return bid.getCpmInNewCurrency('EUR'); + } + + return bid.cpm; +} + +const analyticsOptions = {} + +export const parseBidderCode = function (bid) { + let bidderCode = bid.bidderCode || bid.bidder + return bidderCode.toLowerCase() +} + +export const parseAdUnitCode = function (bidResponse) { + return bidResponse.adUnitCode.toLowerCase() +} + +export const malltvAnalyticsAdapter = Object.assign(adapter({DEFAULT_SERVER, analyticsType}), { + + cachedAuctions: {}, + + initConfig(config) { + /** + * Required option: propertyId + * + * Optional option: server + * @type {boolean} + */ + analyticsOptions.options = deepClone(config.options) + if (typeof config.options.propertyId !== 'string' || config.options.propertyId.length < 1) { + logError('"options.propertyId" is required.') + return false + } + + analyticsOptions.propertyId = config.options.propertyId + analyticsOptions.server = config.options.server || DEFAULT_SERVER + + return true + }, + track({eventType, args}) { + switch (eventType) { + case BID_TIMEOUT: + this.handleBidTimeout(args) + break + case AUCTION_END: + this.handleAuctionEnd(args) + break + } + }, + handleBidTimeout(timeoutBids) { + timeoutBids.forEach((bid) => { + const cachedAuction = this.getCachedAuction(bid.auctionId) + cachedAuction.timeoutBids.push(bid) + }) + }, + handleAuctionEnd(auctionEndArgs) { + const cachedAuction = this.getCachedAuction(auctionEndArgs.auctionId) + const highestCpmBids = getGlobal().getHighestCpmBids() + this.sendEventMessage('end', + this.createBidMessage(auctionEndArgs, highestCpmBids, cachedAuction.timeoutBids) + ) + }, + createBidMessage(auctionEndArgs, winningBids, timeoutBids) { + const {auctionId, timestamp, timeout, auctionEnd, adUnitCodes, bidsReceived, noBids} = auctionEndArgs + const message = this.createCommonMessage(auctionId) + + message.auctionElapsed = (auctionEnd - timestamp) + message.timeout = timeout + + adUnitCodes.forEach((adUnitCode) => { + message.adUnits[adUnitCode] = {} + }) + + // We handled noBids first because when currency conversion is enabled, a bid with a foreign currency + // will be set to NO_BID initially, and then set to BID after the currency rate json file is fully loaded. + // In this situation, the bid exists in both noBids and bids arrays. + noBids.forEach(bid => this.addBidResponseToMessage(message, bid, BIDDER_STATUS.NO_BID)) + + // This array may contain some timeout bids (responses come back after auction timeout) + bidsReceived.forEach(bid => this.addBidResponseToMessage(message, bid, BIDDER_STATUS.BID)) + + // We handle timeout after bids since it's possible that a bid has a response, but the response comes back + // after auction end. In this case, the bid exists in both bidsReceived and timeoutBids arrays. + timeoutBids.forEach(bid => this.addBidResponseToMessage(message, bid, BIDDER_STATUS.TIMEOUT)) + + // mark the winning bids with prebidWon = true + winningBids.forEach(bid => { + const adUnitCode = parseAdUnitCode(bid) + const bidder = parseBidderCode(bid) + message.adUnits[adUnitCode][bidder].prebidWon = true + }) + return message + }, + createCommonMessage(auctionId) { + return { + analyticsVersion: ANALYTICS_VERSION, + auctionId: auctionId, + propertyId: analyticsOptions.propertyId, + referrer: window.location.href, + prebidVersion: '$prebid.version$', + adUnits: {}, + } + }, + addBidResponseToMessage(message, bid, status) { + const adUnitCode = parseAdUnitCode(bid) + message.adUnits[adUnitCode] = message.adUnits[adUnitCode] || {} + const bidder = parseBidderCode(bid) + const bidResponse = this.serializeBidResponse(bid, status) + message.adUnits[adUnitCode][bidder] = bidResponse + }, + serializeBidResponse(bid, status) { + const result = { + prebidWon: (status === BIDDER_STATUS.BID_WON), + isTimeout: (status === BIDDER_STATUS.TIMEOUT), + status: status, + } + if (status === BIDDER_STATUS.BID || status === BIDDER_STATUS.BID_WON) { + Object.assign(result, { + time: bid.timeToRespond, + cpm: bid.cpm, + currency: bid.currency, + originalCpm: bid.originalCpm || bid.cpm, + cpmEur: getCpmInEur(bid), + originalCurrency: bid.originalCurrency || bid.currency, + vastUrl: bid.vastUrl + }) + } + return result + }, + sendEventMessage(endPoint, data) { + logInfo(`AJAX: ${endPoint}: ` + JSON.stringify(data)) + + ajax(`${analyticsOptions.server}/${endPoint}`, null, JSON.stringify(data), { + contentType: 'application/json' + }) + }, + getCachedAuction(auctionId) { + this.cachedAuctions[auctionId] = this.cachedAuctions[auctionId] || { + timeoutBids: [], + } + return this.cachedAuctions[auctionId] + }, + getAnalyticsOptions() { + return analyticsOptions + }, +}) + +// save the base class function +malltvAnalyticsAdapter.originEnableAnalytics = malltvAnalyticsAdapter.enableAnalytics + +// override enableAnalytics so we can get access to the config passed in from the page +malltvAnalyticsAdapter.enableAnalytics = function (config) { + if (this.initConfig(config)) { + malltvAnalyticsAdapter.originEnableAnalytics(config) // call the base class function + } +} + +adapterManager.registerAnalyticsAdapter({ + adapter: malltvAnalyticsAdapter, + code: 'malltvAnalytics' +}) diff --git a/modules/malltvAnalyticsAdapter.md b/modules/malltvAnalyticsAdapter.md new file mode 100644 index 00000000000..cc62d939694 --- /dev/null +++ b/modules/malltvAnalyticsAdapter.md @@ -0,0 +1,25 @@ +# Overview + +Module Name: Malltv Analytics Adapter + +Module Type: Analytics Adapter + +Maintainer: arditb@gjirafa.com + +# Description + +Analytics adapter for Malltv + +# Parameters + +``` +{ + provider: 'malltvAnalytics', + options: { + 'propertyId': 'YOUR_PROPERTY_ID', // Required + 'server': 'YOUR_ANALYTICS_SERVER' // Optional + } +} +``` + +PS. [Prebid currency module](http://prebid.org/dev-docs/modules/currency.html) is required, please make sure your prebid code contains currency module code. diff --git a/test/spec/modules/malltvAnalyticsAdapter_spec.js b/test/spec/modules/malltvAnalyticsAdapter_spec.js new file mode 100644 index 00000000000..599ac6e4256 --- /dev/null +++ b/test/spec/modules/malltvAnalyticsAdapter_spec.js @@ -0,0 +1,540 @@ +import { + malltvAnalyticsAdapter, parseBidderCode, parseAdUnitCode, + ANALYTICS_VERSION, BIDDER_STATUS, DEFAULT_SERVER +} from 'modules/malltvAnalyticsAdapter.js' +import { expect } from 'chai' +import { getCpmInEur } from '../../../modules/malltvAnalyticsAdapter' +import events from 'src/events' +import constants from 'src/constants.json' + +const auctionId = 'b0b39610-b941-4659-a87c-de9f62d3e13e' +const propertyId = '123456' +const server = 'https://analytics.server.url/v1' + +describe('Malltv Prebid AnalyticsAdapter Testing', function () { + describe('event tracking and message cache manager', function () { + beforeEach(function () { + const configOptions = { propertyId } + + sinon.stub(events, 'getEvents').returns([]) + malltvAnalyticsAdapter.enableAnalytics({ + provider: 'malltvAnalytics', + options: configOptions + }) + }) + + afterEach(function () { + malltvAnalyticsAdapter.disableAnalytics() + events.getEvents.restore() + }) + + describe('#getCpmInEur()', function() { + it('should get bid cpm as currency is EUR', function() { + const receivedBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_1', + bidder: 'malltv', + bidderCode: 'MALLTV', + requestId: 'a1b2c3d4', + timeToRespond: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + ad: 'fake ad1' + }, + ] + const result = getCpmInEur(receivedBids[0]) + expect(result).to.equal(0.1) + }) + }) + + describe('#parseBidderCode()', function() { + it('should get lower case bidder code from bidderCode field value', function() { + const receivedBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_1', + bidder: 'malltv', + bidderCode: 'MALLTV', + requestId: 'a1b2c3d4', + timeToRespond: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + ad: 'fake ad1' + }, + ] + const result = parseBidderCode(receivedBids[0]) + expect(result).to.equal('malltv') + }) + + it('should get lower case bidder code from bidder field value as bidderCode field is missing', function() { + const receivedBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_1', + bidder: 'MALLTV', + bidderCode: '', + requestId: 'a1b2c3d4', + timeToRespond: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + ad: 'fake ad1' + }, + ] + const result = parseBidderCode(receivedBids[0]) + expect(result).to.equal('malltv') + }) + }) + + describe('#parseAdUnitCode()', function() { + it('should get lower case adUnit code from adUnitCode field value', function() { + const receivedBids = [ + { + auctionId: auctionId, + adUnitCode: 'ADUNIT', + bidder: 'malltv', + bidderCode: 'MALLTV', + requestId: 'a1b2c3d4', + timeToRespond: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + ad: 'fake ad1' + }, + ] + const result = parseAdUnitCode(receivedBids[0]) + expect(result).to.equal('adunit') + }) + }) + + describe('#getCachedAuction()', function() { + const existing = {timeoutBids: [{}]} + malltvAnalyticsAdapter.cachedAuctions['test_auction_id'] = existing + + it('should get the existing cached object if it exists', function() { + const result = malltvAnalyticsAdapter.getCachedAuction('test_auction_id') + + expect(result).to.equal(existing) + }) + + it('should create a new object and store it in the cache on cache miss', function() { + const result = malltvAnalyticsAdapter.getCachedAuction('no_such_id') + + expect(result).to.deep.include({ + timeoutBids: [], + }) + }) + }) + + describe('when formatting JSON payload sent to backend', function() { + const receivedBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_1', + bidder: 'malltv', + bidderCode: 'malltv', + requestId: 'a1b2c3d4', + timeToRespond: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + ad: 'fake ad1', + vastUrl: null + }, + { + auctionId: auctionId, + adUnitCode: 'adunit_1', + bidder: 'gjirafa', + bidderCode: 'gjirafa', + requestId: 'b2c3d4e5', + timeToRespond: 100, + cpm: 0.08, + currency: 'EUR', + originalCpm: 0.08, + originalCurrency: 'EUR', + ad: 'fake ad2', + vastUrl: null + }, + { + auctionId: auctionId, + adUnitCode: 'adunit_2', + bidder: 'malltv', + bidderCode: 'malltv', + requestId: 'c3d4e5f6', + timeToRespond: 120, + cpm: 0.09, + currency: 'EUR', + originalCpm: 0.09, + originalCurrency: 'EUR', + ad: 'fake ad3', + vastUrl: null + }, + ] + const highestCpmBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_1', + bidder: 'malltv', + bidderCode: 'malltv', + // No requestId + timeToRespond: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + ad: 'fake ad1', + vastUrl: null + } + ] + const noBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_2', + bidder: 'malltv', + bidderCode: 'malltv', + bidId: 'a1b2c3d4', + } + ] + const timeoutBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_2', + bidder: 'gjirafa', + bidderCode: 'gjirafa', + bidId: '00123d4c', + } + ] + const withoutOriginalCpmBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_2', + bidder: 'malltv', + bidderCode: 'malltv', + requestId: 'c3d4e5f6', + timeToRespond: 120, + cpm: 0.29, + currency: 'EUR', + originalCpm: '', + originalCurrency: 'EUR', + ad: 'fake ad3' + }, + ] + const withoutOriginalCurrencyBids = [ + { + auctionId: auctionId, + adUnitCode: 'adunit_2', + bidder: 'malltv', + bidderCode: 'malltv', + requestId: 'c3d4e5f6', + timeToRespond: 120, + cpm: 0.09, + currency: 'EUR', + originalCpm: 0.09, + originalCurrency: '', + ad: 'fake ad3' + }, + ] + + function assertHavingRequiredMessageFields(message) { + expect(message).to.include({ + analyticsVersion: ANALYTICS_VERSION, + auctionId: auctionId, + propertyId: propertyId, + prebidVersion: '$prebid.version$', + }) + } + + describe('#createCommonMessage', function() { + it('should correctly serialize some common fields', function() { + const message = malltvAnalyticsAdapter.createCommonMessage(auctionId) + + assertHavingRequiredMessageFields(message) + }) + }) + + describe('#serializeBidResponse', function() { + it('should handle BID properly and serialize bid price related fields', function() { + const result = malltvAnalyticsAdapter.serializeBidResponse(receivedBids[0], BIDDER_STATUS.BID) + + expect(result).to.include({ + prebidWon: false, + isTimeout: false, + status: BIDDER_STATUS.BID, + time: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + cpmEur: 0.1, + }) + }) + + it('should handle NO_BID properly and set status to noBid', function() { + const result = malltvAnalyticsAdapter.serializeBidResponse(noBids[0], BIDDER_STATUS.NO_BID) + + expect(result).to.include({ + prebidWon: false, + isTimeout: false, + status: BIDDER_STATUS.NO_BID, + }) + }) + + it('should handle BID_WON properly and serialize bid price related fields', function() { + const result = malltvAnalyticsAdapter.serializeBidResponse(receivedBids[0], BIDDER_STATUS.BID_WON) + + expect(result).to.include({ + prebidWon: true, + isTimeout: false, + status: BIDDER_STATUS.BID_WON, + time: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + cpmEur: 0.1, + }) + }) + + it('should handle TIMEOUT properly and set status to timeout and isTimeout to true', function() { + const result = malltvAnalyticsAdapter.serializeBidResponse(noBids[0], BIDDER_STATUS.TIMEOUT) + + expect(result).to.include({ + prebidWon: false, + isTimeout: true, + status: BIDDER_STATUS.TIMEOUT, + }) + }) + + it('should handle BID_WON properly and fill originalCpm field with cpm in missing originalCpm case', function() { + const result = malltvAnalyticsAdapter.serializeBidResponse(withoutOriginalCpmBids[0], BIDDER_STATUS.BID_WON) + + expect(result).to.include({ + prebidWon: true, + isTimeout: false, + status: BIDDER_STATUS.BID_WON, + time: 120, + cpm: 0.29, + currency: 'EUR', + originalCpm: 0.29, + originalCurrency: 'EUR', + cpmEur: 0.29, + }) + }) + + it('should handle BID_WON properly and fill originalCurrency field with currency in missing originalCurrency case', function() { + const result = malltvAnalyticsAdapter.serializeBidResponse(withoutOriginalCurrencyBids[0], BIDDER_STATUS.BID_WON) + expect(result).to.include({ + prebidWon: true, + isTimeout: false, + status: BIDDER_STATUS.BID_WON, + time: 120, + cpm: 0.09, + currency: 'EUR', + originalCpm: 0.09, + originalCurrency: 'EUR', + cpmEur: 0.09, + }) + }) + }) + + describe('#addBidResponseToMessage()', function() { + it('should add a bid response in the output message, grouped by adunit_id and bidder', function() { + const message = { + adUnits: {} + } + malltvAnalyticsAdapter.addBidResponseToMessage(message, noBids[0], BIDDER_STATUS.NO_BID) + + expect(message.adUnits).to.deep.include({ + 'adunit_2': { + 'malltv': { + prebidWon: false, + isTimeout: false, + status: BIDDER_STATUS.NO_BID, + } + } + }) + }) + }) + + describe('#createBidMessage()', function() { + it('should format auction message sent to the backend', function() { + const args = { + auctionId: auctionId, + timestamp: 1234567890, + timeout: 3000, + auctionEnd: 1234567990, + adUnitCodes: ['adunit_1', 'adunit_2'], + bidsReceived: receivedBids, + noBids: noBids + } + + const result = malltvAnalyticsAdapter.createBidMessage(args, highestCpmBids, timeoutBids) + + assertHavingRequiredMessageFields(result) + expect(result).to.deep.include({ + auctionElapsed: 100, + timeout: 3000, + adUnits: { + 'adunit_1': { + 'malltv': { + prebidWon: true, + isTimeout: false, + status: BIDDER_STATUS.BID, + time: 72, + cpm: 0.1, + currency: 'EUR', + originalCpm: 0.1, + originalCurrency: 'EUR', + cpmEur: 0.1, + vastUrl: null + }, + 'gjirafa': { + prebidWon: false, + isTimeout: false, + status: BIDDER_STATUS.BID, + time: 100, + cpm: 0.08, + currency: 'EUR', + originalCpm: 0.08, + originalCurrency: 'EUR', + cpmEur: 0.08, + vastUrl: null + } + }, + 'adunit_2': { + // this bid result exists in both bid and noBid arrays and should be treated as bid + 'malltv': { + prebidWon: false, + isTimeout: false, + time: 120, + cpm: 0.09, + currency: 'EUR', + originalCpm: 0.09, + originalCurrency: 'EUR', + cpmEur: 0.09, + status: BIDDER_STATUS.BID, + vastUrl: null + }, + 'gjirafa': { + prebidWon: false, + isTimeout: true, + status: BIDDER_STATUS.TIMEOUT, + } + } + } + }) + }) + }) + + describe('#handleBidTimeout()', function() { + it('should cached the timeout bid as BID_TIMEOUT event was triggered', function() { + malltvAnalyticsAdapter.cachedAuctions['test_timeout_auction_id'] = { 'timeoutBids': [] } + const args = [{ + auctionId: 'test_timeout_auction_id', + timestamp: 1234567890, + timeout: 3000, + auctionEnd: 1234567990, + bidsReceived: receivedBids, + noBids: noBids + }] + + malltvAnalyticsAdapter.handleBidTimeout(args) + const result = malltvAnalyticsAdapter.getCachedAuction('test_timeout_auction_id') + expect(result).to.deep.include({ + timeoutBids: [{ + auctionId: 'test_timeout_auction_id', + timestamp: 1234567890, + timeout: 3000, + auctionEnd: 1234567990, + bidsReceived: receivedBids, + noBids: noBids + }] + }) + }) + }) + }) + }) + + describe('Malltv Analytics Adapter track handler ', function () { + const configOptions = { propertyId } + + beforeEach(function () { + sinon.stub(events, 'getEvents').returns([]) + malltvAnalyticsAdapter.enableAnalytics({ + provider: 'malltvAnalytics', + options: configOptions + }) + }) + + afterEach(function () { + malltvAnalyticsAdapter.disableAnalytics() + events.getEvents.restore() + }) + + it('should call handleBidTimeout as BID_TIMEOUT trigger event', function() { + sinon.spy(malltvAnalyticsAdapter, 'handleBidTimeout') + events.emit(constants.EVENTS.BID_TIMEOUT, {}) + sinon.assert.callCount(malltvAnalyticsAdapter.handleBidTimeout, 1) + malltvAnalyticsAdapter.handleBidTimeout.restore() + }) + + it('should call handleAuctionEnd as AUCTION_END trigger event', function() { + sinon.spy(malltvAnalyticsAdapter, 'handleAuctionEnd') + events.emit(constants.EVENTS.AUCTION_END, {}) + sinon.assert.callCount(malltvAnalyticsAdapter.handleAuctionEnd, 1) + malltvAnalyticsAdapter.handleAuctionEnd.restore() + }) + }) + + describe('enableAnalytics and config parser', function () { + const configOptions = { propertyId, server } + + beforeEach(function () { + sinon.stub(events, 'getEvents').returns([]) + malltvAnalyticsAdapter.enableAnalytics({ + provider: 'malltvAnalytics', + options: configOptions + }) + }) + + afterEach(function () { + malltvAnalyticsAdapter.disableAnalytics() + events.getEvents.restore() + }) + + it('should parse config correctly with optional values', function () { + const { options, propertyId, server } = malltvAnalyticsAdapter.getAnalyticsOptions() + + expect(options).to.deep.equal(configOptions) + expect(propertyId).to.equal(configOptions.propertyId) + expect(server).to.equal(configOptions.server) + }) + + it('should not enable Analytics when propertyId is missing', function() { + const configOptions = { + options: { } + } + + const isConfigValid = malltvAnalyticsAdapter.initConfig(configOptions) + expect(isConfigValid).to.equal(false) + }) + + it('should use DEFAULT_SERVER when server is missing', function () { + const configOptions = { + options: { + propertyId + } + } + malltvAnalyticsAdapter.initConfig(configOptions) + expect(malltvAnalyticsAdapter.getAnalyticsOptions().server).to.equal(DEFAULT_SERVER) + }) + }) +})