diff --git a/.gitignore b/.gitignore index e6809560..c1897fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ demos/**/*.html !demos/index-nocookies.html !demos/index.html +.wakatime-project +.vscode/ + # pnpm .pnpm-debug.log diff --git a/browser/sdk.ts b/browser/sdk.ts index 8166bcec..d56266e6 100644 --- a/browser/sdk.ts +++ b/browser/sdk.ts @@ -2,12 +2,14 @@ import Commands from "./commands"; import { resolveMultiNodeTargeting } from "../lib/core/resolvers/resolveMultiTargeting"; import OptableSDK, { InitConfig } from "../lib/sdk"; +import OptablePrebidAnalytics from "../lib/addons/prebid/analytics"; import "../lib/addons/gpt"; import "../lib/addons/try-identify"; type OptableGlobal = { cmd: Commands | Function[]; SDK: OptableSDK["constructor"]; + OptablePrebidAnalytics: typeof OptablePrebidAnalytics; utils: Record; instance?: OptableSDK; instance_config?: InitConfig; @@ -24,6 +26,7 @@ declare global { // window.optable = window.optable || {}; window.optable.SDK = OptableSDK; +window.optable.OptablePrebidAnalytics = OptablePrebidAnalytics; window.optable.cmd = new Commands(window.optable.cmd || []); window.optable.utils = { resolveMultiNodeTargeting }; diff --git a/lib/addons/prebid/README.md b/lib/addons/prebid/README.md new file mode 100644 index 00000000..cde1cbba --- /dev/null +++ b/lib/addons/prebid/README.md @@ -0,0 +1,81 @@ +# Optable Prebid Analytics Addon + +This addon integrates Optable analytics with Prebid.js, allowing you to send auction and bid data to Optable for analysis. It is designed to be used as a plugin within your Optable SDK setup. + +## Installation + +1. **Configure Analytics** + Use the following code snippet to enable analytics and configure the integration withing your Optable SDK wrapper: + + ```js + import OptablePrebidAnalytics from "./analytics"; + + // ... + const tenant = "my_tenant"; + const analyticsSample = sessionStorage.optableEnableAnalytics || 0.1; + window.optable.runAnalytics = analyticsSample > Math.random(); + // ... + + window.optable.customAnalytics = function () { + const customAnalyticsObject = {}; + // ... + return customAnalyticsObject; + }; + + // ... + if (window.optable.runAnalytics && tenant) { + window.optable[`${tenant}_analytics`] = new window.optable.SDK({ + host: "na.edge.optable.co", + node: "analytics", + site: "analytics", + readOnly: true, + cookies: false, + }); + + window.optable.analytics = new OptablePrebidAnalytics(window.optable[`${tenant}_analytics`], { + analytics: true, + tenant, + debug: !!sessionStorage.optableDebug, + samplingRate: 0.75, + }); + window.optable.analytics.hookIntoPrebid(window.pbjs); + } + // ... + ``` + + - Replace 'my_tenant' with your Optable tenant name. + - Optionally, implement `window.optable.customAnalytics` to add custom key-value pairs to each analytics event. + +## Usage + +- **Sampling**: + The `analyticsSample` variable controls the sampling rate. Set it to a float between 0 and 1 to control what fraction of users send analytics. + +- **Debugging**: + Set `sessionStorage.optableDebug` to `true` to force analytics to run and enable debug logging. + +- **Custom Analytics Data**: + Implement `window.optable.customAnalytics` to return an object with custom data to be included in analytics events. + +## API + +### `OptablePrebidAnalytics` + +- **Constructor**: + `new OptablePrebidAnalytics(sdkInstance, options)` + - `sdkInstance`: An instance of the Optable SDK. + - `options`: Object with options such as `debug`, `analytics`, and `tenant`. + +- **hookIntoPrebid(pbjs)**: + Hooks the analytics into the provided Prebid.js instance. + +## Example + +```js +window.optable.customAnalytics = function () { + return { + pageType: "homepage", + userSegment: "premium", + }; +}; +``` diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts new file mode 100644 index 00000000..284207ce --- /dev/null +++ b/lib/addons/prebid/analytics.test.ts @@ -0,0 +1,1158 @@ +import OptablePrebidAnalytics from "./analytics"; +import type OptableSDK from "../../sdk"; + +// Mock the SDK_WRAPPER_VERSION global +declare global { + const SDK_WRAPPER_VERSION: string; +} + +(global as any).SDK_WRAPPER_VERSION = "1.0.0-test"; + +describe("OptablePrebidAnalytics", () => { + let mockOptableInstance: OptableSDK; + let analytics: OptablePrebidAnalytics; + + beforeEach(() => { + // Create a mock OptableSDK instance + mockOptableInstance = { + witness: jest.fn().mockResolvedValue(undefined), + } as any; + + // Mock window.location + delete (window as any).location; + (window as any).location = { + hostname: "example.com", + pathname: "/test", + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Class instantiation", () => { + it("should initialize successfully with valid optable instance", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + + expect(analytics.isInitialized).toBe(true); + }); + + it("should throw error if optable instance is invalid", () => { + expect(() => { + new OptablePrebidAnalytics(null as any); + }).toThrow("OptablePrebidAnalytics requires a valid optable instance with witness() method"); + }); + + it("should throw error if optable instance lacks witness method", () => { + const invalidInstance = {} as any; + + expect(() => { + new OptablePrebidAnalytics(invalidInstance); + }).toThrow("OptablePrebidAnalytics requires a valid optable instance with witness() method"); + }); + + it("should set default config values", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + + expect(analytics["config"].debug).toBe(false); + expect(analytics["config"].samplingRate).toBe(1); + }); + + it("should accept custom config values", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + debug: true, + samplingRate: 0.5, + tenant: "test-tenant", + }); + + expect(analytics["config"].debug).toBe(true); + expect(analytics["config"].samplingRate).toBe(0.5); + expect(analytics["config"].tenant).toBe("test-tenant"); + }); + }); + + describe("shouldSample", () => { + it("should return false when samplingRate is 0", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: 0, + }); + + expect(analytics.shouldSample()).toBe(false); + }); + + it("should return false when samplingRate is negative", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: -0.5, + }); + + expect(analytics.shouldSample()).toBe(false); + }); + + it("should return true when samplingRate is 1", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: 1, + }); + + expect(analytics.shouldSample()).toBe(true); + }); + + it("should return true when samplingRate is greater than 1", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: 1.5, + }); + + expect(analytics.shouldSample()).toBe(true); + }); + + it("should use custom samplingRateFn when provided", () => { + const mockSamplingFn = jest.fn().mockReturnValue(true); + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: 0.5, + samplingRateFn: mockSamplingFn, + }); + + const result = analytics.shouldSample(); + + expect(mockSamplingFn).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should use deterministic sampling with samplingSeed", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: 0.5, + samplingSeed: "test-seed", + }); + + // Should return consistent result for same seed + const result1 = analytics.shouldSample(); + const result2 = analytics.shouldSample(); + + expect(result1).toBe(result2); + }); + + it("should use random sampling when no seed or function provided", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + samplingRate: 0.5, + }); + + // Mock Math.random to control the result + const mockRandom = jest.spyOn(Math, "random"); + mockRandom.mockReturnValue(0.3); + + expect(analytics.shouldSample()).toBe(true); + + mockRandom.mockReturnValue(0.7); + expect(analytics.shouldSample()).toBe(false); + + mockRandom.mockRestore(); + }); + }); + + describe("toWitness", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + }); + }); + + it("should transform auction and bid won events to witness format", async () => { + const auctionEndEvent = { + auctionId: "auction-123", + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], + }, + }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + adUnitId: "ad-id-1", + transactionId: "trans-1", + src: "client", + floorData: { floorMin: 0.5 }, + }, + ], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + bidderCode: "bidder1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + const result = await analytics.toWitness(auctionEndEvent, bidWonEvent); + + expect(result).toMatchObject({ + auctionId: "auction-123", + adUnitCode: "unknown", + totalRequests: 1, + optableMatchers: ["matcher1"], + optableSources: ["source1"], + tenant: "test-tenant", + url: "example.com/test", + optableWrapperVersion: "1.0.0-test", + missed: false, + }); + + expect(result.bidWon).toMatchObject({ + bidderCode: "bidder1", + adUnitCode: "ad-unit-1", + }); + + expect(result.bidderRequests).toHaveLength(1); + expect(result.bidderRequests[0]).toMatchObject({ + bidderCode: "bidder1", + bids: [ + { + floorMin: 0.5, + bidId: "bid-1", + }, + ], + }); + }); + + it("should handle events with no optable EIDs", async () => { + const auctionEndEvent = { + auctionId: "auction-456", + bidderRequests: [ + { + bidderCode: "bidder2", + bidderRequestId: "req-2", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + bidderCode: "bidder2", + adUnitCode: "ad-unit-2", + cpm: 2.0, + }; + + const result = await analytics.toWitness(auctionEndEvent, bidWonEvent); + + expect(result.optableMatchers).toEqual([]); + expect(result.optableSources).toEqual([]); + }); + }); + + describe("trackAuctionEnd", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + }); + + it("should store auction data", async () => { + const event = { + auctionId: "auction-789", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-789"); + expect(storedAuction).toBeDefined(); + expect(storedAuction?.auctionEnd).toBe(event); + expect(storedAuction?.missed).toBe(false); + }); + + it("should mark auction as missed when specified", async () => { + const event = { + auctionId: "auction-missed", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event, true); + + const storedAuction = analytics["auctions"].get("auction-missed"); + expect(storedAuction?.missed).toBe(true); + }); + }); + + describe("trackBidWon", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + analytics: true, + tenant: "test-tenant", + }); + }); + + it("should skip if auction data is missing", async () => { + const event = { + auctionId: "non-existent-auction", + bidderCode: "bidder1", + requestId: "bid-1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + await analytics.trackBidWon(event); + + expect(mockOptableInstance.witness).not.toHaveBeenCalled(); + }); + + it("should send witness event when auction data exists", async () => { + const auctionEndEvent = { + auctionId: "auction-complete", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + auctionId: "auction-complete", + bidderCode: "bidder1", + requestId: "bid-1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + // First track the auction end + await analytics.trackAuctionEnd(auctionEndEvent); + + // Then track the bid won + await analytics.trackBidWon(bidWonEvent); + + expect(mockOptableInstance.witness).toHaveBeenCalledWith( + "optable.prebid.auction", + expect.objectContaining({ + auctionId: "auction-complete", + tenant: "test-tenant", + missed: false, + }) + ); + }); + + it("should mark bid won as missed when specified", async () => { + const auctionEndEvent = { + auctionId: "auction-missed-bid", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + auctionId: "auction-missed-bid", + bidderCode: "bidder1", + requestId: "bid-1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + await analytics.trackAuctionEnd(auctionEndEvent); + await analytics.trackBidWon(bidWonEvent, true); + + expect(mockOptableInstance.witness).toHaveBeenCalledWith( + "optable.prebid.auction", + expect.objectContaining({ + missed: true, + }) + ); + }); + }); + + describe("log", () => { + it("should not log when debug is false", () => { + const consoleSpy = jest.spyOn(console, "log"); + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + debug: false, + }); + + analytics.log("test message", { data: "value" }); + + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("should log when debug is true", () => { + const consoleSpy = jest.spyOn(console, "log"); + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + debug: true, + }); + + analytics.log("test message", { data: "value" }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + "test message", + { data: "value" } + ); + consoleSpy.mockRestore(); + }); + }); + + describe("sendToWitnessAPI", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + analytics: true, + samplingRate: 1, + }); + }); + + it("should not send when analytics is disabled", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + analytics: false, + }); + + const result = await analytics.sendToWitnessAPI("test.event", { prop: "value" }); + + expect(result).toEqual({ + disabled: true, + eventName: "test.event", + properties: { prop: "value" }, + }); + expect(mockOptableInstance.witness).not.toHaveBeenCalled(); + }); + + it("should not send when sampling returns false", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + analytics: true, + samplingRate: 0, + }); + + const result = await analytics.sendToWitnessAPI("test.event", { prop: "value" }); + + expect(result).toEqual({ + disabled: true, + eventName: "test.event", + properties: { prop: "value" }, + }); + expect(mockOptableInstance.witness).not.toHaveBeenCalled(); + }); + + it("should send to witness API when enabled and sampled", async () => { + const result = await analytics.sendToWitnessAPI("test.event", { prop: "value" }); + + expect(result).toEqual({ + disabled: false, + eventName: "test.event", + properties: { prop: "value" }, + }); + expect(mockOptableInstance.witness).toHaveBeenCalledWith("test.event", { prop: "value" }); + }); + + it("should handle errors from witness API", async () => { + const error = new Error("Witness API error"); + mockOptableInstance.witness = jest.fn().mockRejectedValue(error); + + await expect(analytics.sendToWitnessAPI("test.event", { prop: "value" })).rejects.toThrow("Witness API error"); + }); + }); + + describe("cleanupOldAuctions", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + }); + + it("should remove oldest auction when size exceeds maxAuctionDataSize", async () => { + // Add 51 auctions (max is 50) + for (let i = 0; i < 51; i++) { + const event = { + auctionId: `auction-${i}`, + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + await analytics.trackAuctionEnd(event); + } + + // First auction should be removed + expect(analytics["auctions"].has("auction-0")).toBe(false); + // Last auction should still exist + expect(analytics["auctions"].has("auction-50")).toBe(true); + // Size should be at max + expect(analytics["auctions"].size).toBe(50); + }); + }); + + describe("clearData", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + }); + + it("should clear all auction data", async () => { + // Add some auctions + const event = { + auctionId: "auction-test", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + await analytics.trackAuctionEnd(event); + + expect(analytics["auctions"].size).toBe(1); + + analytics.clearData(); + + expect(analytics["auctions"].size).toBe(0); + }); + }); + + describe("hookIntoPrebid", () => { + it("should return false when pbjs is undefined", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + + const result = analytics.hookIntoPrebid(undefined); + + expect(result).toBe(false); + }); + + it("should hook into prebid when onEvent is available", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + const mockPbjs = { + onEvent: jest.fn(), + getEvents: jest.fn().mockReturnValue([]), + }; + + const result = analytics.hookIntoPrebid(mockPbjs as any); + + expect(result).toBe(true); + expect(mockPbjs.onEvent).toHaveBeenCalledTimes(2); + expect(mockPbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidWon", expect.any(Function)); + }); + + it("should queue hooks when onEvent is not available", () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + const mockPbjs = { + que: [], + }; + + const result = analytics.hookIntoPrebid(mockPbjs as any); + + expect(result).toBe(true); + expect(mockPbjs.que).toHaveLength(1); + }); + }); + + describe("trackAuctionEnd - advanced scenarios", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + }); + + it("should handle bidsReceived and update bid status", async () => { + const event = { + auctionId: "auction-with-bids", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + adUnitId: "ad-id-1", + transactionId: "trans-1", + src: "client", + }, + ], + }, + ], + bidsReceived: [ + { + requestId: "bid-1", + cpm: 1.5, + width: 300, + height: 250, + currency: "USD", + adUnitCode: "ad-unit-1", + }, + ], + noBids: [], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-with-bids"); + expect(storedAuction).toBeDefined(); + }); + + it("should handle noBids and update status", async () => { + const event = { + auctionId: "auction-no-bids", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [{ bidderRequestId: "req-1" }], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-no-bids"); + expect(storedAuction).toBeDefined(); + }); + + it("should handle timeoutBids and update status", async () => { + const event = { + auctionId: "auction-timeout", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [{ bidderRequestId: "req-1" }], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-timeout"); + expect(storedAuction).toBeDefined(); + }); + }); + + describe("toWitness - advanced scenarios", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + }); + }); + + it("should handle multiple bidder requests", async () => { + const auctionEndEvent = { + auctionId: "auction-multi", + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], + }, + }, + bids: [], + }, + { + bidderCode: "bidder2", + bidderRequestId: "req-2", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [{ inserter: "optable.co", matcher: "matcher2", source: "source2" }], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + bidderCode: "bidder1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + const result = await analytics.toWitness(auctionEndEvent, bidWonEvent); + + expect(result.totalRequests).toBe(2); + expect(result.bidderRequests).toHaveLength(2); + expect(result.optableMatchers).toContain("matcher1"); + expect(result.optableMatchers).toContain("matcher2"); + }); + + it("should handle mixed EIDs (optable and non-optable)", async () => { + const auctionEndEvent = { + auctionId: "auction-mixed-eids", + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [ + { inserter: "optable.co", matcher: "matcher1", source: "source1" }, + { inserter: "other.com", matcher: "other-matcher", source: "other-source" }, + ], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + bidderCode: "bidder1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + const result = await analytics.toWitness(auctionEndEvent, bidWonEvent); + + // Should only include optable EIDs + expect(result.optableMatchers).toEqual(["matcher1"]); + expect(result.optableMatchers).not.toContain("other-matcher"); + }); + + it("should call custom analytics when available", async () => { + const customData = { customProp: "customValue" }; + (window as any).optable = { + customAnalytics: jest.fn().mockResolvedValue(customData), + }; + + const auctionEndEvent = { + auctionId: "auction-custom", + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const bidWonEvent = { + bidderCode: "bidder1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }; + + const result = await analytics.toWitness(auctionEndEvent, bidWonEvent); + + expect(window.optable.customAnalytics).toHaveBeenCalled(); + expect(result).toMatchObject(customData); + + delete (window as any).optable; + }); + }); + + describe("setHooks", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + debug: true, + }); + }); + + it("should process missed auctionEnd events", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "auctionEnd", + args: { + auctionId: "missed-auction", + 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); + + expect(analytics["auctions"].has("missed-auction")).toBe(true); + const auction = analytics["auctions"].get("missed-auction"); + expect(auction?.missed).toBe(true); + }); + + it("should process missed bidWon events", async () => { + const witnessspy = jest.fn().mockResolvedValue(undefined); + const testInstance = { + witness: witnessspy, + } as any; + + analytics = new OptablePrebidAnalytics(testInstance, { + analytics: true, + tenant: "test-tenant", + debug: true, + }); + + // First add an auction + const auctionEvent = { + auctionId: "auction-for-missed-bid", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + await analytics.trackAuctionEnd(auctionEvent); + + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([ + { + eventType: "bidWon", + args: { + auctionId: "auction-for-missed-bid", + bidderCode: "bidder1", + requestId: "bid-1", + adUnitCode: "ad-unit-1", + cpm: 1.5, + }, + }, + ]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(witnessspy).toHaveBeenCalledWith( + "optable.prebid.auction", + expect.objectContaining({ + auctionId: "auction-for-missed-bid", + missed: true, + }) + ); + }); + + it("should register event callbacks", () => { + const mockPbjs = { + getEvents: jest.fn().mockReturnValue([]), + onEvent: jest.fn(), + }; + + analytics["setHooks"](mockPbjs); + + expect(mockPbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + expect(mockPbjs.onEvent).toHaveBeenCalledWith("bidWon", expect.any(Function)); + }); + }); + + describe("trackAuctionEnd - edge cases", () => { + beforeEach(() => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + }); + + it("should handle bidsReceived with unknown bidId", async () => { + const event = { + auctionId: "auction-unknown-bid", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [], + }, + ], + bidsReceived: [ + { + requestId: "unknown-bid-id", + cpm: 1.5, + width: 300, + height: 250, + currency: "USD", + }, + ], + noBids: [], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-unknown-bid"); + expect(storedAuction).toBeDefined(); + }); + + it("should create new bid object when bid not in index", async () => { + const event = { + auctionId: "auction-new-bid", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + adUnitId: "ad-id-1", + transactionId: "trans-1", + src: "client", + }, + ], + }, + ], + bidsReceived: [ + { + requestId: "bid-1", + cpm: 2.0, + width: 728, + height: 90, + currency: "EUR", + adUnitCode: "ad-unit-2", + adUnitId: "ad-id-2", + transactionId: "trans-2", + src: "server", + }, + ], + noBids: [], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-new-bid"); + expect(storedAuction).toBeDefined(); + }); + + it("should mark bids with NO_BID status when bids exist", async () => { + const event = { + auctionId: "auction-no-bid-with-bids", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + adUnitId: "ad-id-1", + transactionId: "trans-1", + src: "client", + }, + ], + }, + ], + bidsReceived: [], + noBids: [{ bidderRequestId: "req-1" }], + timeoutBids: [], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-no-bid-with-bids"); + expect(storedAuction).toBeDefined(); + }); + + it("should mark bids with TIMEOUT status when bids exist", async () => { + const event = { + auctionId: "auction-timeout-with-bids", + timeout: 3000, + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { + site: { domain: "example.com" }, + user: { + eids: [], + }, + }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + adUnitId: "ad-id-1", + transactionId: "trans-1", + src: "client", + }, + ], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [{ bidderRequestId: "req-1" }], + }; + + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-timeout-with-bids"); + expect(storedAuction).toBeDefined(); + }); + }); +}); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts new file mode 100644 index 00000000..22a8d2cb --- /dev/null +++ b/lib/addons/prebid/analytics.ts @@ -0,0 +1,531 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-console */ +import type { WitnessProperties } from "../../edge/witness"; +import type OptableSDK from "../../sdk"; + +import * as Bowser from "bowser"; + +declare const SDK_WRAPPER_VERSION: string; + +declare global { + interface Window { + pbjs?: any; + } +} + +const STATUS = { + REQUESTED: "REQUESTED", + RECEIVED: "RECEIVED", + NO_BID: "NO_BID", + TIMEOUT: "TIMEOUT", +}; + +const SESSION_SAMPLE_KEY = "optable:prebid:analytics:sample-number"; + +interface OptablePrebidAnalyticsConfig { + debug?: boolean; + analytics?: boolean; + tenant?: string; + bidWinTimeout?: number; + samplingVolume?: "session" | "event"; + samplingSeed?: string; + samplingRate?: number; + samplingRateFn?: () => boolean; +} + +interface AuctionItem { + auctionEnd: unknown | null; + auctionEndTimeoutId: NodeJS.Timeout | null; + missed: boolean; + createdAt: Date; +} + +class OptablePrebidAnalytics { + public readonly isInitialized: boolean = false; + + private readonly labelStyle = "color: white; background-color: #9198dc; padding: 2px 4px; border-radius: 2px;"; + private readonly maxAuctionDataSize: number = 50; + + private auctions = new Map(); + + /** + * Create a new OptablePrebidAnalytics instance. + * @param optableInstance - An initialized Optable SDK instance that exposes a `witness()` method. + * @param config - Optional configuration for sampling, debug and analytics behavior. + */ + constructor( + private readonly optableInstance: OptableSDK, + private config: OptablePrebidAnalyticsConfig = { samplingRate: 1, samplingVolume: "event", bidWinTimeout: 10_000 } + ) { + if (!optableInstance || typeof optableInstance.witness !== "function") { + throw new Error("OptablePrebidAnalytics requires a valid optable instance with witness() method"); + } + + this.config.debug = config.debug ?? false; + this.config.bidWinTimeout = config.bidWinTimeout ?? 10_000; + this.config.samplingRate = config.samplingRate ?? 1; + this.config.samplingVolume = config.samplingVolume ?? "event"; + + if (this.config.samplingVolume === "session") { + sessionStorage.setItem(SESSION_SAMPLE_KEY, Math.random().toFixed(2)); + } else { + sessionStorage.removeItem(SESSION_SAMPLE_KEY); + } + + this.isInitialized = true; + + // Store auction data + this.maxAuctionDataSize = 50; + + this.log("OptablePrebidAnalytics initialized"); + } + + /** + * Log messages to the console when debugging is enabled. + * @param args - Values to log. + * @returns void + */ + log(...args: unknown[]) { + if (this.config.debug) { + console.log("%cOptable%c [OptablePrebidAnalytics]", this.labelStyle, "color: inherit;", ...args); + } + } + + /** + * Determine whether the current event/session should be sampled according to + * the configured sampling rate, seed or function. + * @returns true if the event should be sampled and analytics calls may proceed. + */ + shouldSample(): boolean { + if (this.config.samplingRate! <= 0) return false; + if (this.config.samplingRate! >= 1) return true; + + if (this.config.samplingRateFn) { + return this.config.samplingRateFn(); + } + + if (this.config.samplingVolume === "session") { + const samplingNumber = Number(sessionStorage.getItem(SESSION_SAMPLE_KEY) || "1"); + return samplingNumber < this.config.samplingRate!; + } + + // Optional: deterministic sampling by seed (e.g., user ID) + if (this.config.samplingSeed) { + const hash = [...this.config.samplingSeed].reduce((acc, c) => acc + c.charCodeAt(0), 0); + const normalized = (hash % 10000) / 10000; + return normalized < this.config.samplingRate!; + } + + // Random sampling + return Math.random() < this.config.samplingRate!; + } + + /** + * Send an event to the Witness API when analytics are enabled and sampling passes. + * @param eventName - The name of the event to send (e.g. "optable.prebid.auction"). + * @param properties - An object of event properties to include in the payload. + * @returns A small result object indicating whether the call was disabled or sent. + */ + async sendToWitnessAPI(eventName: string, properties: Record = {}) { + if (!this.config.analytics) { + this.log("Witness API calls disabled - would send:", eventName, properties); + return { disabled: true, eventName, properties }; + } + + if (!this.shouldSample()) { + this.log("Event not sampled - skipping Witness API call for:", eventName, properties); + return { disabled: true, eventName, properties }; + } + + try { + await this.optableInstance.witness(eventName, properties); + this.log("Sending to Witness API:", eventName, properties); + } catch (error) { + this.log("Error sending to Witness API:", eventName, properties, error); + throw error; + } + + return { disabled: false, eventName, properties }; + } + + /** + * Attach listeners to a Prebid.js instance and process any missed events. + * This will replay past `auctionEnd` and `bidWon` events and then register live handlers. + * @param pbjs - The Prebid.js global instance (or equivalent) to hook into. + * @returns void + */ + setHooks(pbjs: any) { + this.log("Processing missed auctionEnd"); + pbjs.getEvents().forEach((event: any) => { + if (event.eventType === "auctionEnd") { + this.log("auction missed"); + this.trackAuctionEnd(event.args, true); + } + if (event.eventType === "bidWon") { + this.log("bid won missed"); + this.trackBidWon(event.args, true); + } + }); + + this.log("Hooking into Prebid.js events"); + pbjs.onEvent("auctionEnd", (event: any) => { + this.log("auctionEnd event received"); + this.trackAuctionEnd(event); + }); + pbjs.onEvent("bidWon", (event: any) => { + this.log("bidWon event received"); + this.trackBidWon(event); + }); + } + + /** + * Hook into Prebid.js by attaching event hooks either immediately or by + * queueing callbacks when `pbjs.onEvent` is not available yet. + * @param prebidInstance - Optional Prebid.js instance to use (defaults to `window.pbjs`). + * @returns true when a hook has been registered, false when Prebid is not present. + */ + hookIntoPrebid(prebidInstance = window.pbjs) { + const pbjs = prebidInstance; + if (typeof pbjs === "undefined") { + this.log("Prebid.js not found"); + return false; + } + + if (typeof pbjs.onEvent !== "function") { + pbjs.que = pbjs.que || []; + pbjs.que.push(() => this.setHooks(pbjs)); + } else { + this.setHooks(pbjs); + } + + return true; + } + + /** + * Process a Prebid `auctionEnd` event: build an internal representation of + * requests, merge in received bids and schedule a delayed Witness API call + * (to allow `bidWon` to be received) or mark as missed. + * @param event - The raw Prebid auctionEnd event object. + * @param missed - True when the event was previously emitted (missed replay). + * @returns void + */ + async trackAuctionEnd(event: any, missed: boolean = false) { + const { auctionId, timeout, bidderRequests = [], bidsReceived = [], noBids = [], timeoutBids = [] } = event; + + this.log(`Processing auction ${auctionId} with ${bidderRequests.length} bidder requests`); + + // Build auction object with bidder requests and EID flags + const auction = { + auctionId, + timeout, + bidderRequests: bidderRequests.map((br: any) => { + const { bidderCode, bidderRequestId, bids = [] } = br; + const domain = br.ortb2.site?.domain ?? "unknown"; + const eids = br.ortb2.user?.eids ?? []; + + // Optable EIDs + const optableEIDS = eids.filter((e: { inserter: string }) => e.inserter === "optable.co"); + const optableMatchers = [...new Set(optableEIDS.map((e: any) => e.matcher).filter(Boolean))]; + const optableSources = [...new Set(optableEIDS.map((e: any) => e.source).filter(Boolean))]; + + return { + bidderCode, + bidderRequestId, + domain, + hasOEids: optableEIDS.length > 0, + optableMatchers, + optableSources, + status: STATUS.REQUESTED, + bids: bids.map( + (b: { + bidId: string; + adUnitCode: string; + adUnitId: string; + transactionId: string; + src: string; + floorData?: { floorMin: number }; + }) => ({ + bidId: b.bidId, + bidderRequestId, + adUnitCode: b.adUnitCode, + adUnitId: b.adUnitId, + transactionId: b.transactionId, + src: b.src, + floorMin: b.floorData?.floorMin, + status: STATUS.REQUESTED, + }) + ), + }; + }), + }; + + // Build lookup tables for 1:many relationship + const requestIndex: { [key: string]: any } = {}; + const bidIndex: { [key: string]: any } = {}; + const bidToRequest: { [key: string]: any } = {}; + + auction.bidderRequests.forEach((br: { bidderRequestId: string; bids: Array<{ bidId: string }> }) => { + requestIndex[br.bidderRequestId] = br; + + br.bids.forEach((bid) => { + bidIndex[bid.bidId] = bid; + bidToRequest[bid.bidId] = br; + }); + }); + + // Merge in bidsReceived → update individual bids as RECEIVED + bidsReceived.forEach((b: any) => { + const bidId = b.requestId; + const br = bidToRequest[bidId]; + if (!br) { + this.log(`No bidderRequest found for bidId=${bidId}`); + return; + } + + // Find the specific bid to update + let bidObj = bidIndex[bidId]; + if (bidObj) { + // Update existing bid + Object.assign(bidObj, { + status: STATUS.RECEIVED, + cpm: b.cpm, + size: `${b.width}x${b.height}`, + currency: b.currency, + }); + } else { + // Create new bid object for this response + bidObj = { + bidId, + bidderRequestId: br.bidderRequestId, + adUnitCode: b.adUnitCode, + adUnitId: b.adUnitId, + transactionId: b.transactionId, + src: b.src, + cpm: b.cpm, + size: `${b.width}x${b.height}`, + currency: b.currency, + status: STATUS.RECEIVED, + }; + br.bids.push(bidObj); + bidIndex[bidId] = bidObj; + bidToRequest[bidId] = br; + } + + // Update bidder request status to RECEIVED if any bid was received + if (br.status === STATUS.REQUESTED) { + br.status = STATUS.RECEIVED; + } + }); + + // Handle noBids → mark the entire request as NO_BID + noBids.forEach((nb: { bidderRequestId: string }) => { + const br = requestIndex[nb.bidderRequestId]; + if (!br) return; + br.status = STATUS.NO_BID; + // Mark all bids in this request as NO_BID + br.bids.forEach((bid: { status: string }) => { + bid.status = STATUS.NO_BID; + }); + }); + + // Handle timeoutBids → mark the entire request as TIMEOUT + timeoutBids.forEach((tb: { bidderRequestId: string }) => { + const br = requestIndex[tb.bidderRequestId]; + if (!br) return; + br.status = STATUS.TIMEOUT; + // Mark all bids in this request as TIMEOUT + br.bids.forEach((bid: { status: string }) => { + bid.status = STATUS.TIMEOUT; + }); + }); + + const createdAt = new Date(); + const auctionEndTimeoutId = setTimeout(async () => { + const payload = await this.toWitness(event, null, missed); + payload["auctionEndAt"] = createdAt.toISOString(); + payload["bidWonAt"] = null; + payload["optableLoaded"] = !missed; + + this.sendToWitnessAPI("optable.prebid.auction", payload); + }, this.config.bidWinTimeout); + + // Store the processed auction + this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId }); + + // Clean up old auctions + this.cleanupOldAuctions(); + } + + /** + * 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) { + 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) { + this.log("Missing 'auctionEnd' event. Skipping."); + return; + } + + 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); + } + + /** + * Clean up old auctions to prevent memory leaks. + * Removes the oldest auction when the internal store grows past the configured size. + * @returns void + */ + cleanupOldAuctions() { + const auctionIds = [...this.auctions.keys()]; + if (auctionIds.length > this.maxAuctionDataSize) { + const oldestAuctionId = auctionIds[0]; + this.auctions.delete(oldestAuctionId); + this.log(`Cleaned up old auction: ${oldestAuctionId}`); + } + } + + /** + * Clear all stored analytics data (useful for tests). + * @returns void + */ + clearData() { + this.auctions.clear(); + this.log("All analytics data cleared"); + } + + /** + * 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 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, bidWonEvent: any | null, missed = false): Promise> { + const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [], timeoutBids = [] } = auctionEndEvent; + + const oMatchersSet = new Set(); + const oSourcesSet = new Set(); + let adUnitCode: string = "unknown"; + let totalBids = 0; + let device = null; + + const requests = bidderRequests.map((br: any) => { + const { bidderCode, bidderRequestId, bids = [] } = br; + const domain = br.ortb2.site?.domain ?? "unknown"; + const eids = br.ortb2.user?.eids ?? []; + + // Optable EIDs + const optableEIDS = eids.filter((e: { inserter: string }) => e.inserter === "optable.co"); + const optableMatchers = [...new Set(optableEIDS.map((e: any) => e.matcher).filter(Boolean))]; + const optableSources = [...new Set(optableEIDS.map((e: any) => e.source).filter(Boolean))]; + + device = br.ortb2.device; + + return { + bidderCode, + bidderRequestId, + domain, + optableTargetingDone: optableEIDS.length > 0, + optableMatchers, + optableSources, + status: STATUS.REQUESTED, + bids: bids.map( + (b: { + bidId: string; + adUnitCode: string; + adUnitId: string; + transactionId: string; + src: string; + floorData?: { floorMin: number }; + }) => ({ + bidId: b.bidId, + bidderRequestId, + adUnitCode: b.adUnitCode, + adUnitId: b.adUnitId, + transactionId: b.transactionId, + src: b.src, + floorMin: b.floorData?.floorMin, + status: STATUS.REQUESTED, + }) + ), + }; + }); + + const witnessData: WitnessProperties = { + bidderRequests: requests.map((br: any) => { + br.optableMatchers.forEach((m: unknown) => oMatchersSet.add(m)); + br.optableSources.forEach((s: unknown) => oSourcesSet.add(s)); + + return br; + }), + auctionId, + adUnitCode, + totalRequests: bidderRequests.length, + optableSampling: this.config.samplingRate || 1, + 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, + missed, + url: `${window.location.hostname}${window.location.pathname}`, + tenant: this.config.tenant!, + // eslint-disable-next-line no-undef + optableWrapperVersion: SDK_WRAPPER_VERSION || "unknown", + userAgent: Bowser.parse(window.navigator.userAgent) as unknown as Record, + device, + }; + + // Log summary with bid counts + this.log( + `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); + }); + } + + return witnessData; + } +} + +export default OptablePrebidAnalytics; diff --git a/lib/edge/witness.ts b/lib/edge/witness.ts index ae9d0248..21b57744 100644 --- a/lib/edge/witness.ts +++ b/lib/edge/witness.ts @@ -2,7 +2,7 @@ import type { ResolvedConfig } from "../config"; import { fetch } from "../core/network"; type WitnessProperties = { - [key: string]: string | number | boolean; + [key: string]: string | number | boolean | unknown[] | null | { [key: string]: unknown }; }; function Witness(config: ResolvedConfig, event: string, properties: WitnessProperties): Promise { diff --git a/package.json b/package.json index 076dae2e..4836f819 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@babel/preset-typescript": "^7.12.1", "@types/googletag": "^3.1.3", "@types/jest": "^26.0.15", + "@types/node": "^25.0.3", "babel-jest": "^29.7.0", "babel-loader": "^8.2.2", "core-js": "^3.7.0", @@ -36,6 +37,7 @@ "dependencies": { "@babel/runtime": "^7.27.0", "@optable/web-sdk": "^0.40.0", + "bowser": "^2.12.1", "iab-adcom": "^1.0.6", "iab-openrtb": "^1.0.1", "js-sha256": "^0.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2250b40f..beb1d5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@optable/web-sdk': specifier: ^0.40.0 version: 0.40.0 + bowser: + specifier: ^2.12.1 + version: 2.13.1 iab-adcom: specifier: ^1.0.6 version: 1.0.6 @@ -48,6 +51,9 @@ importers: '@types/jest': specifier: ^26.0.15 version: 26.0.24 + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 babel-jest: specifier: ^29.7.0 version: 29.7.0(@babel/core@7.28.5) @@ -59,7 +65,7 @@ importers: version: 3.47.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@24.10.1) + version: 29.7.0(@types/node@25.0.3) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -68,7 +74,7 @@ importers: version: 2.4.26 msw: specifier: ^2.6.9 - version: 2.12.3(@types/node@24.10.1)(typescript@5.9.3) + version: 2.12.3(@types/node@25.0.3)(typescript@5.9.3) prettier: specifier: ^3.6.2 version: 3.7.3 @@ -908,8 +914,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1152,6 +1158,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3534,31 +3543,31 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/confirm@5.1.21(@types/node@24.10.1)': + '@inquirer/confirm@5.1.21(@types/node@25.0.3)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.1) - '@inquirer/type': 3.0.10(@types/node@24.10.1) + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 - '@inquirer/core@10.3.2(@types/node@24.10.1)': + '@inquirer/core@10.3.2(@types/node@25.0.3)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@25.0.3) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 '@inquirer/figures@1.0.15': {} - '@inquirer/type@3.0.10(@types/node@24.10.1)': + '@inquirer/type@3.0.10(@types/node@25.0.3)': optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 '@istanbuljs/load-nyc-config@1.1.0': dependencies: @@ -3573,7 +3582,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -3586,14 +3595,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@25.0.3) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -3618,7 +3627,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -3636,7 +3645,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 24.10.1 + '@types/node': 25.0.3 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -3658,7 +3667,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 24.10.1 + '@types/node': 25.0.3 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -3727,7 +3736,7 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.10.1 + '@types/node': 25.0.3 '@types/yargs': 15.0.20 chalk: 4.1.2 @@ -3736,7 +3745,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.10.1 + '@types/node': 25.0.3 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -3838,7 +3847,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 '@types/istanbul-lib-coverage@2.0.6': {} @@ -3857,13 +3866,13 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 '@types/json-schema@7.0.15': {} - '@types/node@24.10.1': + '@types/node@25.0.3': dependencies: undici-types: 7.16.0 @@ -4158,6 +4167,8 @@ snapshots: boolean@3.2.0: {} + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4285,13 +4296,13 @@ snapshots: core-util-is@1.0.3: {} - create-jest@29.7.0(@types/node@24.10.1): + create-jest@29.7.0(@types/node@25.0.3): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@25.0.3) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -4787,7 +4798,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.0 @@ -4807,16 +4818,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@24.10.1): + jest-cli@29.7.0(@types/node@25.0.3): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.10.1) + create-jest: 29.7.0(@types/node@25.0.3) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@25.0.3) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -4826,7 +4837,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@24.10.1): + jest-config@29.7.0(@types/node@25.0.3): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -4851,7 +4862,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -4888,7 +4899,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 24.10.1 + '@types/node': 25.0.3 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -4902,7 +4913,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -4914,7 +4925,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 24.10.1 + '@types/node': 25.0.3 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -4955,7 +4966,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -4990,7 +5001,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -5018,7 +5029,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -5064,7 +5075,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -5083,7 +5094,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.1 + '@types/node': 25.0.3 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -5092,23 +5103,23 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@24.10.1): + jest@29.7.0(@types/node@25.0.3): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.10.1) + jest-cli: 29.7.0(@types/node@25.0.3) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5238,9 +5249,9 @@ snapshots: ms@2.1.3: {} - msw@2.12.3(@types/node@24.10.1)(typescript@5.9.3): + msw@2.12.3(@types/node@25.0.3)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@24.10.1) + '@inquirer/confirm': 5.1.21(@types/node@25.0.3) '@mswjs/interceptors': 0.40.0 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6