Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for instream video ads bidsWon tracking #5481

Merged
merged 1 commit into from
Sep 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions modules/dfpAdServerVideo.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { deepAccess, isEmpty, logError, parseSizesInput, formatQS, parseUrl, bui
import { config } from '../src/config.js';
import { getHook, submodule } from '../src/hook.js';
import { auctionManager } from '../src/auctionManager.js';
import events from '../src/events.js';
import CONSTANTS from '../src/constants.json';

/**
* @typedef {Object} DfpVideoParams
Expand Down Expand Up @@ -245,17 +247,20 @@ function getCustParams(bid, options) {
allTargetingData = (allTargeting) ? allTargeting[adUnit.code] : {};
}

const optCustParams = deepAccess(options, 'params.cust_params');
let customParams = Object.assign({},
const prebidTargetingSet = Object.assign({},
// Why are we adding standard keys here ? Refer https://github.com/prebid/Prebid.js/issues/3664
{ hb_uuid: bid && bid.videoCacheKey },
// hb_uuid will be deprecated and replaced by hb_cache_id
{ hb_cache_id: bid && bid.videoCacheKey },
allTargetingData,
adserverTargeting,
optCustParams,
);
return encodeURIComponent(formatQS(customParams));
events.emit(CONSTANTS.EVENTS.SET_TARGETING, {[adUnit.code]: prebidTargetingSet});
monis0395 marked this conversation as resolved.
Show resolved Hide resolved

// merge the prebid + publisher targeting sets
const publisherTargetingSet = deepAccess(options, 'params.cust_params');
const targetingSet = Object.assign({}, prebidTargetingSet, publisherTargetingSet);
return encodeURIComponent(formatQS(targetingSet));
}

registerVideoSupport('dfp', {
Expand Down
114 changes: 114 additions & 0 deletions modules/instreamTracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { config } from '../src/config.js';
import { auctionManager } from '../src/auctionManager.js';
import { INSTREAM } from '../src/video.js';
import * as events from '../src/events.js';
import * as utils from '../src/utils.js';
import { BID_STATUS, EVENTS, TARGETING_KEYS } from '../src/constants.json';

const {CACHE_ID, UUID} = TARGETING_KEYS;
const {BID_WON, AUCTION_END} = EVENTS;
const {RENDERED} = BID_STATUS;

const INSTREAM_TRACKING_DEFAULT_CONFIG = {
enabled: false,
maxWindow: 1000 * 60, // the time in ms after which polling for instream delivery stops
pollingFreq: 500 // the frequency of polling
};

// Set instreamTracking default values
config.setDefaults({
'instreamTracking': utils.deepClone(INSTREAM_TRACKING_DEFAULT_CONFIG)
});

const whitelistedResources = /video|fetch|xmlhttprequest|other/;

/**
* Here the idea is
* find all network entries via performance.getEntriesByType()
* filter it by video cache key in the url
* and exclude the ad server urls so that we dont match twice
* eg:
* dfp ads call: https://securepubads.g.doubleclick.net/gampad/ads?...hb_cache_id%3D55e85cd3-6ea4-4469-b890-84241816b131%26...
* prebid cache url: https://prebid.adnxs.com/pbc/v1/cache?uuid=55e85cd3-6ea4-4469-b890-84241816b131
*
* if the entry exists, emit the BID_WON
*
* Note: this is a workaround till a better approach is engineered.
*
* @param {Array<AdUnit>} adUnits
* @param {Array<Bid>} bidsReceived
* @param {Array<BidRequest>} bidderRequests
*
* @return {boolean} returns TRUE if tracking started
*/
export function trackInstreamDeliveredImpressions({adUnits, bidsReceived, bidderRequests}) {
const instreamTrackingConfig = config.getConfig('instreamTracking') || {};
// check if instreamTracking is enabled and performance api is available
if (!instreamTrackingConfig.enabled || !window.performance || !window.performance.getEntriesByType) {
return false;
}

// filter for video bids
const instreamBids = bidsReceived.filter(bid => {
const bidderRequest = utils.getBidRequest(bid.requestId, bidderRequests);
return bidderRequest && utils.deepAccess(bidderRequest, 'mediaTypes.video.context') === INSTREAM && bid.videoCacheKey;
});
if (!instreamBids.length) {
return false;
}

// find unique instream ad units
const instreamAdUnitMap = {};
adUnits.forEach(adUnit => {
if (!instreamAdUnitMap[adUnit.code] && utils.deepAccess(adUnit, 'mediaTypes.video.context') === INSTREAM) {
instreamAdUnitMap[adUnit.code] = true;
}
});
const instreamAdUnitsCount = Object.keys(instreamAdUnitMap).length;

const start = Date.now();
const {maxWindow, pollingFreq, urlPattern} = instreamTrackingConfig;

let instreamWinningBidsCount = 0;
let lastRead = 0; // offset for performance.getEntriesByType

function poll() {
// get network entries using the last read offset
const entries = window.performance.getEntriesByType('resource').splice(lastRead);
for (const resource of entries) {
const url = resource.name;
// check if the resource is of whitelisted resource to avoid checking img or css or script urls
if (!whitelistedResources.test(resource.initiatorType)) {
continue;
}

instreamBids.forEach((bid) => {
// match the video cache key excluding ad server call
const matches = !(url.indexOf(CACHE_ID) !== -1 || url.indexOf(UUID) !== -1) && url.indexOf(bid.videoCacheKey) !== -1;
if (urlPattern && urlPattern instanceof RegExp && !urlPattern.test(url)) {
return;
}
if (matches && bid.status !== RENDERED) {
// video found
instreamWinningBidsCount++;
auctionManager.addWinningBid(bid);
events.emit(BID_WON, bid);
}
});
}
// update offset
lastRead += entries.length;

const timeElapsed = Date.now() - start;
if (timeElapsed < maxWindow && instreamWinningBidsCount < instreamAdUnitsCount) {
setTimeout(poll, pollingFreq);
}
}

// start polling for network entries
setTimeout(poll, pollingFreq);

return true;
}

events.on(AUCTION_END, trackInstreamDeliveredImpressions)
221 changes: 221 additions & 0 deletions test/spec/modules/instreamTracking_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { assert } from 'chai';
import { trackInstreamDeliveredImpressions } from 'modules/instreamTracking.js';
import { config } from 'src/config.js';
import * as events from 'src/events.js';
import * as utils from 'src/utils.js';
import * as sinon from 'sinon';
import { INSTREAM, OUTSTREAM } from 'src/video.js';

const BIDDER_CODE = 'sampleBidder';
const VIDEO_CACHE_KEY = '4cf395af-8fee-4960-af0e-88d44e399f14';

let sandbox;

function enableInstreamTracking(regex) {
let configStub = sandbox.stub(config, 'getConfig');
configStub.withArgs('instreamTracking').returns(Object.assign(
{
enabled: true,
maxWindow: 10,
pollingFreq: 0
},
regex && {urlPattern: regex},
));
}

function mockPerformanceApi({adServerCallSent, videoPresent}) {
let performanceStub = sandbox.stub(window.performance, 'getEntriesByType');
let entries = [{
name: 'https://domain.com/img.png',
initiatorType: 'img'
}, {
name: 'https://domain.com/script.js',
initiatorType: 'script'
}, {
name: 'https://domain.com/xhr',
initiatorType: 'xmlhttprequest'
}, {
name: 'https://domain.com/fetch',
initiatorType: 'fetch'
}];

if (adServerCallSent || videoPresent) {
entries.push({
name: 'https://adserver.com/ads?custom_params=hb_uuid%3D' + VIDEO_CACHE_KEY + '%26pos%3D' + VIDEO_CACHE_KEY,
initiatorType: 'xmlhttprequest'
});
}

if (videoPresent) {
entries.push({
name: 'https://prebid-vast-cache.com/cache?key=' + VIDEO_CACHE_KEY,
initiatorType: 'xmlhttprequest'
});
}

performanceStub.withArgs('resource').returns(entries);
}

function mockBidResponse(adUnit, requestId) {
const bid = {
'adUnitCod': adUnit.code,
'bidderCode': adUnit.bids[0].bidder,
'width': adUnit.sizes[0][0],
'height': adUnit.sizes[0][1],
'statusMessage': 'Bid available',
'adId': 'id',
'requestId': requestId,
'source': 'client',
'no_bid': false,
'cpm': '1.1495',
'ttl': 180,
'creativeId': 'id',
'netRevenue': true,
'currency': 'USD',
}
if (adUnit.mediaTypes.video) {
bid.videoCacheKey = VIDEO_CACHE_KEY;
}
return bid
}

function mockBidRequest(adUnit, bidResponse) {
return {
'bidderCode': bidResponse.bidderCode,
'auctionId': '20882439e3238c',
'bidderRequestId': 'bidderRequestId',
'bids': [
{
'adUnitCode': adUnit.code,
'mediaTypes': adUnit.mediaTypes,
'bidder': bidResponse.bidderCode,
'bidId': bidResponse.requestId,
'sizes': adUnit.sizes,
'params': adUnit.bids[0].params,
'bidderRequestId': 'bidderRequestId',
'auctionId': '20882439e3238c',
}
],
'auctionStart': 1505250713622,
'timeout': 3000
};
}

function getMockInput(mediaType) {
const bannerAdUnit = {
code: 'banner',
mediaTypes: {banner: {sizes: [[300, 250]]}},
sizes: [[300, 250]],
bids: [{bidder: BIDDER_CODE, params: {placementId: 'id'}}]
};
const outStreamAdUnit = {
code: 'video-' + OUTSTREAM,
mediaTypes: {video: {playerSize: [640, 480], context: OUTSTREAM}},
sizes: [[640, 480]],
bids: [{bidder: BIDDER_CODE, params: {placementId: 'id'}}]
};
const inStreamAdUnit = {
code: 'video-' + INSTREAM,
mediaTypes: {video: {playerSize: [640, 480], context: INSTREAM}},
sizes: [[640, 480]],
bids: [{bidder: BIDDER_CODE, params: {placementId: 'id'}}]
};

let adUnit;
switch (mediaType) {
default:
case 'banner':
adUnit = bannerAdUnit;
break;
case OUTSTREAM:
adUnit = outStreamAdUnit;
break;
case INSTREAM:
adUnit = inStreamAdUnit;
break;
}

const bidResponse = mockBidResponse(adUnit, utils.getUniqueIdentifierStr());
const bidderRequest = mockBidRequest(adUnit, bidResponse);
return {
adUnits: [adUnit],
bidsReceived: [bidResponse],
bidderRequests: [bidderRequest],
};
}

describe('Instream Tracking', function () {
beforeEach(function () {
sandbox = sinon.sandbox.create();
});

afterEach(function () {
sandbox.restore();
});

describe('gaurd checks', function () {
it('skip if tracking not enable', function () {
sandbox.stub(config, 'getConfig').withArgs('instreamTracking').returns(undefined);
assert.isNotOk(trackInstreamDeliveredImpressions({
adUnits: [],
bidsReceived: [],
bidderRequests: []
}), 'should not start tracking when tracking is disabled');
});

it('run only if instream bids are present', function () {
enableInstreamTracking();
assert.isNotOk(trackInstreamDeliveredImpressions({adUnits: [], bidsReceived: [], bidderRequests: []}));
});

it('checks for instream bids', function (done) {
enableInstreamTracking();
assert.isNotOk(trackInstreamDeliveredImpressions(getMockInput('banner')), 'should not start tracking when banner bids are present')
assert.isNotOk(trackInstreamDeliveredImpressions(getMockInput(OUTSTREAM)), 'should not start tracking when outstream bids are present')
mockPerformanceApi({});
assert.isOk(trackInstreamDeliveredImpressions(getMockInput(INSTREAM)), 'should start tracking when instream bids are present')
setTimeout(done, 10);
});
});

describe('instream bids check', function () {
let spyEventsOn;

beforeEach(function () {
spyEventsOn = sandbox.spy(events, 'emit');
});

it('BID WON event is not emitted when no video cache key entries are present', function (done) {
enableInstreamTracking();
trackInstreamDeliveredImpressions(getMockInput(INSTREAM));
mockPerformanceApi({});
setTimeout(function () {
assert.isNotOk(spyEventsOn.calledWith('bidWon'))
done()
}, 10);
});

it('BID WON event is not emitted when ad server call is sent', function (done) {
enableInstreamTracking();
mockPerformanceApi({adServerCallSent: true});
setTimeout(function () {
assert.isNotOk(spyEventsOn.calledWith('bidWon'))
done()
}, 10);
});

it('BID WON event is emitted when video cache key is present', function (done) {
enableInstreamTracking(/cache/);
const bidWonSpy = sandbox.spy();
events.on('bidWon', bidWonSpy);
mockPerformanceApi({adServerCallSent: true, videoPresent: true});

trackInstreamDeliveredImpressions(getMockInput(INSTREAM));
setTimeout(function () {
assert.isOk(spyEventsOn.calledWith('bidWon'))
assert(bidWonSpy.args[0][0].videoCacheKey, VIDEO_CACHE_KEY, 'Video cache key in bid won should be equal to video cache call');
done()
}, 10);
});
});
});