diff --git a/lib/addons/prebid/README.md b/lib/addons/prebid/README.md index cde1cbb..59ac273 100644 --- a/lib/addons/prebid/README.md +++ b/lib/addons/prebid/README.md @@ -4,78 +4,77 @@ This addon integrates Optable analytics with Prebid.js, allowing you to send auc ## 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. +### Quick start: `initPrebidAnalytics` + +`initPrebidAnalytics` bootstraps the integration in one call: it constructs +`OptablePrebidAnalytics` around an SDK instance you already created and hooks it +into the Prebid.js instance you pass. It returns the analytics instance, or `null` +when no Prebid.js instance is provided. + +```ts +import OptableSDK from "@optable/web-sdk"; +import { initPrebidAnalytics } from "@optable/web-sdk/lib/addons/prebid/analytics"; + +const sdk = new OptableSDK({ host: "na.edge.optable.co", node: "analytics", site: "analytics" }); + +initPrebidAnalytics({ + // An already-initialized Optable SDK instance + sdkInstance: sdk, + // The Prebid.js instance to hook into + pbjsInstance: window.pbjs, + // Forwarded to the OptablePrebidAnalytics constructor + analytics: { + samplingRate: 0.1, + debug: !!sessionStorage.optableDebug, + // Any OptablePrebidAnalyticsConfig option is forwarded, e.g. samplingVolume. + }, +}); +``` -## Usage +`initPrebidAnalytics` reuses the SDK instance you already set up for +targeting/identify rather than creating its own. The tenant reported in every +payload is read from that instance's `node`. + +### Manual setup + +For full control, construct the collector yourself and hook it into Prebid.js: -- **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. +```js +import OptablePrebidAnalytics from "@optable/web-sdk/lib/addons/prebid/analytics"; + +const analytics = new OptablePrebidAnalytics(sdk, { + analytics: true, + debug: !!sessionStorage.optableDebug, + samplingRate: 0.75, +}); +analytics.hookIntoPrebid(window.pbjs); +``` -- **Debugging**: - Set `sessionStorage.optableDebug` to `true` to force analytics to run and enable debug logging. +The payload tenant is read from the SDK instance's `node`. + +## Usage -- **Custom Analytics Data**: - Implement `window.optable.customAnalytics` to return an object with custom data to be included in analytics events. +- **Sampling**: `samplingRate` (0–1) controls the fraction of events/sessions sent. +- **Debugging**: set `sessionStorage.optableDebug` to enable debug logging. +- **Custom fields**: set `window.optable.customAnalytics` to an async function + returning an object; its keys are merged into every auction payload. ## API +### `initPrebidAnalytics(options)` + +Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid.js instance is provided. + +- `options.sdkInstance`: An already-initialized Optable SDK instance. The tenant reported in the payload is read from its `node`. +- `options.pbjsInstance`: The Prebid.js instance to hook into (e.g. `window.pbjs`). +- `options.analytics`: Config forwarded to the `OptablePrebidAnalytics` constructor (`samplingRate`, `debug`, …). + ### `OptablePrebidAnalytics` - **Constructor**: `new OptablePrebidAnalytics(sdkInstance, options)` - `sdkInstance`: An instance of the Optable SDK. - - `options`: Object with options such as `debug`, `analytics`, and `tenant`. + - `options`: Object with options such as `debug`, `analytics`, and `samplingRate`. The payload tenant is derived from the SDK instance's `node`. - **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.md b/lib/addons/prebid/analytics.md new file mode 100644 index 0000000..3af8879 --- /dev/null +++ b/lib/addons/prebid/analytics.md @@ -0,0 +1,133 @@ +# Prebid Analytics Addon + +This addon collects Prebid.js auction data and reports it to the Optable Witness +API. It hooks into `auctionEnd` and `bidWon` events, replays any events that +fired before the hook attached, tags bidder requests with Optable EID metadata +(matchers/sources), and sends a structured `optable.prebid.auction` payload. + +It exposes two things: + +- `initPrebidAnalytics(options)` — a bootstrap helper that creates the analytics + SDK instance, constructs the collector, and hooks it into Prebid for you. Use + this unless you need to manage those pieces yourself. +- `OptablePrebidAnalytics` — the collector class, for advanced setups. + +## Usage + +### Basic setup (recommended) + +```js +import OptableSDK from "@optable/web-sdk"; +import { initPrebidAnalytics } from "@optable/web-sdk/lib/addons/prebid/analytics"; + +const sdk = new OptableSDK({ host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }); + +const analytics = initPrebidAnalytics({ + sdkInstance: sdk, + pbjsInstance: window.pbjs, + analytics: { + samplingRate: 0.1, + }, +}); +``` + +`initPrebidAnalytics` takes an already-initialized Optable SDK instance and the +Prebid.js instance to hook into, and returns the analytics instance (or `null` +when no Prebid.js instance is provided). The tenant reported in every payload is +read from the SDK instance's `node`. + +### With split-test assignment + +The addon reports a per-bid `splitTestAssignment`, read verbatim from the bid's +`ortb2Imp.ext.optable.splitTestAssignment` (it does no transformation). Use the +[`setupAB` addon](../abTestAssignment.md) to assign a variant and stamp that field +onto every bid; the value then appears in the payload as +`bidderRequests[].bids[].splitTestAssignment`. + +### Passing an explicit Prebid instance + +`pbjsInstance` is the Prebid.js instance to hook into — pass `window.pbjs`, or any +other instance when Prebid lives elsewhere. Hooking is deferred via `pbjs.que` +automatically when `pbjs.onEvent` is not yet available, so you do not need to wrap +the call yourself. + +```js +const analytics = initPrebidAnalytics({ + sdkInstance: sdk, + pbjsInstance: myPrebidInstance, +}); +``` + +### Custom payload fields + +Set `window.optable.customAnalytics` to an async function returning an object; its +keys are merged into every auction payload: + +```js +window.optable.customAnalytics = async () => ({ experiment: "floor-v2" }); +``` + +### Advanced: managing the collector directly + +```js +import OptablePrebidAnalytics from "@optable/web-sdk/lib/addons/prebid/analytics"; + +const sdk = new OptableSDK({ + host: "na.edge.optable.co", + node: "my-tenant", + site: "my-site", + readOnly: true, + cookies: false, +}); + +const analytics = new OptablePrebidAnalytics(sdk, { + analytics: true, + samplingRate: 0.1, +}); + +analytics.hookIntoPrebid(window.pbjs); +``` + +The tenant reported in the payload is taken from the SDK instance's `node`. + +## API + +### `initPrebidAnalytics(options)` + +**Options** + +| Option | Type | Default | Description | +| -------------- | ------------------------------ | -------- | --------------------------------------------------------------------------------------------------- | +| `sdkInstance` | `OptableSDK` | required | An already-initialized Optable SDK instance. The payload tenant is read from its `node`. | +| `pbjsInstance` | `object` | required | The Prebid.js instance to hook into (e.g. `window.pbjs`). | +| `analytics` | `OptablePrebidAnalyticsConfig` | — | Analytics behavior forwarded to the collector (see below). `analytics: true` is applied by default. | + +Returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid instance is provided. + +### `OptablePrebidAnalyticsConfig` + +| Option | Type | Default | Description | +| ---------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------- | +| `analytics` | `boolean` | — | Master switch. When falsy, events are logged (in debug) but not sent to Witness. `initPrebidAnalytics` sets `true`. | +| `debug` | `boolean` | `false` | When true, logs collector activity to the console. | +| `bidWinTimeout` | `number` | `10000` | Milliseconds to wait after `auctionEnd` before sending, to collect `bidWon` events. | +| `samplingRate` | `number` | `1` | Fraction of events/sessions sampled (0–1). `<= 0` sends nothing; `>= 1` sends everything. | +| `samplingVolume` | `"session"` \| `"event"` | `"event"` | `"event"` re-rolls sampling per auction; `"session"` decides once per session. | +| `samplingSeed` | `string` | — | Deterministic sampling seed (e.g. a user id) instead of random sampling. | +| `samplingRateFn` | `() => boolean` | — | Custom sampling predicate; overrides the other sampling options when set. | + +### `OptablePrebidAnalytics` instance + +| Member | Type | Description | +| ---------------- | -------------------- | --------------------------------------------------------------------------------------------------------------- | +| `isInitialized` | `boolean` | `true` once the constructor has run. | +| `hookIntoPrebid` | `(pbjs?) => boolean` | Attaches event hooks. Defers via `pbjs.que` when `onEvent` is not ready. Returns `false` when Prebid is absent. | +| `clearData` | `() => void` | Clears stored auction/missed-event state (useful in tests). | + +## Payload + +Each sampled auction sends an `optable.prebid.auction` event with, among others: +`bidderRequests` (with per-bid `status`, `cpm`, `size`, `splitTestAssignment`), +`optableMatchers`, `optableSources`, `optableTargetingDone`, `bidWon`, `missed`, +`url`, `tenant`, `prebidjsVersion`, `sessionDepth`, `pageAuctionsCount`, +`originSlug`, and the parsed `userAgent`/`device`. diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 102787f..7412b81 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -1,4 +1,4 @@ -import OptablePrebidAnalytics from "./analytics"; +import OptablePrebidAnalytics, { initPrebidAnalytics } from "./analytics"; import type OptableSDK from "../../sdk"; // Mock the SDK_WRAPPER_VERSION global @@ -65,12 +65,10 @@ describe("OptablePrebidAnalytics", () => { 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"); }); }); @@ -153,9 +151,7 @@ describe("OptablePrebidAnalytics", () => { describe("toWitness", () => { beforeEach(() => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", - }); + analytics = new OptablePrebidAnalytics(mockOptableInstance); }); it("should transform auction and bid won events to witness format", async () => { @@ -202,7 +198,6 @@ describe("OptablePrebidAnalytics", () => { totalRequests: 1, optableMatchers: ["matcher1"], optableSources: ["source1"], - tenant: "test-tenant", url: "example.com/test", optableWrapperVersion: "1.0.0-test", missed: false, @@ -330,7 +325,6 @@ describe("OptablePrebidAnalytics", () => { jest.useFakeTimers(); analytics = new OptablePrebidAnalytics(mockOptableInstance, { analytics: true, - tenant: "test-tenant", }); }); @@ -391,7 +385,6 @@ describe("OptablePrebidAnalytics", () => { "optable.prebid.auction", expect.objectContaining({ auctionId: "auction-complete", - tenant: "test-tenant", missed: false, bidWon: [expect.objectContaining({ adUnitCode: "ad-unit-1", bidderCode: "bidder1" })], }) @@ -991,9 +984,7 @@ describe("OptablePrebidAnalytics", () => { describe("toWitness - advanced scenarios", () => { beforeEach(() => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", - }); + analytics = new OptablePrebidAnalytics(mockOptableInstance); }); it("should handle multiple bidder requests", async () => { @@ -1146,7 +1137,6 @@ describe("OptablePrebidAnalytics", () => { analytics = new OptablePrebidAnalytics(mockOptableWithDcn, { analytics: true, - tenant: "test-tenant", }); }); @@ -1219,7 +1209,6 @@ describe("OptablePrebidAnalytics", () => { it("should not flush when analytics is disabled (analytics: false)", async () => { const disabledAnalytics = new OptablePrebidAnalytics(mockOptableWithDcn, { analytics: false, - tenant: "test-tenant", }); const event = { @@ -1250,7 +1239,6 @@ describe("OptablePrebidAnalytics", () => { const holdoutAnalytics = new OptablePrebidAnalytics(mockOptableWithDcn, { analytics: true, samplingRate: 0, - tenant: "test-tenant", }); const event = { @@ -1583,7 +1571,6 @@ describe("OptablePrebidAnalytics", () => { const testAnalytics = new OptablePrebidAnalytics(testInstance, { analytics: true, - tenant: "test-tenant", debug: true, }); @@ -1674,7 +1661,6 @@ describe("OptablePrebidAnalytics", () => { analytics = new OptablePrebidAnalytics(testInstance, { analytics: true, - tenant: "test-tenant", debug: true, }); @@ -1807,11 +1793,78 @@ describe("OptablePrebidAnalytics", () => { }); }); + describe("splitTestAssignment", () => { + // Build a minimal auction with one bid, optionally stamping a + // splitTestAssignment onto the bid's ortb2Imp and/or the received bid. + const makeAuction = (auctionId: string, bidStamp?: string, receivedStamp?: string) => ({ + auctionId, + 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", + ...(bidStamp ? { ortb2Imp: { ext: { optable: { splitTestAssignment: bidStamp } } } } : {}), + }, + ], + }, + ], + bidsReceived: receivedStamp + ? [ + { + requestId: "bid-1", + cpm: 1.5, + width: 300, + height: 250, + currency: "USD", + adUnitCode: "ad-unit-1", + ortb2Imp: { ext: { optable: { splitTestAssignment: receivedStamp } } }, + }, + ] + : [], + noBids: [], + timeoutBids: [], + }); + + it("reads the assignment stamped on the request bid's ortb2Imp", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + + const payload = await analytics.toWitness(makeAuction("auction-request-stamp", "test"), []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); + + it("reads the assignment stamped on a received bid's ortb2Imp", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + + const event = makeAuction("auction-received-stamp", undefined, "test"); + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-received-stamp"); + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); + + it("leaves the assignment undefined when nothing is stamped on the bid", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); + + const payload = await analytics.toWitness(makeAuction("auction-no-stamp"), []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBeUndefined(); + }); + }); + describe("EID deduplication", () => { beforeEach(() => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", - }); + analytics = new OptablePrebidAnalytics(mockOptableInstance); }); it("should deduplicate EIDs by source when both ext.eids and eids contain same source", async () => { @@ -2209,3 +2262,109 @@ describe("OptablePrebidAnalytics", () => { }); }); }); + +describe("initPrebidAnalytics", () => { + // A stub SDK instance exposing witness() and a dcn config, enough for + // OptablePrebidAnalytics. + function makeFakeSDK(node?: string) { + const witness = jest.fn().mockResolvedValue(undefined); + return { witness, dcn: { node } } as unknown as OptableSDK; + } + + function loadedPrebid() { + return { getEvents: jest.fn(() => []), onEvent: jest.fn() }; + } + + it("returns null when no prebid instance is provided", () => { + const result = initPrebidAnalytics({ sdkInstance: makeFakeSDK(), pbjsInstance: undefined }); + + expect(result).toBeNull(); + }); + + it("hooks the passed prebid instance", () => { + const pbjs = loadedPrebid(); + + const result = initPrebidAnalytics({ + sdkInstance: makeFakeSDK("n"), + pbjsInstance: pbjs, + }); + + expect(result).toBeInstanceOf(OptablePrebidAnalytics); + expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + }); + + it("enables analytics and forwards the analytics config to the collector", () => { + const pbjs = loadedPrebid(); + + const result = initPrebidAnalytics({ + sdkInstance: makeFakeSDK("n"), + pbjsInstance: pbjs, + analytics: { samplingRate: 0.25 }, + }); + + expect(result!["config"].analytics).toBe(true); + expect(result!["config"].samplingRate).toBe(0.25); + }); + + it("reports the tenant from the SDK instance's node in the payload", async () => { + const pbjs = loadedPrebid(); + + const result = initPrebidAnalytics({ + sdkInstance: makeFakeSDK("acme"), + pbjsInstance: pbjs, + }); + + const auctionEndEvent = { + auctionId: "auction-tenant", + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [{ bidId: "bid-1", adUnitCode: "ad-unit-1", transactionId: "trans-1", src: "client" }], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const payload = await result!.toWitness(auctionEndEvent, []); + expect(payload.tenant).toBe("acme"); + }); + + it("carries a splitTestAssignment stamped on the bid's ortb2Imp into the payload", async () => { + const pbjs = loadedPrebid(); + + const result = initPrebidAnalytics({ + sdkInstance: makeFakeSDK("n"), + pbjsInstance: pbjs, + }); + + const auctionEndEvent = { + auctionId: "auction-init-split", + bidderRequests: [ + { + bidderCode: "bidder1", + bidderRequestId: "req-1", + ortb2: { site: { domain: "example.com" }, user: { eids: [] } }, + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + transactionId: "trans-1", + src: "client", + ortb2Imp: { ext: { optable: { splitTestAssignment: "test" } } }, + }, + ], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const payload = await result!.toWitness(auctionEndEvent, []); + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); +}); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 6daefda..95a0ed4 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -26,7 +26,6 @@ const SESSION_SAMPLE_KEY = "optable:prebid:analytics:sample-number"; interface OptablePrebidAnalyticsConfig { debug?: boolean; analytics?: boolean; - tenant?: string; bidWinTimeout?: number; samplingVolume?: "session" | "event"; samplingSeed?: string; @@ -572,8 +571,9 @@ class OptablePrebidAnalytics { bid.cpm = bidReceived.cpm; bid.size = `${bidReceived.width}x${bidReceived.height}`; bid.currency = bidReceived.currency; - if (bidReceived.ortb2Imp?.ext?.optable?.splitTestAssignment) { - bid.splitTestAssignment = bidReceived.ortb2Imp.ext.optable.splitTestAssignment; + const receivedSplitTest = bidReceived.ortb2Imp?.ext?.optable?.splitTestAssignment; + if (receivedSplitTest !== undefined) { + bid.splitTestAssignment = receivedSplitTest; } if (request.status === STATUS.REQUESTED) request.status = STATUS.RECEIVED; } @@ -607,7 +607,7 @@ class OptablePrebidAnalytics { })), missed, url: `${window.location.hostname}${window.location.pathname}`, - tenant: this.config.tenant!, + tenant: this.optableInstance?.dcn?.node ?? "unknown", // eslint-disable-next-line no-undef optableWrapperVersion: SDK_WRAPPER_VERSION || "unknown", userAgent: Bowser.parse(window.navigator.userAgent) as unknown as Record, @@ -635,3 +635,45 @@ class OptablePrebidAnalytics { } export default OptablePrebidAnalytics; + +/** + * Options for {@link initPrebidAnalytics}. + */ +export interface InitPrebidAnalyticsOptions { + /** + * An already-initialized Optable SDK instance. The tenant reported in every + * payload is read from its config (`sdkInstance.dcn.node`), so no separate + * `tenant` option is needed. + */ + sdkInstance: OptableSDK; + /** The Prebid.js instance to hook into. */ + pbjsInstance: any; + /** + * Analytics behavior forwarded to the `OptablePrebidAnalytics` constructor + * (samplingRate, debug, …). + */ + analytics?: OptablePrebidAnalyticsConfig; +} + +/** + * Bootstrap Prebid.js analytics for a bundle: construct an + * `OptablePrebidAnalytics` around a caller-provided SDK instance and hook it + * into the caller-provided Prebid.js instance. + * + * Returns the analytics instance, or `null` when no Prebid.js instance is + * provided. + * @param options - See {@link InitPrebidAnalyticsOptions}. + */ +export function initPrebidAnalytics(options: InitPrebidAnalyticsOptions): OptablePrebidAnalytics | null { + const { sdkInstance, pbjsInstance, analytics: analyticsConfig } = options; + + if (!pbjsInstance) return null; + + const analytics = new OptablePrebidAnalytics(sdkInstance, { + analytics: true, + ...analyticsConfig, + }); + + analytics.hookIntoPrebid(pbjsInstance); + return analytics; +}