From f7d2dcd30a73ec9b00a725f49780e2dd5126863e Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 16 Jul 2026 10:32:25 -0600 Subject: [PATCH 1/6] fix(prebid-analytics): populate bid cpm/status and track timeout bids correctly - Merge cpm, size, currency, and status from bidsReceived into each bid in toWitness, fixing the win_cpm fallback in the downstream Spark job which requires bid_status != REQUESTED and bid_cpm IS NOT NULL - Mark bidder request status as NO_BID or TIMEOUT from the respective sets - Track BID_TIMEOUT events via a new pendingTimeoutBids map (bidTimeout fires as a separate Prebid event before auctionEnd, not as a field on it) - Store timeoutBids on AuctionItem and clean up pendingTimeoutBids after trackAuctionEnd; replay missed bidTimeout events in setHooks - Add tests asserting the actual toWitness payload for cpm, size, currency, RECEIVED/NO_BID/TIMEOUT statuses, live bidTimeout listener, and missed event replay Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/prebid/analytics.test.ts | 127 +++++++++++++++++++++++----- lib/addons/prebid/analytics.ts | 85 +++++++++++++++++-- 2 files changed, 184 insertions(+), 28 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index c63b9a2..37fc227 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -730,7 +730,8 @@ describe("OptablePrebidAnalytics", () => { const result = analytics.hookIntoPrebid(mockPbjs as any); expect(result).toBe(true); - expect(mockPbjs.onEvent).toHaveBeenCalledTimes(2); + expect(mockPbjs.onEvent).toHaveBeenCalledTimes(3); + expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidTimeout", expect.any(Function)); expect(mockPbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidWon", expect.any(Function)); }); @@ -789,13 +790,20 @@ describe("OptablePrebidAnalytics", () => { }, ], noBids: [], - timeoutBids: [], }; await analytics.trackAuctionEnd(event); const storedAuction = analytics["auctions"].get("auction-with-bids"); expect(storedAuction).toBeDefined(); + + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + const bid = payload.bidderRequests[0].bids[0]; + expect(bid.status).toBe("RECEIVED"); + expect(bid.cpm).toBe(1.5); + expect(bid.size).toBe("300x250"); + expect(bid.currency).toBe("USD"); + expect(payload.bidderRequests[0].status).toBe("RECEIVED"); }); it("should extract splitTestAssignment from bidsReceived", async () => { @@ -921,25 +929,28 @@ describe("OptablePrebidAnalytics", () => { bidderRequestId: "req-1", ortb2: { site: { domain: "example.com" }, - user: { - eids: [], - }, + user: { eids: [] }, }, bids: [], }, ], bidsReceived: [], noBids: [{ bidderRequestId: "req-1" }], - timeoutBids: [], }; await analytics.trackAuctionEnd(event); const storedAuction = analytics["auctions"].get("auction-no-bids"); expect(storedAuction).toBeDefined(); + + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + expect(payload.bidderRequests[0].status).toBe("NO_BID"); }); - it("should handle timeoutBids and update status", async () => { + it("should handle timeoutBids via pendingTimeoutBids and update status in toWitness", async () => { + // Simulate the bidTimeout event arriving before auctionEnd (the real Prebid flow) + analytics["pendingTimeoutBids"].set("auction-timeout", [{ bidderRequestId: "req-1", auctionId: "auction-timeout" }]); + const event = { auctionId: "auction-timeout", timeout: 3000, @@ -949,22 +960,27 @@ describe("OptablePrebidAnalytics", () => { bidderRequestId: "req-1", ortb2: { site: { domain: "example.com" }, - user: { - eids: [], - }, + user: { eids: [] }, }, bids: [], }, ], bidsReceived: [], noBids: [], - timeoutBids: [{ bidderRequestId: "req-1" }], }; await analytics.trackAuctionEnd(event); + // pendingTimeoutBids should be cleaned up after trackAuctionEnd + expect(analytics["pendingTimeoutBids"].has("auction-timeout")).toBe(false); + + // timeoutBids should be stored on the AuctionItem const storedAuction = analytics["auctions"].get("auction-timeout"); - expect(storedAuction).toBeDefined(); + expect(storedAuction!.timeoutBids).toHaveLength(1); + + // toWitness should mark the bidder request as TIMEOUT + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + expect(payload.bidderRequests[0].status).toBe("TIMEOUT"); }); }); @@ -1515,9 +1531,75 @@ describe("OptablePrebidAnalytics", () => { analytics["setHooks"](mockPbjs); + expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidTimeout", expect.any(Function)); expect(mockPbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidWon", expect.any(Function)); }); + + it("should accumulate timeout bids into pendingTimeoutBids via live bidTimeout listener", () => { + const capturedHandlers: Record = {}; + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([]), + onEvent: jest.fn((event: string, handler: Function) => { + capturedHandlers[event] = handler; + }), + }; + + analytics["setHooks"](mockPbjs); + + // Simulate bidTimeout firing before auctionEnd + capturedHandlers["bidTimeout"]([ + { auctionId: "auction-live-timeout", bidderRequestId: "req-1" }, + { auctionId: "auction-live-timeout", bidderRequestId: "req-2" }, + ]); + + expect(analytics["pendingTimeoutBids"].get("auction-live-timeout")).toHaveLength(2); + }); + + it("should replay missed bidTimeout events before processing missed auctionEnd", async () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "bidTimeout", + args: [ + { auctionId: "auction-missed-timeout", bidderRequestId: "req-1" }, + ], + }, + { + eventType: "auctionEnd", + args: { + auctionId: "auction-missed-timeout", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { eids: [] }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + }, + }, + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Wait for async trackAuctionEnd to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + const storedAuction = analytics["auctions"].get("auction-missed-timeout"); + expect(storedAuction!.timeoutBids).toHaveLength(1); + + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + expect(payload.bidderRequests[0].status).toBe("TIMEOUT"); + }); }); describe("EID deduplication", () => { @@ -1858,9 +1940,7 @@ describe("OptablePrebidAnalytics", () => { bidderRequestId: "req-1", ortb2: { site: { domain: "example.com" }, - user: { - eids: [], - }, + user: { eids: [] }, }, bids: [ { @@ -1875,16 +1955,21 @@ describe("OptablePrebidAnalytics", () => { ], bidsReceived: [], noBids: [{ bidderRequestId: "req-1" }], - timeoutBids: [], }; await analytics.trackAuctionEnd(event); const storedAuction = analytics["auctions"].get("auction-no-bid-with-bids"); - expect(storedAuction).toBeDefined(); + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + expect(payload.bidderRequests[0].status).toBe("NO_BID"); }); it("should mark bids with TIMEOUT status when bids exist", async () => { + // Pre-populate pendingTimeoutBids as Prebid's BID_TIMEOUT fires before auctionEnd + analytics["pendingTimeoutBids"].set("auction-timeout-with-bids", [ + { bidderRequestId: "req-1", auctionId: "auction-timeout-with-bids" }, + ]); + const event = { auctionId: "auction-timeout-with-bids", timeout: 3000, @@ -1894,9 +1979,7 @@ describe("OptablePrebidAnalytics", () => { bidderRequestId: "req-1", ortb2: { site: { domain: "example.com" }, - user: { - eids: [], - }, + user: { eids: [] }, }, bids: [ { @@ -1911,13 +1994,13 @@ describe("OptablePrebidAnalytics", () => { ], bidsReceived: [], noBids: [], - timeoutBids: [{ bidderRequestId: "req-1" }], }; await analytics.trackAuctionEnd(event); const storedAuction = analytics["auctions"].get("auction-timeout-with-bids"); - expect(storedAuction).toBeDefined(); + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + expect(payload.bidderRequests[0].status).toBe("TIMEOUT"); }); }); }); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index f03aad3..608e5dd 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -2,6 +2,7 @@ /* eslint-disable no-console */ import type { WitnessProperties } from "../../edge/witness"; import type OptableSDK from "../../sdk"; +import { buildRequest } from "../../core/network"; import * as Bowser from "bowser"; @@ -39,6 +40,8 @@ interface AuctionItem { missed: boolean; createdAt: Date; bidWonEvents: any[]; + timeoutBids: any[]; + sampled: boolean; } class OptablePrebidAnalytics { @@ -49,6 +52,7 @@ class OptablePrebidAnalytics { private auctions = new Map(); private missedAuctionIds = new Set(); + private pendingTimeoutBids = new Map(); private prebidInstance: any; /** @@ -82,9 +86,41 @@ class OptablePrebidAnalytics { // Store auction data this.maxAuctionDataSize = 50; + document.addEventListener("visibilitychange", this.handleVisibilityChange); + this.log("OptablePrebidAnalytics initialized"); } + private handleVisibilityChange = () => { + if (document.visibilityState !== "hidden") return; + if (!this.optableInstance.dcn) return; + + const witnessUrl = buildRequest("/witness", this.optableInstance.dcn).url; + + this.auctions.forEach((auction, auctionId) => { + if (!auction.auctionEndTimeoutId) return; + if (!auction.sampled) return; + clearTimeout(auction.auctionEndTimeoutId); + this.auctions.delete(auctionId); + + this.toWitness(auction.auctionEnd, auction.bidWonEvents, auction.missed).then((payload) => { + payload["auctionEndAt"] = auction.createdAt.toISOString(); + payload["bidWonAt"] = + auction.bidWonEvents.length > 0 + ? new Date(Math.min(...auction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString() + : null; + payload["optableLoaded"] = !auction.missed; + + navigator.sendBeacon( + witnessUrl, + new Blob([JSON.stringify({ event: "optable.prebid.auction", properties: payload })], { + type: "application/json", + }) + ); + }); + }); + }; + /** * Log messages to the console when debugging is enabled. * @param args - Values to log. @@ -164,6 +200,12 @@ class OptablePrebidAnalytics { pbjs.getEvents().forEach((event: any) => { if (event.eventType === "auctionInit") { this.missedAuctionIds.add(event.args.auctionId); + } else if (event.eventType === "bidTimeout") { + (event.args as any[]).forEach((bid: any) => { + const existing = this.pendingTimeoutBids.get(bid.auctionId) || []; + existing.push(bid); + this.pendingTimeoutBids.set(bid.auctionId, existing); + }); } else if (event.eventType === "auctionEnd") { this.missedAuctionIds.delete(event.args.auctionId); this.log(`auction ${event.args.auctionId} missed (completed before hook)`); @@ -175,6 +217,14 @@ class OptablePrebidAnalytics { }); this.log("Hooking into Prebid.js events"); + pbjs.onEvent("bidTimeout", (timedOutBids: any[]) => { + this.log("bidTimeout event received", timedOutBids); + timedOutBids.forEach((bid: any) => { + const existing = this.pendingTimeoutBids.get(bid.auctionId) || []; + existing.push(bid); + this.pendingTimeoutBids.set(bid.auctionId, existing); + }); + }); pbjs.onEvent("auctionEnd", (event: any) => { this.log("auctionEnd event received"); const missed = this.missedAuctionIds.has(event.auctionId); @@ -223,7 +273,9 @@ class OptablePrebidAnalytics { * @returns void */ async trackAuctionEnd(event: any, missed: boolean = false) { - const { auctionId, timeout, bidderRequests = [], bidsReceived = [], noBids = [], timeoutBids = [] } = event; + const { auctionId, timeout, bidderRequests = [], bidsReceived = [], noBids = [] } = event; + const timeoutBids = this.pendingTimeoutBids.get(auctionId) || []; + const sampled = !!this.config.analytics && this.shouldSample(); this.log(`Processing auction ${auctionId} with ${bidderRequests.length} bidder requests`); @@ -365,6 +417,10 @@ class OptablePrebidAnalytics { const auctionEndTimeoutId = setTimeout(async () => { const storedAuction = this.auctions.get(auctionId); if (!storedAuction) return; + if (!storedAuction.sampled) { + this.auctions.delete(auctionId); + return; + } const effectiveMissed = storedAuction.missed; const payload = await this.toWitness(event, storedAuction.bidWonEvents, effectiveMissed); payload["auctionEndAt"] = createdAt.toISOString(); @@ -378,7 +434,8 @@ class OptablePrebidAnalytics { }, this.config.bidWinTimeout); // Store the auction data - this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, bidWonEvents: [] }); + this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, bidWonEvents: [], timeoutBids, sampled }); + this.pendingTimeoutBids.delete(auctionId); // Clean up old auctions this.cleanupOldAuctions(); @@ -439,7 +496,8 @@ class OptablePrebidAnalytics { * @returns A payload object compatible with the Witness API. */ async toWitness(auctionEndEvent: any, bidWonEvents: any[], missed = false): Promise> { - const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [], timeoutBids = [] } = auctionEndEvent; + const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [] } = auctionEndEvent; + const timeoutBids = this.auctions.get(auctionId)?.timeoutBids || []; const oMatchersSet = new Set(); const oSourcesSet = new Set(); @@ -494,15 +552,30 @@ class OptablePrebidAnalytics { }; }); - // Merge splitTestAssignment from bidsReceived into the requests const bidsReceivedMap = new Map(bidsReceived.map((b: any) => [b.requestId, b])); + const noBidRequestIds = new Set(noBids.map((nb: any) => nb.bidderRequestId)); + const timedOutRequestIds = new Set(timeoutBids.map((tb: any) => tb.bidderRequestId)); + requests.forEach((request: any) => { request.bids.forEach((bid: any) => { const bidReceived = bidsReceivedMap.get(bid.bidId); - if (bidReceived?.ortb2Imp?.ext?.optable?.splitTestAssignment) { - bid.splitTestAssignment = bidReceived.ortb2Imp.ext.optable.splitTestAssignment; + if (bidReceived) { + bid.status = STATUS.RECEIVED; + bid.cpm = bidReceived.cpm; + bid.size = `${bidReceived.width}x${bidReceived.height}`; + bid.currency = bidReceived.currency; + if (bidReceived.ortb2Imp?.ext?.optable?.splitTestAssignment) { + bid.splitTestAssignment = bidReceived.ortb2Imp.ext.optable.splitTestAssignment; + } + if (request.status === STATUS.REQUESTED) request.status = STATUS.RECEIVED; } }); + if (noBidRequestIds.has(request.bidderRequestId) && request.status === STATUS.REQUESTED) { + request.status = STATUS.NO_BID; + } + if (timedOutRequestIds.has(request.bidderRequestId)) { + request.status = STATUS.TIMEOUT; + } }); const witnessData: WitnessProperties = { From 96ec76fd0344d571f77a938f78ae358eb15a69ae Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 16 Jul 2026 10:42:13 -0600 Subject: [PATCH 2/6] fix(prebid-analytics): flush pending auctions via sendBeacon on beforeunload Auctions waiting for a bidWon event (held in a setTimeout up to bidWinTimeout) were silently dropped on page unload. Added a beforeunload listener that cancels pending timeouts and sends each unresolved auction immediately via navigator.sendBeacon, which is guaranteed to complete even as the page tears down. Sampling and analytics-enabled checks are respected. Includes tests for the flush path, the disabled/already-sent no-op paths, and global listener cleanup. Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/prebid/analytics.test.ts | 128 ++++++++++++++++++++++++++++ lib/addons/prebid/analytics.ts | 126 +++++++++++++-------------- 2 files changed, 188 insertions(+), 66 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 37fc227..411a5a6 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -27,6 +27,9 @@ describe("OptablePrebidAnalytics", () => { }); afterEach(() => { + if (analytics) { + window.removeEventListener("beforeunload", (analytics as any).handleBeforeUnload); + } jest.clearAllMocks(); }); @@ -1115,6 +1118,131 @@ describe("OptablePrebidAnalytics", () => { }); }); + describe("beforeunload flush", () => { + let sendBeaconMock: jest.Mock; + let mockOptableWithDcn: OptableSDK; + + beforeEach(() => { + sendBeaconMock = jest.fn().mockReturnValue(true); + (navigator as any).sendBeacon = sendBeaconMock; + + mockOptableWithDcn = { + witness: jest.fn().mockResolvedValue(undefined), + dcn: { + host: "dcn.example.com", + cookies: false, + sessionID: "test-session-id", + consent: {}, + }, + } as any; + + analytics = new OptablePrebidAnalytics(mockOptableWithDcn, { + analytics: true, + tenant: "test-tenant", + }); + }); + + it("should flush pending auctions via sendBeacon on beforeunload", async () => { + const event = { + auctionId: "auction-unload", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + }; + + await analytics.trackAuctionEnd(event); + + // Confirm a timeout is pending + const storedAuction = analytics["auctions"].get("auction-unload"); + expect(storedAuction!.auctionEndTimeoutId).not.toBeNull(); + + window.dispatchEvent(new Event("beforeunload")); + + // Allow the toWitness promise to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(sendBeaconMock).toHaveBeenCalledTimes(1); + const [url, blob] = sendBeaconMock.mock.calls[0]; + expect(url).toContain("/witness"); + const body = JSON.parse(await new Response(blob).text()); + expect(body.event).toBe("optable.prebid.auction"); + expect(body.properties.auctionId).toBe("auction-unload"); + expect(body.properties.bidWonAt).toBeNull(); + }); + + it("should not flush when analytics is disabled", async () => { + const disabledAnalytics = new OptablePrebidAnalytics(mockOptableWithDcn, { + analytics: false, + tenant: "test-tenant", + }); + + const event = { + auctionId: "auction-disabled", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + }; + + await disabledAnalytics.trackAuctionEnd(event); + window.dispatchEvent(new Event("beforeunload")); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(sendBeaconMock).not.toHaveBeenCalled(); + window.removeEventListener("beforeunload", (disabledAnalytics as any).handleBeforeUnload); + }); + + it("should not flush auctions that have no pending timeout", async () => { + const auctionEndEvent = { + auctionId: "auction-already-sent", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + }; + const bidWonEvent = { + auctionId: "auction-already-sent", + bidderCode: "bidder1", + requestId: "bid-1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + await analytics.trackAuctionEnd(auctionEndEvent); + await analytics.trackBidWon(bidWonEvent); + + // auction was deleted by trackBidWon, so nothing to flush + window.dispatchEvent(new Event("beforeunload")); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // witness was called once (by trackBidWon), not again by beforeunload + expect(mockOptableWithDcn.witness).toHaveBeenCalledTimes(1); + expect(sendBeaconMock).not.toHaveBeenCalled(); + }); + }); + describe("setHooks", () => { beforeEach(() => { analytics = new OptablePrebidAnalytics(mockOptableInstance, { diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 608e5dd..2994971 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -36,12 +36,10 @@ interface OptablePrebidAnalyticsConfig { interface AuctionItem { auctionEnd: unknown | null; - auctionEndTimeoutId: ReturnType | null; + auctionEndTimeoutId: NodeJS.Timeout | null; missed: boolean; createdAt: Date; - bidWonEvents: any[]; timeoutBids: any[]; - sampled: boolean; } class OptablePrebidAnalytics { @@ -51,7 +49,6 @@ class OptablePrebidAnalytics { private readonly maxAuctionDataSize: number = 50; private auctions = new Map(); - private missedAuctionIds = new Set(); private pendingTimeoutBids = new Map(); private prebidInstance: any; @@ -86,29 +83,24 @@ class OptablePrebidAnalytics { // Store auction data this.maxAuctionDataSize = 50; - document.addEventListener("visibilitychange", this.handleVisibilityChange); + window.addEventListener("beforeunload", this.handleBeforeUnload); this.log("OptablePrebidAnalytics initialized"); } - private handleVisibilityChange = () => { - if (document.visibilityState !== "hidden") return; + private handleBeforeUnload = () => { + if (!this.config.analytics || !this.shouldSample()) return; if (!this.optableInstance.dcn) return; const witnessUrl = buildRequest("/witness", this.optableInstance.dcn).url; - this.auctions.forEach((auction, auctionId) => { + this.auctions.forEach((auction) => { if (!auction.auctionEndTimeoutId) return; - if (!auction.sampled) return; clearTimeout(auction.auctionEndTimeoutId); - this.auctions.delete(auctionId); - this.toWitness(auction.auctionEnd, auction.bidWonEvents, auction.missed).then((payload) => { + this.toWitness(auction.auctionEnd, null, auction.missed).then((payload) => { payload["auctionEndAt"] = auction.createdAt.toISOString(); - payload["bidWonAt"] = - auction.bidWonEvents.length > 0 - ? new Date(Math.min(...auction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString() - : null; + payload["bidWonAt"] = null; payload["optableLoaded"] = !auction.missed; navigator.sendBeacon( @@ -196,21 +188,20 @@ class OptablePrebidAnalytics { * @returns void */ setHooks(pbjs: any) { - this.log("Processing past events"); + this.log("Processing missed auctionEnd"); pbjs.getEvents().forEach((event: any) => { - if (event.eventType === "auctionInit") { - this.missedAuctionIds.add(event.args.auctionId); - } else if (event.eventType === "bidTimeout") { + if (event.eventType === "bidTimeout") { (event.args as any[]).forEach((bid: any) => { const existing = this.pendingTimeoutBids.get(bid.auctionId) || []; existing.push(bid); this.pendingTimeoutBids.set(bid.auctionId, existing); }); - } else if (event.eventType === "auctionEnd") { - this.missedAuctionIds.delete(event.args.auctionId); - this.log(`auction ${event.args.auctionId} missed (completed before hook)`); + } + if (event.eventType === "auctionEnd") { + this.log("auction missed"); this.trackAuctionEnd(event.args, true); - } else if (event.eventType === "bidWon") { + } + if (event.eventType === "bidWon") { this.log("bid won missed"); this.trackBidWon(event.args, true); } @@ -227,11 +218,7 @@ class OptablePrebidAnalytics { }); pbjs.onEvent("auctionEnd", (event: any) => { this.log("auctionEnd event received"); - const missed = this.missedAuctionIds.has(event.auctionId); - if (missed) { - this.missedAuctionIds.delete(event.auctionId); - } - this.trackAuctionEnd(event, missed); + this.trackAuctionEnd(event); }); pbjs.onEvent("bidWon", (event: any) => { this.log("bidWon event received"); @@ -275,7 +262,6 @@ class OptablePrebidAnalytics { async trackAuctionEnd(event: any, missed: boolean = false) { const { auctionId, timeout, bidderRequests = [], bidsReceived = [], noBids = [] } = event; const timeoutBids = this.pendingTimeoutBids.get(auctionId) || []; - const sampled = !!this.config.analytics && this.shouldSample(); this.log(`Processing auction ${auctionId} with ${bidderRequests.length} bidder requests`); @@ -289,9 +275,7 @@ class OptablePrebidAnalytics { bidderRequests: bidderRequests.map((br: any) => { const { bidderCode, bidderRequestId, bids = [] } = br; const domain = br.ortb2.site?.domain ?? "unknown"; - const allEids = [...(br.ortb2.user?.ext?.eids ?? []), ...(br.ortb2.user?.eids ?? [])]; - // Deduplicate EIDs by source - const eids = Array.from(new Map(allEids.map((eid: any) => [eid.source, eid])).values()); + const eids = br.ortb2.user?.eids ?? []; // Optable EIDs const optableEIDS = eids.filter((e: { inserter: string }) => e.inserter === "optable.co"); @@ -415,26 +399,16 @@ class OptablePrebidAnalytics { const createdAt = new Date(); const auctionEndTimeoutId = setTimeout(async () => { - const storedAuction = this.auctions.get(auctionId); - if (!storedAuction) return; - if (!storedAuction.sampled) { - this.auctions.delete(auctionId); - return; - } - const effectiveMissed = storedAuction.missed; - const payload = await this.toWitness(event, storedAuction.bidWonEvents, effectiveMissed); + const payload = await this.toWitness(event, null, missed); payload["auctionEndAt"] = createdAt.toISOString(); - payload["bidWonAt"] = - storedAuction.bidWonEvents.length > 0 - ? new Date(Math.min(...storedAuction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString() - : null; - payload["optableLoaded"] = !effectiveMissed; + payload["bidWonAt"] = null; + payload["optableLoaded"] = !missed; + this.sendToWitnessAPI("optable.prebid.auction", payload); - this.auctions.delete(auctionId); }, this.config.bidWinTimeout); // Store the auction data - this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, bidWonEvents: [], timeoutBids, sampled }); + this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, timeoutBids }); this.pendingTimeoutBids.delete(auctionId); // Clean up old auctions @@ -442,14 +416,21 @@ class OptablePrebidAnalytics { } /** - * Accumulate a Prebid `bidWon` event into the matching auction's event list - * for deferred emission when the auction timeout fires. + * Handle a Prebid `bidWon` event by finalizing the matching auction, clearing + * the pending timeout and sending the combined payload to Witness. * @param event - The raw Prebid bidWon event object. * @param missed - True when the event was previously emitted (missed replay). * @returns void */ async trackBidWon(event: any, missed: boolean = false) { - this.log("bidWon event", { auctionId: event.auctionId, bidderCode: event.bidderCode, missed }); + const filteredEvent = { + auctionId: event.auctionId, + bidderCode: event.bidderCode, + bidId: event.requestId, + tenant: this.config.tenant, + missed, + }; + this.log("bidWon filtered event", filteredEvent); const auction = this.auctions.get(event.auctionId); if (!auction) { @@ -457,10 +438,18 @@ class OptablePrebidAnalytics { return; } - auction.bidWonEvents.push({ ...event, _receivedAt: new Date() }); - if (missed) { - auction.missed = true; + if (auction.auctionEndTimeoutId) { + clearTimeout(auction.auctionEndTimeoutId); } + + const payload = await this.toWitness(auction.auctionEnd, event, missed); + payload["auctionEndAt"] = auction.createdAt.toISOString(); + payload["bidWonAt"] = new Date().toISOString(); + payload["optableLoaded"] = !missed; + + this.sendToWitnessAPI("optable.prebid.auction", payload); + + this.auctions.delete(event.auctionId); } /** @@ -483,19 +472,18 @@ class OptablePrebidAnalytics { */ clearData() { this.auctions.clear(); - this.missedAuctionIds.clear(); this.log("All analytics data cleared"); } /** - * Convert internal auction state and accumulated bidWon events into a Witness payload. + * Convert internal auction state and optional bidWon event into a Witness payload. * This collects matcher/source metadata, bid counts and optional custom analytics. * @param auctionEndEvent - The `auctionEnd` event object from Prebid.js. - * @param bidWonEvents - Array of `bidWon` events accumulated during the auction window. + * @param bidWonEvent - Optional `bidWon` event when a winning bid exists. * @param missed - True when the original events were already emitted (replayed). * @returns A payload object compatible with the Witness API. */ - async toWitness(auctionEndEvent: any, bidWonEvents: any[], missed = false): Promise> { + async toWitness(auctionEndEvent: any, bidWonEvent: any | null, missed = false): Promise> { const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [] } = auctionEndEvent; const timeoutBids = this.auctions.get(auctionId)?.timeoutBids || []; @@ -509,9 +497,7 @@ class OptablePrebidAnalytics { const requests = bidderRequests.map((br: any) => { const { bidderCode, bidderRequestId, bids = [] } = br; const domain = br.ortb2.site?.domain ?? "unknown"; - const allEids = [...(br.ortb2.user?.ext?.eids ?? []), ...(br.ortb2.user?.eids ?? [])]; - // Deduplicate EIDs by source - const eids = Array.from(new Map(allEids.map((eid: any) => [eid.source, eid])).values()); + const eids = br.ortb2.user?.eids ?? []; // Optable EIDs const optableEIDS = eids.filter((e: { inserter: string }) => e.inserter === "optable.co"); @@ -592,11 +578,20 @@ class OptablePrebidAnalytics { optableTargetingDone: oMatchersSet.size || oSourcesSet.size, optableMatchers: Array.from(oMatchersSet), optableSources: Array.from(oSourcesSet), - bidWon: bidWonEvents.map((e) => ({ - bidderCode: e.bidderCode, - adUnitCode: e.adUnitCode, - cpm: e.cpm, - })), + bidWon: bidWonEvent + ? { + message: + bidWonEvent.bidderCode + + " won the ad server auction for ad unit " + + bidWonEvent.adUnitCode + + " at " + + bidWonEvent.cpm + + " CPM", + bidderCode: bidWonEvent.bidderCode, + adUnitCode: bidWonEvent.adUnitCode, + cpm: bidWonEvent.cpm, + } + : null, missed, url: `${window.location.hostname}${window.location.pathname}`, tenant: this.config.tenant!, @@ -607,7 +602,6 @@ class OptablePrebidAnalytics { prebidjsVersion: this.prebidInstance?.version || "unknown", sessionDepth: sessionStorage?.optableSessionDepth || 1, pageAuctionsCount: (window as any).optable?.pageAuctionsCount || 1, - originSlug: this.optableInstance?.dcn?.site || (window as any).optable?.site || "unknown", }; // Log summary with bid counts From 91621d5538b5fe56b37d2ee7e93382069f5d17a9 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 16 Jul 2026 12:05:31 -0600 Subject: [PATCH 3/6] fix: store sampling decision once per auction to prevent holdout flush via beacon The `sampled` boolean is now evaluated exactly once in `trackAuctionEnd` (analytics enabled AND shouldSample()) and stored on `AuctionItem`. The `handleBeforeUnload` beacon path, the delayed timeout callback, and `trackBidWon` all read `auction.sampled` instead of re-evaluating, so auctions in the sampling holdout can never be accidentally sent. Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/prebid/analytics.test.ts | 37 ++++++++++++++++++++++++++++- lib/addons/prebid/analytics.ts | 12 ++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 411a5a6..08738d1 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -1178,7 +1178,7 @@ describe("OptablePrebidAnalytics", () => { expect(body.properties.bidWonAt).toBeNull(); }); - it("should not flush when analytics is disabled", async () => { + it("should not flush when analytics is disabled (analytics: false)", async () => { const disabledAnalytics = new OptablePrebidAnalytics(mockOptableWithDcn, { analytics: false, tenant: "test-tenant", @@ -1207,6 +1207,41 @@ describe("OptablePrebidAnalytics", () => { window.removeEventListener("beforeunload", (disabledAnalytics as any).handleBeforeUnload); }); + it("should not flush auctions in sampling holdout (sampled=false)", async () => { + // analytics: true but samplingRate: 0 → shouldSample() returns false → sampled=false + const holdoutAnalytics = new OptablePrebidAnalytics(mockOptableWithDcn, { + analytics: true, + samplingRate: 0, + tenant: "test-tenant", + }); + + const event = { + auctionId: "auction-holdout", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + }; + + await holdoutAnalytics.trackAuctionEnd(event); + + const storedAuction = holdoutAnalytics["auctions"].get("auction-holdout"); + expect(storedAuction!.sampled).toBe(false); + + window.dispatchEvent(new Event("beforeunload")); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(sendBeaconMock).not.toHaveBeenCalled(); + window.removeEventListener("beforeunload", (holdoutAnalytics as any).handleBeforeUnload); + }); + it("should not flush auctions that have no pending timeout", async () => { const auctionEndEvent = { auctionId: "auction-already-sent", diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 2994971..2f28441 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -40,6 +40,7 @@ interface AuctionItem { missed: boolean; createdAt: Date; timeoutBids: any[]; + sampled: boolean; } class OptablePrebidAnalytics { @@ -89,13 +90,13 @@ class OptablePrebidAnalytics { } private handleBeforeUnload = () => { - if (!this.config.analytics || !this.shouldSample()) return; if (!this.optableInstance.dcn) return; const witnessUrl = buildRequest("/witness", this.optableInstance.dcn).url; this.auctions.forEach((auction) => { if (!auction.auctionEndTimeoutId) return; + if (!auction.sampled) return; clearTimeout(auction.auctionEndTimeoutId); this.toWitness(auction.auctionEnd, null, auction.missed).then((payload) => { @@ -262,6 +263,7 @@ class OptablePrebidAnalytics { async trackAuctionEnd(event: any, missed: boolean = false) { const { auctionId, timeout, bidderRequests = [], bidsReceived = [], noBids = [] } = event; const timeoutBids = this.pendingTimeoutBids.get(auctionId) || []; + const sampled = !!this.config.analytics && this.shouldSample(); this.log(`Processing auction ${auctionId} with ${bidderRequests.length} bidder requests`); @@ -399,6 +401,7 @@ class OptablePrebidAnalytics { const createdAt = new Date(); const auctionEndTimeoutId = setTimeout(async () => { + if (!sampled) return; const payload = await this.toWitness(event, null, missed); payload["auctionEndAt"] = createdAt.toISOString(); payload["bidWonAt"] = null; @@ -408,7 +411,7 @@ class OptablePrebidAnalytics { }, this.config.bidWinTimeout); // Store the auction data - this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, timeoutBids }); + this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, timeoutBids, sampled }); this.pendingTimeoutBids.delete(auctionId); // Clean up old auctions @@ -442,6 +445,11 @@ class OptablePrebidAnalytics { clearTimeout(auction.auctionEndTimeoutId); } + if (!auction.sampled) { + this.auctions.delete(event.auctionId); + return; + } + const payload = await this.toWitness(auction.auctionEnd, event, missed); payload["auctionEndAt"] = auction.createdAt.toISOString(); payload["bidWonAt"] = new Date().toISOString(); From dbade8c5fbaebbd64833ad41297fff561c459410 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 16 Jul 2026 12:33:56 -0600 Subject: [PATCH 4/6] fix: use visibilitychange instead of beforeunload for beacon flush visibilitychange fires reliably on mobile (where beforeunload often doesn't), and also catches tab switches and app backgrounding. The handler checks document.visibilityState === 'hidden' before flushing and deletes each flushed auction from the map to prevent a double-send if the user returns to the tab and a bidWon fires. Added a test for the 'visible' (no-op) case alongside the existing hidden/disabled/holdout/no-pending-timeout cases. Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/prebid/analytics.test.ts | 56 +++++++++++++++++++++++------ lib/addons/prebid/analytics.ts | 8 +++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 08738d1..6998767 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -28,7 +28,7 @@ describe("OptablePrebidAnalytics", () => { afterEach(() => { if (analytics) { - window.removeEventListener("beforeunload", (analytics as any).handleBeforeUnload); + document.removeEventListener("visibilitychange", (analytics as any).handleVisibilityChange); } jest.clearAllMocks(); }); @@ -1118,11 +1118,17 @@ describe("OptablePrebidAnalytics", () => { }); }); - describe("beforeunload flush", () => { + describe("visibilitychange flush", () => { let sendBeaconMock: jest.Mock; let mockOptableWithDcn: OptableSDK; + const fireHidden = () => { + Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + }; + beforeEach(() => { + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); sendBeaconMock = jest.fn().mockReturnValue(true); (navigator as any).sendBeacon = sendBeaconMock; @@ -1142,7 +1148,11 @@ describe("OptablePrebidAnalytics", () => { }); }); - it("should flush pending auctions via sendBeacon on beforeunload", async () => { + afterEach(() => { + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + }); + + it("should flush pending auctions via sendBeacon when page becomes hidden", async () => { const event = { auctionId: "auction-unload", timeout: 3000, @@ -1164,7 +1174,7 @@ describe("OptablePrebidAnalytics", () => { const storedAuction = analytics["auctions"].get("auction-unload"); expect(storedAuction!.auctionEndTimeoutId).not.toBeNull(); - window.dispatchEvent(new Event("beforeunload")); + fireHidden(); // Allow the toWitness promise to resolve await new Promise((resolve) => setTimeout(resolve, 10)); @@ -1178,6 +1188,32 @@ describe("OptablePrebidAnalytics", () => { expect(body.properties.bidWonAt).toBeNull(); }); + it("should not flush when visibilityState is visible", async () => { + const event = { + auctionId: "auction-visible", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + }; + + await analytics.trackAuctionEnd(event); + + // Fire with visible state (e.g. tab becomes foreground) + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(sendBeaconMock).not.toHaveBeenCalled(); + }); + it("should not flush when analytics is disabled (analytics: false)", async () => { const disabledAnalytics = new OptablePrebidAnalytics(mockOptableWithDcn, { analytics: false, @@ -1200,11 +1236,11 @@ describe("OptablePrebidAnalytics", () => { }; await disabledAnalytics.trackAuctionEnd(event); - window.dispatchEvent(new Event("beforeunload")); + fireHidden(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(sendBeaconMock).not.toHaveBeenCalled(); - window.removeEventListener("beforeunload", (disabledAnalytics as any).handleBeforeUnload); + document.removeEventListener("visibilitychange", (disabledAnalytics as any).handleVisibilityChange); }); it("should not flush auctions in sampling holdout (sampled=false)", async () => { @@ -1235,11 +1271,11 @@ describe("OptablePrebidAnalytics", () => { const storedAuction = holdoutAnalytics["auctions"].get("auction-holdout"); expect(storedAuction!.sampled).toBe(false); - window.dispatchEvent(new Event("beforeunload")); + fireHidden(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(sendBeaconMock).not.toHaveBeenCalled(); - window.removeEventListener("beforeunload", (holdoutAnalytics as any).handleBeforeUnload); + document.removeEventListener("visibilitychange", (holdoutAnalytics as any).handleVisibilityChange); }); it("should not flush auctions that have no pending timeout", async () => { @@ -1269,10 +1305,10 @@ describe("OptablePrebidAnalytics", () => { await analytics.trackBidWon(bidWonEvent); // auction was deleted by trackBidWon, so nothing to flush - window.dispatchEvent(new Event("beforeunload")); + fireHidden(); await new Promise((resolve) => setTimeout(resolve, 10)); - // witness was called once (by trackBidWon), not again by beforeunload + // witness was called once (by trackBidWon), not again by visibilitychange expect(mockOptableWithDcn.witness).toHaveBeenCalledTimes(1); expect(sendBeaconMock).not.toHaveBeenCalled(); }); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 2f28441..59fd643 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -84,20 +84,22 @@ class OptablePrebidAnalytics { // Store auction data this.maxAuctionDataSize = 50; - window.addEventListener("beforeunload", this.handleBeforeUnload); + document.addEventListener("visibilitychange", this.handleVisibilityChange); this.log("OptablePrebidAnalytics initialized"); } - private handleBeforeUnload = () => { + private handleVisibilityChange = () => { + if (document.visibilityState !== "hidden") return; if (!this.optableInstance.dcn) return; const witnessUrl = buildRequest("/witness", this.optableInstance.dcn).url; - this.auctions.forEach((auction) => { + this.auctions.forEach((auction, auctionId) => { if (!auction.auctionEndTimeoutId) return; if (!auction.sampled) return; clearTimeout(auction.auctionEndTimeoutId); + this.auctions.delete(auctionId); this.toWitness(auction.auctionEnd, null, auction.missed).then((payload) => { payload["auctionEndAt"] = auction.createdAt.toISOString(); From 793a673fce149003361be0b8d1546b377b97567a Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 16 Jul 2026 14:41:32 -0600 Subject: [PATCH 5/6] fix(prebid-analytics): merge master changes with bid-cpm-status fixes Integrates origin/master restructuring (bidWonEvents accumulator, missedAuctionIds tracking, auctionInit replay, EID deduplication, originSlug field) while preserving our branch additions: bidTimeout tracking via pendingTimeoutBids, once-per-auction sampled flag, and visibilitychange beacon flush instead of beforeunload. Also fixes handleVisibilityChange to pass auction.bidWonEvents to toWitness and compute bidWonAt from them rather than hardcoding null. Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/prebid/analytics.test.ts | 12 ++- lib/addons/prebid/analytics.ts | 120 ++++++++++++++-------------- 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 6998767..ea302bd 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -1279,6 +1279,8 @@ describe("OptablePrebidAnalytics", () => { }); it("should not flush auctions that have no pending timeout", async () => { + jest.useFakeTimers(); + const auctionEndEvent = { auctionId: "auction-already-sent", timeout: 3000, @@ -1304,12 +1306,16 @@ describe("OptablePrebidAnalytics", () => { await analytics.trackAuctionEnd(auctionEndEvent); await analytics.trackBidWon(bidWonEvent); - // auction was deleted by trackBidWon, so nothing to flush + // Let the bidWinTimeout fire — sends via witness and deletes the auction + await jest.runAllTimersAsync(); + expect(mockOptableWithDcn.witness).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + + // No auctions remain, so visibilitychange should not trigger sendBeacon fireHidden(); await new Promise((resolve) => setTimeout(resolve, 10)); - // witness was called once (by trackBidWon), not again by visibilitychange - expect(mockOptableWithDcn.witness).toHaveBeenCalledTimes(1); expect(sendBeaconMock).not.toHaveBeenCalled(); }); }); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 59fd643..608e5dd 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -36,9 +36,10 @@ interface OptablePrebidAnalyticsConfig { interface AuctionItem { auctionEnd: unknown | null; - auctionEndTimeoutId: NodeJS.Timeout | null; + auctionEndTimeoutId: ReturnType | null; missed: boolean; createdAt: Date; + bidWonEvents: any[]; timeoutBids: any[]; sampled: boolean; } @@ -50,6 +51,7 @@ class OptablePrebidAnalytics { private readonly maxAuctionDataSize: number = 50; private auctions = new Map(); + private missedAuctionIds = new Set(); private pendingTimeoutBids = new Map(); private prebidInstance: any; @@ -101,9 +103,12 @@ class OptablePrebidAnalytics { clearTimeout(auction.auctionEndTimeoutId); this.auctions.delete(auctionId); - this.toWitness(auction.auctionEnd, null, auction.missed).then((payload) => { + this.toWitness(auction.auctionEnd, auction.bidWonEvents, auction.missed).then((payload) => { payload["auctionEndAt"] = auction.createdAt.toISOString(); - payload["bidWonAt"] = null; + payload["bidWonAt"] = + auction.bidWonEvents.length > 0 + ? new Date(Math.min(...auction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString() + : null; payload["optableLoaded"] = !auction.missed; navigator.sendBeacon( @@ -191,20 +196,21 @@ class OptablePrebidAnalytics { * @returns void */ setHooks(pbjs: any) { - this.log("Processing missed auctionEnd"); + this.log("Processing past events"); pbjs.getEvents().forEach((event: any) => { - if (event.eventType === "bidTimeout") { + if (event.eventType === "auctionInit") { + this.missedAuctionIds.add(event.args.auctionId); + } else if (event.eventType === "bidTimeout") { (event.args as any[]).forEach((bid: any) => { const existing = this.pendingTimeoutBids.get(bid.auctionId) || []; existing.push(bid); this.pendingTimeoutBids.set(bid.auctionId, existing); }); - } - if (event.eventType === "auctionEnd") { - this.log("auction missed"); + } else if (event.eventType === "auctionEnd") { + this.missedAuctionIds.delete(event.args.auctionId); + this.log(`auction ${event.args.auctionId} missed (completed before hook)`); this.trackAuctionEnd(event.args, true); - } - if (event.eventType === "bidWon") { + } else if (event.eventType === "bidWon") { this.log("bid won missed"); this.trackBidWon(event.args, true); } @@ -221,7 +227,11 @@ class OptablePrebidAnalytics { }); pbjs.onEvent("auctionEnd", (event: any) => { this.log("auctionEnd event received"); - this.trackAuctionEnd(event); + const missed = this.missedAuctionIds.has(event.auctionId); + if (missed) { + this.missedAuctionIds.delete(event.auctionId); + } + this.trackAuctionEnd(event, missed); }); pbjs.onEvent("bidWon", (event: any) => { this.log("bidWon event received"); @@ -279,7 +289,9 @@ class OptablePrebidAnalytics { bidderRequests: bidderRequests.map((br: any) => { const { bidderCode, bidderRequestId, bids = [] } = br; const domain = br.ortb2.site?.domain ?? "unknown"; - const eids = br.ortb2.user?.eids ?? []; + const allEids = [...(br.ortb2.user?.ext?.eids ?? []), ...(br.ortb2.user?.eids ?? [])]; + // Deduplicate EIDs by source + const eids = Array.from(new Map(allEids.map((eid: any) => [eid.source, eid])).values()); // Optable EIDs const optableEIDS = eids.filter((e: { inserter: string }) => e.inserter === "optable.co"); @@ -403,17 +415,26 @@ class OptablePrebidAnalytics { const createdAt = new Date(); const auctionEndTimeoutId = setTimeout(async () => { - if (!sampled) return; - const payload = await this.toWitness(event, null, missed); + const storedAuction = this.auctions.get(auctionId); + if (!storedAuction) return; + if (!storedAuction.sampled) { + this.auctions.delete(auctionId); + return; + } + const effectiveMissed = storedAuction.missed; + const payload = await this.toWitness(event, storedAuction.bidWonEvents, effectiveMissed); payload["auctionEndAt"] = createdAt.toISOString(); - payload["bidWonAt"] = null; - payload["optableLoaded"] = !missed; - + payload["bidWonAt"] = + storedAuction.bidWonEvents.length > 0 + ? new Date(Math.min(...storedAuction.bidWonEvents.map((e: any) => e._receivedAt.getTime()))).toISOString() + : null; + payload["optableLoaded"] = !effectiveMissed; this.sendToWitnessAPI("optable.prebid.auction", payload); + this.auctions.delete(auctionId); }, this.config.bidWinTimeout); // Store the auction data - this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, timeoutBids, sampled }); + this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, bidWonEvents: [], timeoutBids, sampled }); this.pendingTimeoutBids.delete(auctionId); // Clean up old auctions @@ -421,21 +442,14 @@ class OptablePrebidAnalytics { } /** - * Handle a Prebid `bidWon` event by finalizing the matching auction, clearing - * the pending timeout and sending the combined payload to Witness. + * Accumulate a Prebid `bidWon` event into the matching auction's event list + * for deferred emission when the auction timeout fires. * @param event - The raw Prebid bidWon event object. * @param missed - True when the event was previously emitted (missed replay). * @returns void */ async trackBidWon(event: any, missed: boolean = false) { - const filteredEvent = { - auctionId: event.auctionId, - bidderCode: event.bidderCode, - bidId: event.requestId, - tenant: this.config.tenant, - missed, - }; - this.log("bidWon filtered event", filteredEvent); + this.log("bidWon event", { auctionId: event.auctionId, bidderCode: event.bidderCode, missed }); const auction = this.auctions.get(event.auctionId); if (!auction) { @@ -443,23 +457,10 @@ class OptablePrebidAnalytics { return; } - if (auction.auctionEndTimeoutId) { - clearTimeout(auction.auctionEndTimeoutId); - } - - if (!auction.sampled) { - this.auctions.delete(event.auctionId); - return; + auction.bidWonEvents.push({ ...event, _receivedAt: new Date() }); + if (missed) { + auction.missed = true; } - - const payload = await this.toWitness(auction.auctionEnd, event, missed); - payload["auctionEndAt"] = auction.createdAt.toISOString(); - payload["bidWonAt"] = new Date().toISOString(); - payload["optableLoaded"] = !missed; - - this.sendToWitnessAPI("optable.prebid.auction", payload); - - this.auctions.delete(event.auctionId); } /** @@ -482,18 +483,19 @@ class OptablePrebidAnalytics { */ clearData() { this.auctions.clear(); + this.missedAuctionIds.clear(); this.log("All analytics data cleared"); } /** - * Convert internal auction state and optional bidWon event into a Witness payload. + * Convert internal auction state and accumulated bidWon events into a Witness payload. * This collects matcher/source metadata, bid counts and optional custom analytics. * @param auctionEndEvent - The `auctionEnd` event object from Prebid.js. - * @param bidWonEvent - Optional `bidWon` event when a winning bid exists. + * @param bidWonEvents - Array of `bidWon` events accumulated during the auction window. * @param missed - True when the original events were already emitted (replayed). * @returns A payload object compatible with the Witness API. */ - async toWitness(auctionEndEvent: any, bidWonEvent: any | null, missed = false): Promise> { + async toWitness(auctionEndEvent: any, bidWonEvents: any[], missed = false): Promise> { const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [] } = auctionEndEvent; const timeoutBids = this.auctions.get(auctionId)?.timeoutBids || []; @@ -507,7 +509,9 @@ class OptablePrebidAnalytics { const requests = bidderRequests.map((br: any) => { const { bidderCode, bidderRequestId, bids = [] } = br; const domain = br.ortb2.site?.domain ?? "unknown"; - const eids = br.ortb2.user?.eids ?? []; + const allEids = [...(br.ortb2.user?.ext?.eids ?? []), ...(br.ortb2.user?.eids ?? [])]; + // Deduplicate EIDs by source + const eids = Array.from(new Map(allEids.map((eid: any) => [eid.source, eid])).values()); // Optable EIDs const optableEIDS = eids.filter((e: { inserter: string }) => e.inserter === "optable.co"); @@ -588,20 +592,11 @@ class OptablePrebidAnalytics { optableTargetingDone: oMatchersSet.size || oSourcesSet.size, optableMatchers: Array.from(oMatchersSet), optableSources: Array.from(oSourcesSet), - bidWon: bidWonEvent - ? { - message: - bidWonEvent.bidderCode + - " won the ad server auction for ad unit " + - bidWonEvent.adUnitCode + - " at " + - bidWonEvent.cpm + - " CPM", - bidderCode: bidWonEvent.bidderCode, - adUnitCode: bidWonEvent.adUnitCode, - cpm: bidWonEvent.cpm, - } - : null, + bidWon: bidWonEvents.map((e) => ({ + bidderCode: e.bidderCode, + adUnitCode: e.adUnitCode, + cpm: e.cpm, + })), missed, url: `${window.location.hostname}${window.location.pathname}`, tenant: this.config.tenant!, @@ -612,6 +607,7 @@ class OptablePrebidAnalytics { prebidjsVersion: this.prebidInstance?.version || "unknown", sessionDepth: sessionStorage?.optableSessionDepth || 1, pageAuctionsCount: (window as any).optable?.pageAuctionsCount || 1, + originSlug: this.optableInstance?.dcn?.site || (window as any).optable?.site || "unknown", }; // Log summary with bid counts From 23810a722211ca5be262d5b56ac0a82c02e9635c Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 16 Jul 2026 14:47:24 -0600 Subject: [PATCH 6/6] style: apply prettier formatting to analytics files Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/prebid/analytics.test.ts | 8 ++++---- lib/addons/prebid/analytics.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index ea302bd..102787f 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -952,7 +952,9 @@ describe("OptablePrebidAnalytics", () => { it("should handle timeoutBids via pendingTimeoutBids and update status in toWitness", async () => { // Simulate the bidTimeout event arriving before auctionEnd (the real Prebid flow) - analytics["pendingTimeoutBids"].set("auction-timeout", [{ bidderRequestId: "req-1", auctionId: "auction-timeout" }]); + analytics["pendingTimeoutBids"].set("auction-timeout", [ + { bidderRequestId: "req-1", auctionId: "auction-timeout" }, + ]); const event = { auctionId: "auction-timeout", @@ -1766,9 +1768,7 @@ describe("OptablePrebidAnalytics", () => { getEvents: jest.fn().mockReturnValue([ { eventType: "bidTimeout", - args: [ - { auctionId: "auction-missed-timeout", bidderRequestId: "req-1" }, - ], + args: [{ auctionId: "auction-missed-timeout", bidderRequestId: "req-1" }], }, { eventType: "auctionEnd", diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 608e5dd..6daefda 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -434,7 +434,15 @@ class OptablePrebidAnalytics { }, this.config.bidWinTimeout); // Store the auction data - this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId, bidWonEvents: [], timeoutBids, sampled }); + this.auctions.set(auctionId, { + auctionEnd: event, + createdAt, + missed, + auctionEndTimeoutId, + bidWonEvents: [], + timeoutBids, + sampled, + }); this.pendingTimeoutBids.delete(auctionId); // Clean up old auctions