From 47a82d2ac3bd5367a80d1fd7789df5b5b8b6edaf Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Fri, 17 Jul 2026 15:31:38 -0400 Subject: [PATCH 1/7] Add initPrebidAnalytics bootstrap to the prebid (V2) analytics addon --- lib/addons/prebid/README.md | 127 +++++++++++++++++++--------- lib/addons/prebid/analytics.test.ts | 102 +++++++++++++++++++++- lib/addons/prebid/analytics.ts | 54 ++++++++++++ 3 files changed, 241 insertions(+), 42 deletions(-) diff --git a/lib/addons/prebid/README.md b/lib/addons/prebid/README.md index cde1cbba..12733b3d 100644 --- a/lib/addons/prebid/README.md +++ b/lib/addons/prebid/README.md @@ -4,47 +4,82 @@ 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 whole integration in one call: it creates a +dedicated read-only analytics SDK instance, constructs `OptablePrebidAnalytics`, +and hooks it into the publisher's Prebid.js global. It returns the analytics +instance, or `null` when no Prebid.js global is present (in which case no SDK +instance is created). + +```ts +import OptableSDK from "@optable/web-sdk"; +import { initPrebidAnalytics } from "@optable/web-sdk/lib/dist/addons/prebid/analytics"; + +initPrebidAnalytics({ + SDK: OptableSDK, + // Config for the dedicated read-only analytics SDK instance + instance: { host: "na.edge.optable.co", node: "analytics", site: "analytics" }, + // Prebid global by name (default "pbjs"), or pass the object directly via `pbjs` + prebidGlobal: "pbjs", + // Forwarded to the OptablePrebidAnalytics constructor + analytics: { + tenant: "my_tenant", + samplingRate: 0.1, + debug: !!sessionStorage.optableDebug, + // Any OptablePrebidAnalyticsConfig option is forwarded, e.g. + // getSplitTestAssignment: () => window.optable?.selectedTest?.id, + }, +}); +``` + +The `SDK` constructor is passed in (rather than imported by this module) so that +consumers of the `OptablePrebidAnalytics` class don't bundle the whole SDK. The +`instance` config sets where analytics data is sent; `readOnly: true` and +`cookies: false` are applied by default and can be overridden through `instance`. + +### Manual setup + +For full control over the instances, wire the pieces up yourself: + +```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 @@ -59,6 +94,16 @@ This addon integrates Optable analytics with Prebid.js, allowing you to send auc ## API +### `initPrebidAnalytics(options)` + +Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid.js global is present. + +- `options.SDK`: The Optable SDK constructor (pass the imported `OptableSDK`). +- `options.instance`: Config for the dedicated read-only analytics SDK instance (`host`/`node`/`site`/…). `readOnly: true` and `cookies: false` default on and can be overridden here. +- `options.pbjs`: The Prebid.js global to hook into. When omitted, `window[prebidGlobal]` is used. +- `options.prebidGlobal`: Name of the prebid global on `window` (default `"pbjs"`), used when `pbjs` is not passed. +- `options.analytics`: Config forwarded to the `OptablePrebidAnalytics` constructor (`tenant`, `samplingRate`, `debug`, `getSplitTestAssignment`, …). + ### `OptablePrebidAnalytics` - **Constructor**: diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 102787f2..f07b11d7 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 @@ -2209,3 +2209,103 @@ describe("OptablePrebidAnalytics", () => { }); }); }); + +describe("initPrebidAnalytics", () => { + // A stub SDK constructor whose instances expose witness(), enough for + // OptablePrebidAnalytics. Records the config it was constructed with. + function makeFakeSDK() { + const witness = jest.fn().mockResolvedValue(undefined); + const ctor = jest.fn().mockImplementation(() => ({ witness })); + return { ctor: ctor as unknown as new (config: any) => any, calls: ctor.mock.calls, witness }; + } + + function loadedPrebid() { + return { getEvents: jest.fn(() => []), onEvent: jest.fn() }; + } + + beforeEach(() => { + delete (window as any).pbjs; + delete (window as any).fusePbjs; + }); + + it("returns null and creates no SDK when no prebid global is present", () => { + const { ctor } = makeFakeSDK(); + const result = initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" } }); + + expect(result).toBeNull(); + expect(ctor).not.toHaveBeenCalled(); + }); + + it("creates a read-only analytics SDK instance and hooks the passed prebid global", () => { + const pbjs = loadedPrebid(); + const { ctor } = makeFakeSDK(); + + const result = initPrebidAnalytics({ + SDK: ctor, + instance: { host: "h", node: "n", site: "s" }, + pbjs, + analytics: { tenant: "acme" }, + }); + + expect(result).toBeInstanceOf(OptablePrebidAnalytics); + expect(ctor).toHaveBeenCalledWith( + expect.objectContaining({ host: "h", node: "n", site: "s", readOnly: true, cookies: false }) + ); + expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + }); + + it("reads the named prebid global from window when pbjs is not passed", () => { + (window as any).fusePbjs = loadedPrebid(); + const { ctor } = makeFakeSDK(); + + initPrebidAnalytics({ + SDK: ctor, + instance: { host: "h", site: "s" }, + prebidGlobal: "fusePbjs", + analytics: { tenant: "acme" }, + }); + + expect((window as any).fusePbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + }); + + it("defaults to window.pbjs when no global is configured", () => { + (window as any).pbjs = loadedPrebid(); + const { ctor } = makeFakeSDK(); + + initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" }, analytics: { tenant: "acme" } }); + + expect((window as any).pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + }); + + it("forwards the analytics config through to the OptablePrebidAnalytics instance", () => { + const pbjs = loadedPrebid(); + const { ctor } = makeFakeSDK(); + + // Any OptablePrebidAnalyticsConfig option flows through, including + // getSplitTestAssignment once it lands in the config interface. + const result = initPrebidAnalytics({ + SDK: ctor, + instance: { host: "h", site: "s" }, + pbjs, + analytics: { tenant: "acme", samplingRate: 0.25 }, + }); + + expect(result!["config"].analytics).toBe(true); + expect(result!["config"].tenant).toBe("acme"); + expect(result!["config"].samplingRate).toBe(0.25); + }); + + it("lets the instance config override the read-only defaults", () => { + const pbjs = loadedPrebid(); + const { ctor } = makeFakeSDK(); + + initPrebidAnalytics({ + SDK: ctor, + instance: { host: "h", site: "s", readOnly: false }, + pbjs, + analytics: { tenant: "acme" }, + }); + + expect(ctor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: false })); + }); +}); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 6daefda9..6894bea2 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -3,6 +3,7 @@ import type { WitnessProperties } from "../../edge/witness"; import type OptableSDK from "../../sdk"; import { buildRequest } from "../../core/network"; +import type { InitConfig } from "../../config"; import * as Bowser from "bowser"; @@ -635,3 +636,56 @@ class OptablePrebidAnalytics { } export default OptablePrebidAnalytics; + +/** + * Options for {@link initPrebidAnalytics}. + */ +export interface InitPrebidAnalyticsOptions { + /** + * The Optable SDK constructor (pass the imported `OptableSDK`). Taken as a + * parameter so this module keeps a type-only SDK import and consumers of the + * `OptablePrebidAnalytics` class don't bundle the whole SDK. + */ + SDK: new (config: InitConfig) => OptableSDK; + /** Config for the dedicated read-only analytics SDK instance (host/node/site/…). */ + instance: InitConfig; + /** Prebid.js global to hook into. When omitted, `window[prebidGlobal]` is used. */ + pbjs?: any; + /** Name of the prebid global on `window` (default `"pbjs"`), used when `pbjs` is not passed. */ + prebidGlobal?: string; + /** + * Analytics behavior forwarded to the `OptablePrebidAnalytics` constructor + * (tenant, samplingRate, debug, getSplitTestAssignment, …). + */ + analytics?: OptablePrebidAnalyticsConfig; +} + +/** + * Bootstrap Prebid.js analytics for a bundle: create a dedicated read-only + * analytics SDK instance, construct an `OptablePrebidAnalytics`, and hook it + * into the publisher's Prebid.js global. + * + * Returns the analytics instance, or `null` when no Prebid.js global is present + * (in which case no analytics SDK instance is created). + * @param options - See {@link InitPrebidAnalyticsOptions}. + */ +export function initPrebidAnalytics(options: InitPrebidAnalyticsOptions): OptablePrebidAnalytics | null { + const { SDK, instance, pbjs, prebidGlobal, analytics: analyticsConfig } = options; + + const prebid = pbjs ?? (window as any)[prebidGlobal || "pbjs"]; + if (!prebid) return null; + + const analyticsSDK = new SDK({ + readOnly: true, + cookies: false, + ...instance, + }); + + const analytics = new OptablePrebidAnalytics(analyticsSDK, { + analytics: true, + ...analyticsConfig, + }); + + analytics.hookIntoPrebid(prebid); + return analytics; +} From a51d3863a13a9cfaa81e143e04d0896b428a1d53 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Tue, 21 Jul 2026 13:26:24 -0400 Subject: [PATCH 2/7] Add splitTestAssignment configuration + read me --- lib/addons/prebid/analytics.md | 170 ++++++++++++++++++++++++++++ lib/addons/prebid/analytics.test.ts | 142 +++++++++++++++++++++++ lib/addons/prebid/analytics.ts | 35 +++++- 3 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 lib/addons/prebid/analytics.md diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md new file mode 100644 index 00000000..51ccf315 --- /dev/null +++ b/lib/addons/prebid/analytics.md @@ -0,0 +1,170 @@ +# 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 analytics = initPrebidAnalytics({ + SDK: OptableSDK, + instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, + analytics: { + tenant: "my-tenant", + samplingRate: 0.1, + }, +}); +``` + +`initPrebidAnalytics` returns the analytics instance, or `null` when no Prebid.js +global is present (in which case no SDK instance is created). The analytics SDK +is created with `readOnly: true` and `cookies: false` by default — you only pass +`host`/`node`/`site`. + +### With split-test assignment + +When A/B testing, the analytics payload can record which variant each bid belonged +to via a per-bid `splitTestAssignment` field. There are two ways that field gets +populated: + +1. **From the bid itself** — if `ortb2Imp.ext.optable.splitTestAssignment` is already + set on the Prebid bid (e.g. the [A/B test addon](../abTestAssignment.md) stamped + it), the addon reads it directly. +2. **From `getSplitTestAssignment`** — a callback you configure. When set, its return + value is stamped onto **every** bid as it is built (initial requests, received + bids, and won bids), and it **takes precedence** over any value already on the + bid. Return `undefined` to fall back to the value on the bid. + +Use `getSplitTestAssignment` when the assignment lives in your own code rather than +on the bids. This replaces the older pattern of wrapping `trackAuctionEnd` to mutate +Prebid events yourself — the callback covers received/won bids too, which an +`auctionEnd`-only wrapper would miss. + +```js +import { setupAB } from "@optable/web-sdk/lib/addons/abTestAssignment"; + +const ab = setupAB({ + variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }], +}); + +const analytics = initPrebidAnalytics({ + SDK: OptableSDK, + instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, + analytics: { + tenant: "my-tenant", + samplingRate: 0.1, + // Map "none" to "test" so control/holdout still reports a comparable bucket. + getSplitTestAssignment: () => (ab.variant.id === "none" ? "test" : ab.variant.id), + }, +}); +``` + +The resolved value appears on every bid in the payload as +`bidderRequests[].bids[].splitTestAssignment`. + +### Passing an explicit Prebid instance + +By default the addon reads `window.pbjs`. Pass `pbjs` (or `prebidGlobal` to change +the window key) 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({ + SDK: OptableSDK, + instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, + pbjs: myPrebidInstance, + analytics: { tenant: "my-tenant" }, +}); +``` + +### 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, + tenant: "my-tenant", + samplingRate: 0.1, +}); + +analytics.hookIntoPrebid(window.pbjs); +``` + +## API + +### `initPrebidAnalytics(options)` + +**Options** + +| Option | Type | Default | Description | +| -------------- | ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------- | +| `SDK` | `new (config) => OptableSDK` | required | The Optable SDK constructor. Passed in so this module keeps a type-only SDK import. | +| `instance` | `InitConfig` | required | Config for the read-only analytics SDK (`host`/`node`/`site`/…). `readOnly`/`cookies` default false. | +| `pbjs` | `object` | — | Prebid.js instance to hook into. When omitted, `window[prebidGlobal]` is used. | +| `prebidGlobal` | `string` | `"pbjs"` | Window key used to find Prebid when `pbjs` is not passed. | +| `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 global is found. + +### `OptablePrebidAnalyticsConfig` + +| Option | Type | Default | Description | +| ------------------------ | --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ | +| `analytics` | `boolean` | — | Master switch. When falsy, events are logged (in debug) but not sent to Witness. `initPrebidAnalytics` sets `true`. | +| `tenant` | `string` | — | Tenant identifier included in every payload. | +| `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. | +| `getSplitTestAssignment` | `() => string \| undefined` | — | Returns the split-test assignment stamped onto every bid. Takes precedence over the value on the bid. | + +### `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 f07b11d7..3a56e13b 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -1807,6 +1807,118 @@ describe("OptablePrebidAnalytics", () => { }); }); + describe("getSplitTestAssignment", () => { + // 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("stamps the callback value onto request bids in the payload", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + getSplitTestAssignment: () => "test", + }); + + const payload = await analytics.toWitness(makeAuction("auction-cb-request"), []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); + + it("overrides a value already present on the bid", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + getSplitTestAssignment: () => "production", + }); + + const payload = await analytics.toWitness(makeAuction("auction-cb-override", "test"), []); + + // Callback wins over the bid's own ortb2Imp value. + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("production"); + }); + + it("overrides the value on received bids", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + getSplitTestAssignment: () => "production", + }); + + const event = makeAuction("auction-cb-received", undefined, "test"); + await analytics.trackAuctionEnd(event); + + const storedAuction = analytics["auctions"].get("auction-cb-received"); + const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("production"); + }); + + it("falls back to the bid value when the callback returns undefined", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + getSplitTestAssignment: () => undefined, + }); + + const payload = await analytics.toWitness(makeAuction("auction-cb-undefined", "test"), []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); + + it("uses the bid value when no callback is configured", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + }); + + const payload = await analytics.toWitness(makeAuction("auction-no-cb", "test"), []); + + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); + + it("is invoked once per bid as bids are built", async () => { + const getSplitTestAssignment = jest.fn(() => "test"); + analytics = new OptablePrebidAnalytics(mockOptableInstance, { + tenant: "test-tenant", + getSplitTestAssignment, + }); + + await analytics.toWitness(makeAuction("auction-cb-called"), []); + + expect(getSplitTestAssignment).toHaveBeenCalled(); + }); + }); + describe("EID deduplication", () => { beforeEach(() => { analytics = new OptablePrebidAnalytics(mockOptableInstance, { @@ -2295,6 +2407,36 @@ describe("initPrebidAnalytics", () => { expect(result!["config"].samplingRate).toBe(0.25); }); + it("forwards getSplitTestAssignment so it stamps bids in the payload", async () => { + const pbjs = loadedPrebid(); + const { ctor } = makeFakeSDK(); + + const result = initPrebidAnalytics({ + SDK: ctor, + instance: { host: "h", site: "s" }, + pbjs, + analytics: { tenant: "acme", getSplitTestAssignment: () => "test" }, + }); + + 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" }], + }, + ], + bidsReceived: [], + noBids: [], + timeoutBids: [], + }; + + const payload = await result!.toWitness(auctionEndEvent, []); + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + }); + it("lets the instance config override the read-only defaults", () => { const pbjs = loadedPrebid(); const { ctor } = makeFakeSDK(); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 6894bea2..509a754c 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -33,6 +33,13 @@ interface OptablePrebidAnalyticsConfig { samplingSeed?: string; samplingRate?: number; samplingRateFn?: () => boolean; + /** + * Optional callback returning the split-test assignment for the current page. + * When provided, it is stamped onto every bid's `splitTestAssignment` field in + * the Witness payload, overriding any value already present on the bid. Use this + * to attach A/B assignment without mutating Prebid events yourself. + */ + getSplitTestAssignment?: () => string | undefined; } interface AuctionItem { @@ -133,6 +140,21 @@ class OptablePrebidAnalytics { } } + /** + * Resolve the split-test assignment for a bid. When a `getSplitTestAssignment` + * callback is configured its value takes precedence; otherwise the assignment + * already present on the bid (`ortb2Imp.ext.optable.splitTestAssignment`) is used. + * @param bidValue - The assignment already stamped on the bid, if any. + * @returns The resolved assignment, or undefined when neither source provides one. + */ + private resolveSplitTestAssignment(bidValue?: string): string | undefined { + if (this.config.getSplitTestAssignment) { + const value = this.config.getSplitTestAssignment(); + if (value !== undefined) return value; + } + return bidValue; + } + /** * Determine whether the current event/session should be sampled according to * the configured sampling rate, seed or function. @@ -324,7 +346,7 @@ class OptablePrebidAnalytics { transactionId: b.transactionId, src: b.src, floorMin: b.floorData?.floorMin, - splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, + splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), status: STATUS.REQUESTED, }) ), @@ -364,7 +386,7 @@ class OptablePrebidAnalytics { cpm: b.cpm, size: `${b.width}x${b.height}`, currency: b.currency, - splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, + splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), }); } else { // Create new bid object for this response @@ -379,7 +401,7 @@ class OptablePrebidAnalytics { size: `${b.width}x${b.height}`, currency: b.currency, status: STATUS.RECEIVED, - splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, + splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), }; br.bids.push(bidObj); bidIndex[bidId] = bidObj; @@ -554,7 +576,7 @@ class OptablePrebidAnalytics { transactionId: b.transactionId, src: b.src, floorMin: b.floorData?.floorMin, - splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, + splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), status: STATUS.REQUESTED, }) ), @@ -573,8 +595,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 resolved = this.resolveSplitTestAssignment(bidReceived.ortb2Imp?.ext?.optable?.splitTestAssignment); + if (resolved !== undefined) { + bid.splitTestAssignment = resolved; } if (request.status === STATUS.REQUESTED) request.status = STATUS.RECEIVED; } From 86b42e7ce5a63bbb4a8a0c5f662ec16f36d5b4d5 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 22 Jul 2026 11:02:59 -0400 Subject: [PATCH 3/7] Address comments --- lib/addons/prebid/README.md | 15 ++++--- lib/addons/prebid/analytics.md | 64 ++++++++++++++--------------- lib/addons/prebid/analytics.test.ts | 57 +++++++------------------ lib/addons/prebid/analytics.ts | 17 ++++---- 4 files changed, 61 insertions(+), 92 deletions(-) diff --git a/lib/addons/prebid/README.md b/lib/addons/prebid/README.md index 12733b3d..ba668ace 100644 --- a/lib/addons/prebid/README.md +++ b/lib/addons/prebid/README.md @@ -20,11 +20,10 @@ initPrebidAnalytics({ SDK: OptableSDK, // Config for the dedicated read-only analytics SDK instance instance: { host: "na.edge.optable.co", node: "analytics", site: "analytics" }, - // Prebid global by name (default "pbjs"), or pass the object directly via `pbjs` - prebidGlobal: "pbjs", + // Prebid instance by name (default "pbjs"), or pass the object directly via `pbjsInstance` + pbjsInstanceName: "pbjs", // Forwarded to the OptablePrebidAnalytics constructor analytics: { - tenant: "my_tenant", samplingRate: 0.1, debug: !!sessionStorage.optableDebug, // Any OptablePrebidAnalyticsConfig option is forwarded, e.g. @@ -37,6 +36,7 @@ The `SDK` constructor is passed in (rather than imported by this module) so that consumers of the `OptablePrebidAnalytics` class don't bundle the whole SDK. The `instance` config sets where analytics data is sent; `readOnly: true` and `cookies: false` are applied by default and can be overridden through `instance`. +The tenant reported in every payload is derived from `instance.node`. ### Manual setup @@ -69,7 +69,6 @@ if (window.optable.runAnalytics && tenant) { window.optable.analytics = new OptablePrebidAnalytics(window.optable[`${tenant}_analytics`], { analytics: true, - tenant, debug: !!sessionStorage.optableDebug, samplingRate: 0.75, }); @@ -100,16 +99,16 @@ Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or - `options.SDK`: The Optable SDK constructor (pass the imported `OptableSDK`). - `options.instance`: Config for the dedicated read-only analytics SDK instance (`host`/`node`/`site`/…). `readOnly: true` and `cookies: false` default on and can be overridden here. -- `options.pbjs`: The Prebid.js global to hook into. When omitted, `window[prebidGlobal]` is used. -- `options.prebidGlobal`: Name of the prebid global on `window` (default `"pbjs"`), used when `pbjs` is not passed. -- `options.analytics`: Config forwarded to the `OptablePrebidAnalytics` constructor (`tenant`, `samplingRate`, `debug`, `getSplitTestAssignment`, …). +- `options.pbjsInstance`: The Prebid.js instance to hook into. When omitted, `window[pbjsInstanceName]` is used. +- `options.pbjsInstanceName`: Name of the Prebid.js global on `window` (default `"pbjs"`), used when `pbjsInstance` is not passed. +- `options.analytics`: Config forwarded to the `OptablePrebidAnalytics` constructor (`samplingRate`, `debug`, `getSplitTestAssignment`, …). The tenant reported in the payload comes from the analytics SDK instance's `node`. ### `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. diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 51ccf315..119c9ce2 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -24,7 +24,6 @@ const analytics = initPrebidAnalytics({ SDK: OptableSDK, instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, analytics: { - tenant: "my-tenant", samplingRate: 0.1, }, }); @@ -33,7 +32,8 @@ const analytics = initPrebidAnalytics({ `initPrebidAnalytics` returns the analytics instance, or `null` when no Prebid.js global is present (in which case no SDK instance is created). The analytics SDK is created with `readOnly: true` and `cookies: false` by default — you only pass -`host`/`node`/`site`. +`host`/`node`/`site`. The tenant reported in every payload is derived from +`instance.node`. ### With split-test assignment @@ -65,7 +65,6 @@ const analytics = initPrebidAnalytics({ SDK: OptableSDK, instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, analytics: { - tenant: "my-tenant", samplingRate: 0.1, // Map "none" to "test" so control/holdout still reports a comparable bucket. getSplitTestAssignment: () => (ab.variant.id === "none" ? "test" : ab.variant.id), @@ -78,17 +77,16 @@ The resolved value appears on every bid in the payload as ### Passing an explicit Prebid instance -By default the addon reads `window.pbjs`. Pass `pbjs` (or `prebidGlobal` to change -the window key) 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. +By default the addon reads `window.pbjs`. Pass `pbjsInstance` (or `pbjsInstanceName` +to change the window key) 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({ SDK: OptableSDK, instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, - pbjs: myPrebidInstance, - analytics: { tenant: "my-tenant" }, + pbjsInstance: myPrebidInstance, }); ``` @@ -116,50 +114,50 @@ const sdk = new OptableSDK({ const analytics = new OptablePrebidAnalytics(sdk, { analytics: true, - tenant: "my-tenant", 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 | -| -------------- | ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------- | -| `SDK` | `new (config) => OptableSDK` | required | The Optable SDK constructor. Passed in so this module keeps a type-only SDK import. | -| `instance` | `InitConfig` | required | Config for the read-only analytics SDK (`host`/`node`/`site`/…). `readOnly`/`cookies` default false. | -| `pbjs` | `object` | — | Prebid.js instance to hook into. When omitted, `window[prebidGlobal]` is used. | -| `prebidGlobal` | `string` | `"pbjs"` | Window key used to find Prebid when `pbjs` is not passed. | -| `analytics` | `OptablePrebidAnalyticsConfig` | — | Analytics behavior forwarded to the collector (see below). `analytics: true` is applied by default. | +| Option | Type | Default | Description | +| ------------------ | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------- | +| `SDK` | `new (config) => OptableSDK` | required | The Optable SDK constructor. Passed in so this module keeps a type-only SDK import. | +| `instance` | `InitConfig` | required | Config for the read-only analytics SDK (`host`/`node`/`site`/…). `readOnly`/`cookies` default false. | +| `pbjsInstance` | `object` | — | Prebid.js instance to hook into. When omitted, `window[pbjsInstanceName]` is used. | +| `pbjsInstanceName` | `string` | `"pbjs"` | Window key used to find Prebid when `pbjsInstance` is not passed. | +| `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 global is found. ### `OptablePrebidAnalyticsConfig` -| Option | Type | Default | Description | -| ------------------------ | --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ | -| `analytics` | `boolean` | — | Master switch. When falsy, events are logged (in debug) but not sent to Witness. `initPrebidAnalytics` sets `true`. | -| `tenant` | `string` | — | Tenant identifier included in every payload. | -| `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. | -| `getSplitTestAssignment` | `() => string \| undefined` | — | Returns the split-test assignment stamped onto every bid. Takes precedence over the value on the bid. | +| 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. | +| `getSplitTestAssignment` | `() => string \| undefined` | — | Returns the split-test assignment stamped onto every bid. Takes precedence over the value on the bid. | ### `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). | +| 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 diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 3a56e13b..93d8ca64 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -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, }); @@ -1849,7 +1835,6 @@ describe("OptablePrebidAnalytics", () => { it("stamps the callback value onto request bids in the payload", async () => { analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", getSplitTestAssignment: () => "test", }); @@ -1860,7 +1845,6 @@ describe("OptablePrebidAnalytics", () => { it("overrides a value already present on the bid", async () => { analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", getSplitTestAssignment: () => "production", }); @@ -1872,7 +1856,6 @@ describe("OptablePrebidAnalytics", () => { it("overrides the value on received bids", async () => { analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", getSplitTestAssignment: () => "production", }); @@ -1887,7 +1870,6 @@ describe("OptablePrebidAnalytics", () => { it("falls back to the bid value when the callback returns undefined", async () => { analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", getSplitTestAssignment: () => undefined, }); @@ -1897,9 +1879,7 @@ describe("OptablePrebidAnalytics", () => { }); it("uses the bid value when no callback is configured", async () => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", - }); + analytics = new OptablePrebidAnalytics(mockOptableInstance); const payload = await analytics.toWitness(makeAuction("auction-no-cb", "test"), []); @@ -1909,7 +1889,6 @@ describe("OptablePrebidAnalytics", () => { it("is invoked once per bid as bids are built", async () => { const getSplitTestAssignment = jest.fn(() => "test"); analytics = new OptablePrebidAnalytics(mockOptableInstance, { - tenant: "test-tenant", getSplitTestAssignment, }); @@ -1921,9 +1900,7 @@ describe("OptablePrebidAnalytics", () => { 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 () => { @@ -2348,15 +2325,14 @@ describe("initPrebidAnalytics", () => { expect(ctor).not.toHaveBeenCalled(); }); - it("creates a read-only analytics SDK instance and hooks the passed prebid global", () => { + it("creates a read-only analytics SDK instance and hooks the passed prebid instance", () => { const pbjs = loadedPrebid(); const { ctor } = makeFakeSDK(); const result = initPrebidAnalytics({ SDK: ctor, instance: { host: "h", node: "n", site: "s" }, - pbjs, - analytics: { tenant: "acme" }, + pbjsInstance: pbjs, }); expect(result).toBeInstanceOf(OptablePrebidAnalytics); @@ -2366,25 +2342,24 @@ describe("initPrebidAnalytics", () => { expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); }); - it("reads the named prebid global from window when pbjs is not passed", () => { + it("reads the named prebid instance from window when pbjsInstance is not passed", () => { (window as any).fusePbjs = loadedPrebid(); const { ctor } = makeFakeSDK(); initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" }, - prebidGlobal: "fusePbjs", - analytics: { tenant: "acme" }, + pbjsInstanceName: "fusePbjs", }); expect((window as any).fusePbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); }); - it("defaults to window.pbjs when no global is configured", () => { + it("defaults to window.pbjs when no instance is configured", () => { (window as any).pbjs = loadedPrebid(); const { ctor } = makeFakeSDK(); - initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" }, analytics: { tenant: "acme" } }); + initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" } }); expect((window as any).pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); }); @@ -2398,12 +2373,11 @@ describe("initPrebidAnalytics", () => { const result = initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" }, - pbjs, - analytics: { tenant: "acme", samplingRate: 0.25 }, + pbjsInstance: pbjs, + analytics: { samplingRate: 0.25 }, }); expect(result!["config"].analytics).toBe(true); - expect(result!["config"].tenant).toBe("acme"); expect(result!["config"].samplingRate).toBe(0.25); }); @@ -2414,8 +2388,8 @@ describe("initPrebidAnalytics", () => { const result = initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" }, - pbjs, - analytics: { tenant: "acme", getSplitTestAssignment: () => "test" }, + pbjsInstance: pbjs, + analytics: { getSplitTestAssignment: () => "test" }, }); const auctionEndEvent = { @@ -2444,8 +2418,7 @@ describe("initPrebidAnalytics", () => { initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s", readOnly: false }, - pbjs, - analytics: { tenant: "acme" }, + pbjsInstance: pbjs, }); expect(ctor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: false })); diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index 509a754c..8bf55f02 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -27,7 +27,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; @@ -631,7 +630,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, @@ -672,13 +671,13 @@ export interface InitPrebidAnalyticsOptions { SDK: new (config: InitConfig) => OptableSDK; /** Config for the dedicated read-only analytics SDK instance (host/node/site/…). */ instance: InitConfig; - /** Prebid.js global to hook into. When omitted, `window[prebidGlobal]` is used. */ - pbjs?: any; - /** Name of the prebid global on `window` (default `"pbjs"`), used when `pbjs` is not passed. */ - prebidGlobal?: string; + /** Prebid.js instance to hook into. When omitted, `window[pbjsInstanceName]` is used. */ + pbjsInstance?: any; + /** Name of the Prebid.js global on `window` (default `"pbjs"`), used when `pbjsInstance` is not passed. */ + pbjsInstanceName?: string; /** * Analytics behavior forwarded to the `OptablePrebidAnalytics` constructor - * (tenant, samplingRate, debug, getSplitTestAssignment, …). + * (samplingRate, debug, getSplitTestAssignment, …). */ analytics?: OptablePrebidAnalyticsConfig; } @@ -693,9 +692,9 @@ export interface InitPrebidAnalyticsOptions { * @param options - See {@link InitPrebidAnalyticsOptions}. */ export function initPrebidAnalytics(options: InitPrebidAnalyticsOptions): OptablePrebidAnalytics | null { - const { SDK, instance, pbjs, prebidGlobal, analytics: analyticsConfig } = options; + const { SDK, instance, pbjsInstance, pbjsInstanceName, analytics: analyticsConfig } = options; - const prebid = pbjs ?? (window as any)[prebidGlobal || "pbjs"]; + const prebid = pbjsInstance ?? (window as any)[pbjsInstanceName || "pbjs"]; if (!prebid) return null; const analyticsSDK = new SDK({ From f6407d69fd6ec25e4824f5258d11e96fe7220506 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 22 Jul 2026 13:37:13 -0400 Subject: [PATCH 4/7] remove instance init --- lib/addons/prebid/README.md | 38 +++++------ lib/addons/prebid/analytics.md | 44 +++++++------ lib/addons/prebid/analytics.test.ts | 97 +++++++++++------------------ lib/addons/prebid/analytics.ts | 42 +++++-------- 4 files changed, 88 insertions(+), 133 deletions(-) diff --git a/lib/addons/prebid/README.md b/lib/addons/prebid/README.md index ba668ace..b19cbe45 100644 --- a/lib/addons/prebid/README.md +++ b/lib/addons/prebid/README.md @@ -6,22 +6,22 @@ This addon integrates Optable analytics with Prebid.js, allowing you to send auc ### Quick start: `initPrebidAnalytics` -`initPrebidAnalytics` bootstraps the whole integration in one call: it creates a -dedicated read-only analytics SDK instance, constructs `OptablePrebidAnalytics`, -and hooks it into the publisher's Prebid.js global. It returns the analytics -instance, or `null` when no Prebid.js global is present (in which case no SDK -instance is created). +`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/dist/addons/prebid/analytics"; +const sdk = new OptableSDK({ host: "na.edge.optable.co", node: "analytics", site: "analytics" }); + initPrebidAnalytics({ - SDK: OptableSDK, - // Config for the dedicated read-only analytics SDK instance - instance: { host: "na.edge.optable.co", node: "analytics", site: "analytics" }, - // Prebid instance by name (default "pbjs"), or pass the object directly via `pbjsInstance` - pbjsInstanceName: "pbjs", + // 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, @@ -32,11 +32,9 @@ initPrebidAnalytics({ }); ``` -The `SDK` constructor is passed in (rather than imported by this module) so that -consumers of the `OptablePrebidAnalytics` class don't bundle the whole SDK. The -`instance` config sets where analytics data is sent; `readOnly: true` and -`cookies: false` are applied by default and can be overridden through `instance`. -The tenant reported in every payload is derived from `instance.node`. +`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 @@ -95,13 +93,11 @@ if (window.optable.runAnalytics && tenant) { ### `initPrebidAnalytics(options)` -Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid.js global is present. +Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid.js instance is provided. -- `options.SDK`: The Optable SDK constructor (pass the imported `OptableSDK`). -- `options.instance`: Config for the dedicated read-only analytics SDK instance (`host`/`node`/`site`/…). `readOnly: true` and `cookies: false` default on and can be overridden here. -- `options.pbjsInstance`: The Prebid.js instance to hook into. When omitted, `window[pbjsInstanceName]` is used. -- `options.pbjsInstanceName`: Name of the Prebid.js global on `window` (default `"pbjs"`), used when `pbjsInstance` is not passed. -- `options.analytics`: Config forwarded to the `OptablePrebidAnalytics` constructor (`samplingRate`, `debug`, `getSplitTestAssignment`, …). The tenant reported in the payload comes from the analytics SDK instance's `node`. +- `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`, `getSplitTestAssignment`, …). ### `OptablePrebidAnalytics` diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 119c9ce2..3036b8e4 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -20,20 +20,21 @@ It exposes two things: 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({ - SDK: OptableSDK, - instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, + sdkInstance: sdk, + pbjsInstance: window.pbjs, analytics: { samplingRate: 0.1, }, }); ``` -`initPrebidAnalytics` returns the analytics instance, or `null` when no Prebid.js -global is present (in which case no SDK instance is created). The analytics SDK -is created with `readOnly: true` and `cookies: false` by default — you only pass -`host`/`node`/`site`. The tenant reported in every payload is derived from -`instance.node`. +`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 @@ -62,8 +63,8 @@ const ab = setupAB({ }); const analytics = initPrebidAnalytics({ - SDK: OptableSDK, - instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, + sdkInstance: sdk, + pbjsInstance: window.pbjs, analytics: { samplingRate: 0.1, // Map "none" to "test" so control/holdout still reports a comparable bucket. @@ -77,15 +78,14 @@ The resolved value appears on every bid in the payload as ### Passing an explicit Prebid instance -By default the addon reads `window.pbjs`. Pass `pbjsInstance` (or `pbjsInstanceName` -to change the window key) 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. +`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({ - SDK: OptableSDK, - instance: { host: "na.edge.optable.co", node: "my-tenant", site: "my-site" }, + sdkInstance: sdk, pbjsInstance: myPrebidInstance, }); ``` @@ -128,15 +128,13 @@ The tenant reported in the payload is taken from the SDK instance's `node`. **Options** -| Option | Type | Default | Description | -| ------------------ | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------- | -| `SDK` | `new (config) => OptableSDK` | required | The Optable SDK constructor. Passed in so this module keeps a type-only SDK import. | -| `instance` | `InitConfig` | required | Config for the read-only analytics SDK (`host`/`node`/`site`/…). `readOnly`/`cookies` default false. | -| `pbjsInstance` | `object` | — | Prebid.js instance to hook into. When omitted, `window[pbjsInstanceName]` is used. | -| `pbjsInstanceName` | `string` | `"pbjs"` | Window key used to find Prebid when `pbjsInstance` is not passed. | -| `analytics` | `OptablePrebidAnalyticsConfig` | — | Analytics behavior forwarded to the collector (see below). `analytics: true` is applied by default. | +| 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 global is found. +Returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid instance is provided. ### `OptablePrebidAnalyticsConfig` diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index 93d8ca64..dd202f45 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -2300,79 +2300,40 @@ describe("OptablePrebidAnalytics", () => { }); describe("initPrebidAnalytics", () => { - // A stub SDK constructor whose instances expose witness(), enough for - // OptablePrebidAnalytics. Records the config it was constructed with. - function makeFakeSDK() { + // A stub SDK instance exposing witness() and a dcn config, enough for + // OptablePrebidAnalytics. + function makeFakeSDK(node?: string) { const witness = jest.fn().mockResolvedValue(undefined); - const ctor = jest.fn().mockImplementation(() => ({ witness })); - return { ctor: ctor as unknown as new (config: any) => any, calls: ctor.mock.calls, witness }; + return { witness, dcn: { node } } as unknown as OptableSDK; } function loadedPrebid() { return { getEvents: jest.fn(() => []), onEvent: jest.fn() }; } - beforeEach(() => { - delete (window as any).pbjs; - delete (window as any).fusePbjs; - }); - - it("returns null and creates no SDK when no prebid global is present", () => { - const { ctor } = makeFakeSDK(); - const result = initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" } }); + it("returns null when no prebid instance is provided", () => { + const result = initPrebidAnalytics({ sdkInstance: makeFakeSDK(), pbjsInstance: undefined }); expect(result).toBeNull(); - expect(ctor).not.toHaveBeenCalled(); }); - it("creates a read-only analytics SDK instance and hooks the passed prebid instance", () => { + it("hooks the passed prebid instance", () => { const pbjs = loadedPrebid(); - const { ctor } = makeFakeSDK(); const result = initPrebidAnalytics({ - SDK: ctor, - instance: { host: "h", node: "n", site: "s" }, + sdkInstance: makeFakeSDK("n"), pbjsInstance: pbjs, }); expect(result).toBeInstanceOf(OptablePrebidAnalytics); - expect(ctor).toHaveBeenCalledWith( - expect.objectContaining({ host: "h", node: "n", site: "s", readOnly: true, cookies: false }) - ); expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); }); - it("reads the named prebid instance from window when pbjsInstance is not passed", () => { - (window as any).fusePbjs = loadedPrebid(); - const { ctor } = makeFakeSDK(); - - initPrebidAnalytics({ - SDK: ctor, - instance: { host: "h", site: "s" }, - pbjsInstanceName: "fusePbjs", - }); - - expect((window as any).fusePbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); - }); - - it("defaults to window.pbjs when no instance is configured", () => { - (window as any).pbjs = loadedPrebid(); - const { ctor } = makeFakeSDK(); - - initPrebidAnalytics({ SDK: ctor, instance: { host: "h", site: "s" } }); - - expect((window as any).pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); - }); - - it("forwards the analytics config through to the OptablePrebidAnalytics instance", () => { + it("enables analytics and forwards the analytics config to the collector", () => { const pbjs = loadedPrebid(); - const { ctor } = makeFakeSDK(); - // Any OptablePrebidAnalyticsConfig option flows through, including - // getSplitTestAssignment once it lands in the config interface. const result = initPrebidAnalytics({ - SDK: ctor, - instance: { host: "h", site: "s" }, + sdkInstance: makeFakeSDK("n"), pbjsInstance: pbjs, analytics: { samplingRate: 0.25 }, }); @@ -2381,19 +2342,16 @@ describe("initPrebidAnalytics", () => { expect(result!["config"].samplingRate).toBe(0.25); }); - it("forwards getSplitTestAssignment so it stamps bids in the payload", async () => { + it("reports the tenant from the SDK instance's node in the payload", async () => { const pbjs = loadedPrebid(); - const { ctor } = makeFakeSDK(); const result = initPrebidAnalytics({ - SDK: ctor, - instance: { host: "h", site: "s" }, + sdkInstance: makeFakeSDK("acme"), pbjsInstance: pbjs, - analytics: { getSplitTestAssignment: () => "test" }, }); const auctionEndEvent = { - auctionId: "auction-init-split", + auctionId: "auction-tenant", bidderRequests: [ { bidderCode: "bidder1", @@ -2408,19 +2366,34 @@ describe("initPrebidAnalytics", () => { }; const payload = await result!.toWitness(auctionEndEvent, []); - expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); + expect(payload.tenant).toBe("acme"); }); - it("lets the instance config override the read-only defaults", () => { + it("forwards getSplitTestAssignment so it stamps bids in the payload", async () => { const pbjs = loadedPrebid(); - const { ctor } = makeFakeSDK(); - initPrebidAnalytics({ - SDK: ctor, - instance: { host: "h", site: "s", readOnly: false }, + const result = initPrebidAnalytics({ + sdkInstance: makeFakeSDK("n"), pbjsInstance: pbjs, + analytics: { getSplitTestAssignment: () => "test" }, }); - expect(ctor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: false })); + 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" }], + }, + ], + 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 8bf55f02..f28c41a3 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -3,7 +3,6 @@ import type { WitnessProperties } from "../../edge/witness"; import type OptableSDK from "../../sdk"; import { buildRequest } from "../../core/network"; -import type { InitConfig } from "../../config"; import * as Bowser from "bowser"; @@ -664,17 +663,13 @@ export default OptablePrebidAnalytics; */ export interface InitPrebidAnalyticsOptions { /** - * The Optable SDK constructor (pass the imported `OptableSDK`). Taken as a - * parameter so this module keeps a type-only SDK import and consumers of the - * `OptablePrebidAnalytics` class don't bundle the whole SDK. + * 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. */ - SDK: new (config: InitConfig) => OptableSDK; - /** Config for the dedicated read-only analytics SDK instance (host/node/site/…). */ - instance: InitConfig; - /** Prebid.js instance to hook into. When omitted, `window[pbjsInstanceName]` is used. */ - pbjsInstance?: any; - /** Name of the Prebid.js global on `window` (default `"pbjs"`), used when `pbjsInstance` is not passed. */ - pbjsInstanceName?: string; + sdkInstance: OptableSDK; + /** The Prebid.js instance to hook into. */ + pbjsInstance: any; /** * Analytics behavior forwarded to the `OptablePrebidAnalytics` constructor * (samplingRate, debug, getSplitTestAssignment, …). @@ -683,31 +678,24 @@ export interface InitPrebidAnalyticsOptions { } /** - * Bootstrap Prebid.js analytics for a bundle: create a dedicated read-only - * analytics SDK instance, construct an `OptablePrebidAnalytics`, and hook it - * into the publisher's Prebid.js global. + * 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 global is present - * (in which case no analytics SDK instance is created). + * 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 { SDK, instance, pbjsInstance, pbjsInstanceName, analytics: analyticsConfig } = options; + const { sdkInstance, pbjsInstance, analytics: analyticsConfig } = options; - const prebid = pbjsInstance ?? (window as any)[pbjsInstanceName || "pbjs"]; - if (!prebid) return null; + if (!pbjsInstance) return null; - const analyticsSDK = new SDK({ - readOnly: true, - cookies: false, - ...instance, - }); - - const analytics = new OptablePrebidAnalytics(analyticsSDK, { + const analytics = new OptablePrebidAnalytics(sdkInstance, { analytics: true, ...analyticsConfig, }); - analytics.hookIntoPrebid(prebid); + analytics.hookIntoPrebid(pbjsInstance); return analytics; } From b7513bb89852e542a1d0dbd5e12543bb58ca5347 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 22 Jul 2026 13:40:29 -0400 Subject: [PATCH 5/7] lint --- lib/addons/prebid/analytics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index 3036b8e4..8829f44a 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -131,7 +131,7 @@ The tenant reported in the payload is taken from the SDK instance's `node`. | 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`). | +| `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. From faaf8aa43c2ae83472685892c309be03959beccf Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 22 Jul 2026 14:05:00 -0400 Subject: [PATCH 6/7] rework splitTest --- lib/addons/prebid/README.md | 75 +++++++---------------------- lib/addons/prebid/analytics.md | 41 ++-------------- lib/addons/prebid/analytics.test.ts | 71 ++++++++------------------- lib/addons/prebid/analytics.ts | 38 +++------------ 4 files changed, 50 insertions(+), 175 deletions(-) diff --git a/lib/addons/prebid/README.md b/lib/addons/prebid/README.md index b19cbe45..59ac2737 100644 --- a/lib/addons/prebid/README.md +++ b/lib/addons/prebid/README.md @@ -13,7 +13,7 @@ when no Prebid.js instance is provided. ```ts import OptableSDK from "@optable/web-sdk"; -import { initPrebidAnalytics } from "@optable/web-sdk/lib/dist/addons/prebid/analytics"; +import { initPrebidAnalytics } from "@optable/web-sdk/lib/addons/prebid/analytics"; const sdk = new OptableSDK({ host: "na.edge.optable.co", node: "analytics", site: "analytics" }); @@ -26,8 +26,7 @@ initPrebidAnalytics({ analytics: { samplingRate: 0.1, debug: !!sessionStorage.optableDebug, - // Any OptablePrebidAnalyticsConfig option is forwarded, e.g. - // getSplitTestAssignment: () => window.optable?.selectedTest?.id, + // Any OptablePrebidAnalyticsConfig option is forwarded, e.g. samplingVolume. }, }); ``` @@ -38,56 +37,27 @@ payload is read from that instance's `node`. ### Manual setup -For full control over the instances, wire the pieces up yourself: +For full control, construct the collector yourself and hook it into Prebid.js: ```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, - debug: !!sessionStorage.optableDebug, - samplingRate: 0.75, - }); - window.optable.analytics.hookIntoPrebid(window.pbjs); -} -// ... +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); ``` -- Replace 'my_tenant' with your Optable tenant name. -- Optionally, implement `window.optable.customAnalytics` to add custom key-value pairs to each analytics event. +The payload tenant is read from the SDK instance's `node`. ## 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. +- **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 @@ -97,7 +67,7 @@ Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or - `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`, `getSplitTestAssignment`, …). +- `options.analytics`: Config forwarded to the `OptablePrebidAnalytics` constructor (`samplingRate`, `debug`, …). ### `OptablePrebidAnalytics` @@ -108,14 +78,3 @@ Bootstraps the integration and returns the `OptablePrebidAnalytics` instance, or - **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 index 8829f44a..b37dd747 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -38,42 +38,10 @@ read from the SDK instance's `node`. ### With split-test assignment -When A/B testing, the analytics payload can record which variant each bid belonged -to via a per-bid `splitTestAssignment` field. There are two ways that field gets -populated: - -1. **From the bid itself** — if `ortb2Imp.ext.optable.splitTestAssignment` is already - set on the Prebid bid (e.g. the [A/B test addon](../abTestAssignment.md) stamped - it), the addon reads it directly. -2. **From `getSplitTestAssignment`** — a callback you configure. When set, its return - value is stamped onto **every** bid as it is built (initial requests, received - bids, and won bids), and it **takes precedence** over any value already on the - bid. Return `undefined` to fall back to the value on the bid. - -Use `getSplitTestAssignment` when the assignment lives in your own code rather than -on the bids. This replaces the older pattern of wrapping `trackAuctionEnd` to mutate -Prebid events yourself — the callback covers received/won bids too, which an -`auctionEnd`-only wrapper would miss. - -```js -import { setupAB } from "@optable/web-sdk/lib/addons/abTestAssignment"; - -const ab = setupAB({ - variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }], -}); - -const analytics = initPrebidAnalytics({ - sdkInstance: sdk, - pbjsInstance: window.pbjs, - analytics: { - samplingRate: 0.1, - // Map "none" to "test" so control/holdout still reports a comparable bucket. - getSplitTestAssignment: () => (ab.variant.id === "none" ? "test" : ab.variant.id), - }, -}); -``` - -The resolved value appears on every bid in the payload as +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 @@ -147,7 +115,6 @@ Returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid instance | `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. | -| `getSplitTestAssignment` | `() => string \| undefined` | — | Returns the split-test assignment stamped onto every bid. Takes precedence over the value on the bid. | ### `OptablePrebidAnalytics` instance diff --git a/lib/addons/prebid/analytics.test.ts b/lib/addons/prebid/analytics.test.ts index dd202f45..7412b817 100644 --- a/lib/addons/prebid/analytics.test.ts +++ b/lib/addons/prebid/analytics.test.ts @@ -1793,7 +1793,7 @@ describe("OptablePrebidAnalytics", () => { }); }); - describe("getSplitTestAssignment", () => { + 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) => ({ @@ -1833,68 +1833,32 @@ describe("OptablePrebidAnalytics", () => { timeoutBids: [], }); - it("stamps the callback value onto request bids in the payload", async () => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - getSplitTestAssignment: () => "test", - }); + it("reads the assignment stamped on the request bid's ortb2Imp", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); - const payload = await analytics.toWitness(makeAuction("auction-cb-request"), []); + const payload = await analytics.toWitness(makeAuction("auction-request-stamp", "test"), []); expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); }); - it("overrides a value already present on the bid", async () => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - getSplitTestAssignment: () => "production", - }); - - const payload = await analytics.toWitness(makeAuction("auction-cb-override", "test"), []); - - // Callback wins over the bid's own ortb2Imp value. - expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("production"); - }); - - it("overrides the value on received bids", async () => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - getSplitTestAssignment: () => "production", - }); + it("reads the assignment stamped on a received bid's ortb2Imp", async () => { + analytics = new OptablePrebidAnalytics(mockOptableInstance); - const event = makeAuction("auction-cb-received", undefined, "test"); + const event = makeAuction("auction-received-stamp", undefined, "test"); await analytics.trackAuctionEnd(event); - const storedAuction = analytics["auctions"].get("auction-cb-received"); + const storedAuction = analytics["auctions"].get("auction-received-stamp"); const payload = await analytics.toWitness(storedAuction!.auctionEnd, []); - expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("production"); - }); - - it("falls back to the bid value when the callback returns undefined", async () => { - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - getSplitTestAssignment: () => undefined, - }); - - const payload = await analytics.toWitness(makeAuction("auction-cb-undefined", "test"), []); - expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); }); - it("uses the bid value when no callback is configured", async () => { + 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-cb", "test"), []); + const payload = await analytics.toWitness(makeAuction("auction-no-stamp"), []); - expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBe("test"); - }); - - it("is invoked once per bid as bids are built", async () => { - const getSplitTestAssignment = jest.fn(() => "test"); - analytics = new OptablePrebidAnalytics(mockOptableInstance, { - getSplitTestAssignment, - }); - - await analytics.toWitness(makeAuction("auction-cb-called"), []); - - expect(getSplitTestAssignment).toHaveBeenCalled(); + expect(payload.bidderRequests[0].bids[0].splitTestAssignment).toBeUndefined(); }); }); @@ -2369,13 +2333,12 @@ describe("initPrebidAnalytics", () => { expect(payload.tenant).toBe("acme"); }); - it("forwards getSplitTestAssignment so it stamps bids in the payload", async () => { + 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, - analytics: { getSplitTestAssignment: () => "test" }, }); const auctionEndEvent = { @@ -2385,7 +2348,15 @@ describe("initPrebidAnalytics", () => { 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" }], + bids: [ + { + bidId: "bid-1", + adUnitCode: "ad-unit-1", + transactionId: "trans-1", + src: "client", + ortb2Imp: { ext: { optable: { splitTestAssignment: "test" } } }, + }, + ], }, ], bidsReceived: [], diff --git a/lib/addons/prebid/analytics.ts b/lib/addons/prebid/analytics.ts index f28c41a3..95a0ed4c 100644 --- a/lib/addons/prebid/analytics.ts +++ b/lib/addons/prebid/analytics.ts @@ -31,13 +31,6 @@ interface OptablePrebidAnalyticsConfig { samplingSeed?: string; samplingRate?: number; samplingRateFn?: () => boolean; - /** - * Optional callback returning the split-test assignment for the current page. - * When provided, it is stamped onto every bid's `splitTestAssignment` field in - * the Witness payload, overriding any value already present on the bid. Use this - * to attach A/B assignment without mutating Prebid events yourself. - */ - getSplitTestAssignment?: () => string | undefined; } interface AuctionItem { @@ -138,21 +131,6 @@ class OptablePrebidAnalytics { } } - /** - * Resolve the split-test assignment for a bid. When a `getSplitTestAssignment` - * callback is configured its value takes precedence; otherwise the assignment - * already present on the bid (`ortb2Imp.ext.optable.splitTestAssignment`) is used. - * @param bidValue - The assignment already stamped on the bid, if any. - * @returns The resolved assignment, or undefined when neither source provides one. - */ - private resolveSplitTestAssignment(bidValue?: string): string | undefined { - if (this.config.getSplitTestAssignment) { - const value = this.config.getSplitTestAssignment(); - if (value !== undefined) return value; - } - return bidValue; - } - /** * Determine whether the current event/session should be sampled according to * the configured sampling rate, seed or function. @@ -344,7 +322,7 @@ class OptablePrebidAnalytics { transactionId: b.transactionId, src: b.src, floorMin: b.floorData?.floorMin, - splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), + splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, status: STATUS.REQUESTED, }) ), @@ -384,7 +362,7 @@ class OptablePrebidAnalytics { cpm: b.cpm, size: `${b.width}x${b.height}`, currency: b.currency, - splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), + splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, }); } else { // Create new bid object for this response @@ -399,7 +377,7 @@ class OptablePrebidAnalytics { size: `${b.width}x${b.height}`, currency: b.currency, status: STATUS.RECEIVED, - splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), + splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, }; br.bids.push(bidObj); bidIndex[bidId] = bidObj; @@ -574,7 +552,7 @@ class OptablePrebidAnalytics { transactionId: b.transactionId, src: b.src, floorMin: b.floorData?.floorMin, - splitTestAssignment: this.resolveSplitTestAssignment(b.ortb2Imp?.ext?.optable?.splitTestAssignment), + splitTestAssignment: b.ortb2Imp?.ext?.optable?.splitTestAssignment, status: STATUS.REQUESTED, }) ), @@ -593,9 +571,9 @@ class OptablePrebidAnalytics { bid.cpm = bidReceived.cpm; bid.size = `${bidReceived.width}x${bidReceived.height}`; bid.currency = bidReceived.currency; - const resolved = this.resolveSplitTestAssignment(bidReceived.ortb2Imp?.ext?.optable?.splitTestAssignment); - if (resolved !== undefined) { - bid.splitTestAssignment = resolved; + const receivedSplitTest = bidReceived.ortb2Imp?.ext?.optable?.splitTestAssignment; + if (receivedSplitTest !== undefined) { + bid.splitTestAssignment = receivedSplitTest; } if (request.status === STATUS.REQUESTED) request.status = STATUS.RECEIVED; } @@ -672,7 +650,7 @@ export interface InitPrebidAnalyticsOptions { pbjsInstance: any; /** * Analytics behavior forwarded to the `OptablePrebidAnalytics` constructor - * (samplingRate, debug, getSplitTestAssignment, …). + * (samplingRate, debug, …). */ analytics?: OptablePrebidAnalyticsConfig; } From d7a1c9b687807f991330e1e357f4145bc3c7ee67 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Wed, 22 Jul 2026 15:57:53 -0400 Subject: [PATCH 7/7] pretty --- lib/addons/prebid/analytics.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/addons/prebid/analytics.md b/lib/addons/prebid/analytics.md index b37dd747..3af8879e 100644 --- a/lib/addons/prebid/analytics.md +++ b/lib/addons/prebid/analytics.md @@ -106,15 +106,15 @@ Returns the `OptablePrebidAnalytics` instance, or `null` when no Prebid instance ### `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. | +| 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