Skip to content
Open
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
160 changes: 125 additions & 35 deletions libraries/vastTrackers/vastTrackers.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -74,8 +96,65 @@ export function insertVastTrackers(trackers, vastXml) {
return vastXml;
}

/**
* Inserts tracking events into <TrackingEvents> under <Linear> elements.
* If <TrackingEvents> 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,
Expand All @@ -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)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not backwards compatible is it? since there's only one user IMO it's worth refactoring it as part of this.

return [
{
event: 'impressions',
url: `${GET_ENDPOINT_RA}?${getLoggingPayload(queryParams, LOG_RA)}`,
},
];

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);
}
62 changes: 45 additions & 17 deletions src/videoCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => `<Impression><![CDATA[${trk}]]></Impression>`).join('') : '';
return `<VAST version="3.0">
<Ad>
<Wrapper>
<AdSystem>prebid.org wrapper</AdSystem>
<VASTAdTagURI><![CDATA[${uri}]]></VASTAdTagURI>
${impressions}
<Creatives></Creatives>
</Wrapper>
</Ad>
</VAST>`;

// Build Impression tags
const impressions = trackers?.impression?.length
? trackers.impression.map(trk => `<Impression><![CDATA[${trk}]]></Impression>`).join('')
: '';

// Build Error tags
const errors = trackers?.error?.length
? trackers.error.map(trk => `<Error><![CDATA[${trk}]]></Error>`).join('')
: '';

// Build TrackingEvents for Linear creative
let trackingEventsXml = '';
if (trackers?.trackingEvents?.length) {
const trackingTags = trackers.trackingEvents
.map(({event, url}) => `<Tracking event="${event}"><![CDATA[${url}]]></Tracking>`)
.join('');
trackingEventsXml = `<Creative><Linear><TrackingEvents>${trackingTags}</TrackingEvents></Linear></Creative>`;
}

return '<VAST version="3.0">' +
'<Ad>' +
'<Wrapper>' +
'<AdSystem>prebid.org wrapper</AdSystem>' +
'<VASTAdTagURI><![CDATA[' + uri + ']]></VASTAdTagURI>' +
impressions +
errors +
'<Creatives>' + trackingEventsXml + '</Creatives>' +
'</Wrapper>' +
'</Ad>' +
'</VAST>';
}

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's more adapters that are populating vastImpUrl - either they should be refactored or this should keep accepting it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. We could introduce a fallback (vastImpUrl) for backward compatibility. However, this would require maintaining two separate mechanisms for the same feature. To minimize confusion and reduce long-term maintenance overhead, I recommend standardizing on the new vastTrackers approach instead of supporting both.

  2. To ensure the latest changes related to vastImpUrl are consistently applied across all impacted components, we can update the following files:

    • targetVideoUtils/bidderUtils.js
    • adrelevantisBidAdapter.js
    • appnexusBidAdapter.js
    • mediafuseBidAdapter.js
    • medianetAnalyticsAdapter.js

Please let me know if you have a different opinion or any concerns.

/**
* Cache key to use for caching this bid's VAST.
*/
Expand Down Expand Up @@ -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);
Comment on lines 215 to +216

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve vastImpUrl when wrapping vastUrl bids

This call only passes bid.vastTrackers into wrapURI, so bids that still populate the legacy vastImpUrl (which multiple adapters in this repo do) will no longer get their impression trackers injected when the bid only has vastUrl. That’s a behavior regression for existing adapters unless they’re all updated to emit vastTrackers. Consider merging vastImpUrl into vastTrackers.impression (or passing both) to keep backward compatibility.

Useful? React with 👍 / 👎.

};

/**
Expand Down
Loading
Loading