From 6271f3a49b17b01fc6d38ebdfcd7356c3d18e366 Mon Sep 17 00:00:00 2001 From: Nicolas Kogler Date: Sun, 19 Apr 2026 16:46:06 +0200 Subject: [PATCH] Bidboost: New analytics and RTD module --- modules/bidboostAnalyticsAdapter.md | 53 ++ modules/bidboostAnalyticsAdapter.ts | 673 ++++++++++++++++++ modules/bidboostRtdProvider.md | 82 +++ modules/bidboostRtdProvider.ts | 483 +++++++++++++ src/bidboostShared.ts | 164 +++++ .../modules/bidboostAnalyticsAdapter_spec.js | 313 ++++++++ test/spec/modules/bidboostRtdProvider_spec.js | 154 ++++ 7 files changed, 1922 insertions(+) create mode 100644 modules/bidboostAnalyticsAdapter.md create mode 100644 modules/bidboostAnalyticsAdapter.ts create mode 100644 modules/bidboostRtdProvider.md create mode 100644 modules/bidboostRtdProvider.ts create mode 100644 src/bidboostShared.ts create mode 100644 test/spec/modules/bidboostAnalyticsAdapter_spec.js create mode 100644 test/spec/modules/bidboostRtdProvider_spec.js diff --git a/modules/bidboostAnalyticsAdapter.md b/modules/bidboostAnalyticsAdapter.md new file mode 100644 index 0000000000..762c81fae9 --- /dev/null +++ b/modules/bidboostAnalyticsAdapter.md @@ -0,0 +1,53 @@ +--- +layout: page_v2 +title: Bidboost Analytics Adapter +description: Bidboost analytics adapter for auction telemetry. +page_type: module +module_type: analytics +module_code: bidboost +enable_download: true +sidebarType: 1 +--- + +# Bidboost Analytics Adapter + +## Overview + +Use this module together with `bidboostRtdProvider` to measure controlled traffic-shaping impact. + +## Build + +```bash +gulp build --modules="bidboostAnalyticsAdapter" +``` + +## Configuration + +```javascript +pbjs.enableAnalytics({ + provider: 'bidboost', + options: { + client: 'YOUR_CLIENT_CODE', + site: 'YOUR_SITE_CODE', + collectorUrl: 'https://collect.bidboost.net', + analyticsBatchWindowMs: 1000, + ignoredBidders: ['exampleBidder'], + placementMapper: (adUnit) => adUnit.code, + bidderMapper: (bidder) => bidder + } +}); +``` + +## Analytics Parameters + +Set these fields in `pbjs.enableAnalytics({ provider: 'bidboost', options: ... })`: + +| Field | Type | Description | +| --- | --- | --- | +| `client` | `string` | UUID of the client account running Bidboost. Provided during onboarding. | +| `site` | `string` | Site identifier configured in the Bidboost management UI. | +| `collectorUrl` | `string` | Base URL of the Bidboost collector service. Default: `https://collect.bidboost.net`. | +| `analyticsBatchWindowMs` | `number` | Batch window in milliseconds for auction/impression analytics dispatch. Default: `1000`. | +| `ignoredBidders` | `string[]` | Bidders to exclude from analytics reporting. | +| `placementMapper` | `(adUnit: object) => string` | Maps a Prebid ad unit to the placement identifier configured in Bidboost. Default maps to `adUnit.code`. | +| `bidderMapper` | `(bidderCode: string) => string` | Maps a Prebid bidder code to the bidder identifier configured in Bidboost. Default is identity mapping. | diff --git a/modules/bidboostAnalyticsAdapter.ts b/modules/bidboostAnalyticsAdapter.ts new file mode 100644 index 0000000000..8d5cce9f04 --- /dev/null +++ b/modules/bidboostAnalyticsAdapter.ts @@ -0,0 +1,673 @@ +import AnalyticsAdapter, { type AnalyticsConfig } from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import { ajax } from '../src/ajax.js'; +import adapterManager, { type BidderRequest } from '../src/adapterManager.js'; +import { EVENTS } from '../src/constants.js'; +import type { EventPayload } from '../src/events.js'; +import type { Bid } from '../src/bidfactory.ts'; +import type { AuctionProperties } from '../src/auction.ts'; +import type { BidderCode } from '../src/types/common.d.ts'; +import { getWindowLocation, getWindowSelf, logError, logWarn } from '../src/utils.js'; +import { + BIDBOOST_ANALYTICS_CODE, + ALL_BIDDERS, + type BidboostModuleParams, + type BidboostAdUnitDefinition, + type BidboostModuleParamsInput, + type PredictorSnapshot, + toFiniteNumber, + consumePredictorSnapshotForAuction, + getConnectionType, + hasRequiredParams, + normalizeBidboostParams, + peekPredictorSnapshotForAuction +} from '../src/bidboostShared.js'; + +interface CollectorBid { + b: string; + u: number; + s: number; + c?: number; + m?: number; + r?: number; + p?: number; + z?: number; +} + +interface CollectorPlacement { + c: string; + b: CollectorBid[]; +} + +interface CollectorRequest { + c: string; + s: string; + p: string; + g: number; + ct: number; + bt: number; + fa: number; + na: number; + pl: CollectorPlacement[]; +} + +interface BidLikeWithIdentity { + bidderCode?: string; + bidder?: string; +} + +type BidboostAnalyticsConfig = AnalyticsConfig<'bidboost'> & { options?: BidboostModuleParamsInput }; + +type EventPayloadMap = { + [EVENTS.BID_REQUESTED]: EventPayload; + [EVENTS.AUCTION_END]: EventPayload; + [EVENTS.AD_RENDER_SUCCEEDED]: EventPayload; +}; + +const bidSourceIds: Record = { + client: 1, + s2s: 2 +}; + +const mediaTypeIds: Record = { + banner: 1, + video: 2, + native: 3 +}; + +const currencyIds: Record = { + EUR: 1, + USD: 2, + GBP: 3, + JPY: 4 +}; + +const BidState = { + NoBid: 1, + Timeout: 2, + BidResponse: 3, + FilledImpression: 4, + BidRejected: 5 +} as const; + +const MAX_SNAPSHOT_ENTRIES = 10; + +let params: BidboostModuleParams | null = null; +let pendingAuctionId: string | null = null; +let pendingCollectorRequest: CollectorRequest | null = null; +let pendingCollectorTimeout: ReturnType | null = null; +let activeAuctionId: string | null = null; +let runningAuction = false; +let pageLifecycleHooksRegistered = false; +let analyticsEnabled = false; + +const snapshotByAuctionId: Record = {}; +const snapshotAuctionIds: string[] = []; +const sourcesByBidder: Record = {}; +const uidsByBidder: Record = {}; + +function rememberSnapshot(auctionId: string, snapshot: PredictorSnapshot): void { + if (!snapshotByAuctionId[auctionId]) { + snapshotAuctionIds.push(auctionId); + if (snapshotAuctionIds.length > MAX_SNAPSHOT_ENTRIES) { + const evictedAuctionId = snapshotAuctionIds.shift(); + if (evictedAuctionId) { + delete snapshotByAuctionId[evictedAuctionId]; + } + } + } + snapshotByAuctionId[auctionId] = snapshot; +} + +function clearSnapshotCache(): void { + snapshotAuctionIds.length = 0; + Object.keys(snapshotByAuctionId).forEach((auctionId) => { + delete snapshotByAuctionId[auctionId]; + }); +} + +function removeSnapshot(auctionId: string): void { + delete snapshotByAuctionId[auctionId]; + const index = snapshotAuctionIds.indexOf(auctionId); + if (index !== -1) { + snapshotAuctionIds.splice(index, 1); + } +} + +function onBidRequested(data: BidderRequest): void { + if (!data) { + return; + } + + runningAuction = true; + activeAuctionId = data.auctionId || activeAuctionId; + + const bidderCode = data.bidderCode; + if (bidderCode) { + sourcesByBidder[bidderCode] = getBidSourceId(data.src); + if (hasUserIdSignal(data)) { + uidsByBidder[bidderCode] = true; + } + } + + const bids = Array.isArray(data.bids) ? data.bids : []; + bids.forEach((bid) => { + const bidder = getBidderCode(bid) || bidderCode; + if (!bidder) { + return; + } + sourcesByBidder[bidder] = getBidSourceId(data.src); + if (hasUserIdSignal(bid)) { + uidsByBidder[bidder] = true; + } + }); +} + +function onAuctionEnd(data: AuctionProperties): void { + if (!data || !params) { + return; + } + const activeParams = params; + + runningAuction = false; + activeAuctionId = null; + + const snapshot = consumePredictorSnapshotForAuction(data.auctionId) || null; + if (snapshot) { + rememberSnapshot(data.auctionId, snapshot); + } + + const bidsByAdUnit: Record = {}; + const processedBiddersByAdUnit: Record> = {}; + + processRejectedBids(data, bidsByAdUnit, processedBiddersByAdUnit); + processNoBids(data, bidsByAdUnit, processedBiddersByAdUnit); + if (!processBidResponses(data, bidsByAdUnit, processedBiddersByAdUnit)) { + removeSnapshot(data.auctionId); + return; + } + processBidTimeouts(data, bidsByAdUnit, processedBiddersByAdUnit); + + const request = createDataCollectorRequest(activeParams, snapshot); + Object.keys(bidsByAdUnit).forEach((adUnitCode) => { + request.pl.push({ + c: resolvePlacementCode(activeParams, snapshot, adUnitCode), + b: bidsByAdUnit[adUnitCode] + }); + }); + queueDataCollectorRequest(data.auctionId, request); +} + +function onAdRenderSucceeded(data: EventPayload): void { + if (!data || !data.bid || !params) { + return; + } + + const bid = data.bid; + const bidder = getBidderCode(bid); + if ( + !bidder || + isBidFromNonPrebidIntegration(bid) || + bid.mediaType === 'audio' || + params.ignoredBidders.has(bidder) + ) { + return; + } + + const snapshot = snapshotByAuctionId[bid.auctionId] || null; + const placementCode = resolvePlacementCode(params, snapshot, bid.adUnitCode); + const price = getPrice(bid); + if (!price) { + logError(`bidboostAnalyticsAdapter: unsupported currency '${bid.originalCurrency ?? bid.currency}'`); + return; + } + const filledImpressionBid: CollectorBid = { + b: params.bidderMapper(bidder), + u: hasUid(uidsByBidder, bidder), + s: BidState.FilledImpression, + c: sourcesByBidder[bidder], + m: mediaTypeIds[bid.mediaType], + r: bid.responseTimestamp - bid.requestTimestamp, + p: price._cpm, + z: price._currencyId + }; + + if (pendingAuctionId === bid.auctionId && pendingCollectorRequest && pendingCollectorTimeout !== null) { + const placement = pendingCollectorRequest.pl.find((entry) => entry.c === placementCode); + if (placement) { + placement.b.push(filledImpressionBid); + } + return; + } + + const request = createDataCollectorRequest(params, snapshot); + request.na = 0; + request.pl = [{ c: placementCode, b: [filledImpressionBid] }]; + postDataCollectorRequest(params.collectorUrl, request); +} + +function processRejectedBids( + data: AuctionProperties, + bidsByAdUnit: Record, + processedBiddersByAdUnit: Record> +): void { + const rejectedBids = Array.isArray(data.bidsRejected) ? data.bidsRejected : []; + rejectedBids.forEach((bid) => { + const bidder = getBidderCode(bid); + if (bid.adUnitCode && bidder && bidderIncluded(bidder)) { + addBid(bidsByAdUnit, processedBiddersByAdUnit, bid.adUnitCode, bidder, { + s: BidState.BidRejected, + c: sourcesByBidder[bidder], + r: (bid.responseTimestamp ?? 500) - (bid.requestTimestamp ?? 0) + }); + } + }); +} + +function processNoBids( + data: AuctionProperties, + bidsByAdUnit: Record, + processedBiddersByAdUnit: Record> +): void { + const noBids = Array.isArray(data.noBids) ? data.noBids : []; + noBids.forEach((bid) => { + const bidder = getBidderCode(bid); + if (bidder && bidderIncluded(bidder)) { + addBid(bidsByAdUnit, processedBiddersByAdUnit, bid.adUnitCode, bidder, { + s: BidState.NoBid, + c: sourcesByBidder[bidder], + r: data.auctionEnd - data.timestamp + }); + } + }); +} + +function processBidResponses( + data: AuctionProperties, + bidsByAdUnit: Record, + processedBiddersByAdUnit: Record> +): boolean { + const bidsReceived = Array.isArray(data.bidsReceived) ? data.bidsReceived : []; + for (const bid of bidsReceived) { + const bidder = getBidderCode(bid); + if (bidder && bidderIncluded(bidder) && bid.mediaType !== 'audio') { + const price = getPrice(bid); + if (!price) { + logError(`bidboostAnalyticsAdapter: unsupported currency '${bid.originalCurrency ?? bid.currency}'`); + return false; + } + addBid(bidsByAdUnit, processedBiddersByAdUnit, bid.adUnitCode, bidder, { + s: BidState.BidResponse, + c: sourcesByBidder[bidder], + m: mediaTypeIds[bid.mediaType], + r: bid.responseTimestamp - bid.requestTimestamp, + p: price._cpm, + z: price._currencyId + }); + } + } + + return true; +} + +function processBidTimeouts( + data: AuctionProperties, + bidsByAdUnit: Record, + processedBiddersByAdUnit: Record> +): void { + const bidderRequests = Array.isArray(data.bidderRequests) ? data.bidderRequests : []; + bidderRequests.forEach((bidderRequest) => { + const bidder = bidderRequest.bidderCode; + if (!bidder) { + return; + } + bidderRequest.bids.forEach((bid) => { + const adUnitCode = bid.adUnitCode; + const processedBidders = processedBiddersByAdUnit[adUnitCode]; + if (bidderIncluded(bidder) && (!processedBidders || !processedBidders.has(bidder))) { + addBid(bidsByAdUnit, processedBiddersByAdUnit, adUnitCode, bidder, { + s: BidState.Timeout, + c: sourcesByBidder[bidder] + }); + } + }); + }); +} + +function addBid( + bidsByAdUnit: Record, + processedBiddersByAdUnit: Record>, + adUnitCode: string, + bidder: string, + bid: Omit +): void { + if (!params) { + return; + } + + (processedBiddersByAdUnit[adUnitCode] ||= new Set()).add(bidder); + + const augmentedBid: CollectorBid = { + b: params.bidderMapper(bidder), + u: hasUid(uidsByBidder, bidder), + s: bid.s, + c: bid.c, + m: bid.m, + r: bid.r, + p: bid.p, + z: bid.z + }; + + (bidsByAdUnit[adUnitCode] ||= []).push(augmentedBid); +} + +function bidderIncluded(bidder: string): boolean { + return !!params && !params.ignoredBidders.has(bidder); +} + +function queueDataCollectorRequest(auctionId: string, request: CollectorRequest): void { + if (!params) { + return; + } + + flushPendingCollectorRequest(); + pendingAuctionId = auctionId; + pendingCollectorRequest = request; + pendingCollectorTimeout = setTimeout(flushPendingCollectorRequest, params.analyticsBatchWindowMs); +} + +function flushPendingCollectorRequest(): void { + if (!pendingCollectorRequest || !params) { + return; + } + + if (pendingCollectorTimeout !== null) { + clearTimeout(pendingCollectorTimeout); + pendingCollectorTimeout = null; + } + + postDataCollectorRequest(params.collectorUrl, pendingCollectorRequest); + pendingCollectorRequest = null; + pendingAuctionId = null; +} + +function onPageHide(): void { + if (!params) { + return; + } + + if (pendingCollectorRequest) { + flushPendingCollectorRequest(); + return; + } + + if (!runningAuction) { + return; + } + const snapshot = activeAuctionId ? peekPredictorSnapshotForAuction(activeAuctionId) : null; + const request = createDataCollectorRequest(params, snapshot); + postDataCollectorRequest(params.collectorUrl, request); +} + +function onVisibilityChange(event: Event): void { + const target = event.target as any; + if (target?.visibilityState === 'hidden') { + flushPendingCollectorRequest(); + } +} + +function hasUserIdSignal(input: unknown): boolean { + if (!input || typeof input !== 'object') { + return false; + } + + const data = input as { + userId?: unknown; + userIdAsEids?: unknown; + userIdAsEid?: unknown; + ortb2?: { user?: { eids?: unknown[] } }; + }; + + if (data.userId && typeof data.userId === 'object' && Object.keys(data.userId).length > 0) { + return true; + } + + const eids = data.userIdAsEids || data.userIdAsEid; + if (Array.isArray(eids) && eids.length > 0) { + uidsByBidder[ALL_BIDDERS] = true; + return true; + } + + if (Array.isArray(data.ortb2?.user?.eids) && data.ortb2.user.eids.length > 0) { + uidsByBidder[ALL_BIDDERS] = true; + return true; + } + + return false; +} + +function getBidSourceId(source: string): number { + return bidSourceIds[source] || bidSourceIds.client; +} + +function hasUid(uidLookup: Record, bidder: string): number { + return uidLookup[ALL_BIDDERS] || uidLookup[bidder] ? 1 : 0; +} + +function getPrice(bid: Bid): { _cpm: number; _currencyId: number } | null { + if (bid.originalCpm && bid.originalCurrency) { + const originalCurrencyId = currencyIds[bid.originalCurrency]; + if (originalCurrencyId !== undefined) { + return { _cpm: bid.originalCpm, _currencyId: originalCurrencyId }; + } + } + + const currencyId = currencyIds[bid.currency]; + if (currencyId !== undefined) { + return { _cpm: bid.cpm, _currencyId: currencyId }; + } + + return null; +} + +function createDataCollectorRequest(activeParams: BidboostModuleParams, snapshot: PredictorSnapshot | null): CollectorRequest { + const connectionType = getConnectionType(); + return { + c: activeParams.client, + s: activeParams.site, + p: getPage(), + g: toFiniteNumber(snapshot?.g, 0), + ct: toFiniteNumber(snapshot?.t, connectionType), + bt: toFiniteNumber(snapshot?.b, 3000), + fa: snapshot?.fa === 1 ? 1 : 0, + na: 1, + pl: [] + }; +} + +function postDataCollectorRequest(collectorUrl: string, request: CollectorRequest): void { + const checksum = computeChecksum(request); + const body = JSON.stringify(request); + const url = `${collectorUrl}/auction?id=${checksum}`; + const window = getWindowSelf(); + + try { + if (body.length < 60000 && window?.navigator?.sendBeacon && window.navigator.sendBeacon(url, body)) { + return; + } + } catch (_error) { + // ignore and fallback to ajax + } + + ajax(url, undefined, body, { method: 'POST', contentType: 'text/plain', withCredentials: false }); +} + +function resolvePlacementCode(activeParams: BidboostModuleParams, snapshot: PredictorSnapshot | null, adUnitCode: string): string { + const placementByAdUnitCode = snapshot?.m; + if (placementByAdUnitCode && placementByAdUnitCode[adUnitCode]) { + return placementByAdUnitCode[adUnitCode]; + } + + try { + return activeParams.placementMapper({ code: adUnitCode } as BidboostAdUnitDefinition); + } catch (_error) { + return adUnitCode; + } +} + +function getPage(): string { + try { + return getWindowLocation()?.pathname.replace(/\/$/g, '') || ''; + } catch (_error) { + return ''; + } +} + +function computeChecksum(request: CollectorRequest): number { + let checksum = addToChecksumUuid(0, request.c); + checksum = addToChecksumString(checksum, request.s); + checksum = addToChecksumString(checksum, request.p); + + request.pl.forEach((placement) => { + checksum = addToChecksumString(checksum, placement.c); + placement.b.forEach((bid) => { + checksum = addToChecksumString(checksum, bid.b); + }); + }); + + return checksum; +} + +function addToChecksumUuid(checksum: number, uuid: string): number { + checksum = addToChecksum(checksum, byteAt(uuid, 6)); + checksum = addToChecksum(checksum, byteAt(uuid, 4)); + checksum = addToChecksum(checksum, byteAt(uuid, 2)); + checksum = addToChecksum(checksum, byteAt(uuid, 0)); + + checksum = addToChecksum(checksum, byteAt(uuid, 11)); + checksum = addToChecksum(checksum, byteAt(uuid, 9)); + + checksum = addToChecksum(checksum, byteAt(uuid, 16)); + checksum = addToChecksum(checksum, byteAt(uuid, 14)); + + checksum = addToChecksum(checksum, byteAt(uuid, 19)); + checksum = addToChecksum(checksum, byteAt(uuid, 21)); + + checksum = addToChecksum(checksum, byteAt(uuid, 24)); + checksum = addToChecksum(checksum, byteAt(uuid, 26)); + checksum = addToChecksum(checksum, byteAt(uuid, 28)); + checksum = addToChecksum(checksum, byteAt(uuid, 30)); + checksum = addToChecksum(checksum, byteAt(uuid, 32)); + checksum = addToChecksum(checksum, byteAt(uuid, 34)); + + return checksum; +} + +function byteAt(uuid: string, charIndex: number): number { + return Number(`0x${uuid.substring(charIndex, charIndex + 2)}`); +} + +function addToChecksumString(checksum: number, str: string): number { + for (let index = 0; index < str.length; index++) { + checksum = addToChecksum(checksum, str.charCodeAt(index)); + } + return checksum; +} + +function addToChecksum(checksum: number, value: number): number { + checksum = (checksum << 5) - checksum + value; + checksum &= checksum; + return checksum; +} + +function getBidderCode(value: BidLikeWithIdentity | unknown): string | null { + if (!value || typeof value !== 'object') { + return null; + } + const bidderCode = (value as BidLikeWithIdentity).bidderCode; + if (typeof bidderCode === 'string') { + return bidderCode; + } + const bidder = (value as BidLikeWithIdentity).bidder; + return typeof bidder === 'string' ? bidder : null; +} + +function isBidFromNonPrebidIntegration(bid: Bid): boolean { + const integrationType = (bid as Bid & { integrationType?: unknown }).integrationType; + return integrationType !== undefined && integrationType !== 'prebid'; +} + +const bidboostAnalyticsAdapter = AnalyticsAdapter<'bidboost'>({ + url: '', + analyticsType: 'endpoint' +}); + +const originalEnableAnalytics = bidboostAnalyticsAdapter.enableAnalytics; +const originalDisableAnalytics = bidboostAnalyticsAdapter.disableAnalytics; + +bidboostAnalyticsAdapter.track = function track({ eventType, args }: { eventType: keyof EventPayloadMap; args: unknown }) { + if (!params) { + return; + } + + if (eventType === EVENTS.BID_REQUESTED) { + onBidRequested(args as EventPayload); + return; + } + + if (eventType === EVENTS.AUCTION_END) { + onAuctionEnd(args as EventPayload); + return; + } + + if (eventType === EVENTS.AD_RENDER_SUCCEEDED) { + onAdRenderSucceeded(args as EventPayload); + } +}; + +bidboostAnalyticsAdapter.enableAnalytics = function enableAnalytics(config: BidboostAnalyticsConfig) { + const normalizedParams = normalizeBidboostParams(config?.options || {}); + if (hasRequiredParams(normalizedParams)) { + params = normalizedParams; + } else { + params = null; + logWarn('bidboostAnalyticsAdapter: missing required options "client" and/or "site"'); + } + + if (!pageLifecycleHooksRegistered) { + pageLifecycleHooksRegistered = true; + const window = getWindowSelf(); + const document = window?.document; + if (window && typeof window.addEventListener === 'function') { + window.addEventListener('pagehide', onPageHide); + if (document && typeof document.addEventListener === 'function') { + document.addEventListener('visibilitychange', onVisibilityChange); + } else { + // Fallback for non-DOM environments where document is unavailable. + window.addEventListener('visibilitychange', onVisibilityChange); + } + } + } + + const result = originalEnableAnalytics.call(this, config); + analyticsEnabled = true; + return result; +}; + +bidboostAnalyticsAdapter.disableAnalytics = function disableAnalytics() { + flushPendingCollectorRequest(); + runningAuction = false; + activeAuctionId = null; + clearSnapshotCache(); + if (!analyticsEnabled) { + return; + } + analyticsEnabled = false; + return originalDisableAnalytics.call(this); +}; + +adapterManager.registerAnalyticsAdapter({ + adapter: bidboostAnalyticsAdapter, + code: BIDBOOST_ANALYTICS_CODE +}); + +export default bidboostAnalyticsAdapter; diff --git a/modules/bidboostRtdProvider.md b/modules/bidboostRtdProvider.md new file mode 100644 index 0000000000..affb74d23b --- /dev/null +++ b/modules/bidboostRtdProvider.md @@ -0,0 +1,82 @@ +--- +layout: page_v2 +title: Bidboost RTD Module +description: Bidboost real-time optimization provider. +page_type: module +module_type: rtd +module_code: bidboost +enable_download: true +sidebarType: 1 +--- + +# Bidboost RTD Module + +## Overview + +Bidboost integrates directly into the Prebid.js auction flow to apply traffic shaping where it can improve auction efficiency and monetization outcomes. + +As buyer-side agentic AI systems iterate campaign behavior and bidding logic faster, this module helps publishers adapt auction traffic routing continuously instead of relying on slower manual tuning cycles. + +The integration is designed for controlled evaluation and measurable revenue uplift with minimal operational overhead. + +## Build + +```bash +gulp build --modules="rtdModule,bidboostRtdProvider" +``` + +## Configuration + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 500, + dataProviders: [{ + name: 'bidboost', + waitForIt: true, + params: { + client: 'YOUR_CLIENT_CODE', + site: 'YOUR_SITE_CODE', + predictorUrl: 'https://predict.bidboost.net', + ignoredBidders: ['exampleBidder'], + placementMapper: (adUnit) => adUnit.code, + bidderMapper: (bidder) => bidder, + reverseBidderMapper: (bidder) => bidder, + additionalBidders: [{ + code: 'div-gpt-ad-top', + bids: [ + { bidder: 'ix', params: { siteId: 12345 } }, + { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } } + ] + }] + } + }] + } +}); +``` + +## RTD Parameters + +Set these fields in `realTimeData.dataProviders[].params`: + +| Field | Type | Description | +| --- | --- | --- | +| `client` | `string` | UUID of the client account running Bidboost. Provided during onboarding. | +| `site` | `string` | Site identifier configured in the Bidboost management UI. | +| `predictorUrl` | `string` | Base URL of the Bidboost predictor service. Default: `https://predict.bidboost.net`. | +| `ignoredBidders` | `string[]` | Bidders to exclude from traffic shaping. Useful to exclude special/s2s bidders from optimization decisions. | +| `placementMapper` | `(adUnit: object) => string` | Maps a Prebid ad unit to the placement identifier configured in Bidboost. Default maps to `adUnit.code`. | +| `bidderMapper` | `(bidderCode: string) => string` | Maps a Prebid bidder code to the bidder identifier configured in Bidboost. Default is identity mapping. | +| `reverseBidderMapper` | `(mappedBidderCode: string) => string` | Reverse mapping for `bidderMapper`. Must be provided if bidder mapping is customized. | +| `additionalBidders` | `Array<{ code: string, bids: object[] }>` | Optional additional bidder definitions in ad-unit shape. These are merged by ad unit code and bidder code. | + +## Requirements + +- Publishers must also include and enable the Bidboost analytics adapter for measurement (`pbjs.enableAnalytics({ provider: 'bidboost', ... })`). +- Access requires an active evaluation pilot. + +## Getting Access + +Start by requesting a free evaluation pilot at [https://www.bidboost.net](https://www.bidboost.net). + +After onboarding, you receive a client code and can configure sites/entities in the Bidboost management UI. diff --git a/modules/bidboostRtdProvider.ts b/modules/bidboostRtdProvider.ts new file mode 100644 index 0000000000..cadf0aa9c6 --- /dev/null +++ b/modules/bidboostRtdProvider.ts @@ -0,0 +1,483 @@ +import { submodule } from '../src/hook.js'; +import { ajaxBuilder } from '../src/ajax.js'; +import { deepAccess, generateUUID, logError, logWarn } from '../src/utils.js'; +import type { AdUnitDefinition, AdUnitBid, AdUnitBidDefinition } from '../src/adUnits.ts'; +import type { StartAuctionOptions } from '../src/prebid.ts'; +import type { AllConsentData } from '../src/consentHandler.ts'; +import type { RTDProviderConfig, RtdProviderSpec } from './rtdModule/spec'; +import { + BIDBOOST_RTD_NAME, + ALL_BIDDERS, + type BidboostModuleParams, + type BidboostAdUnitDefinition, + type BidboostModuleParamsInput, + type BidboostAdditionalBid, + type PredictorSnapshot, + toFiniteNumber, + getConnectionType, + hasRequiredParams, + normalizeBidboostParams, + setPredictorSnapshotForAuction +} from '../src/bidboostShared.js'; + +declare module './rtdModule/spec' { + interface ProviderConfig { + bidboost: { + params?: BidboostModuleParamsInput; + } + } +} + +interface AuctionState { + group: number | undefined; + auctionCount: number; +} + +type BidRequestData = Parameters['getBidRequestData']>>[0]; + +interface PredictorRequestBidder { + c: string; + u: 0 | 1; +} + +interface PredictorRequestPlacement { + c: string; + b: PredictorRequestBidder[]; +} + +interface PredictorRequestPayload { + c: string; + s: string; + g: number; + f: 0 | 1; + b: number; + t: number; + d: 0; + p: PredictorRequestPlacement[]; +} + +interface PredictorResponseBidder { + c: string; +} + +interface PredictorResponsePlacement { + b?: PredictorResponseBidder[]; +} + +interface PredictorResponsePayload { + g?: number; + b?: number; + p: Record; +} + +interface IndexedAdUnit { + definition: AdUnitDefinition | BidboostAdUnitDefinition; + bids: AdUnitBidDefinition[]; + bidsByBidder: Record; +} + +interface PredictorRequestContext { + request: PredictorRequestPayload; + adUnitsByCode: Record; + bidderTimeout: number; + placementByAdUnitCode: Record; +} + +let moduleParams: BidboostModuleParams | null = null; +const moduleState: AuctionState = { + group: undefined, + auctionCount: 0 +}; + +function resolvePredictorTimeout(timeoutBudgetMs: number, bidderTimeoutMs: number): number { + const timeoutBudget = toFiniteNumber(timeoutBudgetMs, null); + if (timeoutBudget !== null && timeoutBudget > 0) { + return timeoutBudget; + } + + const bidderTimeout = toFiniteNumber(bidderTimeoutMs, null); + if (bidderTimeout !== null && bidderTimeout > 0) { + return bidderTimeout; + } + + return 500; +} + +function resolvePredictorBidderTimeout( + predictorTimeoutMs: unknown, + fallbackTimeoutMs: number +): number { + const predictorTimeout = toFiniteNumber(predictorTimeoutMs, null); + if (predictorTimeout !== null && predictorTimeout > 0) { + return predictorTimeout; + } + return fallbackTimeoutMs; +} + +function createPredictorRequestContext( + params: BidboostModuleParams, + request: StartAuctionOptions, + auctionState: AuctionState +): PredictorRequestContext { + const adUnits = getAuctionAdUnits(request); + const adUnitsByCode = buildAdUnitsByCode(adUnits, params.additionalBidders); + const bidderTimeout = resolveBidderTimeout(request); + const userIdsByBidder = resolveUserIdAvailability(request, adUnits); + const placementByAdUnitCode: Record = {}; + + const predictorRequest: PredictorRequestPayload = { + c: params.client, + s: params.site, + g: auctionState.group ?? 0, + f: auctionState.auctionCount === 0 ? 1 : 0, + b: bidderTimeout, + t: getConnectionType(), + d: 0, + p: [] + }; + + adUnits.forEach((adUnit) => { + const indexedAdUnit = adUnitsByCode[adUnit.code]; + if (!indexedAdUnit) { + return; + } + + const adUnitDefinition = indexedAdUnit.definition || adUnit; + const placementCode = params.placementMapper(adUnitDefinition as BidboostAdUnitDefinition); + placementByAdUnitCode[adUnit.code] = placementCode; + const placement = getOrAddPlacement(predictorRequest, placementCode); + + const bids = indexedAdUnit.bids; + bids.forEach((bid) => { + const bidderCode = getBidderCode(bid); + if (!bidderCode || params.ignoredBidders.has(bidderCode)) { + return; + } + + const mappedBidderCode = params.bidderMapper(bidderCode); + const bidder = getOrAddBidder(placement, mappedBidderCode); + bidder.u = + userIdsByBidder[ALL_BIDDERS] || userIdsByBidder[bidderCode] || userIdsByBidder[mappedBidderCode] ? 1 : 0; + }); + }); + + return { + request: predictorRequest, + adUnitsByCode, + bidderTimeout, + placementByAdUnitCode + }; +} + +function postPredictorRequest( + request: PredictorRequestPayload, + timeoutInMilliseconds: number, + predictorUrl: string +): Promise { + return new Promise((resolve, reject: (error: unknown) => void) => { + const requestPredictor = ajaxBuilder(timeoutInMilliseconds); + requestPredictor( + `${predictorUrl}/predict`, + { + success: (responseText, response) => { + if (response?.status !== 200) { + reject(responseText || ''); + return; + } + try { + resolve(JSON.parse(responseText) as PredictorResponsePayload); + } catch (error) { + reject(error); + } + }, + error: (message, error) => { + reject(error || new Error(message || 'Request failed')); + } + }, + JSON.stringify(request), + { + method: 'POST', + contentType: 'text/plain', + customHeaders: { + Accept: 'application/json' + }, + withCredentials: false + }, + ); + }); +} + +function applyPredictorResponse( + params: BidboostModuleParams, + request: BidRequestData, + adUnitsByCode: Record, + predictorResponse: PredictorResponsePayload +): void { + const mutableRequest = request as StartAuctionOptions; + const adUnits = getAuctionAdUnits(request); + adUnits.forEach((adUnit) => { + const indexedAdUnit = adUnitsByCode[adUnit.code]; + if (!indexedAdUnit || !Array.isArray(adUnit.bids) || adUnit.bids.length === 0) { + return; + } + + const placementCode = params.placementMapper(indexedAdUnit.definition as BidboostAdUnitDefinition); + const predictions = predictorResponse.p?.[placementCode]; + if (!predictions || !Array.isArray(predictions.b) || predictions.b.length === 0) { + return; + } + + const nextBids = adUnit.bids.filter((bid) => { + const bidderCode = getBidderCode(bid); + return !bidderCode || params.ignoredBidders.has(bidderCode); + }); + const includedBidderCodes = new Set(nextBids.map((bid) => getBidderCode(bid)).filter((bidderCode) => !!bidderCode)); + + predictions.b.forEach((predictedBidder) => { + const bidderCode = params.reverseBidderMapper(predictedBidder.c); + if (params.ignoredBidders.has(bidderCode) || includedBidderCodes.has(bidderCode)) { + return; + } + const bid = indexedAdUnit.bidsByBidder[bidderCode]; + if (bid) { + includedBidderCodes.add(bidderCode); + nextBids.push(bid); + } + }); + + adUnit.bids = nextBids; + }); + + if (predictorResponse.b !== undefined) { + mutableRequest.timeout = resolvePredictorBidderTimeout(predictorResponse.b, resolveBidderTimeout(mutableRequest)); + } +} + +function createPredictorSnapshot( + context: PredictorRequestContext, + predictorResponse: PredictorResponsePayload | null, + groupOverride: number +): PredictorSnapshot { + return { + v: 1, + g: groupOverride, + b: predictorResponse ? resolvePredictorBidderTimeout(predictorResponse.b, context.bidderTimeout) : context.bidderTimeout, + t: context.request.t, + fa: context.request.f, + m: context.placementByAdUnitCode, + r: predictorResponse || null + }; +} + +function resolveGroupAfterPredictor( + _context: PredictorRequestContext, + predictorResponse: PredictorResponsePayload | null, + previousGroup: number | undefined +): number { + if (predictorResponse?.g !== undefined) { + return predictorResponse.g; + } + if (previousGroup !== undefined) { + return previousGroup; + } + return 0; +} + +function resolveBidderTimeout(request: StartAuctionOptions): number { + const timeout = toFiniteNumber(request?.timeout, null); + if (timeout !== null && timeout > 0) { + return timeout; + } + return 3000; +} + +function getAuctionAdUnits(request: StartAuctionOptions): AdUnitDefinition[] { + return Array.isArray(request?.adUnits) ? request.adUnits : []; +} + +function buildAdUnitsByCode( + adUnits: AdUnitDefinition[], + additionalBidders: BidboostAdUnitDefinition[] +): Record { + const adUnitsByCode: Record = {}; + addAdUnitsToIndex(adUnitsByCode, adUnits); + addAdUnitsToIndex(adUnitsByCode, additionalBidders); + return adUnitsByCode; +} + +function addAdUnitsToIndex( + adUnitsByCode: Record, + adUnits: (AdUnitDefinition | BidboostAdUnitDefinition)[] +): void { + if (!Array.isArray(adUnits)) { + return; + } + + adUnits.forEach((adUnit) => { + if (!adUnit?.code) { + return; + } + + const indexedAdUnit = adUnitsByCode[adUnit.code] || (adUnitsByCode[adUnit.code] = { + definition: adUnit, + bids: [], + bidsByBidder: {} + }); + + const bids = Array.isArray(adUnit.bids) ? adUnit.bids : []; + bids.forEach((bid) => { + const bidderCode = getBidderCode(bid); + if (!bidderCode || indexedAdUnit.bidsByBidder[bidderCode]) { + return; + } + + const typedBid = bid as AdUnitBidDefinition; + indexedAdUnit.bidsByBidder[bidderCode] = typedBid; + indexedAdUnit.bids.push(typedBid); + }); + }); +} + +function resolveUserIdAvailability(request: StartAuctionOptions, adUnits: AdUnitDefinition[]): Record { + const availability: Record = {}; + + const hasGlobalIds = + hasUserIdSignal(request) || + hasUserIdSignal(request?.ortb2Fragments?.global) || + hasUserIdSignal(deepAccess(request, 'ortb2Fragments.global')); + if (hasGlobalIds) { + availability[ALL_BIDDERS] = true; + } + + const bidderOrtb2 = deepAccess(request, 'ortb2Fragments.bidder') || {}; + Object.keys(bidderOrtb2).forEach((bidder) => { + if (hasUserIdSignal((bidderOrtb2 as Record)[bidder])) { + availability[bidder] = true; + } + }); + + adUnits.forEach((adUnit) => { + const bids = Array.isArray(adUnit?.bids) ? adUnit.bids : []; + bids.forEach((bid) => { + const bidderCode = getBidderCode(bid); + if (bidderCode && hasUserIdSignal(bid)) { + availability[bidderCode] = true; + } + }); + }); + + return availability; +} + +function hasUserIdSignal(input: unknown): boolean { + if (!input || typeof input !== 'object') { + return false; + } + + const userId = deepAccess(input, 'userId'); + if (userId && typeof userId === 'object' && Object.keys(userId).length > 0) { + return true; + } + + const eids = + deepAccess(input, 'userIdAsEids') || + deepAccess(input, 'userIdAsEid') || + deepAccess(input, 'user.eids') || + deepAccess(input, 'ortb2.user.eids') || + deepAccess(input, 'ortb2.user.ext.eids') || + deepAccess(input, 'user.ext.eids'); + + return Array.isArray(eids) ? eids.length > 0 : !!eids; +} + +function getOrAddPlacement(request: PredictorRequestPayload, placementCode: string): PredictorRequestPlacement { + for (const placement of request.p) { + if (placement.c === placementCode) { + return placement; + } + } + + const placement: PredictorRequestPlacement = { c: placementCode, b: [] }; + request.p.push(placement); + return placement; +} + +function getOrAddBidder(placement: PredictorRequestPlacement, bidderCode: string): PredictorRequestBidder { + for (const bidder of placement.b) { + if (bidder.c === bidderCode) { + return bidder; + } + } + + const bidder: PredictorRequestBidder = { c: bidderCode, u: 0 }; + placement.b.push(bidder); + return bidder; +} + +function getBidderCode(bid: AdUnitBid | BidboostAdditionalBid | unknown): string | null { + if (!bid || typeof bid !== 'object' || !('bidder' in bid)) { + return null; + } + + const bidder = (bid as { bidder?: unknown }).bidder; + return typeof bidder === 'string' ? bidder : null; +} + +export const bidboostSubmodule: RtdProviderSpec<'bidboost'> = { + name: BIDBOOST_RTD_NAME, + + init(config: RTDProviderConfig<'bidboost'>, consent: AllConsentData) { + // Consent checks are enforced by Prebid activity controls before this module runs. + void consent; + + const params = normalizeBidboostParams(config?.params); + if (!hasRequiredParams(params)) { + logError('bidboostRtdProvider: missing required params "client" and/or "site"'); + return false; + } + + moduleParams = params; + moduleState.group = 0; + moduleState.auctionCount = 0; + return true; + }, + + getBidRequestData(request, done, _config, consent, timeoutBudgetMs) { + // Consent checks are enforced by Prebid activity controls before this module runs. + void consent; + + if (!moduleParams) { + done(); + return; + } + + request.auctionId ??= generateUUID(); + + const activeParams = moduleParams; + const auctionRequest = request as StartAuctionOptions; + const context = createPredictorRequestContext(activeParams, auctionRequest, moduleState); + const predictorTimeout = resolvePredictorTimeout(timeoutBudgetMs, context.bidderTimeout); + postPredictorRequest(context.request, predictorTimeout, activeParams.predictorUrl) + .then((predictorResponse) => { + applyPredictorResponse(activeParams, auctionRequest, context.adUnitsByCode, predictorResponse); + return predictorResponse; + }) + .catch((error) => { + logWarn('bidboostRtdProvider: predictor request failed', error); + return null; + }) + .then((predictorResponse) => { + const resolvedGroup = resolveGroupAfterPredictor(context, predictorResponse, moduleState.group); + moduleState.group = resolvedGroup; + const snapshot = createPredictorSnapshot(context, predictorResponse, resolvedGroup); + setPredictorSnapshotForAuction(request.auctionId!, snapshot); + moduleState.auctionCount += 1; + }) + .catch((error) => { + logWarn('bidboostRtdProvider: failed to process auction', error); + }) + .then(done, done); + } +}; + +submodule('realTimeData', bidboostSubmodule as unknown as RtdProviderSpec); diff --git a/src/bidboostShared.ts b/src/bidboostShared.ts new file mode 100644 index 0000000000..4555675c7d --- /dev/null +++ b/src/bidboostShared.ts @@ -0,0 +1,164 @@ +import type { AdUnitDefinition } from './adUnits.js'; +import { getWindowSelf } from './utils.js'; + +declare global { + interface Navigator { + connection?: { + effectiveType?: string; + }; + } +} + +export const BIDBOOST_RTD_NAME = 'bidboost'; +export const BIDBOOST_ANALYTICS_CODE = 'bidboost'; +export const ALL_BIDDERS = '*'; + +export interface BidboostAdditionalBid { + bidder: string; + [key: string]: unknown; +} + +export type BidboostAdUnitDefinition = Partial & { + code: string; + bids?: BidboostAdditionalBid[]; + [key: string]: unknown; +}; + +type PlacementMapper = (adUnit: BidboostAdUnitDefinition) => string; +type BidderMapper = (bidderCode: string) => string; + +export interface BidboostModuleParamsInput { + client?: string; + site?: string; + predictorUrl?: string; + collectorUrl?: string; + analyticsBatchWindowMs?: number; + ignoredBidders?: string[]; + placementMapper?: PlacementMapper; + bidderMapper?: BidderMapper; + reverseBidderMapper?: BidderMapper; + additionalBidders?: BidboostAdUnitDefinition[]; +} + +export interface BidboostModuleParams { + client: string; + site: string; + predictorUrl: string; + collectorUrl: string; + analyticsBatchWindowMs: number; + ignoredBidders: Set; + placementMapper: PlacementMapper; + bidderMapper: BidderMapper; + reverseBidderMapper: BidderMapper; + additionalBidders: BidboostAdUnitDefinition[]; +} + +const BIDBOOST_DEFAULT_PREDICTOR_URL = 'https://predict.bidboost.net'; +const BIDBOOST_DEFAULT_COLLECTOR_URL = 'https://collect.bidboost.net'; + +function defaultPlacementMapper(adUnit: BidboostAdUnitDefinition): string { + return adUnit?.code as string; +} + +function defaultBidderMapper(bidder: string): string { + return bidder; +} + +function normalizeAdditionalBidders(value?: BidboostAdUnitDefinition[]): BidboostAdUnitDefinition[] { + if (!Array.isArray(value)) { + return []; + } + + const adUnitByCode: Record = {}; + value.forEach((adUnit) => { + const code = adUnit?.code; + if (!code) { + return; + } + + const normalized = (adUnitByCode[code] ||= { code, bids: [] }); + const bidderSet = new Set(normalized.bids.map((bid) => bid.bidder).filter(Boolean)); + (Array.isArray(adUnit.bids) ? adUnit.bids : []).forEach((bid) => { + if (!bid?.bidder || bidderSet.has(bid.bidder)) { + return; + } + bidderSet.add(bid.bidder); + normalized.bids.push(bid); + }); + }); + + return Object.keys(adUnitByCode).map((key) => adUnitByCode[key]); +} + +export function toFiniteNumber(value: unknown, fallback: number): number; +export function toFiniteNumber(value: unknown, fallback: null): number | null; +export function toFiniteNumber(value: unknown, fallback: number | null): number | null { + const number = typeof value === 'number' ? value : Number(value); + return Number.isFinite(number) ? number : fallback; +} + +export function normalizeBidboostParams(params: BidboostModuleParamsInput = {}): BidboostModuleParams { + return { + client: typeof params.client === 'string' ? params.client : '', + site: typeof params.site === 'string' ? params.site : '', + predictorUrl: params.predictorUrl || BIDBOOST_DEFAULT_PREDICTOR_URL, + collectorUrl: params.collectorUrl || BIDBOOST_DEFAULT_COLLECTOR_URL, + analyticsBatchWindowMs: toFiniteNumber(params.analyticsBatchWindowMs, 1000), + ignoredBidders: new Set(Array.isArray(params.ignoredBidders) ? params.ignoredBidders : []), + placementMapper: typeof params.placementMapper === 'function' ? params.placementMapper : defaultPlacementMapper, + bidderMapper: typeof params.bidderMapper === 'function' ? params.bidderMapper : defaultBidderMapper, + reverseBidderMapper: typeof params.reverseBidderMapper === 'function' ? params.reverseBidderMapper : defaultBidderMapper, + additionalBidders: normalizeAdditionalBidders(params.additionalBidders) + }; +} + +export function hasRequiredParams(params?: { client?: string; site?: string } | null): boolean { + return !!(params && params.client && params.client.trim().length > 0 && params.site && params.site.trim().length > 0); +} + +export interface PredictorSnapshot { + v: number; + g: number; + b: number; + t: number; + fa: number; + m: Record; + r: unknown; +} + +const predictorSnapshotByAuctionId: Record = {}; + +export function setPredictorSnapshotForAuction(auctionId: string, snapshot: PredictorSnapshot): void { + if (!auctionId || !snapshot) { + return; + } + predictorSnapshotByAuctionId[auctionId] = snapshot; +} + +export function peekPredictorSnapshotForAuction(auctionId: string): PredictorSnapshot | null { + return predictorSnapshotByAuctionId[auctionId] || null; +} + +export function consumePredictorSnapshotForAuction(auctionId: string): PredictorSnapshot | null { + const snapshot = predictorSnapshotByAuctionId[auctionId] || null; + if (snapshot) { + delete predictorSnapshotByAuctionId[auctionId]; + } + return snapshot; +} + +const CONNECTION_TYPE_IDS = { + '4g': 1, + '3g': 2, + '2g': 3, + 'slow-2g': 4 +}; + +export function getConnectionType(): number { + try { + const connectionType = getWindowSelf()?.navigator?.connection?.effectiveType || '4g'; + return CONNECTION_TYPE_IDS[connectionType] || CONNECTION_TYPE_IDS['4g']; + } catch (_e) { + return CONNECTION_TYPE_IDS['4g']; + } +} diff --git a/test/spec/modules/bidboostAnalyticsAdapter_spec.js b/test/spec/modules/bidboostAnalyticsAdapter_spec.js new file mode 100644 index 0000000000..7a31bcaf9b --- /dev/null +++ b/test/spec/modules/bidboostAnalyticsAdapter_spec.js @@ -0,0 +1,313 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import adapterManager from 'src/adapterManager.js'; +import { EVENTS } from 'src/constants.js'; +import * as events from 'src/events.js'; +import { getWindowSelf } from 'src/utils.js'; +import * as utils from 'src/utils.js'; +import bidboostAnalyticsAdapter from 'modules/bidboostAnalyticsAdapter.js'; +import { setPredictorSnapshotForAuction } from 'src/bidboostShared.js'; +import { server } from 'test/mocks/xhr.js'; + +describe('bidboostAnalyticsAdapter', function () { + let sandbox; + let clock; + let beaconPayloads; + let window; + let navigator; + let logErrorStub; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + window = getWindowSelf(); + navigator = window.navigator; + clock = sandbox.useFakeTimers(); + beaconPayloads = []; + + sandbox.stub(navigator, 'sendBeacon').callsFake((url, body) => { + beaconPayloads.push({ url, body }); + return true; + }); + logErrorStub = sandbox.stub(utils, 'logError'); + + server.requests.length = 0; + server.respondWith('POST', /https:\/\/collect\.bidboost\.net\/auction\?id=.*/, [200, {}, '']); + }); + + afterEach(function () { + bidboostAnalyticsAdapter.disableAnalytics(); + sandbox.restore(); + }); + + it('registers adapter under "bidboost" code', function () { + expect(adapterManager.analyticsRegistry.bidboost).to.exist; + }); + + it('posts collector payload on auction end using RTD snapshot', function () { + adapterManager.enableAnalytics({ + provider: 'bidboost', + options: { + client: 'client-code', + site: 'site-code', + collectorUrl: 'https://collect.bidboost.net', + analyticsBatchWindowMs: 10 + }, + includeEvents: [EVENTS.BID_REQUESTED, EVENTS.AUCTION_END] + }); + + setPredictorSnapshotForAuction('auc-1', { + v: 1, + g: 4, + b: 1400, + t: 1, + fa: 1, + m: { ad_1: 'top_slot' } + }); + + events.emit(EVENTS.BID_REQUESTED, { + auctionId: 'auc-1', + bidderCode: 'appnexus', + src: 'client', + bids: [{ + adUnitCode: 'ad_1', + bidder: 'appnexus', + userId: { uid2: 'id' } + }] + }); + + events.emit(EVENTS.AUCTION_END, { + auctionId: 'auc-1', + timestamp: 1000, + auctionEnd: 1600, + bidsRejected: [], + noBids: [{ + adUnitCode: 'ad_1', + bidder: 'rubicon' + }], + bidsReceived: [{ + adUnitCode: 'ad_1', + bidderCode: 'appnexus', + bidder: 'appnexus', + mediaType: 'banner', + responseTimestamp: 1300, + requestTimestamp: 1000, + cpm: 1.2, + currency: 'USD' + }], + bidderRequests: [{ + bidderCode: 'appnexus', + bids: [{ adUnitCode: 'ad_1' }] + }, { + bidderCode: 'rubicon', + bids: [{ adUnitCode: 'ad_1' }] + }] + }); + + clock.tick(20); + + const payload = beaconPayloads[0]?.body; + expect(payload).to.exist; + + const request = JSON.parse(payload); + expect(request.c).to.equal('client-code'); + expect(request.s).to.equal('site-code'); + expect(request.g).to.equal(4); + expect(request.bt).to.equal(1400); + expect(request.fa).to.equal(1); + expect(request.na).to.equal(1); + expect(request.pl[0].c).to.equal('top_slot'); + expect(request.pl[0].b.length).to.equal(2); + }); + + it('drops events when required analytics options are missing', function () { + adapterManager.enableAnalytics({ + provider: 'bidboost', + options: {}, + includeEvents: [EVENTS.BID_REQUESTED, EVENTS.AUCTION_END] + }); + + events.emit(EVENTS.BID_REQUESTED, { + auctionId: 'auc-missing', + bidderCode: 'appnexus', + src: 'client', + bids: [{ adUnitCode: 'ad_1', bidder: 'appnexus' }] + }); + + events.emit(EVENTS.AUCTION_END, { + auctionId: 'auc-missing', + timestamp: 1, + auctionEnd: 2, + bidsRejected: [], + noBids: [], + bidsReceived: [], + bidderRequests: [] + }); + + clock.tick(20); + expect(beaconPayloads).to.have.length(0); + expect(server.requests).to.have.length(0); + }); + + it('posts a filled impression payload when ad renders', function () { + adapterManager.enableAnalytics({ + provider: 'bidboost', + options: { + client: 'client-code', + site: 'site-code', + collectorUrl: 'https://collect.bidboost.net', + analyticsBatchWindowMs: 50 + }, + includeEvents: [EVENTS.BID_REQUESTED, EVENTS.AD_RENDER_SUCCEEDED] + }); + + events.emit(EVENTS.AD_RENDER_SUCCEEDED, { + bid: { + auctionId: 'auc-render', + adUnitCode: 'ad_1', + bidderCode: 'appnexus', + mediaType: 'banner', + responseTimestamp: 1300, + requestTimestamp: 1000, + cpm: 1.3, + currency: 'USD' + } + }); + + const request = JSON.parse(beaconPayloads[0].body); + expect(request.na).to.equal(0); + expect(request.pl[0].b[0].s).to.equal(4); + }); + + it('flushes pending collector payload on visibilitychange with ajax fallback', function () { + navigator.sendBeacon.returns(false); + + adapterManager.enableAnalytics({ + provider: 'bidboost', + options: { + client: 'client-code', + site: 'site-code', + collectorUrl: 'https://collect.bidboost.net', + analyticsBatchWindowMs: 1000 + }, + includeEvents: [EVENTS.BID_REQUESTED, EVENTS.AUCTION_END] + }); + + events.emit(EVENTS.BID_REQUESTED, { + auctionId: 'auc-visibility', + bidderCode: 'appnexus', + src: 'client', + bids: [{ adUnitCode: 'ad_1', bidder: 'appnexus' }] + }); + + events.emit(EVENTS.AUCTION_END, { + auctionId: 'auc-visibility', + timestamp: 100, + auctionEnd: 300, + bidsRejected: [], + noBids: [], + bidsReceived: [{ + adUnitCode: 'ad_1', + bidderCode: 'appnexus', + bidder: 'appnexus', + mediaType: 'banner', + responseTimestamp: 180, + requestTimestamp: 100, + cpm: 1, + currency: 'USD' + }], + bidderRequests: [{ bidderCode: 'appnexus', bids: [{ adUnitCode: 'ad_1' }] }] + }); + + Object.defineProperty(window.document, 'visibilityState', { value: 'hidden', configurable: true }); + window.dispatchEvent(new window.Event('visibilitychange')); + server.respond(); + + expect(server.requests.length).to.be.greaterThan(0); + expect(server.requests[server.requests.length - 1].url).to.match(/collect\.bidboost\.net\/auction\?id=/); + expect(beaconPayloads).to.have.length(0); + }); + + it('posts a heartbeat payload on pagehide while auction is running', function () { + adapterManager.enableAnalytics({ + provider: 'bidboost', + options: { + client: 'client-code', + site: 'site-code', + collectorUrl: 'https://collect.bidboost.net' + }, + includeEvents: [EVENTS.BID_REQUESTED] + }); + + events.emit(EVENTS.BID_REQUESTED, { + auctionId: 'auc-pagehide', + bidderCode: 'appnexus', + src: 'client', + bids: [{ adUnitCode: 'ad_1', bidder: 'appnexus' }] + }); + + window.dispatchEvent(new window.Event('pagehide')); + + const request = JSON.parse(beaconPayloads[0].body); + expect(request.c).to.equal('client-code'); + expect(request.na).to.equal(1); + expect(request.pl).to.have.length(0); + }); + + it('drops whole auction analytics payload when bid currency is unsupported', function () { + adapterManager.enableAnalytics({ + provider: 'bidboost', + options: { + client: 'client-code', + site: 'site-code', + collectorUrl: 'https://collect.bidboost.net', + analyticsBatchWindowMs: 10 + }, + includeEvents: [EVENTS.BID_REQUESTED, EVENTS.AUCTION_END, EVENTS.AD_RENDER_SUCCEEDED] + }); + const initialRequestCount = server.requests.length; + + events.emit(EVENTS.BID_REQUESTED, { + auctionId: 'auc-unsupported-currency', + bidderCode: 'appnexus', + src: 'client', + bids: [{ adUnitCode: 'ad_1', bidder: 'appnexus' }] + }); + + events.emit(EVENTS.AUCTION_END, { + auctionId: 'auc-unsupported-currency', + timestamp: 1000, + auctionEnd: 1400, + bidsRejected: [], + noBids: [], + bidsReceived: [{ + adUnitCode: 'ad_1', + bidderCode: 'appnexus', + bidder: 'appnexus', + mediaType: 'banner', + responseTimestamp: 1200, + requestTimestamp: 1000, + cpm: 1.1, + currency: 'CAD' + }], + bidderRequests: [{ bidderCode: 'appnexus', bids: [{ adUnitCode: 'ad_1' }] }] + }); + + events.emit(EVENTS.AD_RENDER_SUCCEEDED, { + bid: { + auctionId: 'auc-unsupported-currency', + adUnitCode: 'ad_1', + bidderCode: 'appnexus', + mediaType: 'banner', + responseTimestamp: 1300, + requestTimestamp: 1000, + cpm: 1.3, + currency: 'CAD' + } + }); + + clock.tick(20); + + expect(logErrorStub.called).to.equal(true); + expect(server.requests.length).to.equal(initialRequestCount); + }); +}); diff --git a/test/spec/modules/bidboostRtdProvider_spec.js b/test/spec/modules/bidboostRtdProvider_spec.js new file mode 100644 index 0000000000..8e2410ff8a --- /dev/null +++ b/test/spec/modules/bidboostRtdProvider_spec.js @@ -0,0 +1,154 @@ +import { expect } from 'chai'; +import { bidboostSubmodule } from 'modules/bidboostRtdProvider.js'; +import { consumePredictorSnapshotForAuction } from 'src/bidboostShared.js'; +import { server } from 'test/mocks/xhr.js'; + +describe('bidboostRtdProvider', function () { + let responseBody; + + beforeEach(function () { + responseBody = { + g: 2, + b: 1100, + p: { + top_slot: { + b: [{ c: 'ix' }, { c: 'apn' }] + } + } + }; + server.respondWith( + 'POST', + 'https://predict.bidboost.net/predict', + [200, { 'Content-Type': 'application/json' }, JSON.stringify(responseBody)] + ); + }); + + it('fails init when required params are missing', function () { + expect(bidboostSubmodule.init({ params: {} })).to.equal(false); + }); + + it('accepts valid module params', function () { + expect( + bidboostSubmodule.init({ + params: { + client: 'client-code', + site: 'site-code' + } + }) + ).to.equal(true); + }); + + it('posts predictor request, shapes bids, and stores snapshot', function (done) { + bidboostSubmodule.init({ + params: { + client: 'client-code', + site: 'site-code', + ignoredBidders: ['rubicon'], + placementMapper: (adUnit) => `${adUnit.code}_slot`, + bidderMapper: (bidder) => (bidder === 'appnexus' ? 'apn' : bidder), + reverseBidderMapper: (bidder) => (bidder === 'apn' ? 'appnexus' : bidder), + additionalBidders: [{ + code: 'top', + bids: [{ bidder: 'ix', params: { siteId: 123 } }] + }] + } + }); + + const req = { + auctionId: 'auc-1', + timeout: 1500, + adUnits: [{ + code: 'top', + bids: [ + { bidder: 'appnexus', params: { placementId: 1 } }, + { bidder: 'rubicon', params: { accountId: 1 } } + ] + }] + }; + + bidboostSubmodule.getBidRequestData(req, () => { + expect(server.requests).to.have.length(1); + expect(server.requests[0].url).to.equal('https://predict.bidboost.net/predict'); + + const predictorRequest = JSON.parse(server.requests[0].requestBody); + expect(predictorRequest.c).to.equal('client-code'); + expect(predictorRequest.s).to.equal('site-code'); + expect(predictorRequest.b).to.equal(1500); + expect(predictorRequest.p[0].c).to.equal('top_slot'); + expect(predictorRequest.p[0].b.map((bidder) => bidder.c)).to.include.members(['apn', 'ix']); + + const bidderCodes = req.adUnits[0].bids.map((bid) => bid.bidder); + expect(bidderCodes).to.deep.equal(['rubicon', 'ix', 'appnexus']); + expect(req.timeout).to.equal(1100); + + const snapshot = consumePredictorSnapshotForAuction('auc-1'); + expect(snapshot.g).to.equal(2); + expect(snapshot.b).to.equal(1100); + expect(snapshot.m.top).to.equal('top_slot'); + done(); + }); + server.respond(); + }); + + it('assigns auctionId when missing and stores snapshot', function (done) { + bidboostSubmodule.init({ + params: { + client: 'client-code', + site: 'site-code' + } + }); + + const req = { + timeout: 1200, + adUnits: [{ code: 'top', bids: [{ bidder: 'appnexus' }] }] + }; + + bidboostSubmodule.getBidRequestData(req, () => { + expect(server.requests).to.have.length(1); + expect(req.auctionId).to.be.a('string').and.not.empty; + const predictorRequest = JSON.parse(server.requests[0].requestBody); + expect(predictorRequest.c).to.equal('client-code'); + expect(predictorRequest.s).to.equal('site-code'); + const snapshot = consumePredictorSnapshotForAuction(req.auctionId); + expect(snapshot).to.exist; + done(); + }); + server.respond(); + }); + + it('keeps auction runnable when predictor request fails', function (done) { + server.respondWith( + 'POST', + 'https://predict.bidboost.net/predict', + [500, { 'Content-Type': 'text/plain' }, 'boom'] + ); + + bidboostSubmodule.init({ + params: { + client: 'client-code', + site: 'site-code' + } + }); + + const req = { + auctionId: 'auc-failure', + adUnits: [{ + code: 'top', + bids: [{ bidder: 'appnexus' }, { bidder: 'rubicon' }] + }] + }; + + bidboostSubmodule.getBidRequestData(req, () => { + expect(server.requests).to.have.length(1); + expect(req.adUnits[0].bids.map((bid) => bid.bidder)).to.deep.equal(['appnexus', 'rubicon']); + + const snapshot = consumePredictorSnapshotForAuction('auc-failure'); + expect(snapshot).to.exist; + expect(snapshot.g).to.equal(0); + expect(snapshot.b).to.equal(3000); + expect(snapshot.r).to.equal(null); + done(); + }); + server.respond(); + }); +});