Skip to content

Commit ef79531

Browse files
mmoschovasstsepelin
authored andcommitted
Multibid Module: add new module to handle multiple bids from single bidder & update rubicon adapter (prebid#6404)
* Multibid module - create new module - Expands the number of key value pairs going to the ad server in the normal Prebid way by establishing the concept of a "dynamic alias" First commit * Continued updates from 1st commit * Adding logWarn for filtered bids * Update to include passing multibid configuration to PBS requests * Update to rubicon bid adapter to pass query param rp_maxbids value taken from bidderRequest.bidLimit * Update to config to look for camelcase property names according to spec. These convert to all lowercase when passed to PBS endpoint * Adjust RP adapter to always include maxbids value - default is 1 * Added support for bidders array in multibid config * Fixed floor comparison to be <= bid cpm as oppossed to just < bid cpm. Updated md file to fix camelCase tpyo * Update to include originalBidderRequest in video call to prebid cache * Update to ignore adpod bids from multibid and allow them to return as normal bids
1 parent c1681b3 commit ef79531

11 files changed

+1420
-23
lines changed

modules/multibid/index.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* This module adds Multibid support to prebid.js
3+
* @module modules/multibid
4+
*/
5+
6+
import {config} from '../../src/config.js';
7+
import {setupBeforeHookFnOnce, getHook} from '../../src/hook.js';
8+
import * as utils from '../../src/utils.js';
9+
import events from '../../src/events.js';
10+
import CONSTANTS from '../../src/constants.json';
11+
import {addBidderRequests} from '../../src/auction.js';
12+
import {getHighestCpmBidsFromBidPool, sortByDealAndPriceBucketOrCpm} from '../../src/targeting.js';
13+
14+
const MODULE_NAME = 'multibid';
15+
let hasMultibid = false;
16+
let multiConfig = {};
17+
let multibidUnits = {};
18+
19+
// Storing this globally on init for easy reference to configuration
20+
config.getConfig(MODULE_NAME, conf => {
21+
if (!Array.isArray(conf.multibid) || !conf.multibid.length || !validateMultibid(conf.multibid)) return;
22+
23+
resetMultiConfig();
24+
hasMultibid = true;
25+
26+
conf.multibid.forEach(entry => {
27+
if (entry.bidder) {
28+
multiConfig[entry.bidder] = {
29+
maxbids: entry.maxBids,
30+
prefix: entry.targetBiddercodePrefix
31+
}
32+
} else {
33+
entry.bidders.forEach(key => {
34+
multiConfig[key] = {
35+
maxbids: entry.maxBids,
36+
prefix: entry.targetBiddercodePrefix
37+
}
38+
});
39+
}
40+
});
41+
});
42+
43+
/**
44+
* @summary validates multibid configuration entries
45+
* @param {Object[]} multibid - example [{bidder: 'bidderA', maxbids: 2, prefix: 'bidA'}, {bidder: 'bidderB', maxbids: 2}]
46+
* @return {Boolean}
47+
*/
48+
export function validateMultibid(conf) {
49+
let check = true;
50+
let duplicate = conf.filter(entry => {
51+
// Check if entry.bidder is not defined or typeof string, filter entry and reset configuration
52+
if ((!entry.bidder || typeof entry.bidder !== 'string') && (!entry.bidders || !Array.isArray(entry.bidders))) {
53+
utils.logWarn('Filtering multibid entry. Missing required bidder or bidders property.');
54+
check = false;
55+
return false;
56+
}
57+
58+
return true;
59+
}).map(entry => {
60+
// Check if entry.maxbids is not defined, not typeof number, or less than 1, set maxbids to 1 and reset configuration
61+
// Check if entry.maxbids is greater than 9, set maxbids to 9 and reset configuration
62+
if (typeof entry.maxBids !== 'number' || entry.maxBids < 1 || entry.maxBids > 9) {
63+
entry.maxBids = (typeof entry.maxBids !== 'number' || entry.maxBids < 1) ? 1 : 9;
64+
check = false;
65+
}
66+
67+
return entry;
68+
});
69+
70+
if (!check) config.setConfig({multibid: duplicate});
71+
72+
return check;
73+
}
74+
75+
/**
76+
* @summary addBidderRequests before hook
77+
* @param {Function} fn reference to original function (used by hook logic)
78+
* @param {Object[]} array containing copy of each bidderRequest object
79+
*/
80+
export function adjustBidderRequestsHook(fn, bidderRequests) {
81+
bidderRequests.map(bidRequest => {
82+
// Loop through bidderRequests and check if bidderCode exists in multiconfig
83+
// If true, add bidderRequest.bidLimit to bidder request
84+
if (multiConfig[bidRequest.bidderCode]) {
85+
bidRequest.bidLimit = multiConfig[bidRequest.bidderCode].maxbids
86+
}
87+
return bidRequest;
88+
})
89+
90+
fn.call(this, bidderRequests);
91+
}
92+
93+
/**
94+
* @summary addBidResponse before hook
95+
* @param {Function} fn reference to original function (used by hook logic)
96+
* @param {String} ad unit code for bid
97+
* @param {Object} bid object
98+
*/
99+
export function addBidResponseHook(fn, adUnitCode, bid) {
100+
let floor = utils.deepAccess(bid, 'floorData.floorValue');
101+
102+
if (!config.getConfig('multibid')) resetMultiConfig();
103+
// Checks if multiconfig exists and bid bidderCode exists within config and is an adpod bid
104+
// Else checks if multiconfig exists and bid bidderCode exists within config
105+
// Else continue with no modifications
106+
if (hasMultibid && multiConfig[bid.bidderCode] && utils.deepAccess(bid, 'video.context') === 'adpod') {
107+
fn.call(this, adUnitCode, bid);
108+
} else if (hasMultibid && multiConfig[bid.bidderCode]) {
109+
// Set property multibidPrefix on bid
110+
if (multiConfig[bid.bidderCode].prefix) bid.multibidPrefix = multiConfig[bid.bidderCode].prefix;
111+
bid.originalBidder = bid.bidderCode;
112+
// Check if stored bids for auction include adUnitCode.bidder and max limit not reach for ad unit
113+
if (utils.deepAccess(multibidUnits, `${adUnitCode}.${bid.bidderCode}`)) {
114+
// Store request id under new property originalRequestId, create new unique bidId,
115+
// and push bid into multibid stored bids for auction if max not reached and bid cpm above floor
116+
if (!multibidUnits[adUnitCode][bid.bidderCode].maxReached && (!floor || floor <= bid.cpm)) {
117+
bid.originalRequestId = bid.requestId;
118+
119+
bid.requestId = utils.getUniqueIdentifierStr();
120+
multibidUnits[adUnitCode][bid.bidderCode].ads.push(bid);
121+
122+
let length = multibidUnits[adUnitCode][bid.bidderCode].ads.length;
123+
124+
if (multiConfig[bid.bidderCode].prefix) bid.targetingBidder = multiConfig[bid.bidderCode].prefix + length;
125+
if (length === multiConfig[bid.bidderCode].maxbids) multibidUnits[adUnitCode][bid.bidderCode].maxReached = true;
126+
127+
fn.call(this, adUnitCode, bid);
128+
} else {
129+
utils.logWarn(`Filtering multibid received from bidder ${bid.bidderCode}: ` + ((multibidUnits[adUnitCode][bid.bidderCode].maxReached) ? `Maximum bid limit reached for ad unit code ${adUnitCode}` : 'Bid cpm under floors value.'));
130+
}
131+
} else {
132+
if (utils.deepAccess(bid, 'floorData.floorValue')) utils.deepSetValue(multibidUnits, `${adUnitCode}.${bid.bidderCode}`, {floor: utils.deepAccess(bid, 'floorData.floorValue')});
133+
134+
utils.deepSetValue(multibidUnits, `${adUnitCode}.${bid.bidderCode}`, {ads: [bid]});
135+
if (multibidUnits[adUnitCode][bid.bidderCode].ads.length === multiConfig[bid.bidderCode].maxbids) multibidUnits[adUnitCode][bid.bidderCode].maxReached = true;
136+
137+
fn.call(this, adUnitCode, bid);
138+
}
139+
} else {
140+
fn.call(this, adUnitCode, bid);
141+
}
142+
}
143+
144+
/**
145+
* A descending sort function that will sort the list of objects based on the following:
146+
* - bids without dynamic aliases are sorted before bids with dynamic aliases
147+
*/
148+
export function sortByMultibid(a, b) {
149+
if (a.bidder !== a.bidderCode && b.bidder === b.bidderCode) {
150+
return 1;
151+
}
152+
153+
if (a.bidder === a.bidderCode && b.bidder !== b.bidderCode) {
154+
return -1;
155+
}
156+
157+
return 0;
158+
}
159+
160+
/**
161+
* @summary getHighestCpmBidsFromBidPool before hook
162+
* @param {Function} fn reference to original function (used by hook logic)
163+
* @param {Object[]} array of objects containing all bids from bid pool
164+
* @param {Function} function to reduce to only highest cpm value for each bidderCode
165+
* @param {Number} adUnit bidder targeting limit, default set to 0
166+
* @param {Boolean} default set to false, this hook modifies targeting and sets to true
167+
*/
168+
export function targetBidPoolHook(fn, bidsReceived, highestCpmCallback, adUnitBidLimit = 0, hasModified = false) {
169+
if (!config.getConfig('multibid')) resetMultiConfig();
170+
if (hasMultibid) {
171+
const dealPrioritization = config.getConfig('sendBidsControl.dealPrioritization');
172+
let modifiedBids = [];
173+
let buckets = utils.groupBy(bidsReceived, 'adUnitCode');
174+
let bids = [].concat.apply([], Object.keys(buckets).reduce((result, slotId) => {
175+
let bucketBids = [];
176+
// Get bids and group by property originalBidder
177+
let bidsByBidderName = utils.groupBy(buckets[slotId], 'originalBidder');
178+
let adjustedBids = [].concat.apply([], Object.keys(bidsByBidderName).map(key => {
179+
// Reset all bidderCodes to original bidder values and sort by CPM
180+
return bidsByBidderName[key].sort((bidA, bidB) => {
181+
if (bidA.originalBidder && bidA.originalBidder !== bidA.bidderCode) bidA.bidderCode = bidA.originalBidder;
182+
if (bidA.originalBidder && bidB.originalBidder !== bidB.bidderCode) bidB.bidderCode = bidB.originalBidder;
183+
return bidA.cpm > bidB.cpm ? -1 : (bidA.cpm < bidB.cpm ? 1 : 0);
184+
}).map((bid, index) => {
185+
// For each bid (post CPM sort), set dynamic bidderCode using prefix and index if less than maxbid amount
186+
if (utils.deepAccess(multiConfig, `${bid.bidderCode}.prefix`) && index !== 0 && index < multiConfig[bid.bidderCode].maxbids) {
187+
bid.bidderCode = multiConfig[bid.bidderCode].prefix + (index + 1);
188+
}
189+
190+
return bid
191+
})
192+
}));
193+
// Get adjustedBids by bidderCode and reduce using highestCpmCallback
194+
let bidsByBidderCode = utils.groupBy(adjustedBids, 'bidderCode');
195+
Object.keys(bidsByBidderCode).forEach(key => bucketBids.push(bidsByBidderCode[key].reduce(highestCpmCallback)));
196+
// if adUnitBidLimit is set, pass top N number bids
197+
if (adUnitBidLimit > 0) {
198+
bucketBids = dealPrioritization ? bucketBids.sort(sortByDealAndPriceBucketOrCpm(true)) : bucketBids.sort((a, b) => b.cpm - a.cpm);
199+
bucketBids.sort(sortByMultibid);
200+
modifiedBids.push(...bucketBids.slice(0, adUnitBidLimit));
201+
} else {
202+
modifiedBids.push(...bucketBids);
203+
}
204+
205+
return [].concat.apply([], modifiedBids);
206+
}, []));
207+
208+
fn.call(this, bids, highestCpmCallback, adUnitBidLimit, true);
209+
} else {
210+
fn.call(this, bidsReceived, highestCpmCallback, adUnitBidLimit);
211+
}
212+
}
213+
214+
/**
215+
* Resets globally stored multibid configuration
216+
*/
217+
export const resetMultiConfig = () => { hasMultibid = false; multiConfig = {}; };
218+
219+
/**
220+
* Resets globally stored multibid ad unit bids
221+
*/
222+
export const resetMultibidUnits = () => multibidUnits = {};
223+
224+
/**
225+
* Set up hooks on init
226+
*/
227+
function init() {
228+
events.on(CONSTANTS.EVENTS.AUCTION_INIT, resetMultibidUnits);
229+
setupBeforeHookFnOnce(addBidderRequests, adjustBidderRequestsHook);
230+
getHook('addBidResponse').before(addBidResponseHook, 3);
231+
setupBeforeHookFnOnce(getHighestCpmBidsFromBidPool, targetBidPoolHook);
232+
}
233+
234+
init();

modules/multibid/index.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Overview
2+
3+
Module Name: multibid
4+
5+
Purpose: To expand the number of key value pairs going to the ad server in the normal Prebid way by establishing the concept of a "dynamic alias" -- a bidder code that exists only on the response, not in the adunit.
6+
7+
8+
# Description
9+
Allowing a single bidder to multi-bid into an auction has several use cases:
10+
11+
1. allows a bidder to provide both outstream and banner
12+
2. supports the video VAST fallback scenario
13+
3. allows one bid to be blocked in the ad server and the second one still considered
14+
4. add extra high-value bids to the cache for future refreshes
15+
16+
17+
# Example of using config
18+
```
19+
pbjs.setConfig({
20+
multibid: [{
21+
bidder: "bidderA",
22+
maxBids: 3,
23+
targetBiddercodePrefix: "bidA"
24+
},{
25+
bidder: "bidderB",
26+
maxBids: 3,
27+
targetBiddercodePrefix: "bidB"
28+
},{
29+
bidder: "bidderC",
30+
maxBids: 3
31+
},{
32+
bidders: ["bidderD", "bidderE"],
33+
maxBids: 2
34+
}]
35+
});
36+
```
37+
38+
# Please Note:
39+
-
40+

modules/prebidServerBidAdapter/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,21 @@ const OPEN_RTB_PROTOCOL = {
740740
utils.deepSetValue(request, 'ext.prebid.data.eidpermissions', eidPermissions);
741741
}
742742

743+
const multibid = config.getConfig('multibid');
744+
if (multibid) {
745+
utils.deepSetValue(request, 'ext.prebid.multibid', multibid.reduce((result, i) => {
746+
let obj = {};
747+
748+
Object.keys(i).forEach(key => {
749+
obj[key.toLowerCase()] = i[key];
750+
});
751+
752+
result.push(obj);
753+
754+
return result;
755+
}, []));
756+
}
757+
743758
if (bidRequests) {
744759
if (firstBidRequest.gdprConsent) {
745760
// note - gdprApplies & consentString may be undefined in certain use-cases for consentManagement module

modules/rubiconAnalyticsAdapter.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ function sendMessage(auctionId, bidWonId) {
119119
function formatBid(bid) {
120120
return utils.pick(bid, [
121121
'bidder',
122+
'bidderDetail',
122123
'bidId', bidId => utils.deepAccess(bid, 'bidResponse.pbsBidId') || utils.deepAccess(bid, 'bidResponse.seatBidId') || bidId,
123124
'status',
124125
'error',
@@ -673,6 +674,13 @@ let rubiconAdapter = Object.assign({}, baseAdapter, {
673674
break;
674675
case BID_RESPONSE:
675676
let auctionEntry = cache.auctions[args.auctionId];
677+
678+
if (!auctionEntry.bids[args.requestId] && args.originalRequestId) {
679+
auctionEntry.bids[args.requestId] = {...auctionEntry.bids[args.originalRequestId]};
680+
auctionEntry.bids[args.requestId].bidId = args.requestId;
681+
auctionEntry.bids[args.requestId].bidderDetail = args.targetingBidder;
682+
}
683+
676684
let bid = auctionEntry.bids[args.requestId];
677685
// If floor resolved gptSlot but we have not yet, then update the adUnit to have the adSlot name
678686
if (!utils.deepAccess(bid, 'adUnit.gam.adSlot') && utils.deepAccess(args, 'floorData.matchedFields.gptSlot')) {

modules/rubiconBidAdapter.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,21 @@ export const spec = {
256256
utils.deepSetValue(data, 'source.ext.schain', bidRequest.schain);
257257
}
258258

259+
const multibid = config.getConfig('multibid');
260+
if (multibid) {
261+
utils.deepSetValue(data, 'ext.prebid.multibid', multibid.reduce((result, i) => {
262+
let obj = {};
263+
264+
Object.keys(i).forEach(key => {
265+
obj[key.toLowerCase()] = i[key];
266+
});
267+
268+
result.push(obj);
269+
270+
return result;
271+
}, []));
272+
}
273+
259274
applyFPD(bidRequest, VIDEO, data);
260275

261276
// if storedAuctionResponse has been set, pass SRID
@@ -510,6 +525,8 @@ export const spec = {
510525
data['us_privacy'] = encodeURIComponent(bidderRequest.uspConsent);
511526
}
512527

528+
data['rp_maxbids'] = bidderRequest.bidLimit || 1;
529+
513530
applyFPD(bidRequest, BANNER, data);
514531

515532
if (config.getConfig('coppa') === true) {
@@ -640,6 +657,8 @@ export const spec = {
640657
}
641658

642659
let ads = responseObj.ads;
660+
let lastImpId;
661+
let multibid = 0;
643662

644663
// video ads array is wrapped in an object
645664
if (typeof bidRequest === 'object' && !Array.isArray(bidRequest) && bidType(bidRequest) === 'video' && typeof ads === 'object') {
@@ -652,12 +671,14 @@ export const spec = {
652671
}
653672

654673
return ads.reduce((bids, ad, i) => {
674+
(ad.impression_id && lastImpId === ad.impression_id) ? multibid++ : lastImpId = ad.impression_id;
675+
655676
if (ad.status !== 'ok') {
656677
return bids;
657678
}
658679

659680
// associate bidRequests; assuming ads matches bidRequest
660-
const associatedBidRequest = Array.isArray(bidRequest) ? bidRequest[i] : bidRequest;
681+
const associatedBidRequest = Array.isArray(bidRequest) ? bidRequest[i - multibid] : bidRequest;
661682

662683
if (associatedBidRequest && typeof associatedBidRequest === 'object') {
663684
let bid = {

src/auction.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ export function addBidToAuction(auctionInstance, bidResponse) {
452452
function tryAddVideoBid(auctionInstance, bidResponse, bidRequests, afterBidAdded) {
453453
let addBid = true;
454454

455-
const bidderRequest = getBidRequest(bidResponse.requestId, [bidRequests]);
455+
const bidderRequest = getBidRequest(bidResponse.originalRequestId || bidResponse.requestId, [bidRequests]);
456456
const videoMediaType =
457457
bidderRequest && deepAccess(bidderRequest, 'mediaTypes.video');
458458
const context = videoMediaType && deepAccess(videoMediaType, 'context');

0 commit comments

Comments
 (0)