diff --git a/README.md b/README.md index 3d7f3de..f8bb69b 100644 --- a/README.md +++ b/README.md @@ -519,7 +519,24 @@ To automatically capture GPT [SlotRenderEndedEvent](https://developers.google.co ``` -The emitted event types are `gpt_events_slot_render_ended` and `gpt_events_impression_viewable`. +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. diff --git a/lib/addons/gpt.test.js b/lib/addons/gpt.test.js index 4ea499c..b40590e 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 1c92b1e..8bd6883 100644 --- a/lib/addons/gpt.ts +++ b/lib/addons/gpt.ts @@ -28,21 +28,54 @@ 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 () { +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; + + 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 () { - gpt.pubads().addEventListener("slotRenderEnded", function (event: any) { - sdk.witness("gpt_events_slot_render_ended", toWitnessProperties(event)); - }); - gpt.pubads().addEventListener("impressionViewable", function (event: any) { - sdk.witness("gpt_events_impression_viewable", toWitnessProperties(event)); - }); + 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 + } }); };