diff --git a/libraries/nexx360Utils/index.ts b/libraries/nexx360Utils/index.ts index c90d9246bde..13af1e9296c 100644 --- a/libraries/nexx360Utils/index.ts +++ b/libraries/nexx360Utils/index.ts @@ -8,6 +8,7 @@ import { BidResponse, VideoBidResponse } from '../../src/bidfactory.js'; import { StorageManager } from '../../src/storageManager.js'; import { BidRequest, ORTBImp, ORTBRequest, ORTBResponse } from '../../src/prebid.public.js'; import { AdapterResponse, ServerResponse } from '../../src/adapters/bidderFactory.js'; +import { Nexx360ServerAuction } from './types.js'; const OUTSTREAM_RENDERER_URL = 'https://acdn.adnxs.com/video/outstream/ANOutstreamVideo.js'; @@ -213,17 +214,44 @@ export function createResponse(bid:any, ortbResponse:any): BidResponse { return response as BidResponse; } +// --- Server auction data extraction --- + +/** + * Bid response carrying the server-side auction data from the response `ext`, + * for consumption by the Nexx360 analytics adapter on the `bidResponse` event. + */ +export type Nexx360BidResponse = BidResponse & { serverAuctionData?: Nexx360ServerAuction }; + +function getServerAuction(responseBody: any): Nexx360ServerAuction | null { + const serverAuction = deepAccess(responseBody, 'ext.serverAuction'); + if (serverAuction && typeof serverAuction === 'object' && serverAuction.auctionId) { + return serverAuction as Nexx360ServerAuction; + } + return null; +} + export const interpretResponse = (serverResponse: ServerResponse): AdapterResponse => { if (!serverResponse.body) return []; const respBody = serverResponse.body as ORTBResponse; - if (!respBody.seatbid || respBody.seatbid.length === 0) return []; - const responses: BidResponse[] = []; + if (!respBody.seatbid || respBody.seatbid.length === 0) { + return []; + } + + // Attach server-auction data to every bid response (rather than holding it in + // module state) so it reaches the analytics adapter with the bid that produced + // it, and cannot leak into an unrelated auction if a bid is rejected by core. + const serverAuctionData = getServerAuction(respBody); + + const responses: Nexx360BidResponse[] = []; for (let i = 0; i < respBody.seatbid.length; i++) { const seatbid = respBody.seatbid[i]; for (let j = 0; j < seatbid.bid.length; j++) { const bid = seatbid.bid[j]; - const response:BidResponse = createResponse(bid, respBody); + const response:Nexx360BidResponse = createResponse(bid, respBody); + if (serverAuctionData) { + response.serverAuctionData = serverAuctionData; + } responses.push(response); } } diff --git a/libraries/nexx360Utils/types.ts b/libraries/nexx360Utils/types.ts new file mode 100644 index 00000000000..4c6b167ec2e --- /dev/null +++ b/libraries/nexx360Utils/types.ts @@ -0,0 +1,48 @@ +/** Per-SSP bid attempt for a specific impression */ +export interface Nexx360SSPBid { + ssp: string; + status: 'bid' | 'noBid' | 'timeout' | 'error'; + responseTimeMs?: number; + cpm?: number; + currency?: string; + size?: string; + dealId?: string; + bidId?: string; + error?: string; +} + +/** Auction summary for a single impression */ +export interface Nexx360ImpressionAuction { + impId: string; + adUnitCode: string; + bids: Nexx360SSPBid[]; + totalSsps: number; + bidsReceived: number; + timeouts: number; + errors: number; + auctionTimeMs: number; + winner?: { + ssp: string; + cpm: number; + currency: string; + }; +} + +/** Server-side auction data attached to Nexx360 OpenRTB response ext */ +export interface Nexx360ServerAuction { + auctionId: string; + timestamp: number; + impressions: Nexx360ImpressionAuction[]; + totalImpressions: number; + totalSspsCalled: number; + totalBidsReceived: number; + totalTimeouts: number; + totalErrors: number; + auctionTimeMs: number; +} + +/** Top-level Nexx360 response extension */ +export interface Nexx360ResponseExt { + cookies?: unknown[]; + serverAuction?: Nexx360ServerAuction; +} diff --git a/metadata/modules.json b/metadata/modules.json index afebca4a15c..2baff208411 100644 --- a/metadata/modules.json +++ b/metadata/modules.json @@ -6482,6 +6482,11 @@ "componentName": "mobkoi", "gvlid": null }, + { + "componentType": "analytics", + "componentName": "nexx360", + "gvlid": null + }, { "componentType": "analytics", "componentName": "nobid", diff --git a/metadata/modules/nexx360AnalyticsAdapter.json b/metadata/modules/nexx360AnalyticsAdapter.json new file mode 100644 index 00000000000..6bc37cb8114 --- /dev/null +++ b/metadata/modules/nexx360AnalyticsAdapter.json @@ -0,0 +1,12 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "purposes": {}, + "components": [ + { + "componentType": "analytics", + "componentName": "nexx360", + "gvlid": null + } + ] +} diff --git a/modules/nexx360AnalyticsAdapter.md b/modules/nexx360AnalyticsAdapter.md new file mode 100644 index 00000000000..0f95966f0b8 --- /dev/null +++ b/modules/nexx360AnalyticsAdapter.md @@ -0,0 +1,80 @@ +# Overview + +Module Name: Nexx360 Analytics Adapter +Module Type: Analytics Adapter +Maintainer: tech@nexx360.io + +# Description + +The Nexx360 Analytics Adapter collects Prebid.js auction data and sends it to the Nexx360 analytics platform for monitoring and reporting. It tracks auction lifecycle events, bid requests, bid responses, wins, timeouts, and ad render events. + +# Registration + +The Nexx360 Analytics adapter requires a publisher ID from Nexx360. Please contact Nexx360 to obtain your publisher credentials. + +```javascript +pbjs.enableAnalytics({ + provider: 'nexx360', + options: { + publisherId: 'your-publisher-id', + endpoint: 'https://monitoring.nexx360.io' + } +}); +``` + +Sampling is applied server-side (by the Nexx360 collector), so the adapter sends +all events and exposes no sampling option. + +# Analytics Options + +{: .table .table-bordered .table-striped } +| Name | Scope | Description | Example | Type | +|------|-------|-------------|---------|------| +| publisherId | required | Your Nexx360 publisher identifier | `"pub-12345"` | string | +| endpoint | optional | Analytics endpoint URL (defaults to `https://monitoring.nexx360.io`) | `"https://monitoring.nexx360.io"` | string | +| abTestLabel | optional | A/B test variant label, attached to every event for slicing analytics by test arm | `"variantA"` | string | + +# Events Tracked + +The adapter tracks the following Prebid.js events: + +- **auctionInit** - When an auction starts +- **bidRequested** - When bid requests are sent to bidders +- **bidResponse** - When bid responses are received +- **bidWon** - When a bid wins the auction +- **bidTimeout** - When bidders timeout +- **adRenderSucceeded** - When an ad renders successfully +- **adRenderFailed** - When an ad fails to render + +# Example Configuration + +## Basic Setup + +```javascript +pbjs.enableAnalytics({ + provider: 'nexx360', + options: { + publisherId: 'your-publisher-id' + } +}); +``` + +## Production Setup + +```javascript +pbjs.enableAnalytics({ + provider: 'nexx360', + options: { + publisherId: 'your-publisher-id', + endpoint: 'https://monitoring.nexx360.io' + } +}); +``` + +# Build + +To include this analytics adapter in your Prebid.js build: + +```bash +gulp build --modules=nexx360AnalyticsAdapter,... +``` diff --git a/modules/nexx360AnalyticsAdapter.ts b/modules/nexx360AnalyticsAdapter.ts new file mode 100644 index 00000000000..1a66d2af770 --- /dev/null +++ b/modules/nexx360AnalyticsAdapter.ts @@ -0,0 +1,787 @@ +import { noCredsAjax } from '../src/ajax.js'; +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import { type AnalyticsConfig } from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import { EVENTS } from '../src/constants.js'; +import adapterManager from '../src/adapterManager.js'; +import { logInfo, logError } from '../src/utils.js'; +import { Nexx360ImpressionAuction, Nexx360ServerAuction } from '../libraries/nexx360Utils/types.js'; + +const analyticsType = 'endpoint'; +const ANALYTICS_CODE = 'nexx360'; +const DEFAULT_ENDPOINT = 'https://monitoring.nexx360.io'; + +const { + AUCTION_INIT, + AUCTION_END, + BID_REQUESTED, + BID_RESPONSE, + BID_WON, + BID_TIMEOUT, + AD_RENDER_SUCCEEDED, + AD_RENDER_FAILED, +} = EVENTS; + +const EVENT_TYPE_MAP: Record = { + [AUCTION_INIT]: 'auctionInit', + [BID_REQUESTED]: 'bidRequested', + [BID_RESPONSE]: 'bidResponse', + [BID_WON]: 'bidWon', + [BID_TIMEOUT]: 'bidTimeout', + [AD_RENDER_SUCCEEDED]: 'adRenderSucceeded', + [AD_RENDER_FAILED]: 'adRenderFailed', +}; + +// --- Types --- + +/** Publisher-facing options for `pbjs.enableAnalytics({provider: 'nexx360'})` */ +export interface Nexx360AnalyticsOptions { + /** Nexx360 publisher (account) ID. Required. */ + publisherId: string; + /** Collector base URL. Defaults to https://monitoring.nexx360.io */ + endpoint?: string; + /** Optional label to segment A/B test traffic in reports */ + abTestLabel?: string; +} + +declare module '../libraries/analyticsAdapter/AnalyticsAdapter' { + interface AnalyticsProviderConfig { + nexx360: { + options: Nexx360AnalyticsOptions; + } + } +} + +/** Options after defaults are applied */ +interface AnalyticsOptions { + publisherId: string; + endpoint: string; + abTestLabel?: string; +} + +interface Ortb2Imp { + ext?: { + gpid?: string; + }; +} + +interface AuctionInitAdUnitBid { + bidder: string; +} + +interface AuctionInitAdUnit { + code: string; + mediaTypes: Record; + ortb2Imp?: Ortb2Imp; + bids?: AuctionInitAdUnitBid[]; +} + +interface Eid { + source: string; + uids?: { id: string; atype?: number }[]; +} + +interface BidderRequest { + ortb2?: { + user?: { + ext?: { + eids?: Eid[]; + }; + }; + }; +} + +interface AuctionInitArgs { + auctionId: string; + adUnits?: AuctionInitAdUnit[]; + bidderRequests?: BidderRequest[]; + timeout: number; +} + +interface BidRequestBid { + bidder: string; + adUnitCode: string; + bidId: string; + ortb2Imp?: Ortb2Imp; + sizes?: [number, number] | [number, number][]; +} + +interface BidRequestedArgs { + auctionId: string; + bidderCode: string; + bids?: BidRequestBid[]; +} + +interface BidMeta { + demandSource?: string; + advertiserDomains?: string[]; +} + +interface BidFloorData { + floorValue?: number; + floorRule?: string; + floorRuleValue?: number; + floorCurrency?: string; + cpmAfterAdjustments?: number; + matchedFields?: Record; +} + +interface BidResponseArgs { + auctionId: string; + bidderCode?: string; + bidder?: string; + adUnitCode: string; + ortb2Imp?: Ortb2Imp; + cpm: number; + currency: string; + width?: number; + height?: number; + timeToRespond: number; + requestId: string; + statusMessage: string; + meta?: BidMeta; + floorData?: BidFloorData; + serverAuctionData?: Nexx360ServerAuction; +} + +interface BidWonArgs { + auctionId: string; + bidderCode?: string; + bidder?: string; + adUnitCode: string; + ortb2Imp?: Ortb2Imp; + cpm: number; + currency: string; + width?: number; + height?: number; + requestId: string; + meta?: BidMeta; + floorData?: BidFloorData; +} + +interface BidTimeoutBid { + auctionId: string; + bidder: string; + adUnitCode: string; + bidId: string; + ortb2Imp?: Ortb2Imp; + sizes?: [number, number] | [number, number][]; +} + +interface AdRenderBid { + auctionId?: string; + bidder?: string; + bidderCode?: string; + adUnitCode: string; + ortb2Imp?: Ortb2Imp; + adId?: string; + cpm?: number; + currency?: string; + width?: number; + height?: number; + mediaType?: string; + requestId?: string; + timeToRespond?: number; + source?: string; + status?: string; + ttl?: number; + creativeId?: string; + netRevenue?: boolean; + meta?: BidMeta; + floorData?: BidFloorData; +} + +interface AdRenderArgs { + bid?: AdRenderBid; + adId?: string; + reason?: string; +} + +// --- Event payloads --- + +type ConnectionType = 'client' | 'nexx360'; + +interface BaseEvent { + eventType: string; + auctionId: string; + timestamp: number; + publisherId: string; + prebidVersion: string; + referrer: string; + domain: string; + connectionType: ConnectionType; + gpid?: string; + abTestLabel?: string; +} + +// --- Auction-scoped events --- + +type BaseAuctionEvent = BaseEvent; + +interface AdUnitEventPayload { + code: string; + mediaTypes: Record; + gpid?: string; + bids: { bidder: string }[]; +} + +interface IdSolutionsPayload { + id5?: boolean; + firstId?: boolean; + euid2?: boolean; + liveramp?: boolean; +} + +interface AuctionInitEvent extends BaseAuctionEvent { + adUnits: AdUnitEventPayload[]; + timeout: number; + idSolutions?: IdSolutionsPayload; + refreshIndex: number; +} + +interface BidRequestBidPayload { + bidder: string; + adUnitCode: string; + bidId: string; + gpid?: string; + sizes: string[]; +} + +interface BidRequestedEvent extends BaseAuctionEvent { + bidderCode: string; + bids: BidRequestBidPayload[]; +} + +interface BidTimeoutBidPayload { + bidder: string; + adUnitCode: string; + bidId: string; + gpid?: string; + sizes: string[]; +} + +interface BidTimeoutEvent extends BaseAuctionEvent { + bids: BidTimeoutBidPayload[]; +} + +// --- Bid & Impression events --- + +interface FloorDataPayload { + floorValue?: number; + floorRule?: string; + floorCurrency?: string; + cpmAfterAdjustments?: number; +} + +interface BaseBidImpressionEvent extends BaseEvent { + clientSsp?: string; + fullSsp?: string; + adUnitCode: string; + cpm?: number; + currency?: string; + size?: string; + requestId?: string; + floorData?: FloorDataPayload; +} + +interface BidResponseEvent extends BaseBidImpressionEvent { + clientSsp: string; + fullSsp: string; + cpm: number; + currency: string; + requestId: string; + timeToRespond: number; + statusMessage: string; +} + +interface BidWonEvent extends BaseBidImpressionEvent { + clientSsp: string; + fullSsp: string; + cpm: number; + currency: string; + requestId: string; +} + +interface AdRenderEvent extends BaseBidImpressionEvent { + adId?: string; + mediaType?: string; + timeToRespond?: number; + source?: string; + status?: string; + ttl?: number; + creativeId?: string; + netRevenue?: boolean; + reason?: string; +} + +// --- Server auction --- + +interface ServerAuctionEvent extends BaseEvent { + eventType: 'serverAuction'; + serverAuctionId: string; + serverTimestamp: number; + impressions: Nexx360ImpressionAuction[]; + totalImpressions: number; + totalSspsCalled: number; + totalBidsReceived: number; + totalTimeouts: number; + totalErrors: number; + auctionTimeMs: number; +} + +type AnalyticsEvent = + | AuctionInitEvent + | BidRequestedEvent + | BidResponseEvent + | BidWonEvent + | BidTimeoutEvent + | AdRenderEvent + | ServerAuctionEvent; + +type TrackArgs = + | AuctionInitArgs + | BidRequestedArgs + | BidResponseArgs + | BidWonArgs + | BidTimeoutBid[] + | AdRenderArgs + | { auctionId: string }; + +interface AuctionCacheEntry { + events: AnalyticsEvent[]; + sent: boolean; + serverAuctionSent: boolean; +} + +// --- State --- + +let analyticsOptions: AnalyticsOptions = { + publisherId: '', + endpoint: DEFAULT_ENDPOINT, + abTestLabel: undefined, +}; + +let auctionCache: Record = {}; +let auctionCount = 0; + +// --- Helpers --- + +function formatSize(width?: number, height?: number): string | undefined { + return width && height ? `${width}x${height}` : undefined; +} + +function formatSizes(sizes?: [number, number] | [number, number][]): string[] { + if (!Array.isArray(sizes) || sizes.length === 0) return []; + // Prebid also allows a single size as a flat tuple, e.g. `[300, 250]`. + if (typeof sizes[0] === 'number') { + const [width, height] = sizes as [number, number]; + return typeof height === 'number' ? [`${width}x${height}`] : []; + } + return (sizes as [number, number][]).map(s => `${s[0]}x${s[1]}`); +} + +function getAuctionCache(auctionId: string): AuctionCacheEntry { + if (!auctionCache[auctionId]) { + auctionCache[auctionId] = { + events: [], + sent: false, + serverAuctionSent: false, + }; + } + return auctionCache[auctionId]; +} + +function getConnectionType(bidderCode?: string): ConnectionType { + return bidderCode === 'nexx360' ? 'nexx360' : 'client'; +} + +function resolveBidderCode(bidderCode?: string, meta?: BidMeta): string { + if (bidderCode === 'nexx360' && meta?.demandSource) { + return meta.demandSource; + } + return bidderCode || ''; +} + +const ID_SOLUTION_SOURCES: Record = { + id5: 'id5-sync.com', + firstId: 'first-id.fr', + euid2: 'uidapi.com', + liveramp: 'liveramp.com', +}; + +function extractIdSolutions(bidderRequests?: BidderRequest[]): IdSolutionsPayload | undefined { + if (!bidderRequests || bidderRequests.length === 0) return undefined; + const eids = bidderRequests[0]?.ortb2?.user?.ext?.eids; + if (!eids || eids.length === 0) return undefined; + const sources = new Set(eids.map(eid => eid.source)); + const result: IdSolutionsPayload = {}; + let hasAny = false; + Object.keys(ID_SOLUTION_SOURCES).forEach((key) => { + const k = key as keyof IdSolutionsPayload; + if (sources.has(ID_SOLUTION_SOURCES[k])) { + result[k] = true; + hasAny = true; + } + }); + return hasAny ? result : undefined; +} + +function extractFloorData(floorData?: BidFloorData): FloorDataPayload | undefined { + if (!floorData || floorData.floorValue == null) return undefined; + return { + floorValue: floorData.floorValue, + floorRule: floorData.floorRule, + floorCurrency: floorData.floorCurrency, + cpmAfterAdjustments: floorData.cpmAfterAdjustments, + }; +} + +function createBaseEvent( + eventType: string, + auctionId: string, + connectionType: ConnectionType = 'client', +): BaseEvent { + return { + eventType: EVENT_TYPE_MAP[eventType] || eventType, + auctionId, + timestamp: Date.now(), + publisherId: analyticsOptions.publisherId, + prebidVersion: '$prebid.version$', + referrer: window.location.href, + domain: window.location.hostname, + connectionType, + ...(analyticsOptions.abTestLabel ? { abTestLabel: analyticsOptions.abTestLabel } : {}), + }; +} + +// --- Event builders --- + +interface BidImpressionInput { + eventType: string; + auctionId: string; + rawBidderCode: string; + meta?: BidMeta; + adUnitCode: string; + ortb2Imp?: Ortb2Imp; + cpm?: number; + currency?: string; + width?: number; + height?: number; + requestId?: string; + floorData?: BidFloorData; +} + +function createBidImpressionEvent(input: BidImpressionInput): BaseBidImpressionEvent { + const event: BaseBidImpressionEvent = { + ...createBaseEvent(input.eventType, input.auctionId, getConnectionType(input.rawBidderCode)), + clientSsp: input.rawBidderCode, + fullSsp: resolveBidderCode(input.rawBidderCode, input.meta), + adUnitCode: input.adUnitCode, + gpid: input.ortb2Imp?.ext?.gpid, + cpm: input.cpm, + currency: input.currency, + size: formatSize(input.width, input.height), + requestId: input.requestId, + }; + const floorData = extractFloorData(input.floorData); + if (floorData) event.floorData = floorData; + return event; +} + +function buildAuctionInitEvent(args: AuctionInitArgs): AuctionInitEvent { + const refreshIndex = auctionCount; + auctionCount++; + const event: AuctionInitEvent = { + ...createBaseEvent(AUCTION_INIT, args.auctionId), + adUnits: (args.adUnits || []).map((adUnit: AuctionInitAdUnit): AdUnitEventPayload => ({ + code: adUnit.code, + mediaTypes: adUnit.mediaTypes, + gpid: adUnit.ortb2Imp?.ext?.gpid, + bids: (adUnit.bids || []).map((bid: AuctionInitAdUnitBid) => ({ + bidder: bid.bidder, + })), + })), + timeout: args.timeout, + refreshIndex, + }; + const idSolutions = extractIdSolutions(args.bidderRequests); + if (idSolutions) event.idSolutions = idSolutions; + return event; +} + +function buildBidRequestedEvent(args: BidRequestedArgs): BidRequestedEvent { + const event: BidRequestedEvent = { + ...createBaseEvent(BID_REQUESTED, args.auctionId, getConnectionType(args.bidderCode)), + bidderCode: args.bidderCode, + bids: (args.bids || []).map((bid: BidRequestBid): BidRequestBidPayload => ({ + bidder: bid.bidder, + adUnitCode: bid.adUnitCode, + bidId: bid.bidId, + gpid: bid.ortb2Imp?.ext?.gpid, + sizes: formatSizes(bid.sizes), + })), + }; + return event; +} + +function buildBidResponseEvent(args: BidResponseArgs): BidResponseEvent { + return { + ...createBidImpressionEvent({ + eventType: BID_RESPONSE, + auctionId: args.auctionId, + rawBidderCode: args.bidderCode || args.bidder || '', + meta: args.meta, + adUnitCode: args.adUnitCode, + ortb2Imp: args.ortb2Imp, + cpm: args.cpm, + currency: args.currency, + width: args.width, + height: args.height, + requestId: args.requestId, + floorData: args.floorData, + }), + timeToRespond: args.timeToRespond, + statusMessage: args.statusMessage, + } as BidResponseEvent; +} + +function buildBidWonEvent(args: BidWonArgs): BidWonEvent { + return createBidImpressionEvent({ + eventType: BID_WON, + auctionId: args.auctionId, + rawBidderCode: args.bidderCode || args.bidder || '', + meta: args.meta, + adUnitCode: args.adUnitCode, + ortb2Imp: args.ortb2Imp, + cpm: args.cpm, + currency: args.currency, + width: args.width, + height: args.height, + requestId: args.requestId, + floorData: args.floorData, + }) as BidWonEvent; +} + +function buildBidTimeoutEvent(args: BidTimeoutBid[]): BidTimeoutEvent[] { + const events: BidTimeoutEvent[] = []; + const bidsByAuction: Record = {}; + + (args || []).forEach((bid: BidTimeoutBid) => { + const { auctionId } = bid; + if (!bidsByAuction[auctionId]) { + bidsByAuction[auctionId] = []; + } + bidsByAuction[auctionId].push({ + bidder: bid.bidder, + adUnitCode: bid.adUnitCode, + bidId: bid.bidId, + gpid: bid.ortb2Imp?.ext?.gpid, + sizes: formatSizes(bid.sizes), + }); + }); + + Object.keys(bidsByAuction).forEach((auctionId: string) => { + events.push({ + ...createBaseEvent(BID_TIMEOUT, auctionId), + bids: bidsByAuction[auctionId], + }); + }); + + return events; +} + +function buildAdRenderEvent(eventType: string, args: AdRenderArgs): AdRenderEvent { + const bid: AdRenderBid = args.bid || {} as AdRenderBid; + const event: AdRenderEvent = { + ...createBidImpressionEvent({ + eventType, + auctionId: bid.auctionId || 'unknown', + rawBidderCode: bid.bidder || bid.bidderCode || '', + meta: bid.meta, + adUnitCode: bid.adUnitCode, + ortb2Imp: bid.ortb2Imp, + cpm: bid.cpm, + currency: bid.currency, + width: bid.width, + height: bid.height, + requestId: bid.requestId, + floorData: bid.floorData, + }), + adId: args.adId || bid.adId, + mediaType: bid.mediaType, + timeToRespond: bid.timeToRespond, + source: bid.source, + status: bid.status, + ttl: bid.ttl, + creativeId: bid.creativeId, + netRevenue: bid.netRevenue, + }; + if (eventType === AD_RENDER_FAILED) { + event.reason = args.reason; + } + return event; +} + +function buildServerAuctionEvent(auctionId: string, data: Nexx360ServerAuction): ServerAuctionEvent { + return { + ...createBaseEvent('serverAuction', auctionId, 'nexx360'), + eventType: 'serverAuction', + serverAuctionId: data.auctionId, + serverTimestamp: data.timestamp, + impressions: data.impressions, + totalImpressions: data.totalImpressions, + totalSspsCalled: data.totalSspsCalled, + totalBidsReceived: data.totalBidsReceived, + totalTimeouts: data.totalTimeouts, + totalErrors: data.totalErrors, + auctionTimeMs: data.auctionTimeMs, + }; +} + +// --- Transport --- + +function sendEvents(events: AnalyticsEvent[]): void { + if (!events || events.length === 0) { + return; + } + + const endpoint = `${analyticsOptions.endpoint}/events`; + + // Send as text/plain (a CORS "simple request") with no credentials so the + // cross-origin POST avoids a preflight OPTIONS round-trip. The body is still + // a JSON string; the collector parses it regardless of content type. + noCredsAjax(endpoint, { + success: () => { + logInfo(`Nexx360 Analytics: sent ${events.length} events`); + }, + error: (error: string) => { + logError(`Nexx360 Analytics: failed to send events: ${error}`); + }, + }, JSON.stringify(events), { + contentType: 'text/plain', + method: 'POST', + // Low-priority telemetry: let the request survive page navigation. + keepalive: true, + }); +} + +function sendSingleEvent(event: AnalyticsEvent): void { + sendEvents([event]); +} + +function flushAuctionEvents(auctionId: string): void { + const cache = getAuctionCache(auctionId); + if (cache.sent || cache.events.length === 0) { + return; + } + cache.sent = true; + sendEvents(cache.events); + // Clean up cache after a delay to allow post-auction events + setTimeout(() => { + delete auctionCache[auctionId]; + }, 30000); +} + +// --- Adapter --- + +export const nexx360AnalyticsAdapter = Object.assign(adapter({ analyticsType }), { + track({ eventType, args }: { eventType: string; args: TrackArgs }) { + if (!analyticsOptions.publisherId) { + return; + } + + try { + switch (eventType) { + // --- Auction-scoped events: buffered, flushed on AUCTION_END --- + + case AUCTION_INIT: { + const initArgs = args as AuctionInitArgs; + const cache = getAuctionCache(initArgs.auctionId); + cache.events.push(buildAuctionInitEvent(initArgs)); + break; + } + + case BID_REQUESTED: { + const reqArgs = args as BidRequestedArgs; + const cache = getAuctionCache(reqArgs.auctionId); + cache.events.push(buildBidRequestedEvent(reqArgs)); + break; + } + + case BID_RESPONSE: { + const respArgs = args as BidResponseArgs; + const cache = getAuctionCache(respArgs.auctionId); + cache.events.push(buildBidResponseEvent(respArgs)); + const bidderCode = respArgs.bidderCode || respArgs.bidder || ''; + if (bidderCode === 'nexx360' && !cache.serverAuctionSent && respArgs.serverAuctionData) { + cache.events.push(buildServerAuctionEvent(respArgs.auctionId, respArgs.serverAuctionData)); + cache.serverAuctionSent = true; + } + break; + } + + case BID_TIMEOUT: { + const events = buildBidTimeoutEvent(args as BidTimeoutBid[]); + events.forEach((event: BidTimeoutEvent) => { + const cache = getAuctionCache(event.auctionId); + cache.events.push(event); + }); + break; + } + + case AUCTION_END: { + const endArgs = args as { auctionId: string }; + flushAuctionEvents(endArgs.auctionId); + break; + } + + // --- Impression events: sent immediately --- + + case BID_WON: { + sendSingleEvent(buildBidWonEvent(args as BidWonArgs)); + break; + } + + case AD_RENDER_SUCCEEDED: + case AD_RENDER_FAILED: { + sendSingleEvent(buildAdRenderEvent(eventType, args as AdRenderArgs)); + break; + } + + default: + break; + } + } catch (error) { + logError(`Nexx360 Analytics: error processing event ${eventType}:`, error); + } + }, +}); + +const originEnableAnalytics = nexx360AnalyticsAdapter.enableAnalytics; + +// Partial, because this validates what the publisher actually passed rather +// than what the configuration type asks for. +nexx360AnalyticsAdapter.enableAnalytics = function(config: { options?: Partial }) { + const options = config.options || {}; + + if (!options.publisherId) { + logError('Nexx360 Analytics: publisherId is required'); + return; + } + + analyticsOptions = { + publisherId: options.publisherId, + endpoint: options.endpoint || DEFAULT_ENDPOINT, + abTestLabel: options.abTestLabel, + }; + + logInfo(`Nexx360 Analytics: enabled with publisherId ${analyticsOptions.publisherId}`); + + originEnableAnalytics.call(this, config as AnalyticsConfig<'nexx360'>); +}; + +adapterManager.registerAnalyticsAdapter({ + adapter: nexx360AnalyticsAdapter, + code: ANALYTICS_CODE, +}); + +export default nexx360AnalyticsAdapter; diff --git a/test/spec/libraries/nexx360Utils/serverAuction_spec.js b/test/spec/libraries/nexx360Utils/serverAuction_spec.js new file mode 100644 index 00000000000..988b4f2af63 --- /dev/null +++ b/test/spec/libraries/nexx360Utils/serverAuction_spec.js @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import { interpretResponse } from '../../../../libraries/nexx360Utils/index.js'; + +const SERVER_AUCTION = { + auctionId: 'srv-real-1', + timestamp: 1700000000000, + impressions: [], + totalImpressions: 0, + totalSspsCalled: 0, + totalBidsReceived: 0, + totalTimeouts: 0, + totalErrors: 0, + auctionTimeMs: 42, +}; + +// A minimal valid ORTB banner response so interpretResponse produces bids. +function responseWith(serverAuction, { bidCount = 1 } = {}) { + const bid = { + impid: 'imp-1', + price: 1.5, + w: 300, + h: 250, + crid: 'c-1', + adm: '
', + adomain: ['nexx360.io'], + ext: { mediaType: 'banner', ssp: 'test' }, + }; + const body = { + cur: 'USD', + ext: serverAuction ? { serverAuction } : {}, + seatbid: bidCount > 0 + ? [{ bid: Array.from({ length: bidCount }, () => ({ ...bid })) }] + : [], + }; + return { body }; +} + +describe('nexx360Utils server auction extraction', () => { + it('attaches ext.serverAuction to every produced bid response', () => { + const responses = interpretResponse(responseWith(SERVER_AUCTION, { bidCount: 2 })); + expect(responses).to.have.length(2); + responses.forEach((response) => { + expect(response.serverAuctionData).to.deep.equal(SERVER_AUCTION); + }); + }); + + it('attaches nothing when ext.serverAuction is absent', () => { + const responses = interpretResponse(responseWith(null)); + expect(responses).to.have.length(1); + expect(responses[0]).to.not.have.property('serverAuctionData'); + }); + + it('ignores ext.serverAuction that has no auctionId', () => { + const responses = interpretResponse(responseWith({ timestamp: 1 })); + expect(responses).to.have.length(1); + expect(responses[0]).to.not.have.property('serverAuctionData'); + }); + + it('returns [] when the response has no seatbid', () => { + const responses = interpretResponse(responseWith(SERVER_AUCTION, { bidCount: 0 })); + expect(responses).to.deep.equal([]); + }); + + it('returns [] when the response body is missing', () => { + const responses = interpretResponse({}); + expect(responses).to.deep.equal([]); + }); +}); diff --git a/test/spec/modules/nexx360AnalyticsAdapter_spec.js b/test/spec/modules/nexx360AnalyticsAdapter_spec.js new file mode 100644 index 00000000000..0ab1d0d9895 --- /dev/null +++ b/test/spec/modules/nexx360AnalyticsAdapter_spec.js @@ -0,0 +1,518 @@ +import nexx360AnalyticsAdapter from 'modules/nexx360AnalyticsAdapter.js'; +import { expect } from 'chai'; +import { server } from 'test/mocks/xhr.js'; +import { EVENTS } from 'src/constants.js'; +import sinon from 'sinon'; + +const events = require('src/events'); + +const ENDPOINT = 'http://localhost:8052'; +const PUBLISHER_ID = 'pub-1'; + +// The adapter POSTs a raw JSON array to `${endpoint}/events`. (Before the sampling +// refactor it also fires a GET `${endpoint}/config`.) Select only the /events POSTs +// so these assertions hold both before and after the refactor. +function eventPosts() { + return server.requests + .filter((r) => /\/events$/.test(r.url)) + .map((r) => JSON.parse(r.requestBody)); +} + +function enable(options = {}) { + nexx360AnalyticsAdapter.enableAnalytics({ + provider: 'nexx360', + options: { publisherId: PUBLISHER_ID, endpoint: ENDPOINT, ...options }, + }); +} + +describe('Nexx360 Analytics Adapter', function () { + beforeEach(function () { + sinon.stub(events, 'getEvents').returns([]); + }); + + afterEach(function () { + nexx360AnalyticsAdapter.disableAnalytics(); + events.getEvents.restore(); + }); + + it('buffers auction-scoped events and flushes them once on AUCTION_END', function () { + enable(); + const auctionId = 'auction-batch'; + + events.emit(EVENTS.AUCTION_INIT, { + auctionId, + timeout: 1000, + adUnits: [{ code: 'div-1', bids: [{ bidder: 'appnexus' }, { bidder: 'rubicon' }] }], + }); + events.emit(EVENTS.BID_REQUESTED, { + auctionId, + bidderCode: 'appnexus', + bids: [{ bidder: 'appnexus', adUnitCode: 'div-1', bidId: 'b1', sizes: [[300, 250]] }], + }); + events.emit(EVENTS.BID_RESPONSE, { + auctionId, + bidderCode: 'appnexus', + adUnitCode: 'div-1', + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + timeToRespond: 140, + requestId: 'req-1', + statusMessage: 'Bid available', + }); + + // Nothing is sent until the auction ends. + expect(eventPosts().length).to.equal(0); + + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const posts = eventPosts(); + expect(posts.length).to.equal(1); + const batch = posts[0]; + const types = batch.map((e) => e.eventType); + expect(types).to.include.members(['auctionInit', 'bidRequested', 'bidResponse']); + batch.forEach((e) => expect(e.auctionId).to.equal(auctionId)); + + // A direct bidder resolves to identical client/full SSP. + const response = batch.find((e) => e.eventType === 'bidResponse'); + expect(response.clientSsp).to.equal('appnexus'); + expect(response.fullSsp).to.equal('appnexus'); + // A non-nexx360 (direct) bidder is a client-side connection. + expect(response.connectionType).to.equal('client'); + }); + + it('stamps abTestLabel from config onto every event when set', function () { + enable({ abTestLabel: 'variantA' }); + const auctionId = 'auction-ab'; + + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const posts = eventPosts(); + expect(posts.length).to.equal(1); + posts[0].forEach((e) => expect(e.abTestLabel).to.equal('variantA')); + }); + + it('omits abTestLabel when not configured', function () { + enable(); + const auctionId = 'auction-no-ab'; + + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const posts = eventPosts(); + expect(posts.length).to.equal(1); + posts[0].forEach((e) => expect(e).to.not.have.property('abTestLabel')); + }); + + it('sends bidWon immediately as its own request', function () { + enable(); + events.emit(EVENTS.BID_WON, { + auctionId: 'auction-won', + bidderCode: 'appnexus', + adUnitCode: 'div-1', + cpm: 2.0, + currency: 'USD', + width: 300, + height: 250, + requestId: 'req-2', + }); + + const posts = eventPosts(); + expect(posts.length).to.equal(1); + expect(posts[0].length).to.equal(1); + expect(posts[0][0].eventType).to.equal('bidWon'); + expect(posts[0][0].cpm).to.equal(2.0); + }); + + it('sends adRenderSucceeded and adRenderFailed immediately (failure carries reason)', function () { + enable(); + events.emit(EVENTS.AD_RENDER_SUCCEEDED, { + bid: { auctionId: 'a-r', bidder: 'appnexus', adUnitCode: 'div-1', cpm: 1, adId: 'ad-1' }, + }); + events.emit(EVENTS.AD_RENDER_FAILED, { + reason: 'no creative', + bid: { auctionId: 'a-r', bidder: 'appnexus', adUnitCode: 'div-2' }, + }); + + const posts = eventPosts(); + expect(posts.length).to.equal(2); + expect(posts[0][0].eventType).to.equal('adRenderSucceeded'); + expect(posts[1][0].eventType).to.equal('adRenderFailed'); + expect(posts[1][0].reason).to.equal('no creative'); + }); + + it('synthesizes a serverAuction event when a nexx360 bidResponse carries server-auction data', function () { + const fixture = { + auctionId: 'srv-1', + timestamp: 1700000000000, + totalImpressions: 1, + totalSspsCalled: 2, + totalBidsReceived: 1, + totalTimeouts: 0, + totalErrors: 0, + auctionTimeMs: 80, + impressions: [{ + impId: 'imp-1', + adUnitCode: 'div-1', + totalSsps: 2, + bidsReceived: 1, + timeouts: 0, + errors: 0, + auctionTimeMs: 80, + winner: { ssp: 'appnexus', cpm: 1.5, currency: 'USD' }, + bids: [ + { ssp: 'appnexus', status: 'bid', cpm: 1.5, currency: 'USD', size: '300x250' }, + { ssp: 'rubicon', status: 'noBid' }, + ], + }], + }; + enable(); + const auctionId = 'auction-server'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.BID_RESPONSE, { + auctionId, + bidderCode: 'nexx360', + meta: { demandSource: 'rubicon' }, + adUnitCode: 'div-1', + cpm: 1.5, + currency: 'USD', + timeToRespond: 90, + requestId: 'req-3', + statusMessage: 'Bid available', + serverAuctionData: fixture, + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const posts = eventPosts(); + expect(posts.length).to.equal(1); + const serverEvent = posts[0].find((e) => e.eventType === 'serverAuction'); + expect(serverEvent, 'serverAuction event present').to.exist; + expect(serverEvent.serverAuctionId).to.equal('srv-1'); + expect(serverEvent.totalImpressions).to.equal(1); + // The server-auction payload itself must not leak into the bidResponse event. + posts[0].forEach((e) => expect(e).to.not.have.property('serverAuctionData')); + + // The wrapper keeps its client-facing name while full_ssp is the underlying SSP. + const response = posts[0].find((e) => e.eventType === 'bidResponse'); + expect(response.clientSsp).to.equal('nexx360'); + expect(response.fullSsp).to.equal('rubicon'); + // nexx360-wrapped demand (and its synthesized serverAuction) is a server-side connection. + expect(response.connectionType).to.equal('nexx360'); + expect(serverEvent.connectionType).to.equal('nexx360'); + }); + + it('synthesizes the serverAuction event only once per auction', function () { + const fixture = { + auctionId: 'srv-once', + timestamp: 1700000000000, + impressions: [], + totalImpressions: 1, + totalSspsCalled: 1, + totalBidsReceived: 1, + totalTimeouts: 0, + totalErrors: 0, + auctionTimeMs: 50, + }; + enable(); + const auctionId = 'auction-server-once'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + [1, 2].forEach((i) => { + events.emit(EVENTS.BID_RESPONSE, { + auctionId, + bidderCode: 'nexx360', + adUnitCode: `div-${i}`, + cpm: 1, + currency: 'USD', + timeToRespond: 90, + requestId: `req-${i}`, + statusMessage: 'Bid available', + serverAuctionData: fixture, + }); + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const serverEvents = eventPosts().flat().filter((e) => e.eventType === 'serverAuction'); + expect(serverEvents.length).to.equal(1); + }); + + it('does not drop auctions (every auction produces output)', function () { + enable(); + const n = 5; + for (let i = 0; i < n; i++) { + const auctionId = `auction-${i}`; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + } + const posts = eventPosts(); + expect(posts.length).to.equal(n); + posts.forEach((batch) => { + expect(batch.some((e) => e.eventType === 'auctionInit')).to.equal(true); + }); + }); + + it('captures id solutions from auctionInit bidderRequests', function () { + enable(); + const auctionId = 'auction-eids'; + events.emit(EVENTS.AUCTION_INIT, { + auctionId, + timeout: 1000, + adUnits: [{ + code: 'div-1', + ortb2Imp: { ext: { gpid: '/12345/div-1' } }, + bids: [{ bidder: 'nexx360' }], + }], + bidderRequests: [{ + ortb2: { + user: { + ext: { + eids: [ + { source: 'id5-sync.com', uids: [{ id: 'x' }] }, + { source: 'first-id.fr', uids: [{ id: 'y' }] }, + { source: 'uidapi.com', uids: [{ id: 'z' }] }, + { source: 'liveramp.com', uids: [{ id: 'w' }] }, + ] + } + } + }, + }], + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const init = eventPosts()[0].find((e) => e.eventType === 'auctionInit'); + expect(init.idSolutions).to.deep.equal({ id5: true, firstId: true, euid2: true, liveramp: true }); + expect(init.adUnits[0].gpid).to.equal('/12345/div-1'); + expect(init).to.have.property('refreshIndex'); + }); + + it('records bidTimeout events in the auction batch', function () { + enable(); + const auctionId = 'auction-timeout'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.BID_TIMEOUT, [ + { + auctionId, + bidder: 'appnexus', + adUnitCode: 'div-1', + bidId: 'b1', + sizes: [[300, 250]], + ortb2Imp: { ext: { gpid: '/12345/div-1' } }, + }, + ]); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const timeout = eventPosts()[0].find((e) => e.eventType === 'bidTimeout'); + expect(timeout, 'bidTimeout event present').to.exist; + expect(timeout.bids[0].bidder).to.equal('appnexus'); + expect(timeout.bids[0].gpid).to.equal('/12345/div-1'); + expect(timeout.bids[0].sizes).to.deep.equal(['300x250']); + }); + + it('normalizes the single-size (flat) sizes form', function () { + enable(); + const auctionId = 'auction-flatsize'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + // Prebid's single-size form is a flat tuple, e.g. [300, 250] (not [[300, 250]]). + events.emit(EVENTS.BID_REQUESTED, { + auctionId, + bidderCode: 'appnexus', + bids: [{ bidder: 'appnexus', adUnitCode: 'div-1', bidId: 'b1', sizes: [300, 250] }], + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const requested = eventPosts()[0].find((e) => e.eventType === 'bidRequested'); + expect(requested.bids[0].sizes).to.deep.equal(['300x250']); + }); + + it('includes floorData on bid responses when present', function () { + enable(); + const auctionId = 'auction-floor'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.BID_RESPONSE, { + auctionId, + bidderCode: 'appnexus', + adUnitCode: 'div-1', + cpm: 1.5, + currency: 'USD', + width: 300, + height: 250, + requestId: 'req-1', + floorData: { + floorValue: 0.5, + floorRule: 'banner|300x250', + floorCurrency: 'USD', + cpmAfterAdjustments: 1.5, + }, + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const response = eventPosts()[0].find((e) => e.eventType === 'bidResponse'); + expect(response.floorData).to.deep.equal({ + floorValue: 0.5, + floorRule: 'banner|300x250', + floorCurrency: 'USD', + cpmAfterAdjustments: 1.5, + }); + expect(response.size).to.equal('300x250'); + }); + + it('handles auctionInit with no adUnits and empty / non-matching eids', function () { + enable(); + + // empty eids -> idSolutions undefined; no adUnits key -> falls back to [] + events.emit(EVENTS.AUCTION_INIT, { + auctionId: 'a-empty', + timeout: 1000, + bidderRequests: [{ ortb2: { user: { ext: { eids: [] } } } }], + }); + events.emit(EVENTS.AUCTION_END, { auctionId: 'a-empty' }); + + // eids present but none matching known sources -> idSolutions undefined; + // an adUnit without bids -> bids falls back to [] + events.emit(EVENTS.AUCTION_INIT, { + auctionId: 'a-unknown', + timeout: 1000, + adUnits: [{ code: 'd1' }], + bidderRequests: [{ ortb2: { user: { ext: { eids: [{ source: 'unknown.com', uids: [{ id: '1' }] }] } } } }], + }); + events.emit(EVENTS.AUCTION_END, { auctionId: 'a-unknown' }); + + const inits = eventPosts().flat().filter((e) => e.eventType === 'auctionInit'); + const empty = inits.find((e) => e.auctionId === 'a-empty'); + const unknown = inits.find((e) => e.auctionId === 'a-unknown'); + expect(empty.idSolutions).to.equal(undefined); + expect(empty.adUnits).to.deep.equal([]); + expect(unknown.idSolutions).to.equal(undefined); + expect(unknown.adUnits[0].bids).to.deep.equal([]); + }); + + it('handles bidRequested without bids and bidTimeout without sizes', function () { + enable(); + const auctionId = 'a-minimal'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.BID_REQUESTED, { auctionId, bidderCode: 'appnexus' }); + events.emit(EVENTS.BID_TIMEOUT, [{ auctionId, bidder: 'appnexus', adUnitCode: 'd', bidId: 'b' }]); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const batch = eventPosts().flat(); + expect(batch.find((e) => e.eventType === 'bidRequested').bids).to.deep.equal([]); + expect(batch.find((e) => e.eventType === 'bidTimeout').bids[0].sizes).to.deep.equal([]); + }); + + it('falls back to args.bidder and to empty bidder codes', function () { + enable(); + const auctionId = 'a-fallback'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + // bidResponse via args.bidder (no bidderCode); and with neither set + events.emit(EVENTS.BID_RESPONSE, { + auctionId, bidder: 'rubicon', adUnitCode: 'd', cpm: 1, currency: 'USD', requestId: 'r1', + }); + events.emit(EVENTS.BID_RESPONSE, { + auctionId, adUnitCode: 'd2', cpm: 1, currency: 'USD', requestId: 'r2', + }); + // bidWon via args.bidder; and with neither set + events.emit(EVENTS.BID_WON, { + auctionId, bidder: 'rubicon', adUnitCode: 'd', cpm: 1, currency: 'USD', requestId: 'r3', + }); + events.emit(EVENTS.BID_WON, { + auctionId, adUnitCode: 'd', cpm: 1, currency: 'USD', requestId: 'r4', + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const all = eventPosts().flat(); + const responses = all.filter((e) => e.eventType === 'bidResponse'); + const wons = all.filter((e) => e.eventType === 'bidWon'); + expect(responses.some((r) => r.clientSsp === 'rubicon')).to.equal(true); + expect(responses.some((r) => r.clientSsp === '')).to.equal(true); + expect(wons.some((w) => w.clientSsp === 'rubicon')).to.equal(true); + expect(wons.some((w) => w.clientSsp === '')).to.equal(true); + }); + + it('handles adRender events with missing bid fields', function () { + enable(); + // no bid object at all -> bid={}, auctionId='unknown', empty bidder code + events.emit(EVENTS.AD_RENDER_SUCCEEDED, {}); + // bid with bidderCode only (no bidder) + events.emit(EVENTS.AD_RENDER_SUCCEEDED, { bid: { bidderCode: 'appnexus', adUnitCode: 'd' } }); + + const rendered = eventPosts().flat().filter((e) => e.eventType === 'adRenderSucceeded'); + expect(rendered.length).to.equal(2); + expect(rendered[0].auctionId).to.equal('unknown'); + expect(rendered[0].clientSsp).to.equal(''); + expect(rendered[1].clientSsp).to.equal('appnexus'); + }); + + it('does not add a serverAuction event when the bidResponse carries no server data', function () { + enable(); + const auctionId = 'a-noserver'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.BID_RESPONSE, { + auctionId, bidderCode: 'nexx360', adUnitCode: 'd', cpm: 1, currency: 'USD', requestId: 'r', + }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + expect(eventPosts().flat().find((e) => e.eventType === 'serverAuction')).to.equal(undefined); + }); + + it('ignores unknown event types and swallows errors thrown while building events', function () { + enable(); + // Unknown event type hits the switch default branch. + expect(() => nexx360AnalyticsAdapter.track({ eventType: 'unknownEvent', args: {} })).to.not.throw(); + // Malformed args make a builder throw; the adapter must catch and not rethrow. + expect(() => nexx360AnalyticsAdapter.track({ eventType: EVENTS.BID_TIMEOUT, args: 5 })).to.not.throw(); + }); + + it('invokes the ajax success and error callbacks', function () { + enable(); + const isEventsReq = (r) => /\/events$/.test(r.url); + + // Success: resolve the immediate bidWon POST with a 200. + events.emit(EVENTS.BID_WON, { + auctionId: 'won-ok', + bidderCode: 'appnexus', + adUnitCode: 'div-1', + cpm: 1, + currency: 'USD', + requestId: 'r1', + }); + server.requests.filter(isEventsReq).forEach((r) => r.respond(200, {}, '')); + + // Error: fail the next POST at the network level. + events.emit(EVENTS.BID_WON, { + auctionId: 'won-err', + bidderCode: 'appnexus', + adUnitCode: 'div-1', + cpm: 1, + currency: 'USD', + requestId: 'r2', + }); + server.requests + .filter((r) => isEventsReq(r) && r.readyState !== XMLHttpRequest.DONE) + .forEach((r) => r.error()); + + expect(server.requests.filter(isEventsReq).length).to.equal(2); + }); + + it('defaults to the production endpoint when none is configured', function () { + // enableAnalytics without an explicit endpoint should fall back to DEFAULT_ENDPOINT. + nexx360AnalyticsAdapter.enableAnalytics({ + provider: 'nexx360', + options: { publisherId: PUBLISHER_ID }, + }); + const auctionId = 'auction-default-endpoint'; + events.emit(EVENTS.AUCTION_INIT, { auctionId, timeout: 1000, adUnits: [] }); + events.emit(EVENTS.AUCTION_END, { auctionId }); + + const eventsReqs = server.requests.filter((r) => /\/events$/.test(r.url)); + expect(eventsReqs.length).to.equal(1); + expect(eventsReqs[0].url).to.equal('https://monitoring.nexx360.io/events'); + }); + + // Kept last on purpose: enabling without publisherId does not delegate to the base + // adapter, so its enable/disable swap would otherwise leave enableAnalytics pointing + // at the base _enable and break subsequent tests. + it('does not enable (no requests) when publisherId is missing', function () { + enable({ publisherId: undefined }); + expect(eventPosts().length).to.equal(0); + }); +});