Skip to content

Commit 68b0534

Browse files
authored
Holid Bid Adapter: respect auction timeout, ORTB merges, usersync robustness (prebid#14530)
* Holid: respect auction timeout, safer ortb merges, improve usersync * Holid Bid Adapter: add unit tests * chore: re-run CI
1 parent 4ffb4bf commit 68b0534

File tree

2 files changed

+288
-48
lines changed

2 files changed

+288
-48
lines changed

modules/holidBidAdapter.js

Lines changed: 155 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,91 @@ import { registerBidder } from '../src/adapters/bidderFactory.js';
1111

1212
const BIDDER_CODE = 'holid';
1313
const GVLID = 1177;
14+
1415
const ENDPOINT = 'https://helloworld.holid.io/openrtb2/auction';
1516
const COOKIE_SYNC_ENDPOINT = 'https://null.holid.io/sync.html';
17+
1618
const TIME_TO_LIVE = 300;
17-
const TMAX = 500;
19+
20+
// Keep win URLs in-memory (per page-load)
1821
const wurlMap = {};
1922

23+
/**
24+
* Resolve tmax for the outgoing ORTB request.
25+
* Goal: respect publisher's Prebid timeout (bidderRequest.timeout) and allow an optional per-bid override,
26+
* without hard-forcing an arbitrary 500ms.
27+
*
28+
* Rules:
29+
* - If bid.params.tmax is a positive number, use it, but never exceed bidderRequest.timeout when available.
30+
* - Else if bidderRequest.timeout is a positive number, use it.
31+
* - Else omit tmax entirely (PBS will apply its own default / config).
32+
*/
33+
function resolveTmax(bid, bidderRequest) {
34+
const auctionTimeout = Number(bidderRequest?.timeout);
35+
const paramTmax = Number(bid?.params?.tmax);
36+
37+
const hasAuctionTimeout = Number.isFinite(auctionTimeout) && auctionTimeout > 0;
38+
const hasParamTmax = Number.isFinite(paramTmax) && paramTmax > 0;
39+
40+
if (hasParamTmax && hasAuctionTimeout) {
41+
return Math.min(paramTmax, auctionTimeout);
42+
}
43+
if (hasParamTmax) {
44+
return paramTmax;
45+
}
46+
if (hasAuctionTimeout) {
47+
return auctionTimeout;
48+
}
49+
return undefined;
50+
}
51+
52+
/**
53+
* Merge stored request ID into request.ext.prebid.storedrequest.id (without clobbering other ext fields).
54+
* Keeps behavior consistent with the existing adapter expectation of bid.params.adUnitID.
55+
*/
56+
function mergeStoredRequest(ortbRequest, bid) {
57+
const storedId = getBidIdParameter('adUnitID', bid.params);
58+
if (storedId) {
59+
deepSetValue(ortbRequest, 'ext.prebid.storedrequest.id', storedId);
60+
}
61+
}
62+
63+
/**
64+
* Merge schain into request.source.ext.schain (without overwriting request.source / request.ext).
65+
*/
66+
function mergeSchain(ortbRequest, bid) {
67+
const schain = deepAccess(bid, 'ortb2.source.ext.schain');
68+
if (schain) {
69+
deepSetValue(ortbRequest, 'source.ext.schain', schain);
70+
}
71+
}
72+
73+
/**
74+
* Build a sync URL for our sync endpoint.
75+
*/
76+
function buildSyncUrl({ bidders, gdprConsent, uspConsent, type }) {
77+
const queryParams = [];
78+
79+
queryParams.push('bidders=' + bidders);
80+
81+
if (gdprConsent) {
82+
queryParams.push('gdpr=' + (gdprConsent.gdprApplies ? 1 : 0));
83+
queryParams.push(
84+
'gdpr_consent=' + encodeURIComponent(gdprConsent.consentString || '')
85+
);
86+
} else {
87+
queryParams.push('gdpr=0');
88+
}
89+
90+
if (typeof uspConsent !== 'undefined') {
91+
queryParams.push('us_privacy=' + encodeURIComponent(uspConsent));
92+
}
93+
94+
queryParams.push('type=' + encodeURIComponent(type));
95+
96+
return COOKIE_SYNC_ENDPOINT + '?' + queryParams.join('&');
97+
}
98+
2099
export const spec = {
21100
code: BIDDER_CODE,
22101
gvlid: GVLID,
@@ -30,19 +109,23 @@ export const spec = {
30109
// Build request payload including GDPR, GPP, and US Privacy data if available
31110
buildRequests: function (validBidRequests, bidderRequest) {
32111
return validBidRequests.map((bid) => {
112+
// Start from ortb2 (publisher modules may have populated site/user/device/ext/etc)
33113
const requestData = {
34114
...bid.ortb2,
35-
source: {
36-
ext: {
37-
schain: bid?.ortb2?.source?.ext?.schain
38-
}
39-
},
40115
id: bidderRequest.bidderRequestId,
41116
imp: [getImp(bid)],
42-
tmax: TMAX,
43-
...buildStoredRequest(bid),
44117
};
45118

119+
// Merge (don’t overwrite) schain + storedrequest
120+
mergeSchain(requestData, bid);
121+
mergeStoredRequest(requestData, bid);
122+
123+
// Resolve and set tmax (don’t hard-force)
124+
const tmax = resolveTmax(bid, bidderRequest);
125+
if (tmax) {
126+
requestData.tmax = tmax;
127+
}
128+
46129
// GDPR
47130
if (bidderRequest && bidderRequest.gdprConsent) {
48131
deepSetValue(
@@ -67,7 +150,11 @@ export const spec = {
67150

68151
// US Privacy
69152
if (bidderRequest && bidderRequest.usPrivacy) {
70-
deepSetValue(requestData, 'regs.ext.us_privacy', bidderRequest.usPrivacy);
153+
deepSetValue(
154+
requestData,
155+
'regs.ext.us_privacy',
156+
bidderRequest.usPrivacy
157+
);
71158
}
72159

73160
// User IDs
@@ -87,6 +174,7 @@ export const spec = {
87174
// Interpret response: group bids by unique impid and select the highest CPM bid per imp
88175
interpretResponse: function (serverResponse, bidRequest) {
89176
const bidResponsesMap = {}; // Maps impid -> highest bid object
177+
90178
if (!serverResponse.body || !serverResponse.body.seatbid) {
91179
return [];
92180
}
@@ -98,20 +186,30 @@ export const spec = {
98186
// --- MINIMAL CHANGE START ---
99187
// Build meta object and propagate advertiser domains for hb_adomain
100188
const meta = deepAccess(bid, 'ext.prebid.meta', {}) || {};
189+
101190
// Read ORTB adomain; normalize to array of clean strings
102191
let advertiserDomains = deepAccess(bid, 'adomain', []);
103192
advertiserDomains = Array.isArray(advertiserDomains)
104193
? advertiserDomains
105194
.filter(Boolean)
106-
.map(d => String(d).toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').trim())
195+
.map((d) =>
196+
String(d)
197+
.toLowerCase()
198+
.replace(/^https?:\/\//, '')
199+
.replace(/^www\./, '')
200+
.trim()
201+
)
107202
: [];
203+
108204
if (advertiserDomains.length > 0) {
109205
meta.advertiserDomains = advertiserDomains; // <-- Prebid uses this to set hb_adomain
110206
}
207+
111208
const networkId = deepAccess(bid, 'ext.prebid.meta.networkId');
112209
if (networkId) {
113210
meta.networkId = networkId;
114211
}
212+
115213
// Keep writing back for completeness (preserves existing behavior)
116214
deepSetValue(bid, 'ext.prebid.meta', meta);
117215
// --- MINIMAL CHANGE END ---
@@ -157,39 +255,41 @@ export const spec = {
157255
},
158256
];
159257

160-
if (!serverResponse || (Array.isArray(serverResponse) && serverResponse.length === 0)) {
258+
if (
259+
!serverResponse ||
260+
(Array.isArray(serverResponse) && serverResponse.length === 0)
261+
) {
161262
return syncs;
162263
}
163264

164-
const responses = Array.isArray(serverResponse)
165-
? serverResponse
166-
: [serverResponse];
265+
const responses = Array.isArray(serverResponse) ? serverResponse : [serverResponse];
167266
const bidders = getBidders(responses);
168267

268+
// Prefer iframe when allowed
169269
if (optionsType.iframeEnabled && bidders) {
170-
const queryParams = [];
171-
queryParams.push('bidders=' + bidders);
172-
173-
if (gdprConsent) {
174-
queryParams.push('gdpr=' + (gdprConsent.gdprApplies ? 1 : 0));
175-
queryParams.push(
176-
'gdpr_consent=' +
177-
encodeURIComponent(gdprConsent.consentString || '')
178-
);
179-
} else {
180-
queryParams.push('gdpr=0');
181-
}
182-
183-
if (typeof uspConsent !== 'undefined') {
184-
queryParams.push('us_privacy=' + encodeURIComponent(uspConsent));
185-
}
186-
187-
queryParams.push('type=iframe');
188-
const strQueryParams = '?' + queryParams.join('&');
189-
190270
syncs.push({
191271
type: 'iframe',
192-
url: COOKIE_SYNC_ENDPOINT + strQueryParams,
272+
url: buildSyncUrl({
273+
bidders,
274+
gdprConsent,
275+
uspConsent,
276+
type: 'iframe',
277+
}),
278+
});
279+
return syncs;
280+
}
281+
282+
// Fallback: if iframe is disabled but pixels are enabled, attempt a pixel-based sync call
283+
// (Your sync endpoint must support this mode for it to be effective.)
284+
if (optionsType.pixelEnabled && bidders) {
285+
syncs.push({
286+
type: 'image',
287+
url: buildSyncUrl({
288+
bidders,
289+
gdprConsent,
290+
uspConsent,
291+
type: 'image',
292+
}),
193293
});
194294
}
195295

@@ -211,8 +311,8 @@ export const spec = {
211311
function getImp(bid) {
212312
const imp = buildStoredRequest(bid);
213313
imp.id = bid.bidId; // Ensure imp.id is unique to match the bid response correctly
214-
const sizes =
215-
bid.sizes && !Array.isArray(bid.sizes[0]) ? [bid.sizes] : bid.sizes;
314+
315+
const sizes = bid.sizes && !Array.isArray(bid.sizes[0]) ? [bid.sizes] : bid.sizes;
216316

217317
if (deepAccess(bid, 'mediaTypes.banner')) {
218318
imp.banner = {
@@ -244,13 +344,26 @@ function buildStoredRequest(bid) {
244344
}
245345

246346
// Helper: Extract unique bidders from responses for user syncs
347+
// Primary source: ext.responsetimemillis (PBS), fallback: seatbid[].seat
247348
function getBidders(responses) {
248-
const bidders = responses
249-
.map((res) => Object.keys(res.body.ext?.responsetimemillis || {}))
250-
.flat();
349+
const bidderSet = new Set();
350+
351+
responses.forEach((res) => {
352+
const rtm = deepAccess(res, 'body.ext.responsetimemillis');
353+
if (rtm && typeof rtm === 'object') {
354+
Object.keys(rtm).forEach((k) => bidderSet.add(k));
355+
}
356+
357+
const seatbid = deepAccess(res, 'body.seatbid', []);
358+
if (Array.isArray(seatbid)) {
359+
seatbid.forEach((sb) => {
360+
if (sb && sb.seat) bidderSet.add(sb.seat);
361+
});
362+
}
363+
});
251364

252-
if (bidders.length) {
253-
return encodeURIComponent(JSON.stringify([...new Set(bidders)]));
365+
if (bidderSet.size) {
366+
return encodeURIComponent(JSON.stringify([...bidderSet]));
254367
}
255368
}
256369

0 commit comments

Comments
 (0)