-
Notifications
You must be signed in to change notification settings - Fork 2.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Generic viewability module: add new module #7643
Changes from all commits
b648a22
f8a4be8
c577fb4
0233b45
0597e3f
0c66115
2ccd875
9a07414
872163e
57bfc0a
1f8d213
54186a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
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'; | ||
|
||
export const MODULE_NAME = 'viewability'; | ||
|
||
export function init() { | ||
(getGlobal()).viewability = { | ||
startMeasurement: startMeasurement, | ||
stopMeasurement: stopMeasurement, | ||
}; | ||
|
||
listenMessagesFromCreative(); | ||
} | ||
|
||
const observers = {}; | ||
|
||
function isValid(vid, element, tracker, criteria) { | ||
if (!element) { | ||
logWarn(`${MODULE_NAME}: no html element provided`); | ||
return false; | ||
} | ||
|
||
let validTracker = tracker && | ||
((tracker.method === 'img' && isStr(tracker.value)) || | ||
(tracker.method === 'js' && isStr(tracker.value)) || | ||
(tracker.method === 'callback' && isFn(tracker.value))); | ||
|
||
if (!validTracker) { | ||
logWarn(`${MODULE_NAME}: invalid tracker`, tracker); | ||
return false; | ||
} | ||
|
||
if (!criteria || !criteria.inViewThreshold || !criteria.timeInView) { | ||
logWarn(`${MODULE_NAME}: missing criteria`, criteria); | ||
return false; | ||
} | ||
|
||
if (!vid || observers[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(`<script src="${tracker.value}"></script>`); | ||
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' } | ||
* @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; | ||
} | ||
|
||
const options = { | ||
root: null, | ||
rootMargin: '0px', | ||
threshold: criteria.inViewThreshold, | ||
}; | ||
|
||
let observer; | ||
let viewable = false; | ||
let stateChange = (entries) => { | ||
viewable = entries[0].isIntersecting; | ||
|
||
if (viewable) { | ||
observers[vid].timeoutId = window.setTimeout(() => { | ||
viewabilityCriteriaMet(observer, vid, element, tracker); | ||
}, criteria.timeInView); | ||
} else if (observers[vid].timeoutId) { | ||
window.clearTimeout(observers[vid].timeoutId); | ||
} | ||
}; | ||
|
||
observer = new IntersectionObserver(stateChange, options); | ||
observers[vid] = { | ||
observer: observer, | ||
element: element, | ||
timeoutId: null, | ||
done: false, | ||
}; | ||
|
||
observer.observe(element); | ||
ncolletti marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
logInfo(`${MODULE_NAME}: startMeasurement called with:`, arguments); | ||
} | ||
|
||
/** | ||
* Stop measuring viewability of an element | ||
* @param {string} vid unique viewability identifier | ||
*/ | ||
export function stopMeasurement(vid) { | ||
if (!vid || !observers[vid]) { | ||
logWarn(`${MODULE_NAME}: must provide a registered vid`, vid); | ||
return; | ||
} | ||
|
||
observers[vid].observer.unobserve(observers[vid].element); | ||
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() { | ||
window.addEventListener('message', receiveMessage, false); | ||
} | ||
|
||
/** | ||
* Recieve messages from creatives | ||
* @param {MessageEvent} evt | ||
*/ | ||
export 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a little bit confused as to if it should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're handling MessageEvent, as mentioned in the postMessage docs. And MessageEvent we're handling here is a descendant of the base Event. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, I think I got myself confused between Event and MessageEvent Objects. This is fine, thanks. |
||
} | ||
|
||
startMeasurement(data.vid, element, data.tracker, data.criteria); | ||
break; | ||
case 'stopMeasurement': | ||
stopMeasurement(data.vid); | ||
break; | ||
} | ||
} | ||
|
||
init(); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
# 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` | ||
|
||
| 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'` | | ||
|
||
| 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` | | ||
|
||
## 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. | ||
|
||
|
||
## `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', | ||
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), '*'); | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was going through the docs for
IntersectionObserver
API on MDN, on the Browser compatibility section, I found that Safari and few of the other Mobile browsers don't supportoptions.root
. I'm not sure if it'll throw an error and crash on those browsers, do we need a fallback or some error handling?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume your referring to this
options.root parameter can be a Document
? If that's the case, we're setting options.root to null in this PR, not to any document?