diff --git a/libraries/vastTrackers/vastTrackers.js b/libraries/vastTrackers/vastTrackers.js index 7ab1650e9f9..68c20b5a955 100644 --- a/libraries/vastTrackers/vastTrackers.js +++ b/libraries/vastTrackers/vastTrackers.js @@ -1,11 +1,24 @@ import {callPrebidCache} from '../../src/auction.js'; import {VIDEO} from '../../src/mediaTypes.js'; -import {logError} from '../../src/utils.js'; +import {logError, isEmptyStr} from '../../src/utils.js'; +import {isArray, isPlainObject, isStr} from '../../src/utils/objects.js'; import {isActivityAllowed} from '../../src/activities/rules.js'; import {ACTIVITY_REPORT_ANALYTICS} from '../../src/activities/activities.js'; import {activityParams} from '../../src/activities/activityParams.js'; import {auctionManager} from '../../src/auctionManager.js'; +/** + * VAST Trackers Structure: + * { + * impression: string[], // Array of impression pixel URLs + * error: string[], // Array of error pixel URLs + * trackingEvents: Array<{ // Array of video playback tracking events + * event: string, // Event name (e.g., 'start', 'firstQuartile', 'midpoint', 'thirdQuartile', 'complete') + * url: string // Tracking pixel URL + * }> + * } + */ + const vastTrackers = []; let enabled = false; @@ -33,10 +46,7 @@ export function cacheVideoBidHook({index = auctionManager.index} = {}) { const vastTrackers = getVastTrackers(bidResponse, {index}); if (vastTrackers) { bidResponse.vastXml = insertVastTrackers(vastTrackers, bidResponse.vastXml); - const impTrackers = vastTrackers.get('impressions'); - if (impTrackers) { - bidResponse.vastImpUrl = [].concat([...impTrackers]).concat(bidResponse.vastImpUrl).filter(t => t); - } + bidResponse.vastTrackers = vastTrackers; } } next(auctionInstance, bidResponse, afterBidAdded, videoMediaType); @@ -58,13 +68,25 @@ export function insertVastTrackers(trackers, vastXml) { try { if (wrappers.length) { wrappers.forEach(wrapper => { - if (trackers.get('impressions')) { - trackers.get('impressions').forEach(trackingUrl => { + if (isArray(trackers.impression) && trackers.impression.length) { + trackers.impression.forEach(trackingUrl => { const impression = doc.createElement('Impression'); impression.appendChild(doc.createCDATASection(trackingUrl)); wrapper.appendChild(impression); }); } + + if (isArray(trackers.error) && trackers.error.length) { + trackers.error.forEach(trackingUrl => { + const errorElement = doc.createElement('Error'); + errorElement.appendChild(doc.createCDATASection(trackingUrl)); + wrapper.appendChild(errorElement); + }); + } + + if (isArray(trackers.trackingEvents) && trackers.trackingEvents.length) { + insertLinearTrackingEvents(doc, wrapper, trackers.trackingEvents); + } }); vastXml = new XMLSerializer().serializeToString(doc); } @@ -74,8 +96,65 @@ export function insertVastTrackers(trackers, vastXml) { return vastXml; } +/** + * Inserts tracking events into under elements. + * If doesn't exist, it will be created. + * @param {Document} doc - The parsed VAST XML document + * @param {Element} wrapper - The Wrapper or InLine element + * @param {Array<{event: string, url: string}>} trackers - Array of tracking event objects + */ +function insertLinearTrackingEvents(doc, wrapper, trackers) { + const linearElements = wrapper.querySelectorAll('Creatives Creative Linear'); + + if (linearElements.length > 0) { + linearElements.forEach(linear => { + let trackingEvents = linear.querySelector('TrackingEvents'); + if (!trackingEvents) { + trackingEvents = doc.createElement('TrackingEvents'); + linear.appendChild(trackingEvents); + } + appendTrackingElements(doc, trackingEvents, trackers); + }); + } else { + let creatives = wrapper.querySelector('Creatives'); + if (!creatives) { + creatives = doc.createElement('Creatives'); + wrapper.appendChild(creatives); + } + + const creative = doc.createElement('Creative'); + const linear = doc.createElement('Linear'); + const trackingEvents = doc.createElement('TrackingEvents'); + + appendTrackingElements(doc, trackingEvents, trackers); + linear.appendChild(trackingEvents); + creative.appendChild(linear); + creatives.appendChild(creative); + } +} + +/** + * Appends Tracking elements to a TrackingEvents element + * @param {Document} doc - The parsed VAST XML document + * @param {Element} trackingEvents - The TrackingEvents element to append to + * @param {Array<{event: string, url: string}>} trackers - Array of tracking event objects + */ +function appendTrackingElements(doc, trackingEvents, trackers) { + trackers.forEach(({event, url}) => { + const trackingElement = doc.createElement('Tracking'); + trackingElement.setAttribute('event', event); + trackingElement.appendChild(doc.createCDATASection(url)); + trackingEvents.appendChild(trackingElement); + }); +} + export function getVastTrackers(bid, {index = auctionManager.index}) { - const trackers = []; + const mergedTrackers = { + impression: [], + error: [], + trackingEvents: [] + }; + vastTrackers.filter( ({ moduleType, @@ -86,37 +165,48 @@ export function getVastTrackers(bid, {index = auctionManager.index}) { const auction = index.getAuction(bid).getProperties(); const bidRequest = index.getBidRequest(bid); const trackersToAdd = trackerFn(bid, {auction, bidRequest}); - trackersToAdd.forEach(trackerToAdd => { - if (isValidVastTracker(trackers, trackerToAdd)) { - trackers.push(trackerToAdd); + + if (isPlainObject(trackersToAdd)) { + if (isArray(trackersToAdd.impression)) { + trackersToAdd.impression.forEach(url => { + if (isStr(url) && !isEmptyStr(url)) { + mergedTrackers.impression.push(url); + } + }); } - }); + + if (isArray(trackersToAdd.error)) { + trackersToAdd.error.forEach(url => { + if (isStr(url) && !isEmptyStr(url)) { + mergedTrackers.error.push(url); + } + }); + } + + if (isArray(trackersToAdd.trackingEvents)) { + trackersToAdd.trackingEvents.forEach(tracker => { + if (isValidTrackingEvent(tracker)) { + mergedTrackers.trackingEvents.push(tracker); + } + }); + } + } }); - const trackersMap = trackersToMap(trackers); - return (trackersMap.size ? trackersMap : null); -}; -function isValidVastTracker(trackers, trackerToAdd) { - return trackerToAdd.hasOwnProperty('event') && trackerToAdd.hasOwnProperty('url'); -} + const hasTrackers = mergedTrackers.impression.length || + mergedTrackers.error.length || + mergedTrackers.trackingEvents.length; -function trackersToMap(trackers) { - return trackers.reduce((map, {url, event}) => { - !map.has(event) && map.set(event, new Set()); - map.get(event).add(url); - return map; - }, new Map()); + return hasTrackers ? mergedTrackers : null; } -export function addImpUrlToTrackers(bid, trackersMap) { - if (bid.vastImpUrl) { - if (!trackersMap) { - trackersMap = new Map(); - } - if (!trackersMap.get('impressions')) { - trackersMap.set('impressions', new Set()); - } - trackersMap.get('impressions').add(bid.vastImpUrl); - } - return trackersMap; +/** + * Validates a tracking event object + * @param {Object} tracker - The tracker object to validate + * @returns {boolean} - True if valid, false otherwise + */ +function isValidTrackingEvent(tracker) { + return isPlainObject(tracker) && + isStr(tracker.event) && !isEmptyStr(tracker.event) && + isStr(tracker.url) && !isEmptyStr(tracker.url); } diff --git a/src/videoCache.ts b/src/videoCache.ts index b63baf7dd43..350a9c26909 100644 --- a/src/videoCache.ts +++ b/src/videoCache.ts @@ -25,37 +25,65 @@ const ttlBufferInSeconds = 15; export const vastLocalCache = new Map(); +/** + * VAST Trackers interface for video cache + */ +export interface VastTrackers { + impression?: string[]; + error?: string[]; + trackingEvents?: Array<{ event: string; url: string }>; +} + /** * Function which wraps a URI that serves VAST XML, so that it can be loaded. * * @param uri The URI where the VAST content can be found. - * @param impTrackerURLs An impression tracker URL for the delivery of the video ad + * @param trackers VAST trackers object containing impression, error, and trackingEvents * @return A VAST URL which loads XML from the given URI. */ -function wrapURI(uri: string, impTrackerURLs: string | string[]) { - impTrackerURLs = impTrackerURLs && (Array.isArray(impTrackerURLs) ? impTrackerURLs : [impTrackerURLs]); +function wrapURI(uri: string, trackers?: VastTrackers) { // Technically, this is vulnerable to cross-script injection by sketchy vastUrl bids. // We could make sure it's a valid URI... but since we're loading VAST XML from the // URL they provide anyway, that's probably not a big deal. - const impressions = impTrackerURLs ? impTrackerURLs.map(trk => ``).join('') : ''; - return ` - - - prebid.org wrapper - - ${impressions} - - - - `; + + // Build Impression tags + const impressions = trackers?.impression?.length + ? trackers.impression.map(trk => ``).join('') + : ''; + + // Build Error tags + const errors = trackers?.error?.length + ? trackers.error.map(trk => ``).join('') + : ''; + + // Build TrackingEvents for Linear creative + let trackingEventsXml = ''; + if (trackers?.trackingEvents?.length) { + const trackingTags = trackers.trackingEvents + .map(({event, url}) => ``) + .join(''); + trackingEventsXml = `${trackingTags}`; + } + + return '' + + '' + + '' + + 'prebid.org wrapper' + + '' + + impressions + + errors + + '' + trackingEventsXml + '' + + '' + + '' + + ''; } declare module './bidfactory' { interface VideoBidResponseProperties { /** - * VAST impression trackers to attach to this bid. + * VAST trackers to attach to this bid (impression, error, and tracking events). */ - vastImpUrl?: string | string [] + vastTrackers?: VastTrackers /** * Cache key to use for caching this bid's VAST. */ @@ -185,7 +213,7 @@ function shimStorageCallback(done: VideoCacheStoreCallback) { } function getVastXml(bid) { - return bid.vastXml ? bid.vastXml : wrapURI(bid.vastUrl, bid.vastImpUrl); + return bid.vastXml ? bid.vastXml : wrapURI(bid.vastUrl, bid.vastTrackers); }; /** diff --git a/test/spec/libraries/vastTrackers_spec.js b/test/spec/libraries/vastTrackers_spec.js index ddd80e98f9d..8a8a4011ea5 100644 --- a/test/spec/libraries/vastTrackers_spec.js +++ b/test/spec/libraries/vastTrackers_spec.js @@ -1,6 +1,4 @@ import { - addImpUrlToTrackers, - addTrackersToResponse, getVastTrackers, insertVastTrackers, registerVastTrackers, @@ -37,9 +35,11 @@ describe('vast trackers', () => { sandbox = sinon.createSandbox(); index = new AuctionIndex(() => [auction]); tracker = sinon.stub().callsFake(function (bidResponse) { - return [ - {'event': 'impressions', 'url': `https://vasttracking.mydomain.com/vast?cpm=${bidResponse.cpm}`} - ]; + return { + impression: [`https://vasttracking.mydomain.com/vast?cpm=${bidResponse.cpm}`], + error: [], + trackingEvents: [] + }; }); registerVastTrackers(MODULE_TYPE_ANALYTICS, 'test', tracker); }) @@ -52,9 +52,9 @@ describe('vast trackers', () => { it('insert into tracker list', function () { const trackers = getVastTrackers(bid, {index}); - expect(trackers).to.be.a('map'); - expect(trackers.get('impressions')).to.exists; - expect(trackers.get('impressions').has('https://vasttracking.mydomain.com/vast?cpm=1')).to.be.true; + expect(trackers).to.be.an('object'); + expect(trackers.impression).to.be.an('array'); + expect(trackers.impression).to.include('https://vasttracking.mydomain.com/vast?cpm=1'); }); it('insert trackers in vastXml', function () { @@ -70,19 +70,159 @@ describe('vast trackers', () => { sinon.assert.calledWith(tracker, bid, sinon.match({auction: auction.getProperties(), bidRequest})) }) - it('test addImpUrlToTrackers', function () { - const trackers = addImpUrlToTrackers({'vastImpUrl': 'imptracker.com'}, getVastTrackers(bid, {index})); - expect(trackers).to.be.a('map'); - expect(trackers.get('impressions')).to.exists; - expect(trackers.get('impressions').has('imptracker.com')).to.be.true; - }); - if (FEATURES.VIDEO) { it('should add trackers to bid response', () => { cacheVideoBidHook({index})(sinon.stub(), 'au', bid); - expect(bid.vastImpUrl).to.eql([ + expect(bid.vastTrackers).to.be.an('object'); + expect(bid.vastTrackers.impression).to.eql([ 'https://vasttracking.mydomain.com/vast?cpm=1' - ]) + ]); + expect(bid.vastTrackers.error).to.eql([]); + expect(bid.vastTrackers.trackingEvents).to.eql([]); }); } + + describe('error tracking', () => { + beforeEach(() => { + reset(); + }); + + it('should insert error trackers in vastXml', function () { + const errorTracker = sinon.stub().callsFake(function (bidResponse) { + return { + impression: [], + error: [`https://error.mydomain.com/error?cpm=${bidResponse.cpm}`], + trackingEvents: [] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'errorTest', errorTracker); + + const trackers = getVastTrackers(bid, {index}); + let vastXml = ''; + vastXml = insertVastTrackers(trackers, vastXml); + expect(vastXml).to.contain(''); + }); + + it('should insert multiple error trackers', function () { + const errorTracker = sinon.stub().callsFake(function () { + return { + impression: [], + error: ['https://error1.mydomain.com/error', 'https://error2.mydomain.com/error'], + trackingEvents: [] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'errorTest', errorTracker); + + const trackers = getVastTrackers(bid, {index}); + let vastXml = ''; + vastXml = insertVastTrackers(trackers, vastXml); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + }); + + it('should insert both impression and error trackers', function () { + const mixedTracker = sinon.stub().callsFake(function () { + return { + impression: ['https://impression.mydomain.com/imp'], + error: ['https://error.mydomain.com/error'], + trackingEvents: [] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'mixedTest', mixedTracker); + + const trackers = getVastTrackers(bid, {index}); + let vastXml = ''; + vastXml = insertVastTrackers(trackers, vastXml); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + }); + }); + + describe('video playback tracking events', () => { + beforeEach(() => { + reset(); + }); + + it('should insert video playback tracker in vastXml with existing Linear element', function () { + const playbackTracker = sinon.stub().callsFake(function () { + return { + impression: [], + error: [], + trackingEvents: [ + {event: 'start', url: 'https://tracking.mydomain.com/start'} + ] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'playbackTest', playbackTracker); + + const trackers = getVastTrackers(bid, {index}); + let vastXml = '00:00:30'; + vastXml = insertVastTrackers(trackers, vastXml); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + }); + + it('should append to existing TrackingEvents element', function () { + const playbackTracker = sinon.stub().callsFake(function () { + return { + impression: [], + error: [], + trackingEvents: [ + {event: 'start', url: 'https://tracking.mydomain.com/start'} + ] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'appendTest', playbackTracker); + + const trackers = getVastTrackers(bid, {index}); + let vastXml = '00:00:30'; + vastXml = insertVastTrackers(trackers, vastXml); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + }); + + it('should create Linear structure when not present', function () { + const playbackTracker = sinon.stub().callsFake(function () { + return { + impression: [], + error: [], + trackingEvents: [ + {event: 'start', url: 'https://tracking.mydomain.com/start'} + ] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'createStructureTest', playbackTracker); + + const trackers = getVastTrackers(bid, {index}); + let vastXml = ''; + vastXml = insertVastTrackers(trackers, vastXml); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + expect(vastXml).to.contain(''); + }); + + it('should validate tracking events have both event and url', function () { + const invalidTracker = sinon.stub().callsFake(function () { + return { + impression: [], + error: [], + trackingEvents: [ + {event: 'start', url: 'https://tracking.mydomain.com/start'}, + {event: 'midpoint'}, // missing url + {url: 'https://tracking.mydomain.com/invalid'}, // missing event + {event: '', url: 'https://tracking.mydomain.com/empty'}, // empty event + {event: 'complete', url: ''} // empty url + ] + }; + }); + registerVastTrackers(MODULE_TYPE_ANALYTICS, 'validationTest', invalidTracker); + + const trackers = getVastTrackers(bid, {index}); + // Only the valid tracker should be included + expect(trackers.trackingEvents).to.have.lengthOf(1); + expect(trackers.trackingEvents[0].event).to.equal('start'); + }); + }); }) diff --git a/test/spec/videoCache_spec.js b/test/spec/videoCache_spec.js index 76a3bea0127..496f4c76b9f 100644 --- a/test/spec/videoCache_spec.js +++ b/test/spec/videoCache_spec.js @@ -97,43 +97,57 @@ describe('The video cache', function () { }); it('should make the expected request when store() is called on an ad with a vastUrl', function () { - const expectedValue = ` - - - prebid.org wrapper - \n \n - - - `; + const expectedValue = 'prebid.org wrapper'; assertRequestMade({ vastUrl: 'my-mock-url.com', ttl: 25 }, expectedValue) }); - it('should make the expected request when store() is called on an ad with a vastUrl and a vastImpUrl', function () { - const expectedValue = ` - - - prebid.org wrapper - - - - - - `; - assertRequestMade({ vastUrl: 'my-mock-url.com', vastImpUrl: 'imptracker.com', ttl: 25 }, expectedValue) + it('should make the expected request when store() is called on an ad with a vastUrl and impression trackers', function () { + const expectedValue = 'prebid.org wrapper'; + assertRequestMade({ vastUrl: 'my-mock-url.com', vastTrackers: { impression: ['imptracker.com'] }, ttl: 25 }, expectedValue) }); - it('should include multiple vastImpUrl when it\'s an array', function() { - const expectedValue = ` - - - prebid.org wrapper - - - - - - `; - assertRequestMade({ vastUrl: 'my-mock-url.com', vastImpUrl: ['https://vasttracking.mydomain.com/vast?cpm=1.2', 'imptracker.com'], ttl: 25, cpm: 1.2 }, expectedValue) + it('should include multiple impression trackers', function() { + const expectedValue = 'prebid.org wrapper'; + assertRequestMade({ vastUrl: 'my-mock-url.com', vastTrackers: { impression: ['https://vasttracking.mydomain.com/vast?cpm=1.2', 'imptracker.com'] }, ttl: 25, cpm: 1.2 }, expectedValue) + }); + + it('should include error trackers', function() { + const expectedValue = 'prebid.org wrapper'; + assertRequestMade({ vastUrl: 'my-mock-url.com', vastTrackers: { error: ['https://error.mydomain.com/error'] }, ttl: 25 }, expectedValue) + }); + + it('should include both impression and error trackers', function() { + const expectedValue = 'prebid.org wrapper'; + assertRequestMade({ vastUrl: 'my-mock-url.com', vastTrackers: { impression: ['imptracker.com'], error: ['https://error.mydomain.com/error'] }, ttl: 25 }, expectedValue) + }); + + it('should include tracking events', function() { + const expectedValue = 'prebid.org wrapper'; + assertRequestMade({ + vastUrl: 'my-mock-url.com', + vastTrackers: { + trackingEvents: [ + { event: 'start', url: 'https://tracking.mydomain.com/start' }, + { event: 'complete', url: 'https://tracking.mydomain.com/complete' } + ] + }, + ttl: 25 + }, expectedValue) + }); + + it('should include all tracker types together', function() { + const expectedValue = 'prebid.org wrapper'; + assertRequestMade({ + vastUrl: 'my-mock-url.com', + vastTrackers: { + impression: ['imptracker.com'], + error: ['https://error.mydomain.com/error'], + trackingEvents: [ + { event: 'start', url: 'https://tracking.mydomain.com/start' } + ] + }, + ttl: 25 + }, expectedValue) }); it('should make the expected request when store() is called on an ad with vastXml', function () {