From 4f2205a45126f8b6950d8184924293d8dc3a3f7d Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 15 Oct 2026 14:59:54 -0400 Subject: [PATCH] analytics: capture customAnalytics at auctionEnd in V2 prebid analytics V2 called window.optable.customAnalytics when the bidWinTimeout timer fired, so any field that depends on page state at auction time was read ~10s late. Call it in trackAuctionEnd for sampled auctions, keep the result on the stored auction, and merge it in toWitness on both the timer and visibilitychange paths. --- lib/addons/prebid/analytics.md | 4 +++- lib/addons/prebid/analytics.test.ts | 20 ++++++++++++++++++ lib/addons/prebid/analytics.ts | 32 +++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 4023faa8..9abf8f95 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -61,7 +61,9 @@ const analytics = initPrebidAnalytics({ ### Custom payload fields Set `window.optable.customAnalytics` to an async function returning an object; its -keys are merged into every auction payload: +keys are merged into every auction payload. It is called once per sampled auction, at +`auctionEnd`, so its result describes the page when that auction ended rather than when +the payload is sent: ```js window.optable.customAnalytics = async () => ({ experiment: "floor-v2" }); diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 64bba2c6..6028ba8b 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -964,6 +964,26 @@ describe("OptablePrebidAnalytics", () => { analytics = new OptablePrebidAnalytics(mockOptableInstance); }); + it("should capture customAnalytics at auctionEnd, not when the payload is sent", async () => { + jest.useFakeTimers(); + let origin = "game.example.com"; + (window as any).optable = { customAnalytics: jest.fn(async () => ({ optableOrigin: origin })) }; + analytics = new OptablePrebidAnalytics(mockOptableInstance, { analytics: true }); + + analytics.trackAuctionEnd({ auctionId: "auction-custom-timing", bidderRequests: [] }); + origin = "later.example.com"; + await jest.runAllTimersAsync(); + + expect(window.optable.customAnalytics).toHaveBeenCalledTimes(1); + expect(mockOptableInstance.witness).toHaveBeenCalledWith( + "optable.prebid.auction", + expect.objectContaining({ optableOrigin: "game.example.com" }) + ); + + jest.useRealTimers(); + delete (window as any).optable; + }); + it("should handle bidsReceived and update bid status", async () => { const event = { auctionId: "auction-with-bids", diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 490e3bd1..d595a1d3 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -41,6 +41,7 @@ interface AuctionItem { bidWonEvents: any[]; timeoutBids: any[]; sampled: boolean; + customData: Promise> | undefined; } class OptablePrebidAnalytics { @@ -102,7 +103,7 @@ class OptablePrebidAnalytics { clearTimeout(auction.auctionEndTimeoutId); this.auctions.delete(auctionId); - this.toWitness(auction.auctionEnd, auction.bidWonEvents, auction.missed).then((payload) => { + this.toWitness(auction.auctionEnd, auction.bidWonEvents, auction.missed, auction.customData).then((payload) => { payload["auctionEndAt"] = auction.createdAt.toISOString(); payload["bidWonAt"] = auction.bidWonEvents.length > 0 @@ -273,6 +274,8 @@ class OptablePrebidAnalytics { * Process a Prebid `auctionEnd` event: record the raw event and schedule a * delayed Witness API call, giving `bidWon` events a window to arrive. The * payload is built from the raw event by `toWitness` when that timer fires. + * `window.optable.customAnalytics` is called here rather than when the timer + * fires, so its fields describe the page at the time of this auction. * @param event - The raw Prebid auctionEnd event object. * @param missed - True when the event was previously emitted (missed replay). * @returns void @@ -281,6 +284,7 @@ class OptablePrebidAnalytics { const { auctionId, bidderRequests = [] } = event; const timeoutBids = this.pendingTimeoutBids.get(auctionId) || []; const sampled = !!this.config.analytics && this.shouldSample(); + const customData = sampled ? (window as any).optable?.customAnalytics?.() : undefined; this.log(`Processing auction ${auctionId} with ${bidderRequests.length} bidder requests`); @@ -296,7 +300,12 @@ class OptablePrebidAnalytics { return; } const effectiveMissed = storedAuction.missed; - const payload = await this.toWitness(event, storedAuction.bidWonEvents, effectiveMissed); + const payload = await this.toWitness( + event, + storedAuction.bidWonEvents, + effectiveMissed, + storedAuction.customData + ); payload["auctionEndAt"] = createdAt.toISOString(); payload["bidWonAt"] = storedAuction.bidWonEvents.length > 0 @@ -316,6 +325,7 @@ class OptablePrebidAnalytics { bidWonEvents: [], timeoutBids, sampled, + customData, }); this.pendingTimeoutBids.delete(auctionId); @@ -375,9 +385,15 @@ class OptablePrebidAnalytics { * @param auctionEndEvent - The `auctionEnd` event object from Prebid.js. * @param bidWonEvents - Array of `bidWon` events accumulated during the auction window. * @param missed - True when the original events were already emitted (replayed). + * @param customData - Result of `customAnalytics` captured at auctionEnd; called now when omitted. * @returns A payload object compatible with the Witness API. */ - async toWitness(auctionEndEvent: any, bidWonEvents: any[], missed = false): Promise> { + async toWitness( + auctionEndEvent: any, + bidWonEvents: any[], + missed = false, + customData?: Promise> + ): Promise> { const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [] } = auctionEndEvent; const timeoutBids = this.auctions.get(auctionId)?.timeoutBids || []; @@ -510,11 +526,11 @@ class OptablePrebidAnalytics { `Auction ${auctionId} processed: ${bidderRequests.length} requests, ${totalBids} total bids, ${bidsReceived.length} received, ${noBids.length} no-bids, ${timeoutBids.length} timeouts` ); - if ((window as any).optable?.customAnalytics) { - await (window as any).optable.customAnalytics().then((response: any) => { - this.log(`Adding custom data to payload ${JSON.stringify(response)}`); - Object.assign(witnessData, response); - }); + const custom = customData ?? (window as any).optable?.customAnalytics?.(); + if (custom) { + const response = await custom; + this.log(`Adding custom data to payload ${JSON.stringify(response)}`); + Object.assign(witnessData, response); } return witnessData;