From dd6c6887536257e1cf926bd2f7a5fdc459984b66 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Tue, 4 Nov 2025 15:23:42 +0200 Subject: [PATCH 01/12] feat(prebid): add Prebid.js analytics integration addon Add OptablePrebidAnalytics class to track auction and bid events from Prebid.js and send them to Optable's witness API. The addon supports configurable sampling rates, debug logging, and custom analytics data injection. Key features: - Tracks auctionEnd and bidWon events with detailed bid request/response data - Extracts and reports Optable EID matchers and sources from ORTB2 data - Implements deterministic and random sampling strategies - Handles missed events by processing Prebid's event history The witness API type definition is updated to support nested objects and arrays in event properties to accommodate complex auction data structures. --- .gitignore | 3 + browser/sdk.ts | 3 + lib/addons/prebid/README.md | 81 ++ lib/addons/prebid/analytics.test.ts | 1201 +++++++++++++++++++++++++++ lib/addons/prebid/analytics.ts | 463 +++++++++++ lib/edge/witness.ts | 2 +- 6 files changed, 1752 insertions(+), 1 deletion(-) create mode 100644 lib/addons/prebid/README.md create mode 100644 lib/addons/prebid/analytics.test.ts create mode 100644 lib/addons/prebid/analytics.ts diff --git a/.gitignore b/.gitignore index 686d3f63..4c77688d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ demos/**/*.html !demos/react/src/index.html !demos/index-nocookies.html !demos/index.html + +.wakatime-project +.vscode/ diff --git a/browser/sdk.ts b/browser/sdk.ts index 8166bcec..71706892 100644 --- a/browser/sdk.ts +++ b/browser/sdk.ts @@ -2,6 +2,7 @@ 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"; @@ -15,6 +16,7 @@ type OptableGlobal = { declare global { interface Window { + // @ts-ignore optable?: Partial; } } @@ -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..c2b0dc36 --- /dev/null +++ b/lib/addons/prebid/analytics.test.ts @@ -0,0 +1,1201 @@ +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: { + ext: { + 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, + totalBids: 0, + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], + }, + }, + }, + bids: [], + }, + { + bidderCode: "bidder2", + bidderRequestId: "req-2", + ortb2: { + site: { domain: "example.com" }, + user: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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: { + ext: { + 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..f1162813 --- /dev/null +++ b/lib/addons/prebid/analytics.ts @@ -0,0 +1,463 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-console */ +import type { WitnessProperties } from "../../edge/witness"; +import type OptableSDK from "../../sdk"; + +declare const SDK_WRAPPER_VERSION: string; + +declare global { + interface Window { + optable?: any; + pbjs?: any; + } +} + +const STATUS = { + REQUESTED: "REQUESTED", + RECEIVED: "RECEIVED", + NO_BID: "NO_BID", + TIMEOUT: "TIMEOUT", +}; + +interface OptablePrebidAnalyticsConfig { + debug?: boolean; + analytics?: boolean; + tenant?: string; + samplingSeed?: string; + samplingRate?: number; + samplingRateFn?: () => boolean; +} + +interface AuctionItem { + auctionEnd: unknown | 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(); + + constructor( + private readonly optableInstance: OptableSDK, + private config: OptablePrebidAnalyticsConfig = {} + ) { + 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.samplingRate = config.samplingRate ?? 1; + + this.isInitialized = true; + + // Store auction data + this.maxAuctionDataSize = 50; + + this.log("OptablePrebidAnalytics initialized"); + } + + /** + * Log messages if debug is enabled + */ + log(...args: unknown[]) { + if (this.config.debug) { + console.log("%cOptable%c [OptablePrebidAnalytics]", this.labelStyle, "color: inherit;", ...args); + } + } + + shouldSample(): boolean { + if (this.config.samplingRate! <= 0) return false; + if (this.config.samplingRate! >= 1) return true; + + if (this.config.samplingRateFn) { + return this.config.samplingRateFn(); + } + + // 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 event to Witness API + */ + 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 }; + } + + 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 events + */ + 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; + } + + 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, + ortb2: { + site: { domain }, + user: { + ext: { eids }, + }, + }, + bids = [], + } = br; + + // 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; + }); + }); + + // Store the processed auction + this.auctions.set(auctionId, { auctionEnd: event, createdAt: new Date(), missed }); + + // Clean up old auctions + this.cleanupOldAuctions(); + } + + 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; + } + + const payload = await this.toWitness(auction.auctionEnd, event, missed); + payload["auctionEndAt"] = auction.createdAt.toISOString(); + payload["bidWonAt"] = new Date().toISOString(); + payload["missed"] = missed; + + this.sendToWitnessAPI("optable.prebid.auction", payload); + } + + /** + * Clean up old auctions to prevent memory leaks + */ + 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 data (useful for testing) + */ + clearData() { + this.auctions.clear(); + this.log("All analytics data cleared"); + } + + async toWitness(auctionEndEvent: any, bidWonEvent: any, 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; + + const requests = bidderRequests.map((br: any) => { + const { + bidderCode, + bidderRequestId, + ortb2: { + site: { domain }, + user: { + ext: { eids }, + }, + }, + bids = [], + } = br; + + // 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, + hasOptable: 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 { + bidderCode: br.bidderCode, + bids: br.bids.map((b: any) => { + adUnitCode = adUnitCode || b.adUnitCode; + + if (b.cpm != null) totalBids += 1; + + return { floorMin: b.floorMin, cpm: b.cpm, size: b.size, bidId: b.bidId }; + }), + }; + }), + auctionId, + adUnitCode, + totalRequests: bidderRequests.length, + totalBids, + optableMatchers: Array.from(oMatchersSet), + optableSources: Array.from(oSourcesSet), + bidWon: { + 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.cmp, + }, + missed, + url: `${window.location.hostname}${window.location.pathname}`, + tenant: this.config.tenant!, + // eslint-disable-next-line no-undef + optableWrapperVersion: SDK_WRAPPER_VERSION || "unknown", + }; + + // 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.optable?.customAnalytics) { + await window.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..905c82b4 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[] | { [key: string]: unknown }; }; function Witness(config: ResolvedConfig, event: string, properties: WitnessProperties): Promise { From 2bba55a387cc2e43df70c9dbd0d3d467203af868 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 6 Nov 2025 13:01:33 +0200 Subject: [PATCH 02/12] feat(prebid): enhance analytics payload with additional tracking fields Add new fields to improve analytics tracking and debugging: - Replace 'missed' with 'optableLoaded' for clearer semantics - Rename 'hasOptable' to 'optableTargetingDone' for consistency - Include 'optableSampling' rate in witness payload - Add 'userAgent' to payload for better debugging - Expose 'optableTargetingDone' at bidder request level --- lib/addons/prebid/analytics.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index f1162813..f84b79ae 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -322,7 +322,7 @@ class OptablePrebidAnalytics { const payload = await this.toWitness(auction.auctionEnd, event, missed); payload["auctionEndAt"] = auction.createdAt.toISOString(); payload["bidWonAt"] = new Date().toISOString(); - payload["missed"] = missed; + payload["optableLoaded"] = !missed; this.sendToWitnessAPI("optable.prebid.auction", payload); } @@ -377,7 +377,7 @@ class OptablePrebidAnalytics { bidderCode, bidderRequestId, domain, - hasOptable: optableEIDS.length > 0, + optableTargetingDone: optableEIDS.length > 0, optableMatchers, optableSources, status: STATUS.REQUESTED, @@ -409,6 +409,7 @@ class OptablePrebidAnalytics { br.optableSources.forEach((s: unknown) => oSourcesSet.add(s)); return { + optableTargetingDone: br.optableTargetingDone, bidderCode: br.bidderCode, bids: br.bids.map((b: any) => { adUnitCode = adUnitCode || b.adUnitCode; @@ -423,6 +424,8 @@ class OptablePrebidAnalytics { adUnitCode, totalRequests: bidderRequests.length, totalBids, + optableSampling: this.config.samplingRate || 1, + optableTargetingDone: oMatchersSet.size || oSourcesSet.size, optableMatchers: Array.from(oMatchersSet), optableSources: Array.from(oSourcesSet), bidWon: { @@ -442,6 +445,7 @@ class OptablePrebidAnalytics { tenant: this.config.tenant!, // eslint-disable-next-line no-undef optableWrapperVersion: SDK_WRAPPER_VERSION || "unknown", + userAgent: window.navigator.userAgent, }; // Log summary with bid counts From adfc6c3fe8dc078ceb16567847343e1f215247f4 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Fri, 7 Nov 2025 13:57:27 +0200 Subject: [PATCH 03/12] feat(prebid-analytics): add bid win timeout and session sampling Introduce `bidWinTimeout` to track bid win events. If a bid win is not received within the configured timeout after an auction ends, a witness event is sent with `bidWon: null` to indicate a missed bid. Add `samplingVolume` configuration, allowing analytics sampling to be applied either per `event` (default) or per `session`. Session-based sampling ensures a consistent sampling decision for a user throughout their browsing session. The analytics payload is enhanced with `auctionEndAt`, `bidWonAt`, and `optableLoaded` fields, and the `bidWon` object can now be `null` to explicitly represent cases where no bid was won or tracked. Update `witness.ts` to allow `null` values in the witness payload properties. Refactor `analytics.test.ts` to simplify `eids` structure and remove redundant fields. --- lib/addons/prebid/analytics.test.ts | 91 ++++++----------------- lib/addons/prebid/analytics.ts | 109 +++++++++++++++------------- lib/edge/witness.ts | 2 +- package-lock.json | 12 +++ package.json | 1 + 5 files changed, 97 insertions(+), 118 deletions(-) diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index c2b0dc36..284207ce 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -165,9 +165,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], - }, + eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], }, }, bids: [ @@ -199,7 +197,6 @@ describe("OptablePrebidAnalytics", () => { auctionId: "auction-123", adUnitCode: "unknown", totalRequests: 1, - totalBids: 0, optableMatchers: ["matcher1"], optableSources: ["source1"], tenant: "test-tenant", @@ -235,9 +232,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -277,9 +272,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -309,9 +302,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -362,9 +353,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -410,9 +399,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -551,9 +538,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -592,9 +577,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -667,9 +650,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [ @@ -714,9 +695,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -744,9 +723,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -781,9 +758,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], - }, + eids: [{ inserter: "optable.co", matcher: "matcher1", source: "source1" }], }, }, bids: [], @@ -794,9 +769,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [{ inserter: "optable.co", matcher: "matcher2", source: "source2" }], - }, + eids: [{ inserter: "optable.co", matcher: "matcher2", source: "source2" }], }, }, bids: [], @@ -831,12 +804,10 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [ - { inserter: "optable.co", matcher: "matcher1", source: "source1" }, - { inserter: "other.com", matcher: "other-matcher", source: "other-source" }, - ], - }, + eids: [ + { inserter: "optable.co", matcher: "matcher1", source: "source1" }, + { inserter: "other.com", matcher: "other-matcher", source: "other-source" }, + ], }, }, bids: [], @@ -875,9 +846,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -925,9 +894,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -972,9 +939,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -1045,9 +1010,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [], @@ -1083,9 +1046,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [ @@ -1133,9 +1094,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [ @@ -1171,9 +1130,7 @@ describe("OptablePrebidAnalytics", () => { ortb2: { site: { domain: "example.com" }, user: { - ext: { - eids: [], - }, + eids: [], }, }, bids: [ diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index f84b79ae..82c3804e 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -3,6 +3,8 @@ import type { WitnessProperties } from "../../edge/witness"; import type OptableSDK from "../../sdk"; +import * as Bowser from "bowser"; + declare const SDK_WRAPPER_VERSION: string; declare global { @@ -19,10 +21,14 @@ const STATUS = { 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; @@ -30,6 +36,7 @@ interface OptablePrebidAnalyticsConfig { interface AuctionItem { auctionEnd: unknown | null; + auctionEndTimeoutId: NodeJS.Timeout | null; missed: boolean; createdAt: Date; } @@ -44,14 +51,22 @@ class OptablePrebidAnalytics { constructor( private readonly optableInstance: OptableSDK, - private config: OptablePrebidAnalyticsConfig = {} + 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; @@ -78,6 +93,11 @@ class OptablePrebidAnalytics { 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); @@ -168,17 +188,9 @@ class OptablePrebidAnalytics { auctionId, timeout, bidderRequests: bidderRequests.map((br: any) => { - const { - bidderCode, - bidderRequestId, - ortb2: { - site: { domain }, - user: { - ext: { eids }, - }, - }, - bids = [], - } = br; + 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"); @@ -296,8 +308,18 @@ class OptablePrebidAnalytics { }); }); + 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: new Date(), missed }); + this.auctions.set(auctionId, { auctionEnd: event, createdAt, missed, auctionEndTimeoutId }); // Clean up old auctions this.cleanupOldAuctions(); @@ -313,6 +335,8 @@ class OptablePrebidAnalytics { }; this.log("bidWon filtered event", filteredEvent); + this.log("bidWon event", event); + const auction = this.auctions.get(event.auctionId); if (!auction) { this.log("Missing 'auctionEnd' event. Skipping."); @@ -347,7 +371,7 @@ class OptablePrebidAnalytics { this.log("All analytics data cleared"); } - async toWitness(auctionEndEvent: any, bidWonEvent: any, missed = false): Promise> { + async toWitness(auctionEndEvent: any, bidWonEvent: any | null, missed = false): Promise> { const { auctionId, bidderRequests = [], bidsReceived = [], noBids = [], timeoutBids = [] } = auctionEndEvent; const oMatchersSet = new Set(); @@ -356,17 +380,9 @@ class OptablePrebidAnalytics { let totalBids = 0; const requests = bidderRequests.map((br: any) => { - const { - bidderCode, - bidderRequestId, - ortb2: { - site: { domain }, - user: { - ext: { eids }, - }, - }, - bids = [], - } = br; + 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"); @@ -377,6 +393,7 @@ class OptablePrebidAnalytics { bidderCode, bidderRequestId, domain, + device: br.ortb2.device, optableTargetingDone: optableEIDS.length > 0, optableMatchers, optableSources, @@ -408,44 +425,36 @@ class OptablePrebidAnalytics { br.optableMatchers.forEach((m: unknown) => oMatchersSet.add(m)); br.optableSources.forEach((s: unknown) => oSourcesSet.add(s)); - return { - optableTargetingDone: br.optableTargetingDone, - bidderCode: br.bidderCode, - bids: br.bids.map((b: any) => { - adUnitCode = adUnitCode || b.adUnitCode; - - if (b.cpm != null) totalBids += 1; - - return { floorMin: b.floorMin, cpm: b.cpm, size: b.size, bidId: b.bidId }; - }), - }; + return br; }), auctionId, adUnitCode, totalRequests: bidderRequests.length, - totalBids, optableSampling: this.config.samplingRate || 1, optableTargetingDone: oMatchersSet.size || oSourcesSet.size, optableMatchers: Array.from(oMatchersSet), optableSources: Array.from(oSourcesSet), - bidWon: { - 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.cmp, - }, + 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: window.navigator.userAgent, + userAgentRaw: window.navigator.userAgent, + userAgent: Bowser.parse(window.navigator.userAgent) as unknown as Record, }; // Log summary with bid counts diff --git a/lib/edge/witness.ts b/lib/edge/witness.ts index 905c82b4..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 | unknown[] | { [key: string]: unknown }; + [key: string]: string | number | boolean | unknown[] | null | { [key: string]: unknown }; }; function Witness(config: ResolvedConfig, event: string, properties: WitnessProperties): Promise { diff --git a/package-lock.json b/package-lock.json index 381e4f5e..7114f9d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/runtime": "^7.27.0", "@optable/web-sdk": "^0.40.0", + "bowser": "^2.12.1", "iab-openrtb": "^1.0.1", "js-sha256": "^0.11.0", "regenerator-runtime": "^0.13.7" @@ -2837,6 +2838,12 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -10738,6 +10745,11 @@ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "dev": true }, + "bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==" + }, "brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index 04fae58e..e5672642 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@babel/runtime": "^7.27.0", "@optable/web-sdk": "^0.40.0", + "bowser": "^2.12.1", "iab-openrtb": "^1.0.1", "js-sha256": "^0.11.0", "regenerator-runtime": "^0.13.7" From c64fa3aa15d992d98e97a658de60c49ab575d4ae Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Fri, 7 Nov 2025 14:52:15 +0200 Subject: [PATCH 04/12] fix(prebid-analytics): clear bid won timeout and cleanup auction Clears the `auctionEndTimeoutId` when a bid is won to prevent it from firing after the auction is complete. Additionally, removes the auction object from the internal map to prevent memory leaks and ensure proper resource cleanup. --- lib/addons/prebid/analytics.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 82c3804e..ad897584 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -335,20 +335,24 @@ class OptablePrebidAnalytics { }; this.log("bidWon filtered event", filteredEvent); - this.log("bidWon event", event); - 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); } /** From 50e7de1f0d445e1767e33b9c160715867e4d614b Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Fri, 5 Dec 2025 14:53:41 +0200 Subject: [PATCH 05/12] feat(prebid-analytics): streamline device and user agent data Moves device object assignment to a higher scope for consistent use across the analytics payload. Removes the redundant `userAgentRaw` field, as the parsed `userAgent` is already available. --- lib/addons/prebid/analytics.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index ad897584..7af7339e 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -382,6 +382,7 @@ class OptablePrebidAnalytics { 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; @@ -393,11 +394,12 @@ class OptablePrebidAnalytics { 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, - device: br.ortb2.device, optableTargetingDone: optableEIDS.length > 0, optableMatchers, optableSources, @@ -457,8 +459,8 @@ class OptablePrebidAnalytics { tenant: this.config.tenant!, // eslint-disable-next-line no-undef optableWrapperVersion: SDK_WRAPPER_VERSION || "unknown", - userAgentRaw: window.navigator.userAgent, userAgent: Bowser.parse(window.navigator.userAgent) as unknown as Record, + device, }; // Log summary with bid counts From 5d67242d1700ff6fcaac1fe6f83c8ceae062439d Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 11:47:11 +0200 Subject: [PATCH 06/12] docs(prebid): add JSDoc comments and improve type safety Add comprehensive JSDoc documentation to the `OptablePrebidAnalytics` class methods to improve maintainability and developer experience. Refactor type handling for global `optable` access to avoid `@ts-ignore` and ensure the `OptablePrebidAnalytics` constructor is available on the global SDK object. This ensures better integration between the browser SDK and the Prebid analytics addon. --- browser/sdk.ts | 2 +- lib/addons/prebid/analytics.ts | 65 +++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/browser/sdk.ts b/browser/sdk.ts index 71706892..d56266e6 100644 --- a/browser/sdk.ts +++ b/browser/sdk.ts @@ -9,6 +9,7 @@ import "../lib/addons/try-identify"; type OptableGlobal = { cmd: Commands | Function[]; SDK: OptableSDK["constructor"]; + OptablePrebidAnalytics: typeof OptablePrebidAnalytics; utils: Record; instance?: OptableSDK; instance_config?: InitConfig; @@ -16,7 +17,6 @@ type OptableGlobal = { declare global { interface Window { - // @ts-ignore optable?: Partial; } } diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 7af7339e..22a8d2cb 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -9,7 +9,6 @@ declare const SDK_WRAPPER_VERSION: string; declare global { interface Window { - optable?: any; pbjs?: any; } } @@ -49,6 +48,11 @@ class OptablePrebidAnalytics { 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 } @@ -77,7 +81,9 @@ class OptablePrebidAnalytics { } /** - * Log messages if debug is enabled + * Log messages to the console when debugging is enabled. + * @param args - Values to log. + * @returns void */ log(...args: unknown[]) { if (this.config.debug) { @@ -85,6 +91,11 @@ class OptablePrebidAnalytics { } } + /** + * 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; @@ -110,7 +121,10 @@ class OptablePrebidAnalytics { } /** - * Send event to Witness API + * 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) { @@ -134,6 +148,12 @@ class OptablePrebidAnalytics { 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) => { @@ -159,7 +179,10 @@ class OptablePrebidAnalytics { } /** - * Hook into Prebid.js events + * 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; @@ -178,6 +201,14 @@ class OptablePrebidAnalytics { 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; @@ -325,6 +356,13 @@ class OptablePrebidAnalytics { 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, @@ -356,7 +394,9 @@ class OptablePrebidAnalytics { } /** - * Clean up old auctions to prevent memory leaks + * 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()]; @@ -368,13 +408,22 @@ class OptablePrebidAnalytics { } /** - * Clear all stored data (useful for testing) + * 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; @@ -468,8 +517,8 @@ class OptablePrebidAnalytics { `Auction ${auctionId} processed: ${bidderRequests.length} requests, ${totalBids} total bids, ${bidsReceived.length} received, ${noBids.length} no-bids, ${timeoutBids.length} timeouts` ); - if (window.optable?.customAnalytics) { - await window.optable.customAnalytics().then((response: any) => { + 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); }); From 3a4d633a0e48f99690a6d613b247af00cdfc709b Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 13:01:15 +0200 Subject: [PATCH 07/12] build: configure pnpm workspace Add pnpm-workspace.yaml and update pnpm-lock.yaml to define the project as a monorepo. This enables workspace-aware dependency management and prepares the repository for multi-package support. --- pnpm-lock.yaml | 8 ++++++++ pnpm-workspace.yaml | 3 +++ 2 files changed, 11 insertions(+) create mode 100644 pnpm-workspace.yaml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2250b40f..c1302c2f 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 @@ -1152,6 +1155,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==} @@ -4158,6 +4164,8 @@ snapshots: boolean@3.2.0: {} + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..88563bcf --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - core-js + - msw From 1e815b22fc8b179467dd2c481ee851d2c25b0139 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 13:08:16 +0200 Subject: [PATCH 08/12] build: add @types/node dependency Add Node.js type definitions to the project dependencies to improve type safety and development tooling support. --- package.json | 1 + pnpm-lock.yaml | 95 ++++++++++++++++++++++++++------------------------ 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index 9818e8e0..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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1302c2f..beb1d5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,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) @@ -62,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 @@ -71,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 @@ -911,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==} @@ -3540,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: @@ -3579,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 @@ -3592,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 @@ -3624,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': @@ -3642,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 @@ -3664,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 @@ -3733,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 @@ -3742,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 @@ -3844,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': {} @@ -3863,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 @@ -4293,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: @@ -4795,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 @@ -4815,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 @@ -4834,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 @@ -4859,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 @@ -4896,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 @@ -4910,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 @@ -4922,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 @@ -4963,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): @@ -4998,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 @@ -5026,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 @@ -5072,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 @@ -5091,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 @@ -5100,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 @@ -5246,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 From f3d9f29350ebda23a0bd39d3845caec2211a96e4 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 13:52:28 +0200 Subject: [PATCH 09/12] ci(github): specify prefix for demo build commands Update the reusable build workflow to use the `--prefix` flag for React and NPM demo builds. This ensures the build commands are executed within the correct subdirectories following the migration to pnpm workspaces. --- .github/workflows/reusable-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index f17e92f7..57acc558 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -106,7 +106,7 @@ jobs: uses: ./.github/actions/setup-pnpm-demos-react - name: Build react demo - run: pnpm run build + run: pnpm --prefix demo/react run build - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -128,7 +128,7 @@ jobs: uses: ./.github/actions/setup-pnpm-demos-npm - name: Build npm-demo - run: pnpm run build + run: pnpm --prefix demo/npm run build - name: Upload artifacts uses: actions/upload-artifact@v4 From 970d3abfd42444356f414f8236a86b4a7b2c70f5 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 13:54:32 +0200 Subject: [PATCH 10/12] ci(github): remove redundant working-directory defaults Remove the `defaults.run.working-directory` configuration from the `build-react-demo` and `build-npm-demo` jobs. These settings are no longer necessary as the build commands now specify their own paths or prefixes. --- .github/workflows/reusable-build.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 57acc558..06583023 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -85,9 +85,6 @@ jobs: build-react-demo: needs: [build-lib] runs-on: ubuntu-22.04 - defaults: - run: - working-directory: demos/react steps: - name: Checkout code uses: actions/checkout@v4 @@ -117,9 +114,6 @@ jobs: build-npm-demo: runs-on: ubuntu-22.04 - defaults: - run: - working-directory: demos/npm steps: - name: Checkout code uses: actions/checkout@v4 From e0ca4272bb8c14e84e9a070eb9e949bd53192593 Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 13:56:45 +0200 Subject: [PATCH 11/12] ci(github): fix path for demo build commands Correct the directory path from `demo/` to `demos/` in the build steps to match the actual project structure. --- .github/workflows/reusable-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 06583023..842b7484 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -103,7 +103,7 @@ jobs: uses: ./.github/actions/setup-pnpm-demos-react - name: Build react demo - run: pnpm --prefix demo/react run build + run: pnpm --prefix demos/react run build - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -122,7 +122,7 @@ jobs: uses: ./.github/actions/setup-pnpm-demos-npm - name: Build npm-demo - run: pnpm --prefix demo/npm run build + run: pnpm --prefix demos/npm run build - name: Upload artifacts uses: actions/upload-artifact@v4 From b2b0ae2cd92a438b6a528e4353f17429675ef2bf Mon Sep 17 00:00:00 2001 From: Serhii Diachok Date: Thu, 8 Jan 2026 14:02:08 +0200 Subject: [PATCH 12/12] ci(github): simplify demo build steps and remove workspace Set working-directory defaults for React and NPM demo build jobs to simplify build commands. Remove the pnpm-workspace.yaml file as it is no longer required for this workflow configuration. --- .github/workflows/reusable-build.yml | 10 ++++++++-- pnpm-workspace.yaml | 3 --- 2 files changed, 8 insertions(+), 5 deletions(-) delete mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 842b7484..f17e92f7 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -85,6 +85,9 @@ jobs: build-react-demo: needs: [build-lib] runs-on: ubuntu-22.04 + defaults: + run: + working-directory: demos/react steps: - name: Checkout code uses: actions/checkout@v4 @@ -103,7 +106,7 @@ jobs: uses: ./.github/actions/setup-pnpm-demos-react - name: Build react demo - run: pnpm --prefix demos/react run build + run: pnpm run build - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -114,6 +117,9 @@ jobs: build-npm-demo: runs-on: ubuntu-22.04 + defaults: + run: + working-directory: demos/npm steps: - name: Checkout code uses: actions/checkout@v4 @@ -122,7 +128,7 @@ jobs: uses: ./.github/actions/setup-pnpm-demos-npm - name: Build npm-demo - run: pnpm --prefix demos/npm run build + run: pnpm run build - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 88563bcf..00000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,3 +0,0 @@ -onlyBuiltDependencies: - - core-js - - msw