From 4ad40247de7a1c6b6fae3790300209502b31b49f Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 20 Jan 2022 12:30:39 -0800 Subject: [PATCH] Prebid core: accept and propagate AD_RENDER_FAILED / AD_RENDER_SUCCEEDED events from cross-origin ads (#7917) * Prebid core: accept and propagate AD_RENDER_FAILED / AD_RENDER_SUCCEEDED events from cross-origin ads This adds a new type of cross-origin message ('Prebid Event') to allow PUC and other cross-origin renderings to correctly report rendering result, and generates the appropriate events. Addresses https://github.com/prebid/Prebid.js/issues/7702 Related PUC changes: https://github.com/prebid/prebid-universal-creative/pull/152 Documentation changes TBD * Emit AD_RENDER_FAILED on x-origin communication errors * Do not consider bid won if x-origin render fails * Cleanup (address https://github.com/prebid/Prebid.js/pull/7917#pullrequestreview-856914369) --- .../gpt/x-domain/creative.html | 79 ++++++--- src/adRendering.js | 38 +++++ src/prebid.js | 20 +-- src/secureCreatives.js | 153 +++++++++++++----- src/utils.js | 16 +- test/spec/unit/secureCreatives_spec.js | 85 ++++++++++ 6 files changed, 302 insertions(+), 89 deletions(-) create mode 100644 src/adRendering.js diff --git a/integrationExamples/gpt/x-domain/creative.html b/integrationExamples/gpt/x-domain/creative.html index fce46bb380f..610fb222787 100644 --- a/integrationExamples/gpt/x-domain/creative.html +++ b/integrationExamples/gpt/x-domain/creative.html @@ -2,37 +2,40 @@ // this script can be returned by an ad server delivering a cross domain iframe, into which the // creative will be rendered, e.g. DFP delivering a SafeFrame -let windowLocation = window.location; -var urlParser = document.createElement('a'); +const windowLocation = window.location; +const urlParser = document.createElement('a'); urlParser.href = '%%PATTERN:url%%'; -var publisherDomain = urlParser.protocol + '//' + urlParser.hostname; +const publisherDomain = urlParser.protocol + '//' + urlParser.hostname; +const adId = '%%PATTERN:hb_adid%%'; function renderAd(ev) { - var key = ev.message ? 'message' : 'data'; - var adObject = {}; - try { - adObject = JSON.parse(ev[key]); - } catch (e) { - return; - } + const key = ev.message ? 'message' : 'data'; + let adObject = {}; + try { + adObject = JSON.parse(ev[key]); + } catch (e) { + return; + } - var origin = ev.origin || ev.originalEvent.origin; - if (adObject.message && adObject.message === 'Prebid Response' && - publisherDomain === origin && - adObject.adId === '%%PATTERN:hb_adid%%' && - (adObject.ad || adObject.adUrl)) { - var body = window.document.body; - var ad = adObject.ad; - var url = adObject.adUrl; - var width = adObject.width; - var height = adObject.height; + const origin = ev.origin || ev.originalEvent.origin; + if (adObject.message && adObject.message === 'Prebid Response' && + publisherDomain === origin && + adObject.adId === adId) { + try { + const body = window.document.body; + const ad = adObject.ad; + const url = adObject.adUrl; + const width = adObject.width; + const height = adObject.height; if (adObject.mediaType === 'video') { + signalRenderResult(false, { + reason: 'preventWritingOnMainDocument', + message: `Cannot render video ad ${adId}` + }); console.log('Error trying to write ad.'); - } else - - if (ad) { - var frame = document.createElement('iframe'); + } else if (ad) { + const frame = document.createElement('iframe'); frame.setAttribute('FRAMEBORDER', 0); frame.setAttribute('SCROLLING', 'no'); frame.setAttribute('MARGINHEIGHT', 0); @@ -46,18 +49,42 @@ frame.contentDocument.open(); frame.contentDocument.write(ad); frame.contentDocument.close(); + signalRenderResult(true); } else if (url) { body.insertAdjacentHTML('beforeend', ''); + signalRenderResult(true); } else { - console.log('Error trying to write ad. No ad for bid response id: ' + id); + signalRenderResult(false, { + reason: 'noAd', + message: `No ad for ${adId}` + }); + console.log(`Error trying to write ad. No ad markup or adUrl for ${adId}`); } + } catch (e) { + signalRenderResult(false, {reason: 'exception', message: e.message}); + console.log(`Error in rendering ad`, e); } } + function signalRenderResult(success, {reason, message} = {}) { + const payload = { + message: 'Prebid Event', + adId, + event: success ? 'adRenderSucceeded' : 'adRenderFailed', + } + if (!success) { + payload.info = {reason, message}; + } + ev.source.postMessage(JSON.stringify(payload), publisherDomain); + } + +} + + function requestAdFromPrebid() { var message = JSON.stringify({ message: 'Prebid Request', - adId: '%%PATTERN:hb_adid%%' + adId }); window.parent.postMessage(message, publisherDomain); } diff --git a/src/adRendering.js b/src/adRendering.js new file mode 100644 index 00000000000..3bd4b0b918f --- /dev/null +++ b/src/adRendering.js @@ -0,0 +1,38 @@ +import {logError} from './utils.js'; +import events from './events.js'; +import CONSTANTS from './constants.json'; + +const {AD_RENDER_FAILED, AD_RENDER_SUCCEEDED} = CONSTANTS.EVENTS; + +/** + * Emit the AD_RENDER_FAILED event. + * + * @param reason one of the values in CONSTANTS.AD_RENDER_FAILED_REASON + * @param message failure description + * @param bid? bid response object that failed to render + * @param id? adId that failed to render + */ +export function emitAdRenderFail({ reason, message, bid, id }) { + const data = { reason, message }; + if (bid) data.bid = bid; + if (id) data.adId = id; + + logError(message); + events.emit(AD_RENDER_FAILED, data); +} + +/** + * Emit the AD_RENDER_SUCCEEDED event. + * + * @param doc document object that was used to `.write` the ad. Should be `null` if unavailable (e.g. for documents in + * a cross-origin frame). + * @param bid bid response object for the ad that was rendered + * @param id adId that was rendered. + */ +export function emitAdRenderSucceeded({ doc, bid, id }) { + const data = { doc }; + if (bid) data.bid = bid; + if (id) data.adId = id; + + events.emit(AD_RENDER_SUCCEEDED, data); +} diff --git a/src/prebid.js b/src/prebid.js index 3880e628dca..e27d73c40b0 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -19,6 +19,7 @@ import { adunitCounter } from './adUnits.js'; import { executeRenderer, isRendererRequired } from './Renderer.js'; import { createBid } from './bidfactory.js'; import { storageCallbacks } from './storageManager.js'; +import { emitAdRenderSucceeded, emitAdRenderFail } from './adRendering.js'; const $$PREBID_GLOBAL$$ = getGlobal(); const CONSTANTS = require('./constants.json'); @@ -27,7 +28,7 @@ const events = require('./events.js'); const { triggerUserSyncs } = userSync; /* private variables */ -const { ADD_AD_UNITS, BID_WON, REQUEST_BIDS, SET_TARGETING, AD_RENDER_FAILED, AD_RENDER_SUCCEEDED, STALE_RENDER } = CONSTANTS.EVENTS; +const { ADD_AD_UNITS, BID_WON, REQUEST_BIDS, SET_TARGETING, STALE_RENDER } = CONSTANTS.EVENTS; const { PREVENT_WRITING_ON_MAIN_DOCUMENT, NO_AD, EXCEPTION, CANNOT_FIND_AD, MISSING_DOC_OR_ADID } = CONSTANTS.AD_RENDER_FAILED_REASON; const eventValidators = { @@ -385,23 +386,6 @@ $$PREBID_GLOBAL$$.setTargetingForAst = function (adUnitCodes) { events.emit(SET_TARGETING, targeting.getAllTargeting()); }; -function emitAdRenderFail({ reason, message, bid, id }) { - const data = { reason, message }; - if (bid) data.bid = bid; - if (id) data.adId = id; - - logError(message); - events.emit(AD_RENDER_FAILED, data); -} - -function emitAdRenderSucceeded({ doc, bid, id }) { - const data = { doc }; - if (bid) data.bid = bid; - if (id) data.adId = id; - - events.emit(AD_RENDER_SUCCEEDED, data); -} - /** * This function will check for presence of given node in given parent. If not present - will inject it. * @param {Node} node node, whose existance is in question diff --git a/src/secureCreatives.js b/src/secureCreatives.js index 01a84ae254a..8622274a9d3 100644 --- a/src/secureCreatives.js +++ b/src/secureCreatives.js @@ -4,18 +4,25 @@ */ import events from './events.js'; -import { fireNativeTrackers, getAssetMessage, getAllAssetsMessage } from './native.js'; +import {fireNativeTrackers, getAllAssetsMessage, getAssetMessage} from './native.js'; import constants from './constants.json'; -import { logWarn, replaceAuctionPrice, deepAccess, isGptPubadsDefined, isApnGetTagDefined } from './utils.js'; -import { auctionManager } from './auctionManager.js'; +import {deepAccess, isApnGetTagDefined, isGptPubadsDefined, logError, logWarn, replaceAuctionPrice} from './utils.js'; +import {auctionManager} from './auctionManager.js'; import find from 'core-js-pure/features/array/find.js'; -import { isRendererRequired, executeRenderer } from './Renderer.js'; +import {executeRenderer, isRendererRequired} from './Renderer.js'; import includes from 'core-js-pure/features/array/includes.js'; -import { config } from './config.js'; +import {config} from './config.js'; +import {emitAdRenderFail, emitAdRenderSucceeded} from './adRendering.js'; const BID_WON = constants.EVENTS.BID_WON; const STALE_RENDER = constants.EVENTS.STALE_RENDER; +const HANDLER_MAP = { + 'Prebid Request': handleRenderRequest, + 'Prebid Native': handleNativeRequest, + 'Prebid Event': handleEventRequest, +} + export function listenMessagesFromCreative() { window.addEventListener('message', receiveMessage, false); } @@ -29,52 +36,114 @@ export function receiveMessage(ev) { return; } - if (data && data.adId) { + if (data && data.adId && data.message) { const adObject = find(auctionManager.getBidsReceived(), function (bid) { return bid.adId === data.adId; }); + if (HANDLER_MAP.hasOwnProperty(data.message)) { + HANDLER_MAP[data.message](ev, data, adObject); + } + } +} - if (adObject && data.message === 'Prebid Request') { - if (adObject.status === constants.BID_STATUS.RENDERED) { - logWarn(`Ad id ${adObject.adId} has been rendered before`); - events.emit(STALE_RENDER, adObject); - if (deepAccess(config.getConfig('auctionOptions'), 'suppressStaleRender')) { - return; - } - } +function handleRenderRequest(ev, data, adObject) { + if (adObject == null) { + emitAdRenderFail({ + reason: constants.AD_RENDER_FAILED_REASON.CANNOT_FIND_AD, + message: `Cannot find ad '${data.adId}' for cross-origin render request`, + id: data.adId + }); + return; + } + if (adObject.status === constants.BID_STATUS.RENDERED) { + logWarn(`Ad id ${adObject.adId} has been rendered before`); + events.emit(STALE_RENDER, adObject); + if (deepAccess(config.getConfig('auctionOptions'), 'suppressStaleRender')) { + return; + } + } - _sendAdToCreative(adObject, ev); + try { + _sendAdToCreative(adObject, ev); + } catch (e) { + emitAdRenderFail({ + reason: constants.AD_RENDER_FAILED_REASON.EXCEPTION, + message: e.message, + id: data.adId, + bid: adObject + }); + return; + } - // save winning bids - auctionManager.addWinningBid(adObject); + // save winning bids + auctionManager.addWinningBid(adObject); - events.emit(BID_WON, adObject); - } + events.emit(BID_WON, adObject); +} - // handle this script from native template in an ad server - // window.parent.postMessage(JSON.stringify({ - // message: 'Prebid Native', - // adId: '%%PATTERN:hb_adid%%' - // }), '*'); - if (adObject && data.message === 'Prebid Native') { - if (data.action === 'assetRequest') { - const message = getAssetMessage(data, adObject); - ev.source.postMessage(JSON.stringify(message), ev.origin); - } else if (data.action === 'allAssetRequest') { - const message = getAllAssetsMessage(data, adObject); - ev.source.postMessage(JSON.stringify(message), ev.origin); - } else if (data.action === 'resizeNativeHeight') { - adObject.height = data.height; - adObject.width = data.width; - resizeRemoteCreative(adObject); - } else { - const trackerType = fireNativeTrackers(data, adObject); - if (trackerType === 'click') { return; } - - auctionManager.addWinningBid(adObject); - events.emit(BID_WON, adObject); +function handleNativeRequest(ev, data, adObject) { + // handle this script from native template in an ad server + // window.parent.postMessage(JSON.stringify({ + // message: 'Prebid Native', + // adId: '%%PATTERN:hb_adid%%' + // }), '*'); + if (adObject == null) { + logError(`Cannot find ad '${data.adId}' for x-origin event request`); + return; + } + switch (data.action) { + case 'assetRequest': + reply(getAssetMessage(data, adObject)); + break; + case 'allAssetRequest': + reply(getAllAssetsMessage(data, adObject)); + break; + case 'resizeNativeHeight': + adObject.height = data.height; + adObject.width = data.width; + resizeRemoteCreative(adObject); + break; + default: + const trackerType = fireNativeTrackers(data, adObject); + if (trackerType === 'click') { + return; } - } + auctionManager.addWinningBid(adObject); + events.emit(BID_WON, adObject); + } + + function reply(message) { + ev.source.postMessage(JSON.stringify(message), ev.origin); + } +} + +function handleEventRequest(ev, data, adObject) { + if (adObject == null) { + logError(`Cannot find ad '${data.adId}' for x-origin event request`); + return; + } + if (adObject.status !== constants.BID_STATUS.RENDERED) { + logWarn(`Received x-origin event request without corresponding render request for ad '${data.adId}'`); + return; + } + switch (data.event) { + case constants.EVENTS.AD_RENDER_FAILED: + emitAdRenderFail({ + bid: adObject, + id: data.adId, + reason: data.info.reason, + message: data.info.message + }); + break; + case constants.EVENTS.AD_RENDER_SUCCEEDED: + emitAdRenderSucceeded({ + doc: null, + bid: adObject, + id: data.adId + }); + break; + default: + logError(`Received x-origin event request for unsupported event: '${data.event}' (adId: '${data.adId}')`) } } diff --git a/src/utils.js b/src/utils.js index cbe1b1665aa..0f3709487f0 100644 --- a/src/utils.js +++ b/src/utils.js @@ -22,7 +22,17 @@ let consoleLogExists = Boolean(consoleExists && window.console.log); let consoleInfoExists = Boolean(consoleExists && window.console.info); let consoleWarnExists = Boolean(consoleExists && window.console.warn); let consoleErrorExists = Boolean(consoleExists && window.console.error); -var events = require('./events.js'); + +const emitEvent = (function () { + // lazy load events to avoid circular import + let ev; + return function() { + if (ev == null) { + ev = require('./events.js'); + } + return ev.emit.apply(ev, arguments); + } +})(); // this allows stubbing of utility functions that are used internally by other utility functions export const internal = { @@ -265,14 +275,14 @@ export function logWarn() { if (debugTurnedOn() && consoleWarnExists) { console.warn.apply(console, decorateLog(arguments, 'WARNING:')); } - events.emit(CONSTANTS.EVENTS.AUCTION_DEBUG, {type: 'WARNING', arguments: arguments}); + emitEvent(CONSTANTS.EVENTS.AUCTION_DEBUG, {type: 'WARNING', arguments: arguments}); } export function logError() { if (debugTurnedOn() && consoleErrorExists) { console.error.apply(console, decorateLog(arguments, 'ERROR:')); } - events.emit(CONSTANTS.EVENTS.AUCTION_DEBUG, {type: 'ERROR', arguments: arguments}); + emitEvent(CONSTANTS.EVENTS.AUCTION_DEBUG, {type: 'ERROR', arguments: arguments}); } function decorateLog(args, prefix) { diff --git a/test/spec/unit/secureCreatives_spec.js b/test/spec/unit/secureCreatives_spec.js index cee416bd1be..a6c7d48b6e1 100644 --- a/test/spec/unit/secureCreatives_spec.js +++ b/test/spec/unit/secureCreatives_spec.js @@ -1,6 +1,7 @@ import { _sendAdToCreative, receiveMessage } from 'src/secureCreatives.js'; +import * as secureCreatives from 'src/secureCreatives.js'; import * as utils from 'src/utils.js'; import {getAdUnits, getBidRequests, getBidResponses} from 'test/fixtures/fixtures.js'; import {auctionManager} from 'src/auctionManager.js'; @@ -9,6 +10,7 @@ import * as native from 'src/native.js'; import {fireNativeTrackers, getAllAssetsMessage} from 'src/native.js'; import events from 'src/events.js'; import { config as configObj } from 'src/config.js'; +import 'src/prebid.js'; import { expect } from 'chai'; @@ -237,6 +239,38 @@ describe('secureCreatives', () => { configObj.setConfig({'auctionOptions': {}}); }); + + it('should emit AD_RENDER_FAILED if requested missing adId', () => { + const ev = { + data: JSON.stringify({ + message: 'Prebid Request', + adId: 'missing' + }) + }; + receiveMessage(ev); + sinon.assert.calledWith(stubEmit, CONSTANTS.EVENTS.AD_RENDER_FAILED, sinon.match({ + reason: CONSTANTS.AD_RENDER_FAILED_REASON.CANNOT_FIND_AD, + adId: 'missing' + })); + }); + + it('should emit AD_RENDER_FAILED if creative can\'t be sent to rendering frame', () => { + pushBidResponseToAuction({}); + const ev = { + source: { + postMessage: sinon.stub().callsFake(() => { throw new Error(); }) + }, + data: JSON.stringify({ + message: 'Prebid Request', + adId: bidId + }) + } + receiveMessage(ev) + sinon.assert.calledWith(stubEmit, CONSTANTS.EVENTS.AD_RENDER_FAILED, sinon.match({ + reason: CONSTANTS.AD_RENDER_FAILED_REASON.EXCEPTION, + adId: bidId + })); + }); }); describe('Prebid Native', function() { @@ -395,5 +429,56 @@ describe('secureCreatives', () => { expect(adResponse).to.have.property('status', CONSTANTS.BID_STATUS.RENDERED); }); }); + + describe('Prebid Event', () => { + Object.entries({ + 'unrendered': [false, (bid) => { delete bid.status; }], + 'rendered': [true, (bid) => { bid.status = CONSTANTS.BID_STATUS.RENDERED }] + }).forEach(([test, [shouldEmit, prepBid]]) => { + describe(`for ${test} bids`, () => { + beforeEach(() => { + prepBid(adResponse); + pushBidResponseToAuction(adResponse); + }); + + it(`should${shouldEmit ? ' ' : ' not '}emit AD_RENDER_FAILED`, () => { + const event = { + data: JSON.stringify({ + message: 'Prebid Event', + event: CONSTANTS.EVENTS.AD_RENDER_FAILED, + adId: bidId, + info: { + reason: 'Fail reason', + message: 'Fail message', + }, + }) + }; + receiveMessage(event); + expect(stubEmit.calledWith(CONSTANTS.EVENTS.AD_RENDER_FAILED, { + adId: bidId, + bid: adResponse, + reason: 'Fail reason', + message: 'Fail message' + })).to.equal(shouldEmit); + }); + + it(`should${shouldEmit ? ' ' : ' not '}emit AD_RENDER_SUCCEEDED`, () => { + const event = { + data: JSON.stringify({ + message: 'Prebid Event', + event: CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED, + adId: bidId, + }) + }; + receiveMessage(event); + expect(stubEmit.calledWith(CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED, { + adId: bidId, + bid: adResponse, + doc: null + })).to.equal(shouldEmit); + }); + }); + }); + }); }); });