diff --git a/libraries/nexx360Utils/index.ts b/libraries/nexx360Utils/index.ts index c90d9246bde..f5369d2cf1b 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,10 +214,38 @@ export function createResponse(bid:any, ortbResponse:any): BidResponse { return response as BidResponse; } +// --- Server auction data extraction --- + +let lastServerAuctionData: Nexx360ServerAuction | null = null; + +function extractServerAuction(responseBody: any): void { + const serverAuction = deepAccess(responseBody, 'ext.serverAuction'); + if (serverAuction && typeof serverAuction === 'object' && serverAuction.auctionId) { + lastServerAuctionData = serverAuction as Nexx360ServerAuction; + } +} + +export function getLastServerAuctionData(): Nexx360ServerAuction | null { + return lastServerAuctionData; +} + +export function clearLastServerAuctionData(): void { + lastServerAuctionData = 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 []; + + if (!respBody.seatbid || respBody.seatbid.length === 0) { + // No bid responses will be produced, so no analytics `bidResponse` will + // consume server-auction data. Clear it rather than let a stale value leak + // into a later, unrelated auction. + clearLastServerAuctionData(); + return []; + } + + extractServerAuction(respBody); const responses: BidResponse[] = []; for (let i = 0; i < respBody.seatbid.length; i++) { 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 d34fd73f3e3..944e84726df 100644 --- a/metadata/modules.json +++ b/metadata/modules.json @@ -6532,6 +6532,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..e6999c9a8e0 --- /dev/null +++ b/modules/nexx360AnalyticsAdapter.ts @@ -0,0 +1,773 @@ +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 { + getLastServerAuctionData, + clearLastServerAuctionData, +} from '../libraries/nexx360Utils/index.js'; +import { Nexx360ImpressionAuction } 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 --- + +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; +} + +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): ServerAuctionEvent | null { + const data = getLastServerAuctionData(); + if (!data) return null; + clearLastServerAuctionData(); + 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', + }); +} + +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) { + const serverAuctionEvent = buildServerAuctionEvent(respArgs.auctionId); + if (serverAuctionEvent) { + cache.events.push(serverAuctionEvent); + 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; + +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); +}; + +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..6be4ed58d78 --- /dev/null +++ b/test/spec/libraries/nexx360Utils/serverAuction_spec.js @@ -0,0 +1,83 @@ +import { expect } from 'chai'; +import { + interpretResponse, + getLastServerAuctionData, + clearLastServerAuctionData, +} 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 a bid. +function responseWith(serverAuction, { withBid = true } = {}) { + const body = { + cur: 'USD', + ext: serverAuction ? { serverAuction } : {}, + seatbid: withBid + ? [{ + bid: [{ + impid: 'imp-1', + price: 1.5, + w: 300, + h: 250, + crid: 'c-1', + adm: '
', + adomain: ['nexx360.io'], + ext: { mediaType: 'banner', ssp: 'test' }, + }], + }] + : [], + }; + return { body }; +} + +describe('nexx360Utils server auction extraction', () => { + beforeEach(() => { + clearLastServerAuctionData(); + }); + + afterEach(() => { + clearLastServerAuctionData(); + }); + + it('stores ext.serverAuction when the response produces bids, then clears it', () => { + const responses = interpretResponse(responseWith(SERVER_AUCTION)); + expect(responses).to.have.length(1); + expect(getLastServerAuctionData()).to.deep.equal(SERVER_AUCTION); + + clearLastServerAuctionData(); + expect(getLastServerAuctionData()).to.equal(null); + }); + + it('ignores ext.serverAuction that has no auctionId', () => { + interpretResponse(responseWith({ timestamp: 1 })); + expect(getLastServerAuctionData()).to.equal(null); + }); + + it('clears any stored server-auction when a response produces no bids', () => { + // First, store data from a response that has bids. + interpretResponse(responseWith(SERVER_AUCTION)); + expect(getLastServerAuctionData()).to.deep.equal(SERVER_AUCTION); + + // A later Nexx360 response with server-auction data but no bids must not + // leave the previous value around to leak into an unrelated auction. + const responses = interpretResponse(responseWith(SERVER_AUCTION, { withBid: false })); + expect(responses).to.deep.equal([]); + expect(getLastServerAuctionData()).to.equal(null); + }); + + it('returns [] and stores nothing when the response body is missing', () => { + const responses = interpretResponse({}); + expect(responses).to.deep.equal([]); + expect(getLastServerAuctionData()).to.equal(null); + }); +}); diff --git a/test/spec/modules/nexx360AnalyticsAdapter_spec.js b/test/spec/modules/nexx360AnalyticsAdapter_spec.js new file mode 100644 index 00000000000..057c9df3536 --- /dev/null +++ b/test/spec/modules/nexx360AnalyticsAdapter_spec.js @@ -0,0 +1,495 @@ +import nexx360AnalyticsAdapter from 'modules/nexx360AnalyticsAdapter.js'; +import { expect } from 'chai'; +import { server } from 'test/mocks/xhr.js'; +import { EVENTS } from 'src/constants.js'; +import * as nexx360Utils from '../../../libraries/nexx360Utils/index.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 arrives', 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' }, + ], + }], + }; + const getStub = sinon.stub(nexx360Utils, 'getLastServerAuctionData').returns(fixture); + const clearStub = sinon.stub(nexx360Utils, 'clearLastServerAuctionData'); + + try { + 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', + }); + 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 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'); + } finally { + getStub.restore(); + clearStub.restore(); + } + }); + + 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 no server data is available', function () { + const getStub = sinon.stub(nexx360Utils, 'getLastServerAuctionData').returns(null); + try { + 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); + } finally { + getStub.restore(); + } + }); + + 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); + }); +});