From 24a3dd319ffaa96295cef1a9c4112dfacfa83e3b Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 27 May 2026 10:56:51 -0400 Subject: [PATCH] prebidAnalytics: Correct missed auction behaviour --- lib/addons/prebid/analytics.test.ts | 318 +++++++++++++++++++++++++++- lib/addons/prebid/analytics.ts | 20 +- 2 files changed, 328 insertions(+), 10 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 9a9db38..c63b9a2 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -697,6 +697,18 @@ describe("OptablePrebidAnalytics", () => { expect(analytics["auctions"].size).toBe(0); }); + + it("should clear missedAuctionIds", () => { + // Add some missed auction IDs + analytics["missedAuctionIds"].add("auction-1"); + analytics["missedAuctionIds"].add("auction-2"); + + expect(analytics["missedAuctionIds"].size).toBe(2); + + analytics.clearData(); + + expect(analytics["missedAuctionIds"].size).toBe(0); + }); }); describe("hookIntoPrebid", () => { @@ -1094,13 +1106,38 @@ describe("OptablePrebidAnalytics", () => { }); }); - it("should process missed auctionEnd events", () => { + it("should track auctions that started but not finished as missed", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionInit", + args: { + auctionId: "incomplete-auction", + }, + }, + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Auction should be in missedAuctionIds since it started but hasn't ended + expect(analytics["missedAuctionIds"].has("incomplete-auction")).toBe(true); + }); + + it("should not track auctions that completed before hooks as missed", () => { const mockPbjs = { getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionInit", + args: { + auctionId: "complete-auction", + }, + }, { eventType: "auctionEnd", args: { - auctionId: "missed-auction", + auctionId: "complete-auction", timeout: 3000, bidderRequests: [ { @@ -1126,11 +1163,284 @@ describe("OptablePrebidAnalytics", () => { analytics["setHooks"](mockPbjs); - expect(analytics["auctions"].has("missed-auction")).toBe(true); - const auction = analytics["auctions"].get("missed-auction"); + // Auction should NOT be in missedAuctionIds since it both started and ended + expect(analytics["missedAuctionIds"].has("complete-auction")).toBe(false); + }); + + it("should mark live auction as missed when it was in missedAuctionIds", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionInit", + args: { + auctionId: "late-auction", + }, + }, + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Verify it's tracked as missed initially + expect(analytics["missedAuctionIds"].has("late-auction")).toBe(true); + + // Now trigger the live auctionEnd event + const auctionEndCallback = mockPbjs.onEvent.mock.calls.find((call) => call[0] === "auctionEnd")[1]; + auctionEndCallback({ + auctionId: "late-auction", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }); + + // Should be removed from missedAuctionIds + expect(analytics["missedAuctionIds"].has("late-auction")).toBe(false); + + // And auction should be marked as missed + const auction = analytics["auctions"].get("late-auction"); expect(auction?.missed).toBe(true); }); + describe("Comprehensive missed auction scenarios", () => { + it("Scenario 1: auctionInit + auctionEnd both before load => missed", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionInit", + args: { auctionId: "scenario-1" }, + }, + { + eventType: "auctionEnd", + args: { + auctionId: "scenario-1", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { eids: [] }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }, + }, + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Should NOT be in missedAuctionIds (was removed after seeing auctionEnd) + expect(analytics["missedAuctionIds"].has("scenario-1")).toBe(false); + + // Auction should be tracked and marked as missed + const auction = analytics["auctions"].get("scenario-1"); + expect(auction).toBeDefined(); + expect(auction?.missed).toBe(true); + }); + + it("Scenario 2: auctionInit before load + auctionEnd after load => missed", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionInit", + args: { auctionId: "scenario-2" }, + }, + // No auctionEnd in past events + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Should be in missedAuctionIds (started before load, not ended yet) + expect(analytics["missedAuctionIds"].has("scenario-2")).toBe(true); + + // Now trigger live auctionEnd + const auctionEndCallback = mockPbjs.onEvent.mock.calls.find((call) => call[0] === "auctionEnd")[1]; + auctionEndCallback({ + auctionId: "scenario-2", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { eids: [] }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }); + + // Should be removed from missedAuctionIds + expect(analytics["missedAuctionIds"].has("scenario-2")).toBe(false); + + // Auction should be marked as missed + const auction = analytics["auctions"].get("scenario-2"); + expect(auction).toBeDefined(); + expect(auction?.missed).toBe(true); + }); + + it("Scenario 3: auctionInit + auctionEnd both after load => not missed", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + // No past events for this auction + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Should NOT be in missedAuctionIds + expect(analytics["missedAuctionIds"].has("scenario-3")).toBe(false); + + // Now trigger live auctionEnd + const auctionEndCallback = mockPbjs.onEvent.mock.calls.find((call) => call[0] === "auctionEnd")[1]; + auctionEndCallback({ + auctionId: "scenario-3", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { eids: [] }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }); + + // Should still NOT be in missedAuctionIds + expect(analytics["missedAuctionIds"].has("scenario-3")).toBe(false); + + // Auction should be marked as NOT missed + const auction = analytics["auctions"].get("scenario-3"); + expect(auction).toBeDefined(); + expect(auction?.missed).toBe(false); + }); + + it("Scenario with bidWon: auctionEnd + bidWon both in past => accumulates bidWon correctly", async () => { + jest.useFakeTimers(); + + const witnessspy = jest.fn().mockResolvedValue(undefined); + const testInstance = { + witness: witnessspy, + } as any; + + const testAnalytics = new OptablePrebidAnalytics(testInstance, { + analytics: true, + tenant: "test-tenant", + debug: true, + }); + + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionInit", + args: { auctionId: "scenario-bidwon" }, + }, + { + eventType: "auctionEnd", + args: { + auctionId: "scenario-bidwon", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { eids: [] }, + }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + transactionId: "trans-1", + }, + ], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }, + }, + { + eventType: "bidWon", + args: { + auctionId: "scenario-bidwon", + bidderCode: "bidder1", + requestId: "bid-1", + adUnitCode: "ad-unit-1", + cpm: 2.5, + }, + }, + ]), + onEvent: jest.fn(), + }; + + testAnalytics["setHooks"](mockPbjs); + + // Auction should be tracked + const auction = testAnalytics["auctions"].get("scenario-bidwon"); + expect(auction).toBeDefined(); + expect(auction?.missed).toBe(true); + expect(auction?.bidWonEvents).toHaveLength(1); + expect(auction?.bidWonEvents[0].cpm).toBe(2.5); + + // Wait for timeout and check witness call + await jest.runAllTimersAsync(); + + expect(witnessspy).toHaveBeenCalledWith( + "optable.prebid.auction", + expect.objectContaining({ + auctionId: "scenario-bidwon", + missed: true, + bidWon: [ + expect.objectContaining({ + bidderCode: "bidder1", + cpm: 2.5, + }), + ], + }) + ); + + jest.useRealTimers(); + }); + }); + it("should process missed bidWon events", async () => { jest.useFakeTimers(); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index a9883b5..474d6dc 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -48,6 +48,7 @@ class OptablePrebidAnalytics { private readonly maxAuctionDataSize: number = 50; private auctions = new Map(); + private missedAuctionIds = new Set(); private prebidInstance: any; /** @@ -159,13 +160,15 @@ 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 === "auctionEnd") { - this.log("auction missed"); + if (event.eventType === "auctionInit") { + this.missedAuctionIds.add(event.args.auctionId); + } 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); } @@ -174,7 +177,11 @@ class OptablePrebidAnalytics { this.log("Hooking into Prebid.js events"); 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"); @@ -419,6 +426,7 @@ class OptablePrebidAnalytics { */ clearData() { this.auctions.clear(); + this.missedAuctionIds.clear(); this.log("All analytics data cleared"); }