Skip to content

Commit

Permalink
Generic viewability module: add new module (#7643)
Browse files Browse the repository at this point in the history
* - custom viewability core

* - IntersectionObserver implementation

* - support different type of trackers

* - viewability tests wip

* - increase test coverage for viewability

* - move viewability module js

* - remove uneccesary changes

* - allow uncomplete observers to be registered again

* - import explicitly from utils

* - add viewability.md

* - add module name to log messages

* update for higher legibility
  • Loading branch information
aleksatr authored Jan 11, 2022
1 parent 44f6bdb commit a2a1710
Show file tree
Hide file tree
Showing 3 changed files with 544 additions and 0 deletions.
177 changes: 177 additions & 0 deletions modules/viewability.js
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);

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

startMeasurement(data.vid, element, data.tracker, data.criteria);
break;
case 'stopMeasurement':
stopMeasurement(data.vid);
break;
}
}

init();
87 changes: 87 additions & 0 deletions modules/viewability.md
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), '*');
```
Loading

0 comments on commit a2a1710

Please sign in to comment.