From f06cf41dfc6cdd1714a39343ccbdea5d87da8cf1 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:13:38 -0400 Subject: [PATCH 1/5] bugfix customAnalytics promise --- lib/addons/prototypes/analytics.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/addons/prototypes/analytics.js b/lib/addons/prototypes/analytics.js index 8d02b973..1789a689 100644 --- a/lib/addons/prototypes/analytics.js +++ b/lib/addons/prototypes/analytics.js @@ -277,7 +277,6 @@ class OptablePrebidAnalytics { missed, url: `${window.location.hostname}${window.location.pathname}`, tenant: this.config.tenant, - sessionDepth: sessionStorage.optableSessionDepth, // eslint-disable-next-line no-undef optableWrapperVersion: SDK_WRAPPER_VERSION, }; @@ -287,11 +286,10 @@ class OptablePrebidAnalytics { ); if (window.optable.customAnalytics) { - const customData = window.optable.customAnalytics(); - if (Object.entries(customData).length) { - this.log(`Adding custom data to payload ${JSON.stringify(customData)}`); - witnessData = { ...witnessData, ...customData }; - } + await window.optable.customAnalytics().then((response) => { + this.log(`Adding custom data to payload ${JSON.stringify(response)}`); + Object.assign(witnessData, response); + }); } await this.sendToWitnessAPI("auction_processed", { auction: JSON.stringify(witnessData), From 88a462f17674c92ed44e7ae989121a85123b4224 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:35:40 -0500 Subject: [PATCH 2/5] update function, add tests, update readme --- README.md | 26 +++++++++++ lib/addons/gpt.test.js | 103 +++++++++++++++++++++++++++++++++++++++-- lib/addons/gpt.ts | 57 ++++++++++++++++++----- 3 files changed, 171 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 05967115..62d489b8 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,32 @@ To automatically capture GPT [SlotRenderEndedEvent](https://developers.google.co ``` +Advanced usage: +You can customize which GPT events are registered and which event properties to include, per event type, by passing an options object: +```js +// Only listen to impressionViewable and emit only `slot_element_id` +optable.instance.installGPTEventListeners({ impressionViewable: ["slot_element_id"] }); + +// For slotRenderEnded, emit all properties. For impressionViewable, emit only the listed properties. +optable.instance.installGPTEventListeners({ + slotRenderEnded: "all", + impressionViewable: ["slot_element_id", "is_empty"] +}); +``` +The value for each event key can be "all" (to include all witness properties) or an array of property names from the set below (as mapped by the SDK): + +advertiser_id +campaign_id +creative_id +is_empty +line_item_id +service_name +size +slot_element_id +source_agnostic_creative_id +source_agnostic_line_item_id +If no argument is provided, the default behavior is unchanged and both slotRenderEnded and impressionViewable are captured with all properties. + Note that you can call `installGPTEventListeners()` as many times as you like on an SDK instance, there will only be one set of registered event listeners per instance. Each SDK instance can register its own GPT event listeners. A working example of both targeting and event witnessing is available in the demo pages. diff --git a/lib/addons/gpt.test.js b/lib/addons/gpt.test.js index 4ea499c3..86acbac6 100644 --- a/lib/addons/gpt.test.js +++ b/lib/addons/gpt.test.js @@ -13,7 +13,7 @@ describe("OptableSDK - installGPTSecureSignals", () => { window.googletag = { cmd: [], secureSignalProviders: [] }; }); - test("installs secure signals when provided valid signals", () => { + test("installs secure signals when provided valid signals", async () => { const signals = [ { provider: "provider1", id: "idString1" }, { provider: "provider2", id: "idString2" }, @@ -40,9 +40,8 @@ describe("OptableSDK - installGPTSecureSignals", () => { // Verify the collector functions const collectedIds = window.googletag.secureSignalProviders.map((provider) => provider.collectorFunction()); - return Promise.all(collectedIds).then((results) => { - expect(results).toEqual(["idString1", "idString2"]); - }); + const results = await Promise.all(collectedIds); + expect(results).toEqual(["idString1", "idString2"]); }); test("does nothing when no signals are provided", () => { @@ -62,3 +61,99 @@ describe("OptableSDK - installGPTSecureSignals", () => { expect(window.googletag.secureSignalProviders).toHaveLength(0); // No secureSignalProviders should be added }); }); + +describe('installGPTEventListeners', () => { + let sdk; + let handlers; + + const makeGptMock = () => { + handlers = {}; + const pubads = { + addEventListener: (eventName, handler) => { + handlers[eventName] = handlers[eventName] || []; + handlers[eventName].push(handler); + }, + }; + global.googletag = { + cmd: [], + pubads: () => pubads, + }; + // Simulate immediate execution of pushed functions (like GPT does) + global.googletag.cmd.push = (fn) => fn(); + }; + + const makeEvent = () => ({ + advertiserId: 123, + campaignId: 456, + creativeId: 789, + isEmpty: false, + lineItemId: 111, + serviceName: 'svc', + size: '300x250', + slot: { getSlotElementId: () => 'slot-id' }, + sourceAgnosticCreativeId: 222, + sourceAgnosticLineItemId: 333, + }); + + beforeEach(() => { + makeGptMock(); + sdk = new OptableSDK({ host: 'dcn.example', site: 'site' }); + jest.spyOn(sdk, 'witness').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete global.googletag; + }); + + test('default registers both events and sends full props', () => { + sdk.installGPTEventListeners(); + expect(Object.keys(handlers).sort()).toEqual(['impressionViewable', 'slotRenderEnded'].sort()); + + const event = makeEvent(); + handlers.slotRenderEnded.forEach((h) => h(event)); + + // ensure witness was called for the slotRenderEnded event + const call = sdk.witness.mock.calls.find((c) => c[0] === 'gpt_events_slot_render_ended'); + expect(call).toBeDefined(); + const props = call[1]; + expect(props).toHaveProperty('advertiser_id'); + expect(props).toHaveProperty('slot_element_id', 'slot-id'); + }); + + test('per-event filtering sends only specified witness keys', () => { + sdk.installGPTEventListeners({ impressionViewable: ['slot_element_id', 'is_empty'] }); + expect(Object.keys(handlers)).toEqual(['impressionViewable']); + + const event = makeEvent(); + handlers.impressionViewable.forEach((h) => h(event)); + + expect(sdk.witness).toHaveBeenCalledWith( + 'gpt_events_impression_viewable', + { slot_element_id: 'slot-id', is_empty: 'false' } + ); + }); + + test('slotRenderEnded: "all" sends full props', () => { + sdk.installGPTEventListeners({ slotRenderEnded: 'all' }); + expect(Object.keys(handlers)).toEqual(['slotRenderEnded']); + + const event = makeEvent(); + handlers.slotRenderEnded.forEach((h) => h(event)); + + const call = sdk.witness.mock.calls.find((c) => c[0] === 'gpt_events_slot_render_ended'); + expect(call).toBeDefined(); + const props = call[1]; + expect(props).toHaveProperty('advertiser_id'); + expect(props).toHaveProperty('slot_element_id', 'slot-id'); + }); + + test('install is idempotent', () => { + sdk.installGPTEventListeners(); + const firstCount = Object.keys(handlers).length; + // second call should be a no-op + sdk.installGPTEventListeners(); + const secondCount = Object.keys(handlers).length; + expect(firstCount).toEqual(secondCount); + }); +}); diff --git a/lib/addons/gpt.ts b/lib/addons/gpt.ts index 394b7769..45c9a854 100644 --- a/lib/addons/gpt.ts +++ b/lib/addons/gpt.ts @@ -49,18 +49,53 @@ OptableSDK.prototype.installGPTEventListeners = function () { /* * Pass user-defined signals to GAM Secure Signals */ -OptableSDK.prototype.installGPTSecureSignals = function (...signals: Array<{ provider: string; id: string }>) { +type GptEventSpec = Partial>; + +OptableSDK.prototype.installGPTEventListeners = function (eventSpec?: GptEventSpec) { + // Next time we get called is a no-op: + const sdk = this; + sdk.installGPTEventListeners = function () {}; + window.googletag = window.googletag || { cmd: [] }; - const gpt = window.googletag; + const gpt = (window as any).googletag; - if (signals && signals.length > 0) { - gpt.cmd.push(() => { - signals.forEach(({ provider, id }) => { - gpt.secureSignalProviders.push({ - id: provider, - collectorFunction: () => Promise.resolve(id), - }); - }); - }); + const DEFAULT_EVENTS = ["slotRenderEnded", "impressionViewable"]; + + function snakeCase(name: string) { + return name.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase()); } + + function filterProps(obj: any, keys: string[]) { + if (!obj || !keys || !keys.length) return {}; + const out: any = {}; + for (const k of keys) { + if (Object.prototype.hasOwnProperty.call(obj, k)) { + out[k] = obj[k]; + } + } + return out; + } + + gpt.cmd.push(function () { + try { + const pubads = gpt.pubads && gpt.pubads(); + if (!pubads || typeof pubads.addEventListener !== "function") return; + + const eventsToRegister = eventSpec ? Object.keys(eventSpec) : DEFAULT_EVENTS; + + for (const eventName of eventsToRegister) { + const keysOrAll = eventSpec ? eventSpec[eventName] : "all"; + + pubads.addEventListener(eventName, function (event: any) { + const fullProps = toWitnessProperties(event); + const propsToSend = + Array.isArray(keysOrAll) && keysOrAll.length ? filterProps(fullProps, keysOrAll) : fullProps; + + sdk.witness("gpt_events_" + snakeCase(eventName), propsToSend); + }); + } + } catch (e) { + // fail silently to avoid breaking host page + } + }); }; From 04c39e3faac5686cd31714d5f56b4d374c98469c Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:38:58 -0500 Subject: [PATCH 3/5] lint --- README.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/README.md b/README.md index 62d489b8..f3ac86a0 100644 --- a/README.md +++ b/README.md @@ -536,16 +536,7 @@ optable.instance.installGPTEventListeners({ ``` The value for each event key can be "all" (to include all witness properties) or an array of property names from the set below (as mapped by the SDK): -advertiser_id -campaign_id -creative_id -is_empty -line_item_id -service_name -size -slot_element_id -source_agnostic_creative_id -source_agnostic_line_item_id +`advertiser_id`, `campaign_id`, `creative_id`, `is_empty`, `line_item_id`, `service_name`, `size`, `slot_element_id`, `source_agnostic_creative_id`, `source_agnostic_line_item_id`. If no argument is provided, the default behavior is unchanged and both slotRenderEnded and impressionViewable are captured with all properties. Note that you can call `installGPTEventListeners()` as many times as you like on an SDK instance, there will only be one set of registered event listeners per instance. Each SDK instance can register its own GPT event listeners. From 603b855763996e6ef46f8ecc8fbef919a3362627 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:44:30 -0500 Subject: [PATCH 4/5] fix error --- lib/addons/gpt.ts | 58 +++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/lib/addons/gpt.ts b/lib/addons/gpt.ts index 45c9a854..2522daed 100644 --- a/lib/addons/gpt.ts +++ b/lib/addons/gpt.ts @@ -10,16 +10,16 @@ declare module "../sdk" { function toWitnessProperties(event: any): WitnessProperties { return { - advertiserId: event.advertiserId?.toString() as string, - campaignId: event.campaignId?.toString() as string, - creativeId: event.creativeId?.toString() as string, - isEmpty: event.isEmpty?.toString() as string, - lineItemId: event.lineItemId?.toString() as string, - serviceName: event.serviceName?.toString() as string, + advertiser_id: event.advertiserId?.toString() as string, + campaign_id: event.campaignId?.toString() as string, + creative_id: event.creativeId?.toString() as string, + is_empty: event.isEmpty?.toString() as string, + line_item_id: event.lineItemId?.toString() as string, + service_name: event.serviceName?.toString() as string, size: event.size?.toString() as string, - slotElementId: event.slot?.getSlotElementId() as string, - sourceAgnosticCreativeId: event.sourceAgnosticCreativeId?.toString() as string, - sourceAgnosticLineItemId: event.sourceAgnosticLineItemId?.toString() as string, + slot_element_id: event.slot?.getSlotElementId() as string, + source_agnostic_creative_id: event.sourceAgnosticCreativeId?.toString() as string, + source_agnostic_line_item_id: event.sourceAgnosticLineItemId?.toString() as string, }; } @@ -28,27 +28,6 @@ function toWitnessProperties(event: any): WitnessProperties { * "slotRenderEnded" and "impressionViewable" page events, and calls witness() * on the OptableSDK instance to send log data to a DCN. */ -OptableSDK.prototype.installGPTEventListeners = function () { - // Next time we get called is a no-op: - const sdk = this; - sdk.installGPTEventListeners = function () {}; - - window.googletag = window.googletag || { cmd: [] }; - const gpt = window.googletag; - - gpt.cmd.push(function () { - gpt.pubads().addEventListener("slotRenderEnded", function (event: any) { - sdk.witness("googletag.events.slotRenderEnded", toWitnessProperties(event)); - }); - gpt.pubads().addEventListener("impressionViewable", function (event: any) { - sdk.witness("googletag.events.impressionViewable", toWitnessProperties(event)); - }); - }); -}; - -/* - * Pass user-defined signals to GAM Secure Signals - */ type GptEventSpec = Partial>; OptableSDK.prototype.installGPTEventListeners = function (eventSpec?: GptEventSpec) { @@ -99,3 +78,22 @@ OptableSDK.prototype.installGPTEventListeners = function (eventSpec?: GptEventSp } }); }; + +/* + * Pass user-defined signals to GAM Secure Signals + */ +OptableSDK.prototype.installGPTSecureSignals = function (...signals: Array<{ provider: string; id: string }>) { + window.googletag = window.googletag || { cmd: [] }; + const gpt = window.googletag; + + if (signals && signals.length > 0) { + gpt.cmd.push(() => { + signals.forEach(({ provider, id }) => { + gpt.secureSignalProviders.push({ + id: provider, + collectorFunction: () => Promise.resolve(id), + }); + }); + }); + } +}; \ No newline at end of file From 8e80ff5ab4403a9aae5779439175bc09e34c0a15 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:14:55 -0500 Subject: [PATCH 5/5] lint --- README.md | 5 +++-- lib/addons/gpt.test.js | 48 +++++++++++++++++++++--------------------- lib/addons/gpt.ts | 2 +- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e3d41a4c..f8bb69bd 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,7 @@ To automatically capture GPT [SlotRenderEndedEvent](https://developers.google.co Advanced usage: You can customize which GPT events are registered and which event properties to include, per event type, by passing an options object: + ```js // Only listen to impressionViewable and emit only `slot_element_id` optable.instance.installGPTEventListeners({ impressionViewable: ["slot_element_id"] }); @@ -528,15 +529,15 @@ optable.instance.installGPTEventListeners({ impressionViewable: ["slot_element_i // For slotRenderEnded, emit all properties. For impressionViewable, emit only the listed properties. optable.instance.installGPTEventListeners({ slotRenderEnded: "all", - impressionViewable: ["slot_element_id", "is_empty"] + impressionViewable: ["slot_element_id", "is_empty"], }); ``` + The value for each event key can be "all" (to include all witness properties) or an array of property names from the set below (as mapped by the SDK): `advertiser_id`, `campaign_id`, `creative_id`, `is_empty`, `line_item_id`, `service_name`, `size`, `slot_element_id`, `source_agnostic_creative_id`, `source_agnostic_line_item_id`. If no argument is provided, the default behavior is unchanged and both slotRenderEnded and impressionViewable are captured with all properties. - Note that you can call `installGPTEventListeners()` as many times as you like on an SDK instance, there will only be one set of registered event listeners per instance. Each SDK instance can register its own GPT event listeners. A working example of both targeting and event witnessing is available in the demo pages. diff --git a/lib/addons/gpt.test.js b/lib/addons/gpt.test.js index 86acbac6..b40590e9 100644 --- a/lib/addons/gpt.test.js +++ b/lib/addons/gpt.test.js @@ -62,7 +62,7 @@ describe("OptableSDK - installGPTSecureSignals", () => { }); }); -describe('installGPTEventListeners', () => { +describe("installGPTEventListeners", () => { let sdk; let handlers; @@ -88,17 +88,17 @@ describe('installGPTEventListeners', () => { creativeId: 789, isEmpty: false, lineItemId: 111, - serviceName: 'svc', - size: '300x250', - slot: { getSlotElementId: () => 'slot-id' }, + serviceName: "svc", + size: "300x250", + slot: { getSlotElementId: () => "slot-id" }, sourceAgnosticCreativeId: 222, sourceAgnosticLineItemId: 333, }); beforeEach(() => { makeGptMock(); - sdk = new OptableSDK({ host: 'dcn.example', site: 'site' }); - jest.spyOn(sdk, 'witness').mockImplementation(() => {}); + sdk = new OptableSDK({ host: "dcn.example", site: "site" }); + jest.spyOn(sdk, "witness").mockImplementation(() => {}); }); afterEach(() => { @@ -106,49 +106,49 @@ describe('installGPTEventListeners', () => { delete global.googletag; }); - test('default registers both events and sends full props', () => { + test("default registers both events and sends full props", () => { sdk.installGPTEventListeners(); - expect(Object.keys(handlers).sort()).toEqual(['impressionViewable', 'slotRenderEnded'].sort()); + expect(Object.keys(handlers).sort()).toEqual(["impressionViewable", "slotRenderEnded"].sort()); const event = makeEvent(); handlers.slotRenderEnded.forEach((h) => h(event)); // ensure witness was called for the slotRenderEnded event - const call = sdk.witness.mock.calls.find((c) => c[0] === 'gpt_events_slot_render_ended'); + const call = sdk.witness.mock.calls.find((c) => c[0] === "gpt_events_slot_render_ended"); expect(call).toBeDefined(); const props = call[1]; - expect(props).toHaveProperty('advertiser_id'); - expect(props).toHaveProperty('slot_element_id', 'slot-id'); + expect(props).toHaveProperty("advertiser_id"); + expect(props).toHaveProperty("slot_element_id", "slot-id"); }); - test('per-event filtering sends only specified witness keys', () => { - sdk.installGPTEventListeners({ impressionViewable: ['slot_element_id', 'is_empty'] }); - expect(Object.keys(handlers)).toEqual(['impressionViewable']); + test("per-event filtering sends only specified witness keys", () => { + sdk.installGPTEventListeners({ impressionViewable: ["slot_element_id", "is_empty"] }); + expect(Object.keys(handlers)).toEqual(["impressionViewable"]); const event = makeEvent(); handlers.impressionViewable.forEach((h) => h(event)); - expect(sdk.witness).toHaveBeenCalledWith( - 'gpt_events_impression_viewable', - { slot_element_id: 'slot-id', is_empty: 'false' } - ); + expect(sdk.witness).toHaveBeenCalledWith("gpt_events_impression_viewable", { + slot_element_id: "slot-id", + is_empty: "false", + }); }); test('slotRenderEnded: "all" sends full props', () => { - sdk.installGPTEventListeners({ slotRenderEnded: 'all' }); - expect(Object.keys(handlers)).toEqual(['slotRenderEnded']); + sdk.installGPTEventListeners({ slotRenderEnded: "all" }); + expect(Object.keys(handlers)).toEqual(["slotRenderEnded"]); const event = makeEvent(); handlers.slotRenderEnded.forEach((h) => h(event)); - const call = sdk.witness.mock.calls.find((c) => c[0] === 'gpt_events_slot_render_ended'); + const call = sdk.witness.mock.calls.find((c) => c[0] === "gpt_events_slot_render_ended"); expect(call).toBeDefined(); const props = call[1]; - expect(props).toHaveProperty('advertiser_id'); - expect(props).toHaveProperty('slot_element_id', 'slot-id'); + expect(props).toHaveProperty("advertiser_id"); + expect(props).toHaveProperty("slot_element_id", "slot-id"); }); - test('install is idempotent', () => { + test("install is idempotent", () => { sdk.installGPTEventListeners(); const firstCount = Object.keys(handlers).length; // second call should be a no-op diff --git a/lib/addons/gpt.ts b/lib/addons/gpt.ts index 2522daed..8bd68831 100644 --- a/lib/addons/gpt.ts +++ b/lib/addons/gpt.ts @@ -96,4 +96,4 @@ OptableSDK.prototype.installGPTSecureSignals = function (...signals: Array<{ pro }); }); } -}; \ No newline at end of file +};