Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,24 @@ To automatically capture GPT [SlotRenderEndedEvent](https://developers.google.co
</script>
```

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.

Expand Down
103 changes: 99 additions & 4 deletions lib/addons/gpt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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", () => {
Expand All @@ -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);
});
});
49 changes: 41 additions & 8 deletions lib/addons/gpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string[] | "all">>;

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is disabling type safety, can we do without the any cast like it use to be? what's missing in type definitions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine the problem is the typing of GptEventSpec (mostly strings) which is not compatible with what addEventListener expects (likely well known event types)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linter technically was showing window as errored but I know it builds anyway. I can remove.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's address it separately


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
}
});
};

Expand Down