Skip to content

Instantly share code, notes, and snippets.

@samba
Last active October 11, 2021 11:29
Show Gist options
  • Save samba/fe84d2eeec526042695690f8f4023d55 to your computer and use it in GitHub Desktop.
Save samba/fe84d2eeec526042695690f8f4023d55 to your computer and use it in GitHub Desktop.
Google Tag Manager Utility Scripts

Utility Scripts for Google Tag Manager

Each script should include some outline of its usage. In general, the scripts will require installation as a Custom HTML tag in GTM, and should be triggered to run on every page.

Custom HTML scripts in GTM require wrapping Javascript in <script> tags, like so:

<script>
// INSERT THE JAVASCRIPT CODE HERE
</script>

Within <script> tags it's safe to include Javascript comments, and GTM will automatically strip them out when serving the code to a browser.

Goals:

  • Simplify acquisition of useful interactions and associated data, in GTM
  • Reduce complexity for site-maintainers (i.e. developers) to provide useful context to GTM for analytics.
/** Collect "data context" from inherited elements in a document.
* NOTE: GTM will probably require you to remove all or part of this comment
* before deploying, since it contains text like "{{...}}", which it interprets
* as GTM variable references.
*
* Usage:
* 1. Install this tag as a Custom HTML tag that runs on every page.
* 2. Instruct developers to add data attributes (e.g. data-your-property="value")
* on all elements that should have analytics value.
* 2. Create a "Variable" entry for the auto-generated `dataContext` attribute
* in the dataLayer.
* 3. Create "Variable" entries associated to relevant components of the page.
*
* This plugin now installs itself in the GTM DataLayer event stack (the `push()`
* method) to augment all events containing a valid `gtm.element` property. This
* provides an additional property on all such events, named `dataContext`. You
* can then create DataLayer Variables (v2) inside GTM that refer to properties,
* such as `dataContext.title`, `dataContext.alt`, and `dataContext.yourProperty`.
*
* Example: retrieve data context of a specific header, by CSS selector.
* > Define the GTM Custom Javascript variable "data-module-header-context" as:
* function(){
* var selector = "div.module h2";
* return jQuery(selector).dataContext()[0];
* }
*
* Then, to retreive component parts, additional variables:
* function(){
* var ctx = {{data-module-header-context}};
* // supposing an attribute like 'data-module-type=value'
* var name = "moduleType";
* return ctx[name];
* }
*
* Additional considerations:
* - When an element provides attribute "data-something-else", the related
* data property is "somethingElse" (i.e. camel-cased).
* - When an element provides an attribute _without definition_, the result
* value in context will be an empty string.
*
* Occasionally it may be necessary to retrieve context from a "nearby" element.
* In these cases, jQuery provides fairly simple means to seek them out. In a
* GTM variable, you'd provide it like so:
* function(){
* // example: get data from a "selected" option in a nearby menu.
* var container = 'div.container';
* var seek = 'div.menu ul li.selected';
* var target = jQuery({{ element }}).parents(container).first().find(seek);
* var ctx = target.dataContext()[0];
* return ctx[attribute]; // << define your attribute name too.
* }
*
* This plugin also acquires the 'alt' and 'title' attributes; you can add more
* names to the `interesting_attributes` list below to pull them into context
* by default.
*
* In other Javascript:
* jQuery('div.my.element').dataContext() => Array<Object>
*
* Purpose:
* 1. simplify acquisition of meaningful data on each interaction within GTM.
* 2. reduce complexity for site maintainers to add context for analytics.
*
*
*/
(function($, undefined) {
if (!$) {
console.error("jQuery was not available when the dataContext plugin loaded.");
return false;
}
var debug = false;
var dataLayer_name = "dataLayer";
var interesting_attributes = ['alt', 'title'];
var pending_state_clear = false;
function observer(listen) {
// This will add "context.click" and similar events to GTM, so the same
// properties can be acquired on additional interactions.
listen(document.body, 'click mousedown tapstart');
listen(document.body, 'submit', 'form');
listen(document.body, 'change', 'input, select, textarea');
}
function interceptor(event) {
// adapt DataLayer events nicely.
var node = (event && event['gtm.element']);
var data;
if (node && node.nodeType){
data = $(node).dataContext();
event['dataContext'] = data[0];
pending_state_clear = true;
}
return event;
}
function pendingStateClear(dataLayer){
if(pending_state_clear){
dataLayer.push({'dataContext': undefined});
pending_state_clear = false;
}
}
function getElementText(elem) {
if(debug) console.info('Getting text of element', elem);
return $.trim($(elem).text());
}
function getElementData(elem) {
var set = $(elem);
var data = set.data();
// TODO: temporarily disabled due to bug, acquiring whole document text.
// maybeAddAttribute(data, 'text', getElementText(elem));
interesting_attributes.map(function(v) {
maybeAddAttribute(data, v, set);
});
return data;
}
function maybeAddAttribute(data, name, set) {
var value = strip((typeof set == 'string') ? $.trim(set) : $.trim(set.attr(name)));
if (value) data[name] = value;
else data[name] = undefined;
return data;
}
function strip(text) {
return text.replace(/\s+/g, ' ');
}
function mapParents(elem, callback) {
var result = [];
$(elem).parents().each(function(i, node) {
result.push(callback.call(node, node, i));
});
return result;
}
function getContext(elem) {
var data = getElementData(elem);
var inherited = mapParents(elem, getElementData);
if(debug) console.info('Merging data sets', data, inherited);
inherited.map(function(more) {
data = jQuery.extend(data, more);
});
return data;
}
$.fn.dataContext = function() {
return $.makeArray(this).map(getContext);
};
// Apply listeners for additional events
$(document).ready(function() {
observer(function(context, event, selector) {
$(context).on(event, selector, function(e) {
observer.dataLayer.push({
'event': ('context.' + e.type),
'gtm.element': (e.target)
});
});
});
});
function attachPendingStateClearTimer(dataLayer){
setInterval(function(){
pendingStateClear(dataLayer);
}, 1000);
}
// Install the dataLayer hook to inject properties on all suitable events.
(function(dl) {
var _push = dl.push;
var _slice = Array.prototype.slice;
observer.dataLayer = dl;
attachPendingStateClearTimer(dl);
dl.push = function() {
var result = _slice.call(arguments, 0).map(interceptor);
if(debug) result.map(function(n){ console.log('dl:', n) });
return _push.apply(dl, result);
};
}(window[dataLayer_name] = window[dataLayer_name] || []));
}(window.jQuery));
/* IFRAME cross communication for DataLayer integration. */
(function(window, document, jQuery){
var dataLayerName = 'dataLayer';
var _slice = Array.prototype.slice;
var _filter = Array.prototype.filter;
var _hasProp = Object.prototype.hasOwnProperty;
var _listeners = []; // functions receiving message events
var DEBUG = 0;
var policy = {
// should events received on the datalayer of child frames be copied
// up to the parent?
events_dispatch_up: true,
// should events received on the datalayer of the parent be copied down
// to the child frames?
events_dispatch_down: true
// NOTE: if both the above flags are `true`, child and parent dataLayer
// instances will be kept "in sync" (for all new events loaded after
// the framework loads.)
};
function assert(value, message) {
if (!value) {
throw new Error(message);
}
}
assert(_slice && _filter && window.postMessage && window.JSON,
"Cross frame communication requires a newer browser.");
function _getDataLayer(name){
return (window[name] = window[name] || []);
}
function _insertDatLayerCallback(name, callback){
var dl = _getDataLayer(name);
var _attached = (callback.attached = callback.attached || []);
var _push = dl.push;
if(_attached.indexOf(name) < 0){
dl.push = function(){
_slice.call(arguments, 0).map(callback);
return _push.apply(dl, arguments);
};
_attached.push(name);
}
}
function _event(name, element, props) {
var dataLayer = _getDataLayer(dataLayerName);
var eventObject = {
'event': name,
'gtm.element': element
};
var k = null;
if (props)
for (k in props) {
if (_hasProp.call(props, k)) {
eventObject[k] = props[k];
}
}
return dataLayer.push(eventObject);
}
// Simplified means of attaching event handlers...
function attach(nodes, event, handler, capture) {
var _nodes = (nodes && nodes.length) ? nodes : [nodes];
var _listen = (nodes[0].addEventListener || nodes[0].attachEvent);
_nodes.map(function(n) {
event.replace(/(on)?([a-z]+)/ig, function($0, _on, _event) {
_listen.call(n, _event, handler, capture);
});
});
}
// Install single global listener for the message bus.
attach([window], 'message', function(e) {
_listeners.map(function(callback) {
callback.call(window, e);
});
});
function cloneEvent(e){
return e; // nevermind, JSON handling within the browser will assure its safe.
}
// Within the parent (window), handler for interaction with child IFRAMEs
function attachChildClients(callback) {
var frames = window.frames;
var clients = [];
_slice.call(frames, 0).map(function(item) {
if ((item === window) || !(item.frameElement)) return false;
var frm = item.frameElement;
var targetWindow = frm.contentWindow;
var client = { state: { active: false } };
var name = (frm.getAttribute('name') || frm.getAttribute('id'));
function send(message, mark) {
if(typeof message != 'string')
message = JSON.stringify(cloneEvent(message));
return targetWindow.postMessage(message, targetWindow.location.href);
}
client.frame = frm;
client.send = send;
client.url = function(){ return targetWindow.location.href; };
client.name = name;
_listeners.push(function(e) {
if (e.source && (e.source === targetWindow)) {
if(DEBUG > 2) console.debug('parent received', e);
switch (e.data) {
case 'syn':
return send('synack');
case 'ack':
client.state.active = true;
return callback.call(client, 'init', client.state.active);
default:
return callback.call(client, e.data, client.state.active);
}
}
});
return clients.push(client);
});
return clients;
}
// Within a child (iframe), handler for interaction with parent.
function attachParentClient(callback) {
var parent = window.parent;
var parent_url = document.referrer;
if (parent === window) return null; // this is the root.
function send(message) {
if(typeof message != 'string')
message = JSON.stringify(cloneEvent(message));
return parent.postMessage(message, parent_url);
}
var client = {
state: { acknowledged: 0 },
frame: parent,
send: send,
url: parent_url,
name: null
};
_listeners.push(function(e) {
if(DEBUG > 2) console.debug('child received', e);
if (e.source && (e.source === parent)) {
switch (e.data) {
case 'synack':
client.state.acknowledged++;
send('ack')
return callback.call(client, 'init', client.state.acknowledged);
default:
return callback.call(client, e.data, client.state.acknowledged);
}
}
});
// notify parent of my existence...
client.interval = setInterval(function() {
if (!(client.state.acknowledged)) {
send('syn');
clearInterval(client.interval);
}
}, 500);
return client;
}
function isJSON(text){
// VERY loose qualifier of JSON object strings.
return (text.charAt(0) == '{') && (text.charAt(text.length - 1) == '}');
}
function pushJSONEvent(text, mark){
if(isJSON(text)){
var data = JSON.parse(text);
// Prevent echoing events we've already seen.
if(mark && data[mark]) return false;
return _getDataLayer(dataLayerName).push(data);
}
}
function getDataContext(elem){
var nodes = jQuery && jQuery(elem);
return nodes && nodes.dataContext && nodes.dataContext();
}
function onready(callback){
if(onready.ready) callback.call(document);
else (onready.queue = onready.queue || []).push(callback);
}
(function(m){
function _trigger(){
m.ready = true;
m.queue.map(m);
m.queue.length = 0;
}
if(document.readyState != 'loading'){
_trigger();
} else {
attach([window], 'load', _trigger);
// attach([document], 'DOMContentLoaded', _trigger);
}
}(onready));
var _export = (window.iframeRelay = window.iframeRelay || {});
_export.listeners = _listeners;
onready(function(){
var now = (new Date()).getTime();
var mark = ('$cfr_' + Math.floor(Math.random() * 1E10) + now);
// Parent should...
var children = attachChildClients(function(message) {
var element = this.frame;
if(message.indexOf(mark) > 0) return false;
switch (message) {
case 'init':
var passback = {
'event': 'iframeContextData',
'dataContext': getDataContext(element)
};
passback[mark] = true;
this.send(passback);
return _event('dom.iframeLoaded', element, {
'iframeName': this.name,
'iframeURL': this.url()
});
default:
// Assume that unhandled messages should reach the dataLayer only.
if(policy.events_dispatch_up) pushJSONEvent(message, mark);
}
});
// Child should...
var parent = attachParentClient(function(message) {
if(message.indexOf(mark) > 0) return false;
switch(message){
case 'init':
break;
default:
// Assume that unhandled messages should reach the dataLayer only.
if(policy.events_dispatch_down) pushJSONEvent(message, mark);
}
});
if(policy.events_dispatch_down && children.length){
_insertDatLayerCallback(dataLayerName, function(e){
// Mark the event as "Seen", so we can ignore it if it rebounds.
e[mark] = true;
children.map(function(c){
c.send(e);
});
});
}
if(policy.events_dispatch_up && parent){
_insertDatLayerCallback(dataLayerName, function(e){
e[mark] = true;
parent.send(e);
});
}
_export.parent = parent;
_export.children = children;
_export.mark = mark;
});
}(window, document, window.jQuery));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment