Skip to content

Commit f084d56

Browse files
committed
APS Bid Adapter Initial Release
**Overview** ------------ APS (Amazon Publisher Services) bid adapter initial open source release. **Changes** ----------- - (feat) Banner ad support - (feat) Video ad support - (feat) iframe user sync support - (feat) Telemetry and analytics - (docs) Integration guide
1 parent 0b17c03 commit f084d56

File tree

3 files changed

+1430
-0
lines changed

3 files changed

+1430
-0
lines changed

modules/apsBidAdapter.js

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import { isStr, isNumber, logWarn, logError } from '../src/utils.js';
2+
import { registerBidder } from '../src/adapters/bidderFactory.js';
3+
import { config } from '../src/config.js';
4+
import { BANNER, VIDEO } from '../src/mediaTypes.js';
5+
import { ortbConverter } from '../libraries/ortbConverter/converter.js';
6+
7+
const GVLID = 793;
8+
const ADAPTER_VERSION = '2.0.0';
9+
const BIDDER_CODE = 'aps';
10+
const AAX_ENDPOINT = 'https://web.ads.aps.amazon-adsystem.com/e/pb/bid';
11+
const DEFAULT_PREBID_CREATIVE_JS_URL =
12+
'https://client.aps.amazon-adsystem.com/prebid-creative.js';
13+
14+
/**
15+
* Records an event by pushing a CustomEvent onto a global queue.
16+
* Creates an account-specific store on window._aps if needed.
17+
* Automatically prefixes eventName with 'prebidAdapter/' if not already prefixed.
18+
* Automatically appends '/didTrigger' if there is no third part provided in the event name.
19+
*
20+
* @param {string} eventName - The name of the event to record
21+
* @param {object} data - Event data object, typically containing an 'error' property
22+
*/
23+
function record(eventName, data) {
24+
// Check if telemetry is enabled
25+
if (config.readConfig('aps.telemetry') === false) {
26+
return;
27+
}
28+
29+
// Automatically prefix eventName with 'prebidAdapter/' if not already prefixed
30+
const prefixedEventName = eventName.startsWith('prebidAdapter/')
31+
? eventName
32+
: `prebidAdapter/${eventName}`;
33+
34+
// Automatically append 'didTrigger' if there is no third part provided in the event name
35+
const parts = prefixedEventName.split('/');
36+
const finalEventName =
37+
parts.length < 3 ? `${prefixedEventName}/didTrigger` : prefixedEventName;
38+
39+
const accountID = config.readConfig('aps.accountID');
40+
if (!accountID) {
41+
return;
42+
}
43+
44+
window._aps = window._aps || new Map();
45+
if (!window._aps.has(accountID)) {
46+
window._aps.set(accountID, {
47+
queue: [],
48+
store: new Map(),
49+
});
50+
}
51+
52+
// Ensure analytics key exists unless error key is present
53+
const detailData = { ...data };
54+
if (!detailData.error) {
55+
detailData.analytics = detailData.analytics || {};
56+
}
57+
58+
window._aps.get(accountID).queue.push(
59+
new CustomEvent(finalEventName, {
60+
detail: {
61+
...detailData,
62+
source: 'prebid-adapter',
63+
libraryVersion: ADAPTER_VERSION,
64+
},
65+
})
66+
);
67+
}
68+
69+
/**
70+
* Validates whether a given account ID is valid.
71+
*
72+
* @param {string|number} accountID - The account ID to validate
73+
* @returns {boolean} Returns true if the account ID is valid, false otherwise
74+
*/
75+
function isValidAccountID(accountID) {
76+
// null/undefined are not acceptable
77+
if (accountID == null) {
78+
return false;
79+
}
80+
81+
// Numbers are valid (including 0)
82+
if (isNumber(accountID)) {
83+
return true;
84+
}
85+
86+
// Strings must have content after trimming
87+
if (isStr(accountID)) {
88+
return accountID.trim().length > 0;
89+
}
90+
91+
// Other types are invalid
92+
return false;
93+
}
94+
95+
export const converter = ortbConverter({
96+
context: {
97+
netRevenue: true,
98+
},
99+
100+
request(buildRequest, imps, bidderRequest, context) {
101+
const request = buildRequest(imps, bidderRequest, context);
102+
103+
// Remove precise geo locations for privacy.
104+
if (request?.device?.geo) {
105+
delete request.device.geo.lat;
106+
delete request.device.geo.lon;
107+
}
108+
109+
if (request.user) {
110+
// Remove sensitive user data.
111+
delete request.user.gender;
112+
delete request.user.yob;
113+
// Remove both 'keywords' and alternate 'kwarry' if present.
114+
delete request.user.keywords;
115+
delete request.user.kwarry;
116+
delete request.user.customdata;
117+
delete request.user.geo;
118+
delete request.user.data;
119+
}
120+
121+
request.ext = request.ext ?? {};
122+
request.ext.account = config.readConfig('aps.accountID');
123+
request.cur = request.cur ?? ['USD'];
124+
125+
// Validate and process impressions - fail fast on structural issues
126+
if (!request.imp || !Array.isArray(request.imp)) {
127+
throw new Error('Request must contain a valid impressions array');
128+
}
129+
130+
request.imp.forEach((imp, index) => {
131+
if (!imp) {
132+
throw new Error(`Impression at index ${index} is null or undefined`);
133+
}
134+
135+
if (!imp.banner) {
136+
return;
137+
}
138+
139+
const doesHWExist = imp.banner.w >= 0 && imp.banner.h >= 0;
140+
const doesFormatExist =
141+
Array.isArray(imp.banner.format) && imp.banner.format.length > 0;
142+
143+
if (!doesHWExist && doesFormatExist) {
144+
const { w, h } = imp.banner.format[0];
145+
if (typeof w !== 'number' || typeof h !== 'number') {
146+
throw new Error(
147+
`Invalid banner format dimensions at impression ${index}: w=${w}, h=${h}`
148+
);
149+
}
150+
imp.banner.w = w;
151+
imp.banner.h = h;
152+
}
153+
});
154+
155+
return request;
156+
},
157+
158+
bidResponse(buildBidResponse, bid, context) {
159+
let vastUrl;
160+
if (bid.mtype === 2) {
161+
vastUrl = bid.adm;
162+
// Making sure no adm value is passed down to prevent issues with some renderers
163+
delete bid.adm;
164+
}
165+
166+
const bidResponse = buildBidResponse(bid, context);
167+
if (bidResponse.mediaType === VIDEO) {
168+
bidResponse.vastUrl = vastUrl;
169+
}
170+
171+
return bidResponse;
172+
},
173+
});
174+
175+
/** @type {BidderSpec} */
176+
export const spec = {
177+
code: BIDDER_CODE,
178+
gvlid: GVLID,
179+
supportedMediaTypes: [BANNER, VIDEO],
180+
181+
/**
182+
* Validates the bid request.
183+
* Always fires 100% of requests when account ID is valid.
184+
* @param {object} bid
185+
* @return {boolean}
186+
*/
187+
isBidRequestValid: (bid) => {
188+
record('isBidRequestValid');
189+
try {
190+
const accountID = config.readConfig('aps.accountID');
191+
if (!isValidAccountID(accountID)) {
192+
logWarn(`Invalid accountID: ${accountID}`);
193+
return false;
194+
}
195+
return true;
196+
} catch (err) {
197+
record('isBidRequestValid/didError', { error: err });
198+
logError('Error while validating bid request', err);
199+
}
200+
},
201+
202+
/**
203+
* Constructs the server request for the bidder.
204+
* @param {BidRequest[]} bidRequests
205+
* @param {*} bidderRequest
206+
* @return {ServerRequest}
207+
*/
208+
buildRequests: (bidRequests, bidderRequest) => {
209+
record('buildRequests');
210+
try {
211+
let endpoint = config.readConfig('aps.debugURL') ?? AAX_ENDPOINT;
212+
// Append debug parameters to the URL if debug mode is enabled.
213+
if (config.readConfig('aps.debug')) {
214+
const debugQueryChar = endpoint.includes('?') ? '&' : '?';
215+
const renderMethod = config.readConfig('aps.renderMethod');
216+
if (renderMethod === 'fif') {
217+
endpoint += debugQueryChar + 'amzn_debug_mode=fif&amzn_debug_mode=1';
218+
} else {
219+
endpoint += debugQueryChar + 'amzn_debug_mode=1';
220+
}
221+
}
222+
return {
223+
method: 'POST',
224+
url: endpoint,
225+
data: converter.toORTB({ bidRequests, bidderRequest }),
226+
options: {
227+
contentType: 'application/json',
228+
},
229+
};
230+
} catch (err) {
231+
record('buildRequests/didError', { error: err });
232+
logError('Error while building bid request', err);
233+
}
234+
},
235+
236+
/**
237+
* Interprets the response from the server.
238+
* Constructs a creative script to render the ad using a prebid creative JS.
239+
* @param {*} response
240+
* @param {ServerRequest} request
241+
* @return {Bid[] | {bids: Bid[]}}
242+
*/
243+
interpretResponse: (response, request) => {
244+
record('interpretResponse');
245+
try {
246+
const interpretedResponse = converter.fromORTB({
247+
response: response.body,
248+
request: request.data,
249+
});
250+
const accountID = config.readConfig('aps.accountID');
251+
252+
const creativeUrl =
253+
config.readConfig('aps.creativeURL') || DEFAULT_PREBID_CREATIVE_JS_URL;
254+
255+
interpretedResponse.bids.forEach((bid) => {
256+
if (bid.mediaType !== VIDEO) {
257+
delete bid.ad;
258+
bid.ad = `<script src="${creativeUrl}"></script>
259+
<script>
260+
const accountID = '${accountID}';
261+
window._aps = window._aps || new Map();
262+
if (!window._aps.has(accountID)) {
263+
window._aps.set(accountID, { queue: [], store: new Map([['listeners', new Map()]]) });
264+
}
265+
window._aps.get(accountID).queue.push(
266+
new CustomEvent('prebid/creative/render', {
267+
detail: {
268+
aaxResponse: '${btoa(JSON.stringify(response.body))}',
269+
seatBidId: ${JSON.stringify(bid.seatBidId)}
270+
}
271+
})
272+
);
273+
</script>`.trim();
274+
}
275+
});
276+
277+
return interpretedResponse.bids;
278+
} catch (err) {
279+
record('interpretResponse/didError', { error: err });
280+
logError('Error while interpreting bid response', err);
281+
}
282+
},
283+
284+
/**
285+
* Register user syncs to be processed during the shared user ID sync activity
286+
*
287+
* @param {Object} syncOptions - Options for user synchronization
288+
* @param {Array} serverResponses - Array of bid responses
289+
* @param {Object} gdprConsent - GDPR consent information
290+
* @param {Object} uspConsent - USP consent information
291+
* @returns {Array} Array of user sync objects
292+
*/
293+
getUserSyncs: function (
294+
syncOptions,
295+
serverResponses,
296+
gdprConsent,
297+
uspConsent
298+
) {
299+
record('getUserSyncs');
300+
try {
301+
const userSyncs = serverResponses.flatMap(
302+
(res) => res?.body?.ext?.userSyncs ?? []
303+
);
304+
return userSyncs.filter(
305+
(s) =>
306+
(s.type === 'iframe' && syncOptions.iframeEnabled) ||
307+
(s.type === 'image' && syncOptions.pixelEnabled)
308+
);
309+
} catch (err) {
310+
record('getUserSyncs/didError', { error: err });
311+
logError('Error while getting user syncs', err);
312+
}
313+
},
314+
315+
onTimeout: (timeoutData) => {
316+
record('onTimeout', { error: timeoutData });
317+
},
318+
319+
onSetTargeting: (bid) => {
320+
record('onSetTargeting');
321+
},
322+
323+
onAdRenderSucceeded: (bid) => {
324+
record('onAdRenderSucceeded');
325+
},
326+
327+
onBidderError: (error) => {
328+
record('onBidderError', { error });
329+
},
330+
331+
onBidWon: (bid) => {
332+
record('onBidWon');
333+
},
334+
335+
onBidAttribute: (bid) => {
336+
record('onBidAttribute');
337+
},
338+
339+
onBidBillable: (bid) => {
340+
record('onBidBillable');
341+
},
342+
};
343+
344+
registerBidder(spec);

0 commit comments

Comments
 (0)