From b648a222271256694e2fe236f26a8ed93ee3af42 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Wed, 21 Jul 2021 10:00:20 +0200 Subject: [PATCH 01/12] - custom viewability core --- modules/.submodules.json | 3 ++ modules/viewability/index.js | 67 ++++++++++++++++++++++++++++++++++++ modules/viewability/timer.js | 21 +++++++++++ 3 files changed, 91 insertions(+) create mode 100644 modules/viewability/index.js create mode 100644 modules/viewability/timer.js diff --git a/modules/.submodules.json b/modules/.submodules.json index ea3f556dbb4..766caac4638 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -59,5 +59,8 @@ "fpdModule": [ "enrichmentFpdModule", "validationFpdModule" + ], + "viewability": [ + "timer" ] } diff --git a/modules/viewability/index.js b/modules/viewability/index.js new file mode 100644 index 00000000000..e3c2fffc6df --- /dev/null +++ b/modules/viewability/index.js @@ -0,0 +1,67 @@ +import * as utils from '../../src/utils.js'; +import { getGlobal } from '../../src/prebidGlobal.js'; +import { Timer } from './timer.js'; + +// export const VIEWABILITY_CALLBACKS = { +// 'onStart': function(element, data) { utils.logInfo('Element has started being viewable', element, data); }, +// 'onStop': function(element, data) { utils.logInfo('Element has stopped being viewable', element, data); }, +// 'onComplete': function(element, data) { utils.logInfo('Element viewability has changed state', element, data); }, +// 'onError': function(element, data) {}, +// }; + +// var d = document.getElementById('div-gpt-ad-1460505748561-0') +// pbjs.measureElement(d, 63, 10000, {"onStart": function(){console.log("VIEW start");}, "onStop": function(){console.log("VIEW stop");}, "onComplete": function(){console.log("VIEW complete");}}) + +// let observers = {}; + +function measureElement(element, percentage, time, callbacks) { + let options = { + root: null, // defaults to the browser viewport if not specified or if null + rootMargin: '0px', + threshold: percentage / 100.0, + }; + + let timer; + let observer; + let viewable = false; + let stateChange = function(entries) { + viewable = entries[0].isIntersecting; + + // TODO: for debugging, remove + utils.logInfo('viewability state change', entries[0]); + + if (!timer) { + timer = new Timer(function() { + // stop observing + observer.unobserve(element); + + if (callbacks.onComplete) { + window.setTimeout(callbacks.onComplete, 0); + } + }, time, false); + } + + if (viewable) { + timer.resume(); + if (callbacks.onStart) { + window.setTimeout(callbacks.onStart, 0); + } + } else { + timer.pause(); + if (callbacks.onStop) { + window.setTimeout(callbacks.onStop, 0); + } + } + }; + + observer = new IntersectionObserver(stateChange, options); + + observer.observe(element); +} + +function stopMeasurement(element) { + +} + +(getGlobal()).measureElement = measureElement; +(getGlobal()).stopMeasurement = stopMeasurement; diff --git a/modules/viewability/timer.js b/modules/viewability/timer.js new file mode 100644 index 00000000000..61e58a05997 --- /dev/null +++ b/modules/viewability/timer.js @@ -0,0 +1,21 @@ +// kudos: https://gist.github.com/ncou/3a0a1f89c8e22416d0d607f621a948a9 +export function Timer(callback, delay, autostart = true) { + let timerId; + let start; + let remaining = delay; + + this.pause = function () { + window.clearTimeout(timerId); + remaining -= new Date() - start; + }; + + this.resume = function () { + start = new Date(); + window.clearTimeout(timerId); + timerId = window.setTimeout(callback, remaining); + }; + + if (autostart) { + this.resume(); + } +} From f8a4be82e1d6cf2bffe1fa773b310725bda31884 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Fri, 8 Oct 2021 15:06:56 +0200 Subject: [PATCH 02/12] - IntersectionObserver implementation --- modules/viewability/index.js | 124 ++++++++++++++++++++++++----------- modules/viewability/timer.js | 21 ------ 2 files changed, 84 insertions(+), 61 deletions(-) delete mode 100644 modules/viewability/timer.js diff --git a/modules/viewability/index.js b/modules/viewability/index.js index e3c2fffc6df..0d37a6c4a0d 100644 --- a/modules/viewability/index.js +++ b/modules/viewability/index.js @@ -1,67 +1,111 @@ import * as utils from '../../src/utils.js'; import { getGlobal } from '../../src/prebidGlobal.js'; -import { Timer } from './timer.js'; +import find from 'core-js-pure/features/array/find.js'; -// export const VIEWABILITY_CALLBACKS = { -// 'onStart': function(element, data) { utils.logInfo('Element has started being viewable', element, data); }, -// 'onStop': function(element, data) { utils.logInfo('Element has stopped being viewable', element, data); }, -// 'onComplete': function(element, data) { utils.logInfo('Element viewability has changed state', element, data); }, -// 'onError': function(element, data) {}, -// }; +export function init() { + (getGlobal()).viewability = { + startMeasurement: startMeasurement, + stopMeasurement: stopMeasurement, + }; +} + +const observers = {}; -// var d = document.getElementById('div-gpt-ad-1460505748561-0') -// pbjs.measureElement(d, 63, 10000, {"onStart": function(){console.log("VIEW start");}, "onStop": function(){console.log("VIEW stop");}, "onComplete": function(){console.log("VIEW complete");}}) +// vid - unique viewability identifier +// element +// trackerURL +// criteria - { inViewThreshold: 0.5, timeInView: 5000 } +export function startMeasurement(vid, element, trackerURL, criteria) { + if (!element) { + return; + } -// let observers = {}; + if (!criteria || !criteria.inViewThreshold || !criteria.timeInView) { + utils.logWarn('missing criteria', criteria); + return; + } + + if (!vid || observers[vid]) { + utils.logWarn('provide an unregistered vid', vid); + return; + } -function measureElement(element, percentage, time, callbacks) { let options = { - root: null, // defaults to the browser viewport if not specified or if null + root: null, rootMargin: '0px', - threshold: percentage / 100.0, + threshold: criteria.inViewThreshold, }; - let timer; let observer; let viewable = false; - let stateChange = function(entries) { + let timeoutId; + let stateChange = (entries) => { viewable = entries[0].isIntersecting; - // TODO: for debugging, remove - utils.logInfo('viewability state change', entries[0]); - - if (!timer) { - timer = new Timer(function() { + if (viewable) { + timeoutId = window.setTimeout(() => { // stop observing observer.unobserve(element); - if (callbacks.onComplete) { - window.setTimeout(callbacks.onComplete, 0); - } - }, time, false); - } - - if (viewable) { - timer.resume(); - if (callbacks.onStart) { - window.setTimeout(callbacks.onStart, 0); - } - } else { - timer.pause(); - if (callbacks.onStop) { - window.setTimeout(callbacks.onStop, 0); - } + utils.logInfo('element is viewable', element); + utils.triggerPixel(trackerURL, () => { + utils.logInfo('tracker fired', element, trackerURL); + }); + }, criteria.timeInView); + } else if (timeoutId) { + window.clearTimeout(timeoutId); } }; observer = new IntersectionObserver(stateChange, options); - observer.observe(element); + + observers[vid] = { + observer: observer, + element: element, + }; +} + +export function stopMeasurement(vid) { + if (!vid || !observers[vid]) { + utils.logWarn('provide a registered vid', vid); + return; + } + + observers[vid].observer.unobserve(observers[vid].element); } -function stopMeasurement(element) { +function listenMessagesFromCreative() { + window.addEventListener('message', receiveMessage, false); +} + +function receiveMessage(evt) { + var key = evt.message ? 'message' : 'data'; + var data = {}; + try { + data = JSON.parse(evt[key]); + } catch (e) { + return; + } + + if (!data || data.message !== 'Prebid Viewability') { + return; + } + + switch (data.action) { + case 'startMeasurement': + let element = data.elementId && document.getElementById(data.elementId); + if (!element) { + element = find(document.getElementsByTagName('IFRAME'), iframe => (iframe.contentWindow || iframe.contentDocument.defaultView) == evt.source); + } + startMeasurement(data.vid, element, data.trackerURL, data.criteria); + break; + case 'stopMeasurement': + stopMeasurement(data.vid); + break; + } } -(getGlobal()).measureElement = measureElement; -(getGlobal()).stopMeasurement = stopMeasurement; +init(); +listenMessagesFromCreative(); diff --git a/modules/viewability/timer.js b/modules/viewability/timer.js deleted file mode 100644 index 61e58a05997..00000000000 --- a/modules/viewability/timer.js +++ /dev/null @@ -1,21 +0,0 @@ -// kudos: https://gist.github.com/ncou/3a0a1f89c8e22416d0d607f621a948a9 -export function Timer(callback, delay, autostart = true) { - let timerId; - let start; - let remaining = delay; - - this.pause = function () { - window.clearTimeout(timerId); - remaining -= new Date() - start; - }; - - this.resume = function () { - start = new Date(); - window.clearTimeout(timerId); - timerId = window.setTimeout(callback, remaining); - }; - - if (autostart) { - this.resume(); - } -} From c577fb4e330306b861caa0b13c3cc137c9bc3322 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Fri, 8 Oct 2021 17:24:14 +0200 Subject: [PATCH 03/12] - support different type of trackers --- modules/viewability/index.js | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/modules/viewability/index.js b/modules/viewability/index.js index 0d37a6c4a0d..f23e6e4518a 100644 --- a/modules/viewability/index.js +++ b/modules/viewability/index.js @@ -7,16 +7,18 @@ export function init() { startMeasurement: startMeasurement, stopMeasurement: stopMeasurement, }; + + listenMessagesFromCreative(); } const observers = {}; // vid - unique viewability identifier // element -// trackerURL +// tracker { method: 'img', url: 'http://my.tracker/123' } // criteria - { inViewThreshold: 0.5, timeInView: 5000 } -export function startMeasurement(vid, element, trackerURL, criteria) { - if (!element) { +export function startMeasurement(vid, element, tracker, criteria) { + if (!element || !tracker || !tracker.method || !tracker.url) { return; } @@ -47,10 +49,21 @@ export function startMeasurement(vid, element, trackerURL, criteria) { // stop observing observer.unobserve(element); - utils.logInfo('element is viewable', element); - utils.triggerPixel(trackerURL, () => { - utils.logInfo('tracker fired', element, trackerURL); - }); + switch (tracker.method) { + case 'img': + // image + utils.triggerPixel(tracker.url, () => { + utils.logInfo('tracker fired', tracker.url); + }); + break; + case 'js': + // javascript + utils.insertHtmlIntoIframe(``); + break; + default: + utils.logWarn('unsupported tracking method', tracker.method); + break; + } }, criteria.timeInView); } else if (timeoutId) { window.clearTimeout(timeoutId); @@ -99,7 +112,7 @@ function receiveMessage(evt) { element = find(document.getElementsByTagName('IFRAME'), iframe => (iframe.contentWindow || iframe.contentDocument.defaultView) == evt.source); } - startMeasurement(data.vid, element, data.trackerURL, data.criteria); + startMeasurement(data.vid, element, data.tracker, data.criteria); break; case 'stopMeasurement': stopMeasurement(data.vid); @@ -108,4 +121,3 @@ function receiveMessage(evt) { } init(); -listenMessagesFromCreative(); From 0233b45922472336223b84ece68cfa1abc4e1a4b Mon Sep 17 00:00:00 2001 From: aleksatr Date: Wed, 13 Oct 2021 10:53:38 +0200 Subject: [PATCH 04/12] - viewability tests wip --- modules/viewability/index.js | 23 ++++++++--- test/spec/modules/viewability_spec.js | 55 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 test/spec/modules/viewability_spec.js diff --git a/modules/viewability/index.js b/modules/viewability/index.js index f23e6e4518a..07b175c11af 100644 --- a/modules/viewability/index.js +++ b/modules/viewability/index.js @@ -13,10 +13,15 @@ export function init() { const observers = {}; -// vid - unique viewability identifier -// element -// tracker { method: 'img', url: 'http://my.tracker/123' } -// criteria - { inViewThreshold: 0.5, timeInView: 5000 } +/** + * Start measuring viewability of an element + * @typedef {{ method: string='img','js', url: string }} ViewabilityTracker { method: 'img', url: 'http://my.tracker/123' } + * @typedef {{ inViewThreshold: number, timeInView: number }} ViewabilityCriteria { inViewThreshold: 0.5, timeInView: 1000 } + * @param {string} vid unique viewability identifier + * @param {HTMLElement} element + * @param {ViewabilityTracker} tracker + * @param {ViewabilityCriteria} criteria + */ export function startMeasurement(vid, element, tracker, criteria) { if (!element || !tracker || !tracker.method || !tracker.url) { return; @@ -79,6 +84,10 @@ export function startMeasurement(vid, element, tracker, criteria) { }; } +/** + * Stop measuring viewability of an element + * @param {string} vid unique viewability identifier + */ export function stopMeasurement(vid) { if (!vid || !observers[vid]) { utils.logWarn('provide a registered vid', vid); @@ -92,7 +101,11 @@ function listenMessagesFromCreative() { window.addEventListener('message', receiveMessage, false); } -function receiveMessage(evt) { +/** + * Recieve messages from creatives + * @param {MessageEvent} evt + */ +export function receiveMessage(evt) { var key = evt.message ? 'message' : 'data'; var data = {}; try { diff --git a/test/spec/modules/viewability_spec.js b/test/spec/modules/viewability_spec.js new file mode 100644 index 00000000000..b7d9d0a6553 --- /dev/null +++ b/test/spec/modules/viewability_spec.js @@ -0,0 +1,55 @@ +import { expect, spy } from 'chai'; +import * as sinon from 'sinon'; +import * as utils from 'src/utils.js'; +import * as viewability from 'modules/viewability/index.js'; + +describe('viewabilityTest', () => { + describe('start measurement', () => { + it('should fire viewability trackers', () => { + let observeCalled = false; + let unobserveCalled = false; + + let fakeIntersectionObserver = (stateChange, options) => { + return { + observe: (element) => { + observeCalled = true; + stateChange([{isIntersecting: true}]); + }, + unobserve: (element) => { + unobserveCalled = true; + }, + }; + }; + let sandbox = sinon.sandbox.create(); + let intersectionObserverStub = sandbox.stub(window, 'IntersectionObserver').callsFake(fakeIntersectionObserver); + let setTimeoutStub = sandbox.stub(window, 'setTimeout').callsFake((callback, timeout) => { + callback(); + }); + let triggerPixelSpy = sandbox.spy(utils, ['triggerPixel']); + + viewability.startMeasurement('1', {}, { method: 'img', url: 'http://my.tracker/123' }, { inViewThreshold: 0.5, timeInView: 1000 }); + + sinon.assert.called(intersectionObserverStub); + sinon.assert.called(setTimeoutStub); + expect(observeCalled).to.equal(true); + expect(unobserveCalled).to.equal(true); + // check for img tracker + expect(triggerPixelSpy.callCount).to.equal(1); + + // check for js tracker + let insertHtmlIntoIframeSpy = sandbox.spy(utils, ['insertHtmlIntoIframe']); + viewability.startMeasurement('2', {}, { method: 'js', url: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); + expect(insertHtmlIntoIframeSpy.callCount).to.equal(1); + + // check for vid uniqueness + let logWarnSpy = sandbox.spy(utils, 'logWarn'); + viewability.startMeasurement('2', {}, { method: 'js', url: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); + expect(logWarnSpy.callCount).to.equal(1); + expect(logWarnSpy.calledWith('provide an unregistered vid', '2')).to.equal(true); + sandbox.restore(); + }); + + it('should fix invalid sizes object', () => { + }); + }); +}); From 0597e3f5f30c07091b7c522e940a1249a3e58c4f Mon Sep 17 00:00:00 2001 From: aleksatr Date: Thu, 14 Oct 2021 14:58:28 +0200 Subject: [PATCH 05/12] - increase test coverage for viewability --- modules/viewability/index.js | 42 ++-- test/spec/modules/viewability_spec.js | 263 ++++++++++++++++++++++++-- 2 files changed, 272 insertions(+), 33 deletions(-) diff --git a/modules/viewability/index.js b/modules/viewability/index.js index 07b175c11af..735e7b5d9f1 100644 --- a/modules/viewability/index.js +++ b/modules/viewability/index.js @@ -15,7 +15,7 @@ const observers = {}; /** * Start measuring viewability of an element - * @typedef {{ method: string='img','js', url: string }} ViewabilityTracker { method: 'img', url: 'http://my.tracker/123' } + * @typedef {{ method: string='img','js','callback', value: string|function }} ViewabilityTracker { method: 'img', value: 'http://my.tracker/123' } * @typedef {{ inViewThreshold: number, timeInView: number }} ViewabilityCriteria { inViewThreshold: 0.5, timeInView: 1000 } * @param {string} vid unique viewability identifier * @param {HTMLElement} element @@ -23,7 +23,18 @@ const observers = {}; * @param {ViewabilityCriteria} criteria */ export function startMeasurement(vid, element, tracker, criteria) { - if (!element || !tracker || !tracker.method || !tracker.url) { + if (!element) { + utils.logWarn('provide an html element to track'); + return; + } + + let validTracker = tracker && + ((tracker.method === 'img' && utils.isStr(tracker.value)) || + (tracker.method === 'js' && utils.isStr(tracker.value)) || + (tracker.method === 'callback' && utils.isFn(tracker.value))); + + if (!validTracker) { + utils.logWarn('invalid tracker', tracker); return; } @@ -45,43 +56,41 @@ export function startMeasurement(vid, element, tracker, criteria) { let observer; let viewable = false; - let timeoutId; let stateChange = (entries) => { viewable = entries[0].isIntersecting; if (viewable) { - timeoutId = window.setTimeout(() => { + observers[vid].timeoutId = window.setTimeout(() => { // stop observing observer.unobserve(element); switch (tracker.method) { case 'img': - // image - utils.triggerPixel(tracker.url, () => { - utils.logInfo('tracker fired', tracker.url); + utils.triggerPixel(tracker.value, () => { + utils.logInfo('viewability pixel fired', tracker.value); }); break; case 'js': - // javascript - utils.insertHtmlIntoIframe(``); + utils.insertHtmlIntoIframe(``); break; - default: - utils.logWarn('unsupported tracking method', tracker.method); + case 'callback': + tracker.value(element); break; } }, criteria.timeInView); - } else if (timeoutId) { - window.clearTimeout(timeoutId); + } else if (observers[vid].timeoutId) { + window.clearTimeout(observers[vid].timeoutId); } }; observer = new IntersectionObserver(stateChange, options); - observer.observe(element); - observers[vid] = { observer: observer, element: element, + timeoutId: null, }; + + observer.observe(element); } /** @@ -95,6 +104,9 @@ export function stopMeasurement(vid) { } observers[vid].observer.unobserve(observers[vid].element); + if (observers[vid].timeoutId) { + window.clearTimeout(observers[vid].timeoutId); + } } function listenMessagesFromCreative() { diff --git a/test/spec/modules/viewability_spec.js b/test/spec/modules/viewability_spec.js index b7d9d0a6553..0e4ff7ff37c 100644 --- a/test/spec/modules/viewability_spec.js +++ b/test/spec/modules/viewability_spec.js @@ -3,53 +3,280 @@ import * as sinon from 'sinon'; import * as utils from 'src/utils.js'; import * as viewability from 'modules/viewability/index.js'; -describe('viewabilityTest', () => { +describe('viewability test', () => { describe('start measurement', () => { - it('should fire viewability trackers', () => { - let observeCalled = false; - let unobserveCalled = false; + let sandbox; + let intersectionObserverStub; + let setTimeoutStub; + let clearTimeoutStub; + let observeCalled; + let unobserveCalled; + let fakeIntersectionObserver; + let ti = 1; + beforeEach(() => { + observeCalled = false; + unobserveCalled = false; + sandbox = sinon.sandbox.create(); - let fakeIntersectionObserver = (stateChange, options) => { + fakeIntersectionObserver = (stateChange, options) => { return { observe: (element) => { observeCalled = true; - stateChange([{isIntersecting: true}]); + stateChange([{ isIntersecting: true }]); }, unobserve: (element) => { unobserveCalled = true; }, }; }; - let sandbox = sinon.sandbox.create(); - let intersectionObserverStub = sandbox.stub(window, 'IntersectionObserver').callsFake(fakeIntersectionObserver); - let setTimeoutStub = sandbox.stub(window, 'setTimeout').callsFake((callback, timeout) => { + + intersectionObserverStub = sandbox.stub(window, 'IntersectionObserver').callsFake(fakeIntersectionObserver); + setTimeoutStub = sandbox.stub(window, 'setTimeout').callsFake((callback, timeout) => { callback(); + return ti++; }); - let triggerPixelSpy = sandbox.spy(utils, ['triggerPixel']); + clearTimeoutStub = sandbox.stub(window, 'clearTimeout').callsFake((timeoutId) => { }); + }); + + afterEach(() => { + sandbox.restore(); + }); - viewability.startMeasurement('1', {}, { method: 'img', url: 'http://my.tracker/123' }, { inViewThreshold: 0.5, timeInView: 1000 }); + it('should trigger appropriate callbacks', () => { + viewability.startMeasurement('0', {}, { method: 'img', value: 'http://my.tracker/123' }, { inViewThreshold: 0.5, timeInView: 1000 }); sinon.assert.called(intersectionObserverStub); sinon.assert.called(setTimeoutStub); expect(observeCalled).to.equal(true); expect(unobserveCalled).to.equal(true); - // check for img tracker + }); + + it('should trigger img tracker', () => { + let triggerPixelSpy = sandbox.spy(utils, ['triggerPixel']); + viewability.startMeasurement('1', {}, { method: 'img', value: 'http://my.tracker/123' }, { inViewThreshold: 0.5, timeInView: 1000 }); expect(triggerPixelSpy.callCount).to.equal(1); + }); - // check for js tracker + it('should trigger js tracker', () => { let insertHtmlIntoIframeSpy = sandbox.spy(utils, ['insertHtmlIntoIframe']); - viewability.startMeasurement('2', {}, { method: 'js', url: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); + viewability.startMeasurement('2', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); expect(insertHtmlIntoIframeSpy.callCount).to.equal(1); + }); + + it('should trigger callback tracker', () => { + let callbackFired = false; + viewability.startMeasurement('3', {}, { method: 'callback', value: () => { callbackFired = true; } }, { inViewThreshold: 0.5, timeInView: 1000 }); + expect(callbackFired).to.equal(true); + }); + + it('should check for vid uniqueness', () => { + let logWarnSpy = sandbox.spy(utils, 'logWarn'); + viewability.startMeasurement('4', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); + expect(logWarnSpy.callCount).to.equal(0); + + viewability.startMeasurement('4', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); + expect(logWarnSpy.callCount).to.equal(1); + expect(logWarnSpy.calledWith('provide an unregistered vid', '4')).to.equal(true); + }); + + it('should check for valid criteria', () => { + let logWarnSpy = sandbox.spy(utils, 'logWarn'); + viewability.startMeasurement('5', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { timeInView: 1000 }); + expect(logWarnSpy.callCount).to.equal(1); + expect(logWarnSpy.calledWith('missing criteria', { timeInView: 1000 })).to.equal(true); + }); + + it('should check for valid tracker', () => { + let logWarnSpy = sandbox.spy(utils, 'logWarn'); + viewability.startMeasurement('6', {}, { method: 'callback', value: 'string' }, { inViewThreshold: 0.5, timeInView: 1000 }); + expect(logWarnSpy.callCount).to.equal(1); + expect(logWarnSpy.calledWith('invalid tracker', { method: 'callback', value: 'string' })).to.equal(true); + }); - // check for vid uniqueness + it('should check if element provided', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); - viewability.startMeasurement('2', {}, { method: 'js', url: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); + viewability.startMeasurement('7', undefined, { method: 'js', value: 'http://my.tracker/123.js' }, { timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith('provide an unregistered vid', '2')).to.equal(true); + expect(logWarnSpy.calledWith('provide an html element to track')).to.equal(true); + }); + }); + + describe('stop measurement', () => { + let sandbox; + let intersectionObserverStub; + let setTimeoutStub; + let clearTimeoutStub; + let observeCalled; + let unobserveCalled; + let stateChangeBackup; + let ti = 1; + beforeEach(() => { + observeCalled = false; + unobserveCalled = false; + sandbox = sinon.sandbox.create(); + + let fakeIntersectionObserver = (stateChange, options) => { + return { + observe: (element) => { + stateChangeBackup = stateChange; + observeCalled = true; + stateChange([{ isIntersecting: true }]); + }, + unobserve: (element) => { + unobserveCalled = true; + }, + }; + }; + + intersectionObserverStub = sandbox.stub(window, 'IntersectionObserver').callsFake(fakeIntersectionObserver); + setTimeoutStub = sandbox.stub(window, 'setTimeout').callsFake((callback, timeout) => { + // skipping the callback + return ti++; + }); + clearTimeoutStub = sandbox.stub(window, 'clearTimeout').callsFake((timeoutId) => { }); + }); + + afterEach(() => { sandbox.restore(); }); - it('should fix invalid sizes object', () => { + it('should clear the timeout', () => { + viewability.startMeasurement('10', {}, { method: 'img', value: 'http://my.tracker/123' }, { inViewThreshold: 0.5, timeInView: 1000 }); + stateChangeBackup([{ isIntersecting: false }]); + sinon.assert.called(intersectionObserverStub); + sinon.assert.called(setTimeoutStub); + sinon.assert.called(clearTimeoutStub); + expect(observeCalled).to.equal(true); + }); + + it('should unobserve', () => { + viewability.startMeasurement('11', {}, { method: 'img', value: 'http://my.tracker/123' }, { inViewThreshold: 0.5, timeInView: 1000 }); + sinon.assert.called(intersectionObserverStub); + sinon.assert.called(setTimeoutStub); + expect(observeCalled).to.equal(true); + expect(unobserveCalled).to.equal(false); + + viewability.stopMeasurement('11'); + expect(unobserveCalled).to.equal(true); + sinon.assert.called(clearTimeoutStub); + }); + + it('should check for vid existence', () => { + let logWarnSpy = sandbox.spy(utils, 'logWarn'); + viewability.stopMeasurement('100'); + expect(logWarnSpy.callCount).to.equal(1); + expect(logWarnSpy.calledWith('provide a registered vid', '100')).to.equal(true); + }); + }); + + describe('handle creative messages', () => { + let sandbox; + let intersectionObserverStub; + let setTimeoutStub; + let clearTimeoutStub; + let observeCalled; + let unobserveCalled; + let fakeIntersectionObserver; + let fakeGetElementsByTagName; + let fakeGetElementById; + let ti = 1; + let fakeContentWindow = {}; + beforeEach(() => { + observeCalled = false; + unobserveCalled = false; + sandbox = sinon.sandbox.create(); + + fakeIntersectionObserver = (stateChange, options) => { + return { + observe: (element) => { + observeCalled = true; + stateChange([{ isIntersecting: true }]); + }, + unobserve: (element) => { + unobserveCalled = true; + }, + }; + }; + + intersectionObserverStub = sandbox.stub(window, 'IntersectionObserver').callsFake(fakeIntersectionObserver); + setTimeoutStub = sandbox.stub(window, 'setTimeout').callsFake((callback, timeout) => { + callback(); + return ti++; + }); + clearTimeoutStub = sandbox.stub(window, 'clearTimeout').callsFake((timeoutId) => { }); + fakeGetElementsByTagName = sandbox.stub(document, 'getElementsByTagName').callsFake((tagName) => { + return [{ + contentWindow: fakeContentWindow, + }]; + }); + fakeGetElementById = sandbox.stub(document, 'getElementById').callsFake((tagName) => { + return {}; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should find element by contentWindow', () => { + let viewabilityRecord = { + vid: 1000, + tracker: { + value: 'http://my.tracker/123', + method: 'img', + }, + criteria: { inViewThreshold: 0.5, timeInView: 1000 }, + message: 'Prebid Viewability', + action: 'startMeasurement', + }; + let data = JSON.stringify(viewabilityRecord); + + viewability.receiveMessage({ + data: data, + source: fakeContentWindow, + }); + + sinon.assert.called(intersectionObserverStub); + sinon.assert.called(setTimeoutStub); + expect(observeCalled).to.equal(true); + expect(unobserveCalled).to.equal(true); + }); + + it('should find element by id', () => { + let viewabilityRecord = { + vid: 1001, + tracker: { + value: 'http://my.tracker/123', + method: 'img', + }, + criteria: { inViewThreshold: 0.5, timeInView: 1000 }, + message: 'Prebid Viewability', + action: 'startMeasurement', + elementId: '1', + }; + let data = JSON.stringify(viewabilityRecord); + viewability.receiveMessage({ + data: data, + }); + + sinon.assert.called(intersectionObserverStub); + sinon.assert.called(setTimeoutStub); + expect(observeCalled).to.equal(true); + expect(unobserveCalled).to.equal(true); + }); + + it('should stop measurement', () => { + let viewabilityRecord = { + vid: 1001, + message: 'Prebid Viewability', + action: 'stopMeasurement', + }; + let data = JSON.stringify(viewabilityRecord); + viewability.receiveMessage({ + data: data, + }); + + expect(unobserveCalled).to.equal(true); }); }); }); From 0c6611538f39d2297bc01cc559f504f6043cd22a Mon Sep 17 00:00:00 2001 From: aleksatr Date: Thu, 14 Oct 2021 15:14:50 +0200 Subject: [PATCH 06/12] - move viewability module js --- .../{viewability/index.js => viewability.js} | 4 +-- test/spec/modules/viewability_spec.js | 26 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) rename modules/{viewability/index.js => viewability.js} (97%) diff --git a/modules/viewability/index.js b/modules/viewability.js similarity index 97% rename from modules/viewability/index.js rename to modules/viewability.js index 735e7b5d9f1..1cd55d01b7a 100644 --- a/modules/viewability/index.js +++ b/modules/viewability.js @@ -1,5 +1,5 @@ -import * as utils from '../../src/utils.js'; -import { getGlobal } from '../../src/prebidGlobal.js'; +import * as utils from '../src/utils.js'; +import { getGlobal } from '../src/prebidGlobal.js'; import find from 'core-js-pure/features/array/find.js'; export function init() { diff --git a/test/spec/modules/viewability_spec.js b/test/spec/modules/viewability_spec.js index 0e4ff7ff37c..7696e5b43c7 100644 --- a/test/spec/modules/viewability_spec.js +++ b/test/spec/modules/viewability_spec.js @@ -1,24 +1,22 @@ -import { expect, spy } from 'chai'; +import { expect } from 'chai'; import * as sinon from 'sinon'; import * as utils from 'src/utils.js'; -import * as viewability from 'modules/viewability/index.js'; +import * as viewability from 'modules/viewability.js'; describe('viewability test', () => { describe('start measurement', () => { let sandbox; let intersectionObserverStub; let setTimeoutStub; - let clearTimeoutStub; let observeCalled; let unobserveCalled; - let fakeIntersectionObserver; let ti = 1; beforeEach(() => { observeCalled = false; unobserveCalled = false; sandbox = sinon.sandbox.create(); - fakeIntersectionObserver = (stateChange, options) => { + let fakeIntersectionObserver = (stateChange, options) => { return { observe: (element) => { observeCalled = true; @@ -35,7 +33,6 @@ describe('viewability test', () => { callback(); return ti++; }); - clearTimeoutStub = sandbox.stub(window, 'clearTimeout').callsFake((timeoutId) => { }); }); afterEach(() => { @@ -173,20 +170,19 @@ describe('viewability test', () => { let sandbox; let intersectionObserverStub; let setTimeoutStub; - let clearTimeoutStub; let observeCalled; let unobserveCalled; - let fakeIntersectionObserver; - let fakeGetElementsByTagName; - let fakeGetElementById; let ti = 1; + let getElementsByTagStub; + let getElementByIdStub; + let fakeContentWindow = {}; beforeEach(() => { observeCalled = false; unobserveCalled = false; sandbox = sinon.sandbox.create(); - fakeIntersectionObserver = (stateChange, options) => { + let fakeIntersectionObserver = (stateChange, options) => { return { observe: (element) => { observeCalled = true; @@ -203,13 +199,13 @@ describe('viewability test', () => { callback(); return ti++; }); - clearTimeoutStub = sandbox.stub(window, 'clearTimeout').callsFake((timeoutId) => { }); - fakeGetElementsByTagName = sandbox.stub(document, 'getElementsByTagName').callsFake((tagName) => { + + getElementsByTagStub = sandbox.stub(document, 'getElementsByTagName').callsFake((tagName) => { return [{ contentWindow: fakeContentWindow, }]; }); - fakeGetElementById = sandbox.stub(document, 'getElementById').callsFake((tagName) => { + getElementByIdStub = sandbox.stub(document, 'getElementById').callsFake((id) => { return {}; }); }); @@ -236,6 +232,7 @@ describe('viewability test', () => { source: fakeContentWindow, }); + sinon.assert.called(getElementsByTagStub); sinon.assert.called(intersectionObserverStub); sinon.assert.called(setTimeoutStub); expect(observeCalled).to.equal(true); @@ -259,6 +256,7 @@ describe('viewability test', () => { data: data, }); + sinon.assert.called(getElementByIdStub); sinon.assert.called(intersectionObserverStub); sinon.assert.called(setTimeoutStub); expect(observeCalled).to.equal(true); From 2ccd8754bf2f1c8b87251e05c3e2c2961e55eb62 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Thu, 14 Oct 2021 15:28:13 +0200 Subject: [PATCH 07/12] - remove uneccesary changes --- modules/.submodules.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/modules/.submodules.json b/modules/.submodules.json index 766caac4638..ea3f556dbb4 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -59,8 +59,5 @@ "fpdModule": [ "enrichmentFpdModule", "validationFpdModule" - ], - "viewability": [ - "timer" ] } From 9a074146882d5c3903caea9f6ba206e650cc4ac6 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Tue, 26 Oct 2021 15:55:32 +0200 Subject: [PATCH 08/12] - allow uncomplete observers to be registered again --- modules/viewability.js | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/modules/viewability.js b/modules/viewability.js index 1cd55d01b7a..7aefe1647a4 100644 --- a/modules/viewability.js +++ b/modules/viewability.js @@ -13,19 +13,10 @@ export function init() { const observers = {}; -/** - * Start measuring viewability of an element - * @typedef {{ method: string='img','js','callback', value: string|function }} ViewabilityTracker { method: 'img', value: 'http://my.tracker/123' } - * @typedef {{ inViewThreshold: number, timeInView: number }} ViewabilityCriteria { inViewThreshold: 0.5, timeInView: 1000 } - * @param {string} vid unique viewability identifier - * @param {HTMLElement} element - * @param {ViewabilityTracker} tracker - * @param {ViewabilityCriteria} criteria - */ -export function startMeasurement(vid, element, tracker, criteria) { +function isValid(vid, element, tracker, criteria) { if (!element) { utils.logWarn('provide an html element to track'); - return; + return false; } let validTracker = tracker && @@ -35,16 +26,33 @@ export function startMeasurement(vid, element, tracker, criteria) { if (!validTracker) { utils.logWarn('invalid tracker', tracker); - return; + return false; } if (!criteria || !criteria.inViewThreshold || !criteria.timeInView) { utils.logWarn('missing criteria', criteria); - return; + return false; } if (!vid || observers[vid]) { utils.logWarn('provide an unregistered vid', vid); + return false; + } + + return true; +} + +/** + * Start measuring viewability of an element + * @typedef {{ method: string='img','js','callback', value: string|function }} ViewabilityTracker { method: 'img', value: 'http://my.tracker/123' } + * @typedef {{ inViewThreshold: number, timeInView: number }} ViewabilityCriteria { inViewThreshold: 0.5, timeInView: 1000 } + * @param {string} vid unique viewability identifier + * @param {HTMLElement} element + * @param {ViewabilityTracker} tracker + * @param {ViewabilityCriteria} criteria + */ +export function startMeasurement(vid, element, tracker, criteria) { + if (!isValid(vid, element, tracker, criteria)) { return; } @@ -63,6 +71,7 @@ export function startMeasurement(vid, element, tracker, criteria) { observers[vid].timeoutId = window.setTimeout(() => { // stop observing observer.unobserve(element); + observers[vid].done = true; switch (tracker.method) { case 'img': @@ -88,6 +97,7 @@ export function startMeasurement(vid, element, tracker, criteria) { observer: observer, element: element, timeoutId: null, + done: false, }; observer.observe(element); @@ -107,6 +117,11 @@ export function stopMeasurement(vid) { if (observers[vid].timeoutId) { window.clearTimeout(observers[vid].timeoutId); } + + // allow the observer under this vid to be created again + if (!observers[vid].done) { + delete observers[vid]; + } } function listenMessagesFromCreative() { From 872163e74e6ad8625700d8bbd87e557c620a7386 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Fri, 29 Oct 2021 12:37:46 +0200 Subject: [PATCH 09/12] - import explicitly from utils --- modules/viewability.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/modules/viewability.js b/modules/viewability.js index 7aefe1647a4..9ab1f28d0db 100644 --- a/modules/viewability.js +++ b/modules/viewability.js @@ -1,4 +1,4 @@ -import * as utils from '../src/utils.js'; +import { logWarn, logInfo, isStr, isFn, triggerPixel, insertHtmlIntoIframe } from '../src/utils.js'; import { getGlobal } from '../src/prebidGlobal.js'; import find from 'core-js-pure/features/array/find.js'; @@ -15,27 +15,27 @@ const observers = {}; function isValid(vid, element, tracker, criteria) { if (!element) { - utils.logWarn('provide an html element to track'); + logWarn('provide an html element to track'); return false; } let validTracker = tracker && - ((tracker.method === 'img' && utils.isStr(tracker.value)) || - (tracker.method === 'js' && utils.isStr(tracker.value)) || - (tracker.method === 'callback' && utils.isFn(tracker.value))); + ((tracker.method === 'img' && isStr(tracker.value)) || + (tracker.method === 'js' && isStr(tracker.value)) || + (tracker.method === 'callback' && isFn(tracker.value))); if (!validTracker) { - utils.logWarn('invalid tracker', tracker); + logWarn('invalid tracker', tracker); return false; } if (!criteria || !criteria.inViewThreshold || !criteria.timeInView) { - utils.logWarn('missing criteria', criteria); + logWarn('missing criteria', criteria); return false; } if (!vid || observers[vid]) { - utils.logWarn('provide an unregistered vid', vid); + logWarn('provide an unregistered vid', vid); return false; } @@ -75,12 +75,12 @@ export function startMeasurement(vid, element, tracker, criteria) { switch (tracker.method) { case 'img': - utils.triggerPixel(tracker.value, () => { - utils.logInfo('viewability pixel fired', tracker.value); + triggerPixel(tracker.value, () => { + logInfo('viewability pixel fired', tracker.value); }); break; case 'js': - utils.insertHtmlIntoIframe(``); + insertHtmlIntoIframe(``); break; case 'callback': tracker.value(element); @@ -109,7 +109,7 @@ export function startMeasurement(vid, element, tracker, criteria) { */ export function stopMeasurement(vid) { if (!vid || !observers[vid]) { - utils.logWarn('provide a registered vid', vid); + logWarn('provide a registered vid', vid); return; } From 57bfc0ab5758fb2649102ab8863f9edd1d00c7c8 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Fri, 29 Oct 2021 14:08:51 +0200 Subject: [PATCH 10/12] - add viewability.md --- modules/viewability.md | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 modules/viewability.md diff --git a/modules/viewability.md b/modules/viewability.md new file mode 100644 index 00000000000..16eda1f65e3 --- /dev/null +++ b/modules/viewability.md @@ -0,0 +1,67 @@ +# Overview + +Module Name: Viewability + +Purpose: Track when a given HTML element becomes viewable + +Maintainer: atrajkovic@magnite.com + +# Configuration + +Module does not need any configuration, as long as you include it in your PBJS bundle. +Viewability module has only two functions `startMeasurement` and `stopMeasurement` which can be used to enable more complex viewability measurements. Since it allows tracking from within creative (possibly inside a safe frame) this module registers a message listener, for messages with a format that is described bellow. + +`startMeasurement` +This function has 4 parameters when called directly with `pbjs.viewability.startMeasurement()`: + - vid: unique viewability identifier, used to reference particular tracker which can later be used to stop the measurement. It allows for multiple trackers, with different criteria to be registered for a given HTML element, independently. vid is not autogenerated by startMeasurement(), it needs to be provided by caller so that it doesn't have to be posted back to the source iframe (in case viewability is started from within the creative). + - element: reference to an HTML element that needs to be tracked. + - tracker: ViewabilityTracker is an object type with two properties, `method` ('img'|'js'|'callback') and `value` which can be an URL string for 'img' and 'js' trackers, or a function for 'callback' tracker. Example: `{ method: 'img', value: 'http://my.tracker/123' }` + - criteria: ViewabilityCriteria is an object type with two properties, `inViewThreshold` which is a number (0, 1.0] representing a percentage of viewable element we're aiming at, and `timeInView` which is a number of milliseconds that a given element needs to be in view continuously, above a threshold. Example: `{ inViewThreshold: 0.5, timeInView: 1000 }` + +When a tracker needs to be started, without direct access to pbjs, postMessage mechanism can be used to invoke `startMeasurement`, with a following payload: `vid`, `tracker` and `criteria` as described above, but also with `message: 'Prebid Viewability'` and `action: 'startMeasurement'`. Optionally payload can provide `elementId`, if available at that time (for ad servers where name of the iframe is known, or adservers that render outside an iframe). If `elementId` is not provided, viewability module will try to find the iframe that corresponds to the message source. + + +`stopMeasurement`: +This function has only 1 parementer when called directly with `pbjs.viewability.stopMeasurement()`: +- vid: unique viewability identifier, referencing an already started viewability tracker. + +When a tracker needs to be stopped, without direct access to pbjs, postMessage mechanism can be used here as well, to invoke `stopMeasurement`, providing payload with `vid`, `message: 'Prebid Viewability'` and `action: 'stopMeasurement`. + +# Example of starting a viewability measurement, when you have direct access to pbjs +``` +pbjs.viewability.startMeasurement( + 'ae0f9', + document.getElementById('test_div'), + { method: 'img', value: 'http://my.tracker/123' }, + { inViewThreshold: 0.5, timeInView: 1000 } +); +``` + +# Example of starting a viewability measurement from within a rendered creative +``` +let viewabilityRecord = { + vid: 'ae0f9', + tracker: { method: 'img', value: 'http://my.tracker/123'}, + criteria: { inViewThreshold: 0.5, timeInView: 1000 }, + message: 'Prebid Viewability', + action: 'startMeasurement' +} + +window.parent.postMessage(JSON.stringify(viewabilityRecord), '*'); +``` + +# Example of stopping the viewability measurement, when you have direct access to pbjs +``` +pbjs.viewability.stopMeasurement('ae0f9'); +``` + +# Example of stopping the viewability measurement from within a rendered creative +``` +let viewabilityRecord = { + vid: 'ae0f9', + message: 'Prebid Viewability', + action: 'stopMeasurement' +} + +window.parent.postMessage(JSON.stringify(viewabilityRecord), '*'); +``` From 1f8d2139bb42e336bb3d13e8ea3b0571103afd8c Mon Sep 17 00:00:00 2001 From: aleksatr Date: Tue, 30 Nov 2021 11:09:03 +0100 Subject: [PATCH 11/12] - add module name to log messages --- modules/viewability.js | 14 ++++++++------ test/spec/modules/viewability_spec.js | 10 +++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/modules/viewability.js b/modules/viewability.js index 9ab1f28d0db..2761c7acf31 100644 --- a/modules/viewability.js +++ b/modules/viewability.js @@ -2,6 +2,8 @@ import { logWarn, logInfo, isStr, isFn, triggerPixel, insertHtmlIntoIframe } fro import { getGlobal } from '../src/prebidGlobal.js'; import find from 'core-js-pure/features/array/find.js'; +export const MODULE_NAME = 'viewability'; + export function init() { (getGlobal()).viewability = { startMeasurement: startMeasurement, @@ -15,7 +17,7 @@ const observers = {}; function isValid(vid, element, tracker, criteria) { if (!element) { - logWarn('provide an html element to track'); + logWarn(`${MODULE_NAME}: provide an html element to track`); return false; } @@ -25,17 +27,17 @@ function isValid(vid, element, tracker, criteria) { (tracker.method === 'callback' && isFn(tracker.value))); if (!validTracker) { - logWarn('invalid tracker', tracker); + logWarn(`${MODULE_NAME}: invalid tracker`, tracker); return false; } if (!criteria || !criteria.inViewThreshold || !criteria.timeInView) { - logWarn('missing criteria', criteria); + logWarn(`${MODULE_NAME}: missing criteria`, criteria); return false; } if (!vid || observers[vid]) { - logWarn('provide an unregistered vid', vid); + logWarn(`${MODULE_NAME}: provide an unregistered vid`, vid); return false; } @@ -76,7 +78,7 @@ export function startMeasurement(vid, element, tracker, criteria) { switch (tracker.method) { case 'img': triggerPixel(tracker.value, () => { - logInfo('viewability pixel fired', tracker.value); + logInfo(`${MODULE_NAME}: viewability pixel fired`, tracker.value); }); break; case 'js': @@ -109,7 +111,7 @@ export function startMeasurement(vid, element, tracker, criteria) { */ export function stopMeasurement(vid) { if (!vid || !observers[vid]) { - logWarn('provide a registered vid', vid); + logWarn(`${MODULE_NAME}: provide a registered vid`, vid); return; } diff --git a/test/spec/modules/viewability_spec.js b/test/spec/modules/viewability_spec.js index 7696e5b43c7..9ec62bc254b 100644 --- a/test/spec/modules/viewability_spec.js +++ b/test/spec/modules/viewability_spec.js @@ -73,28 +73,28 @@ describe('viewability test', () => { viewability.startMeasurement('4', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith('provide an unregistered vid', '4')).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: provide an unregistered vid`, '4')).to.equal(true); }); it('should check for valid criteria', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); viewability.startMeasurement('5', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith('missing criteria', { timeInView: 1000 })).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: missing criteria`, { timeInView: 1000 })).to.equal(true); }); it('should check for valid tracker', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); viewability.startMeasurement('6', {}, { method: 'callback', value: 'string' }, { inViewThreshold: 0.5, timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith('invalid tracker', { method: 'callback', value: 'string' })).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: invalid tracker`, { method: 'callback', value: 'string' })).to.equal(true); }); it('should check if element provided', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); viewability.startMeasurement('7', undefined, { method: 'js', value: 'http://my.tracker/123.js' }, { timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith('provide an html element to track')).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: provide an html element to track`)).to.equal(true); }); }); @@ -162,7 +162,7 @@ describe('viewability test', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); viewability.stopMeasurement('100'); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith('provide a registered vid', '100')).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: provide a registered vid`, '100')).to.equal(true); }); }); From 54186a590962be50cb6e25a64399591624f32da1 Mon Sep 17 00:00:00 2001 From: aleksatr Date: Wed, 5 Jan 2022 16:15:55 +0100 Subject: [PATCH 12/12] update for higher legibility --- modules/viewability.js | 54 ++++++++++++++++----------- modules/viewability.md | 50 +++++++++++++++++-------- test/spec/modules/viewability_spec.js | 6 +-- 3 files changed, 71 insertions(+), 39 deletions(-) diff --git a/modules/viewability.js b/modules/viewability.js index 2761c7acf31..b12c53b7f59 100644 --- a/modules/viewability.js +++ b/modules/viewability.js @@ -17,7 +17,7 @@ const observers = {}; function isValid(vid, element, tracker, criteria) { if (!element) { - logWarn(`${MODULE_NAME}: provide an html element to track`); + logWarn(`${MODULE_NAME}: no html element provided`); return false; } @@ -37,13 +37,39 @@ function isValid(vid, element, tracker, criteria) { } if (!vid || observers[vid]) { - logWarn(`${MODULE_NAME}: provide an unregistered vid`, vid); + logWarn(`${MODULE_NAME}: must provide an unregistered vid`, vid); return false; } return true; } +function stopObserving(observer, vid, element) { + observer.unobserve(element); + observers[vid].done = true; +} + +function fireViewabilityTracker(element, tracker) { + switch (tracker.method) { + case 'img': + triggerPixel(tracker.value, () => { + logInfo(`${MODULE_NAME}: viewability pixel fired`, tracker.value); + }); + break; + case 'js': + insertHtmlIntoIframe(``); + break; + case 'callback': + tracker.value(element); + break; + } +} + +function viewabilityCriteriaMet(observer, vid, element, tracker) { + stopObserving(observer, vid, element); + fireViewabilityTracker(element, tracker); +} + /** * Start measuring viewability of an element * @typedef {{ method: string='img','js','callback', value: string|function }} ViewabilityTracker { method: 'img', value: 'http://my.tracker/123' } @@ -58,7 +84,7 @@ export function startMeasurement(vid, element, tracker, criteria) { return; } - let options = { + const options = { root: null, rootMargin: '0px', threshold: criteria.inViewThreshold, @@ -71,23 +97,7 @@ export function startMeasurement(vid, element, tracker, criteria) { if (viewable) { observers[vid].timeoutId = window.setTimeout(() => { - // stop observing - observer.unobserve(element); - observers[vid].done = true; - - switch (tracker.method) { - case 'img': - triggerPixel(tracker.value, () => { - logInfo(`${MODULE_NAME}: viewability pixel fired`, tracker.value); - }); - break; - case 'js': - insertHtmlIntoIframe(``); - break; - case 'callback': - tracker.value(element); - break; - } + viewabilityCriteriaMet(observer, vid, element, tracker); }, criteria.timeInView); } else if (observers[vid].timeoutId) { window.clearTimeout(observers[vid].timeoutId); @@ -103,6 +113,8 @@ export function startMeasurement(vid, element, tracker, criteria) { }; observer.observe(element); + + logInfo(`${MODULE_NAME}: startMeasurement called with:`, arguments); } /** @@ -111,7 +123,7 @@ export function startMeasurement(vid, element, tracker, criteria) { */ export function stopMeasurement(vid) { if (!vid || !observers[vid]) { - logWarn(`${MODULE_NAME}: provide a registered vid`, vid); + logWarn(`${MODULE_NAME}: must provide a registered vid`, vid); return; } diff --git a/modules/viewability.md b/modules/viewability.md index 16eda1f65e3..df93b5c40db 100644 --- a/modules/viewability.md +++ b/modules/viewability.md @@ -11,23 +11,43 @@ Maintainer: atrajkovic@magnite.com Module does not need any configuration, as long as you include it in your PBJS bundle. Viewability module has only two functions `startMeasurement` and `stopMeasurement` which can be used to enable more complex viewability measurements. Since it allows tracking from within creative (possibly inside a safe frame) this module registers a message listener, for messages with a format that is described bellow. -`startMeasurement` -This function has 4 parameters when called directly with `pbjs.viewability.startMeasurement()`: - - vid: unique viewability identifier, used to reference particular tracker which can later be used to stop the measurement. It allows for multiple trackers, with different criteria to be registered for a given HTML element, independently. vid is not autogenerated by startMeasurement(), it needs to be provided by caller so that it doesn't have to be posted back to the source iframe (in case viewability is started from within the creative). - - element: reference to an HTML element that needs to be tracked. - - tracker: ViewabilityTracker is an object type with two properties, `method` ('img'|'js'|'callback') and `value` which can be an URL string for 'img' and 'js' trackers, or a function for 'callback' tracker. Example: `{ method: 'img', value: 'http://my.tracker/123' }` - - criteria: ViewabilityCriteria is an object type with two properties, `inViewThreshold` which is a number (0, 1.0] representing a percentage of viewable element we're aiming at, and `timeInView` which is a number of milliseconds that a given element needs to be in view continuously, above a threshold. Example: `{ inViewThreshold: 0.5, timeInView: 1000 }` +## `startMeasurement` -When a tracker needs to be started, without direct access to pbjs, postMessage mechanism can be used to invoke `startMeasurement`, with a following payload: `vid`, `tracker` and `criteria` as described above, but also with `message: 'Prebid Viewability'` and `action: 'startMeasurement'`. Optionally payload can provide `elementId`, if available at that time (for ad servers where name of the iframe is known, or adservers that render outside an iframe). If `elementId` is not provided, viewability module will try to find the iframe that corresponds to the message source. +| startMeasurement Arg Object | Scope | Type | Description | Example | +| --------------------- | -------- | ------------ | -------------------------------------------------------------------------------- | --------- | +| vid | Required | String | Unique viewability identifier, used to reference particular observer | `"ae0f9"` | +| element | Required | HTMLElement | Reference to an HTML element that needs to be tracked | `document.getElementById('test_div')` | +| tracker | Required | ViewabilityTracker | How viewaility event is communicated back to the parties of interest | `{ method: 'img', value: 'http://my.tracker/123' }` | +| criteria | Required | ViewabilityCriteria| Defines custom viewability criteria using the threshold and duration provided | `{ inViewThreshold: 0.5, timeInView: 1000 }` | +| ViewabilityTracker | Scope | Type | Description | Example | +| --------------------- | -------- | ------------ | -------------------------------------------------------------------------------- | --------- | +| method | Required | String | Type of method for Tracker | `'img' OR 'js' OR 'callback'` | +| value | Required | String | URL string for 'img' and 'js' Trackers, or a function for 'callback' Tracker | `'http://my.tracker/123'` | -`stopMeasurement`: -This function has only 1 parementer when called directly with `pbjs.viewability.stopMeasurement()`: -- vid: unique viewability identifier, referencing an already started viewability tracker. +| ViewabilityCriteria | Scope | Type | Description | Example | +| --------------------- | -------- | ------------ | -------------------------------------------------------------------------------- | --------- | +| inViewThreshold | Required | Number | Represents a percentage threshold for the Element to be registered as in view | `0.5` | +| timeInView | Required | Number | Number of milliseconds that a given element needs to be in view continuously, above the threshold | `1000` | -When a tracker needs to be stopped, without direct access to pbjs, postMessage mechanism can be used here as well, to invoke `stopMeasurement`, providing payload with `vid`, `message: 'Prebid Viewability'` and `action: 'stopMeasurement`. +## Please Note: +- `vid` allows for multiple trackers, with different criteria to be registered for a given HTML element, independently. It's not autogenerated by `startMeasurement()`, it needs to be provided by the caller so that it doesn't have to be posted back to the source iframe (in case viewability is started from within the creative). +- In case of 'callback' method, HTML element is being passed back to the callback function. +- When a tracker needs to be started, without direct access to pbjs, postMessage mechanism can be used to invoke `startMeasurement`, with a following payload: `vid`, `tracker` and `criteria` as described above, but also with `message: 'Prebid Viewability'` and `action: 'startMeasurement'`. Optionally payload can provide `elementId`, if available at that time (for ad servers where name of the iframe is known, or adservers that render outside an iframe). If `elementId` is not provided, viewability module will try to find the iframe that corresponds to the message source. -# Example of starting a viewability measurement, when you have direct access to pbjs + +## `stopMeasurement` + +| stopMeasurement Arg Object | Scope | Type | Description | Example | +| --------------------- | -------- | ------------ | -------------------------------------------------------------------------------- | --------- | +| vid | Required | String | Unique viewability identifier, referencing an already started viewability tracker. | `"ae0f9"` | + +## Please Note: +- When a tracker needs to be stopped, without direct access to pbjs, postMessage mechanism can be used here as well. To invoke `stopMeasurement`, you provide the payload with `vid`, `message: 'Prebid Viewability'` and `action: 'stopMeasurement`. Check the example bellow. + +# Examples + +## Example of starting a viewability measurement, when you have direct access to pbjs ``` pbjs.viewability.startMeasurement( 'ae0f9', @@ -37,7 +57,7 @@ pbjs.viewability.startMeasurement( ); ``` -# Example of starting a viewability measurement from within a rendered creative +## Example of starting a viewability measurement from within a rendered creative ``` let viewabilityRecord = { vid: 'ae0f9', @@ -50,12 +70,12 @@ let viewabilityRecord = { window.parent.postMessage(JSON.stringify(viewabilityRecord), '*'); ``` -# Example of stopping the viewability measurement, when you have direct access to pbjs +## Example of stopping the viewability measurement, when you have direct access to pbjs ``` pbjs.viewability.stopMeasurement('ae0f9'); ``` -# Example of stopping the viewability measurement from within a rendered creative +## Example of stopping the viewability measurement from within a rendered creative ``` let viewabilityRecord = { vid: 'ae0f9', diff --git a/test/spec/modules/viewability_spec.js b/test/spec/modules/viewability_spec.js index 9ec62bc254b..ab2753daf53 100644 --- a/test/spec/modules/viewability_spec.js +++ b/test/spec/modules/viewability_spec.js @@ -73,7 +73,7 @@ describe('viewability test', () => { viewability.startMeasurement('4', {}, { method: 'js', value: 'http://my.tracker/123.js' }, { inViewThreshold: 0.5, timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: provide an unregistered vid`, '4')).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: must provide an unregistered vid`, '4')).to.equal(true); }); it('should check for valid criteria', () => { @@ -94,7 +94,7 @@ describe('viewability test', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); viewability.startMeasurement('7', undefined, { method: 'js', value: 'http://my.tracker/123.js' }, { timeInView: 1000 }); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: provide an html element to track`)).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: no html element provided`)).to.equal(true); }); }); @@ -162,7 +162,7 @@ describe('viewability test', () => { let logWarnSpy = sandbox.spy(utils, 'logWarn'); viewability.stopMeasurement('100'); expect(logWarnSpy.callCount).to.equal(1); - expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: provide a registered vid`, '100')).to.equal(true); + expect(logWarnSpy.calledWith(`${viewability.MODULE_NAME}: must provide a registered vid`, '100')).to.equal(true); }); });