From 8a8929cf6db48ceb678b3f5fca5cc60a04641d56 Mon Sep 17 00:00:00 2001 From: jlquaccia Date: Fri, 9 Feb 2024 13:31:13 -0800 Subject: [PATCH 1/2] created client side loss notifications module --- src/lossNotifications.js | 97 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/lossNotifications.js diff --git a/src/lossNotifications.js b/src/lossNotifications.js new file mode 100644 index 00000000000..a074ee993df --- /dev/null +++ b/src/lossNotifications.js @@ -0,0 +1,97 @@ +import {on as onEvent} from './events.js'; +import CONSTANTS from './constants.json'; +import {getStorageManager} from '../src/storageManager.js'; +import { MODULE_TYPE_PREBID } from '../src/activities/modules.js'; + +const MODULE_NAME = 'lossNotifications'; +const storage = getStorageManager({moduleType: MODULE_TYPE_PREBID, moduleName: MODULE_NAME}); +let tidMap = {}; +let lossNotificationsEnabled = false; +let enabledBidders = []; + +export function enableLossNotifications(config) { + const { bidderCode, beaconUrl } = config; + enabledBidders.push({bidderCode, beaconUrl}); + if (lossNotificationsEnabled) return; + lossNotificationsEnabled = true; + onEvent(CONSTANTS.EVENTS.AUCTION_END, onAuctionEndHandler); + onEvent(CONSTANTS.EVENTS.BID_WON, onBidWonHandler); + onEvent(CONSTANTS.EVENTS.BID_REQUESTED, onBidRequestedHandler); +} + +function onAuctionEndHandler(auctionDetails) { + let tid; + let enabledBidderData; + + auctionDetails.bidderRequests.forEach(bidderRequest => { + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidderRequest.bidderCode); + + if (enabledBidder) { + bidderRequest.bids.forEach(bid => { + tid = bid.ortb2Imp.ext.tid || bid.ortb2.source.tid || bid.transactionId || null; // should we only check bid.ortb2Imp.ext.tid here? + enabledBidderData = { + bidderCode: bidderRequest.bidderCode, + bidderRequestId: bidderRequest.bidderRequestId + }; + + if (enabledBidder.beaconUrl) (enabledBidderData.beaconUrl = enabledBidder.beaconUrl); + if (tid) { + if (!tidMap[tid]) { + tidMap[tid] = [enabledBidderData]; + } else { + tidMap[tid].push(enabledBidderData); + } + } + }); + } + }); +} + +function onBidWonHandler(bid) { + const winningBidData = { + bidderCode: bid.bidderCode, + cpm: bid.cpm, + status: bid.status, + transactionId: bid.transactionId, + }; + + tidMap[winningBidData.transactionId].forEach(bidder => { + if (bidder.bidderCode !== winningBidData.bidderCode) { + const lossNotificationPayload = { + bidderRequestId: bidder.bidderRequestId, + auctionId: winningBidData.transactionId, + minBidToWin: winningBidData.cpm, + rendered: winningBidData.status === CONSTANTS.BID_STATUS.RENDERED ? 1 : 0, + }; + + if (bidder.beaconUrl) { + // use js beacon api to fire payload immediately to ssp provided endpoint + navigator.sendBeacon(bidder.beaconUrl, JSON.stringify(lossNotificationPayload)); + } else { + // store the payload in an array under bidder code in local storage and send it out with the respective bidder's next bid request + let pbln = JSON.parse(storage.getDataFromLocalStorage('pbln')); + if (pbln) { + if (pbln[bidder.bidderCode]) { + pbln[bidder.bidderCode].push(lossNotificationPayload); + } else { + pbln[bidder.bidderCode] = [lossNotificationPayload]; + } + storage.setDataInLocalStorage('pbln', JSON.stringify(pbln)); + } else { + pbln = {}; + pbln[bidder.bidderCode] = [lossNotificationPayload]; + storage.setDataInLocalStorage('pbln', JSON.stringify(pbln)); + } + } + } + }); +} + +function onBidRequestedHandler(bidRequest) { + let pbln = JSON.parse(storage.getDataFromLocalStorage('pbln')); + if (pbln && pbln[bidRequest.bidderCode]) { + bidRequest.lossNotification = pbln[bidRequest.bidderCode] + delete pbln[bidRequest.bidderCode]; + storage.setDataInLocalStorage('pbln', JSON.stringify(pbln)); + } +} From ac11a00ea20bcdd32b2e334af50e557866284d9a Mon Sep 17 00:00:00 2001 From: jlquaccia Date: Mon, 11 Nov 2024 16:27:43 -0800 Subject: [PATCH 2/2] addressed feedback and created tests --- .../previousAuctionInfo.js | 130 ++++++++++++++++++ src/lossNotifications.js | 97 ------------- .../libraries/previousAuctionInfo_spec.js | 81 +++++++++++ 3 files changed, 211 insertions(+), 97 deletions(-) create mode 100644 libraries/previousAuctionInfo/previousAuctionInfo.js delete mode 100644 src/lossNotifications.js create mode 100644 test/spec/libraries/previousAuctionInfo_spec.js diff --git a/libraries/previousAuctionInfo/previousAuctionInfo.js b/libraries/previousAuctionInfo/previousAuctionInfo.js new file mode 100644 index 00000000000..b5be8e3a015 --- /dev/null +++ b/libraries/previousAuctionInfo/previousAuctionInfo.js @@ -0,0 +1,130 @@ +import {on as onEvent} from '../../src/events.js'; +import { EVENTS } from '../../src/constants.js'; + +export let previousAuctionInfoEnabled = false; +let enabledBidders = []; +export let winningBidsMap = {}; + +export const resetPreviousAuctionInfo = () => { + previousAuctionInfoEnabled = false; + enabledBidders = []; + winningBidsMap = {}; +}; + +export const enablePreviousAuctionInfo = (config, cb = initHandlers) => { + const { bidderCode, isBidRequestValid } = config; + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidderCode); + if (!enabledBidder) enabledBidders.push({ bidderCode, isBidRequestValid, maxQueueLength: config.maxQueueLength || 10 }); + if (previousAuctionInfoEnabled) return; + previousAuctionInfoEnabled = true; + cb(); +} + +export const initHandlers = () => { + onEvent(EVENTS.AUCTION_END, onAuctionEndHandler); + onEvent(EVENTS.BID_WON, onBidWonHandler); + onEvent(EVENTS.BID_REQUESTED, onBidRequestedHandler); +}; + +export const onAuctionEndHandler = (auctionDetails) => { + // eslint-disable-next-line no-console + console.log('onAuctionEndHandler', auctionDetails); + + try { + let highestCpmBid = 0; + const receivedBidsMap = {}; + const rejectedBidsMap = {}; + + if (auctionDetails.bidsReceived && auctionDetails.bidsReceived.length) { + highestCpmBid = auctionDetails.bidsReceived.reduce((highestBid, currentBid) => { + return currentBid.cpm > highestBid.cpm ? currentBid : highestBid; + }, auctionDetails.bidsReceived[0]); + + auctionDetails.bidsReceived.forEach(bidReceived => { + receivedBidsMap[bidReceived.requestId] = bidReceived; + }); + } + + if (auctionDetails.bidsRejected && auctionDetails.bidsRejected.length) { + auctionDetails.bidsRejected.forEach(bidRejected => { + rejectedBidsMap[bidRejected.requestId] = bidRejected; + }); + } + + if (auctionDetails.bidderRequests && auctionDetails.bidderRequests.length) { + auctionDetails.bidderRequests.forEach(bidderRequest => { + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidderRequest.bidderCode); + + if (enabledBidder) { + bidderRequest.bids.forEach(bid => { + const isValidBid = enabledBidder.isBidRequestValid(bid); + + if (!isValidBid) return; + + const previousAuctionInfoPayload = { + bidderRequestId: bidderRequest.bidderRequestId, + minBidToWin: highestCpmBid?.cpm || 0, + rendered: 0, + transactionId: bid.ortb2Imp.ext.tid || bid.transactionId, + source: 'pbjs', + auctionId: auctionDetails.auctionId, + impId: bid.adUnitCode, + // bidResponseId: FLOAT, // don't think this is available client side? + // targetedbidcpm: FLOAT, // don't think this is available client side? + highestcpm: highestCpmBid?.cpm || 0, + cur: bid.ortb2.cur, + bidderCpm: receivedBidsMap[bid.bidId] ? receivedBidsMap[bid.bidId].cpm : 'nobid', + biddererrorcode: rejectedBidsMap[bid.bidId] ? rejectedBidsMap[bid.bidId].rejectionReason : -1, + timestamp: auctionDetails.timestamp, + } + + window.pbpai = window.pbpai || {}; + if (!window.pbpai[bidderRequest.bidderCode]) { + window.pbpai[bidderRequest.bidderCode] = []; + } + + if (window.pbpai[bidderRequest.bidderCode].length > enabledBidder.maxQueueLength) { + window.pbpai[bidderRequest.bidderCode].shift(); + } + + window.pbpai[bidderRequest.bidderCode].push(previousAuctionInfoPayload); + }); + } + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +} + +const onBidWonHandler = (winningBid) => { + // // eslint-disable-next-line no-console + // console.log('onBidWonHandler', winningBid); + winningBidsMap[winningBid.transactionId] = winningBid; +} + +export const onBidRequestedHandler = (bidRequest) => { + try { + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidRequest.bidderCode); + window.pbpai = window.pbpai || {}; + + if (enabledBidder && window.pbpai[bidRequest.bidderCode]) { + window.pbpai[bidRequest.bidderCode].forEach(prevAuctPayload => { + if (winningBidsMap[prevAuctPayload.transactionId]) { + prevAuctPayload.minBidToWin = winningBidsMap[prevAuctPayload.transactionId].cpm; + prevAuctPayload.rendered = 1; + } + }); + + bidRequest.ortb2 ??= {}; + bidRequest.ortb2.ext ??= {}; + bidRequest.ortb2.ext.prebid ??= {}; + bidRequest.ortb2.ext.prebid.previousauctioninfo = window.pbpai[bidRequest.bidderCode]; + delete window.pbpai[bidRequest.bidderCode]; + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +} diff --git a/src/lossNotifications.js b/src/lossNotifications.js deleted file mode 100644 index a074ee993df..00000000000 --- a/src/lossNotifications.js +++ /dev/null @@ -1,97 +0,0 @@ -import {on as onEvent} from './events.js'; -import CONSTANTS from './constants.json'; -import {getStorageManager} from '../src/storageManager.js'; -import { MODULE_TYPE_PREBID } from '../src/activities/modules.js'; - -const MODULE_NAME = 'lossNotifications'; -const storage = getStorageManager({moduleType: MODULE_TYPE_PREBID, moduleName: MODULE_NAME}); -let tidMap = {}; -let lossNotificationsEnabled = false; -let enabledBidders = []; - -export function enableLossNotifications(config) { - const { bidderCode, beaconUrl } = config; - enabledBidders.push({bidderCode, beaconUrl}); - if (lossNotificationsEnabled) return; - lossNotificationsEnabled = true; - onEvent(CONSTANTS.EVENTS.AUCTION_END, onAuctionEndHandler); - onEvent(CONSTANTS.EVENTS.BID_WON, onBidWonHandler); - onEvent(CONSTANTS.EVENTS.BID_REQUESTED, onBidRequestedHandler); -} - -function onAuctionEndHandler(auctionDetails) { - let tid; - let enabledBidderData; - - auctionDetails.bidderRequests.forEach(bidderRequest => { - const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidderRequest.bidderCode); - - if (enabledBidder) { - bidderRequest.bids.forEach(bid => { - tid = bid.ortb2Imp.ext.tid || bid.ortb2.source.tid || bid.transactionId || null; // should we only check bid.ortb2Imp.ext.tid here? - enabledBidderData = { - bidderCode: bidderRequest.bidderCode, - bidderRequestId: bidderRequest.bidderRequestId - }; - - if (enabledBidder.beaconUrl) (enabledBidderData.beaconUrl = enabledBidder.beaconUrl); - if (tid) { - if (!tidMap[tid]) { - tidMap[tid] = [enabledBidderData]; - } else { - tidMap[tid].push(enabledBidderData); - } - } - }); - } - }); -} - -function onBidWonHandler(bid) { - const winningBidData = { - bidderCode: bid.bidderCode, - cpm: bid.cpm, - status: bid.status, - transactionId: bid.transactionId, - }; - - tidMap[winningBidData.transactionId].forEach(bidder => { - if (bidder.bidderCode !== winningBidData.bidderCode) { - const lossNotificationPayload = { - bidderRequestId: bidder.bidderRequestId, - auctionId: winningBidData.transactionId, - minBidToWin: winningBidData.cpm, - rendered: winningBidData.status === CONSTANTS.BID_STATUS.RENDERED ? 1 : 0, - }; - - if (bidder.beaconUrl) { - // use js beacon api to fire payload immediately to ssp provided endpoint - navigator.sendBeacon(bidder.beaconUrl, JSON.stringify(lossNotificationPayload)); - } else { - // store the payload in an array under bidder code in local storage and send it out with the respective bidder's next bid request - let pbln = JSON.parse(storage.getDataFromLocalStorage('pbln')); - if (pbln) { - if (pbln[bidder.bidderCode]) { - pbln[bidder.bidderCode].push(lossNotificationPayload); - } else { - pbln[bidder.bidderCode] = [lossNotificationPayload]; - } - storage.setDataInLocalStorage('pbln', JSON.stringify(pbln)); - } else { - pbln = {}; - pbln[bidder.bidderCode] = [lossNotificationPayload]; - storage.setDataInLocalStorage('pbln', JSON.stringify(pbln)); - } - } - } - }); -} - -function onBidRequestedHandler(bidRequest) { - let pbln = JSON.parse(storage.getDataFromLocalStorage('pbln')); - if (pbln && pbln[bidRequest.bidderCode]) { - bidRequest.lossNotification = pbln[bidRequest.bidderCode] - delete pbln[bidRequest.bidderCode]; - storage.setDataInLocalStorage('pbln', JSON.stringify(pbln)); - } -} diff --git a/test/spec/libraries/previousAuctionInfo_spec.js b/test/spec/libraries/previousAuctionInfo_spec.js new file mode 100644 index 00000000000..1b8914c3ec7 --- /dev/null +++ b/test/spec/libraries/previousAuctionInfo_spec.js @@ -0,0 +1,81 @@ +import * as previousAuctionInfo from 'libraries/previousAuctionInfo/previousAuctionInfo.js'; +import sinon from 'sinon'; + +describe('previous auction info', () => { + let sandbox; + let initHandlersStub; + + const auctionDetails = { + auctionId: 'auction123', + bidsReceived: [{ requestId: 'bid1', bidderCode: 'testBidder2', cpm: 2 }], + bidsRejected: [{ requestId: 'bid2', rejectionReason: 1 }], + bidderRequests: [ + { + bidderCode: 'testBidder2', + bidderRequestId: 'req1', + bids: [{ bidId: 'bid1', ortb2: { cur: ['US'] }, ortb2Imp: { ext: { tid: 'trans123' } }, adUnitCode: 'adUnit1' }], + }, + ], + timestamp: Date.now(), + }; + + beforeEach(() => { + previousAuctionInfo.resetPreviousAuctionInfo(); + if (window.pbpai) delete window.pbpai; + sandbox = sinon.createSandbox(); + initHandlersStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + if (window.pbpai) delete window.pbpai; + }); + + describe('config', () => { + it('should only be initialized once', () => { + const config = { bidderCode: 'testBidder', isBidRequestValid: () => true }; + previousAuctionInfo.enablePreviousAuctionInfo(config, initHandlersStub); + sandbox.assert.calledOnce(initHandlersStub); + previousAuctionInfo.enablePreviousAuctionInfo(config, initHandlersStub); + sandbox.assert.calledOnce(initHandlersStub); + }); + }); + + describe('on auction end', () => { + it('should only capture data for enabled bids who submitted a valid bid', () => { + const config = { bidderCode: 'testBidder2', isBidRequestValid: (bid) => bid.bidId === 'bid1' }; + previousAuctionInfo.enablePreviousAuctionInfo(config, initHandlersStub); + previousAuctionInfo.onAuctionEndHandler(auctionDetails); + + expect(window.pbpai.testBidder2).to.be.an('array').with.lengthOf(1); + expect(window.pbpai.testBidder2[0]).to.include({ + auctionId: 'auction123', + minBidToWin: 2, + transactionId: 'trans123', + rendered: 0, + }); + }); + }); + + describe('on bid requested', () => { + it('should update the minBidToWin and rendered fields if a pbjs bid wins', () => { + const config = { bidderCode: 'testBidder3', isBidRequestValid: () => true }; + previousAuctionInfo.enablePreviousAuctionInfo(config, initHandlersStub); + const bidRequest = { + bidderCode: 'testBidder3', + ortb2: { ext: { prebid: {} } }, + }; + + previousAuctionInfo.winningBidsMap['trans123'] = { cpm: 5, transactionId: 'trans123' }; + + window.pbpai = { + testBidder3: [{ transactionId: 'trans123', minBidToWin: 0, rendered: 0 }], + }; + + previousAuctionInfo.onBidRequestedHandler(bidRequest); + const updatedInfo = bidRequest.ortb2.ext.prebid.previousauctioninfo; + expect(updatedInfo).to.be.an('array').with.lengthOf(1); + expect(updatedInfo[0]).to.include({ minBidToWin: 5, rendered: 1 }); + }); + }); +});