Event driven data layer - How can you trigger actions on a data layer push?

Code for an event driven data layer

Only when I started working with Adobe Launch - which was before there were event driven data layer (EDDL) Launch extensions - I started to realise how good Google Tag Manager's (GTM) data layer is. At that time I was used to working with GTM; I was used to triggering tags on data layer events. The possibility to use a data layer event as a trigger was normal for me and I hadn't yet thought about how GTM had built this.

Then I suddenly worked with Launch, which made me wonder if Launch provided similar functionality. Amongst other options Launch offered "Data Element Change", a trigger that fires when the value of a data element (a variable in GTM terms) changes. When you couple that with a data element that points to some key in your data layer, you can trigger rules (tags in GTM terms) on a specific change in the data layer. Sounds cool, but when you look into how this trigger functions, it turns out it is not something you want to use too often. The trigger functions by polling the chosen data element every second, which leads to two important drawbacks:

  1. If you'd like to use it for link click tracking, you would need a timeout of 1 full second (the polling interval) for every link on your website, to ensure the trigger has enough time to fire --> this influences the user experience.
  2. For each data element change trigger in use, the referenced data element has to be executed every second, to see if its return value is different from the previous check --> this may influence website performance.

The polling solution of data element change did not impress me; I knew Google Tag Manager's data layer event trigger fired immediately on a (matching) data layer push. Which is what brought me to the question I started this post with. I wanted to know how it works. How can you trigger actions on a data layer push?

In this post I write the code for a basic EDDL, step by step, ultimately answering that question.

Adding actions

If I take a closer look at my goal, I conclude I need 3 main components. A way to create and store the actions I want to trigger, a way to trigger those actions and a data layer I can push data to. When I have those, I need to connect the data layer push to the trigger. I want to write my code in this order, so I start with actions.

The very first thing I need is a place to store my actions.


const actions = {};

          

Done. That was an easy start.

Next, I need to be able to add my actions to this object in a way that I can easily trigger them later. So I need an 'add' function for this object. I have a two main requirements for this function:

  1. I want to link actions to a specific event name.
  2. I want to be able to have multiple actions for the same event.

Now that I know what I want, I can create my add function.


const actions = {}; 
actions.add = (eventName, callback) => {
  actions[eventName] = actions[eventName] || [];
  actions[eventName].push(callback);
};

          

The add function first checks whether the actions object already has a key for the current event name. When it does, its value is kept so that previously added actions for this event are kept as well. When it does not yet exist, the key is created with an empty array as its value. I want this array value, as it enables adding multple actions to the same event.

The action that can be triggered later is the callback part of this function. And by pushing this callback to the array, I can add a new action to an event name every time I use the add function.

Before I can test triggering actions, I need to actually add at least one action to the object. Since I want to test the requirement of multiple actions for one event, I add two separate actions for an event named click:


const actions = {}; 
actions.add = (eventName, callback) => {
  actions[eventName] = actions[eventName] || [];
  actions[eventName].push(callback);
};
            
actions.add('click', () => {
  console.log('A click trigger just fired.');
});
          
actions.add('click', () => {
  console.log('Another click trigger just fired.');
});

          

This code results in an actions object that contains the add function and a click event. The click event holds two functions:

The actions object has a click sub-array, which contains two functions

Now that my actions are stored, I'm ready to trigger them.

Triggering actions

Just like I wanted to be able to have multiple triggers for one event, I also want to be able to trigger a specific event multiple times. The actions object therefore gets a second function, trigger, that executes all callbacks for a given event name. For this function I have one main requirement:

  • When I trigger an action, I want to be able to use some contextual data on the event that happened in the action.

To meet this requirement, the trigger function can simply take a data object as input and pass that to each action callback it executes.


const actions = {}; 
actions.add = (eventName, callback) => {
  actions[eventName] = actions[eventName] || [];
  actions[eventName].push(callback);
};
            
actions.add('click', () => {
  console.log('A click trigger just fired.');
});
          
actions.add('click', () => {
  console.log('Another click trigger just fired.');
});

actions.trigger = (eventName, data) => {
  if (actions[eventName]) {
    actions[eventName].forEach(action => {
      action(data);
    });
  }
};

          

The main goal of the trigger function is to trigger actions, of course. So when an event is triggered, the actions object is checked for the existence of any actions for this specfic event. Each callback that is in the array for the event is called and executed. The optional data object that may have been provided to the trigger function is passed along to the action as well.

At this point in the excercise this data object doesn't mean much yet. The current actions in my object are just static console logs, they do not expect any input and thus wouldn't change in any way if I provided a data object when I trigger them. But the point is that I could now add an action that does take some data and uses it in the callback. Before I move on to testing that part, I first want to trigger my click event to see the console logs appear.


const actions = {}; 
actions.add = (eventName, callback) => {
  actions[eventName] = actions[eventName] || [];
  actions[eventName].push(callback);
};
            
actions.add('click', () => {
  console.log('A click trigger just fired.');
});
          
actions.add('click', () => {
  console.log('Another click trigger just fired.');
});

actions.trigger = (eventName, data) => {
  if (actions[eventName]) {
    actions[eventName].forEach(action => {
      action(data);
    });
  }
};

actions.trigger('click');

          

Any time I run this last line, I see both logs printed in the console. Now I can remove these test actions, restructure the actions object a little bit and perform a quick check of what happens when I add an action that uses a piece of contextual data.


const actions = {
  add: (eventName, callback) => {
    actions[eventName] = actions[eventName] || [];
    actions[eventName].push(callback);
  },
  trigger: (eventName, data) => {
    if (actions[eventName]) {
      actions[eventName].forEach(action => {
        action(data);
      });
    }
  }
};

actions.add('pageview', data => {
  console.log('pageview: ', data.pagepath)
});

actions.trigger('pageview', {pagepath: document.location.pathname});

          

If I paste the blocks above in my console seperately, I can see how calling the trigger function results in direct action:

Triggering a pageview action

Now I can trigger actions and add some contextual data to events. I have the first part of the main question covered. But I can't yet trigger actions on a data layer push. It's time to start working on a data layer.

Creating an event driven data layer

Keeping in mind that my main goal is to trigger actions based on a data layer push, the easiest way to have an object I can push to is to start - just like GTM does - with an array. But when I think about triggering actions, I don't want to pass that same array to the actions. That is because I don't want to search through the indexes of the array to find the data that I need in my action. That would become messy quickly. Instead, I want a seperate object that is also part of my data layer. This object needs to represent the current state of the data layer at all times (and this too is exactly what GTM does).


window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};

          

If I first push page information to the data layer and then I add a user interaction later, I could need data from both pushes in my actions. So if I update my current state object with every data layer push, I can use that object to feed the actions. Which means that what I need to do next, is to add functionality to my data layer's push method. Next to pushing to eventData, the function needs to update eventDataModel. This is also where the trigger function can be called, but first I need eventDataModel to become the current state of the data layer. My requirements for updating the state are:

  • I only want to process objects that are not arrays into my state object. Any other data types pushed to eventData should not alter eventDataModel. This only applies to the main object pushed. Anything nested in that object is allowed to have a different data type.
  • For those objects that are not arrays (in this case it includes nested ones) I want to merge the pushed object with what was already added to eventDataModel earlier. Values are overwritten by pushing a new value to the same key (or removed by pushing null or an empty string). But if an existing eventDataModel key is not included in this eventData.push(), the existing value is kept for that key.

window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};
  
const mergeObjects = (obj, data) => {
  if (Object.prototype.toString.call(data) === '[object Object]') {
    for(var i in data) {
      if (Object.prototype.toString.call(data[i]) !== '[object Object]') {
        obj[i] = data[i];
      } else if (typeof data[i] === 'object') {
        obj[i] = obj[i] || {};
        obj[i] = mergeObjects(obj[i], data[i]);
      }
    } 
  }
  return obj;
};
  
eventData.push = function(...args) {
  args.forEach(function(arg) {
    eventDataModel = mergeObjects(eventDataModel, arg);
  });
  return Array.prototype.push.apply(this,args);
};  

          

Having the mergeObjects function apart from the rest of the changes to eventData's push function makes it easier to see what this code does. So what does this code do? All arguments provided via push are collected in an array using rest parameter syntax. This enables directly calling forEach() on the arguments. Now I'm adding functionality to the push function; every parameter pushed to eventData gets passed to mergeObjects() so eventDataModel can be updated.

The merging follows my requirements. The first check ensures that a push of something that is not an object or an array simply returns eventDataModel without any changes for this eventData.push(). Then, for every key in the object pushed, another data type check is performed. For something that is not an object or an array this key is set in eventDataModel with the value from this push. If the key holds a nested object however, the code keeps the existing key (and possible values it already holds). It only creates a new key if it doesn't yet exist in eventDatModel. Then, the function simply calls itself to do for any nested object what it does for the parent eventDataModel object: add all keys that did not yet exist, overwrite all values included in this push whilst preserving everything that is not included in this push. By calling itself, the function can merge objects for any amount of nested object, no matter how many levels of nesting exist.

When merging is finished I still want to keep the original functionality of push(). So in the end my new push function returns the regular push function (Array.prototype.push()) and applies it to eventData (this) with the original arguments.

To see all of that in action, I push a few objects to eventData and check eventDataModel (I could even uncomment the debugger statement right before mergeObjects() is called, to follow step-by-step what the function does).


window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};
  
const mergeObjects = (obj, data) => {
  if (Object.prototype.toString.call(data) === '[object Object]') {
    for(var i in data) {
      if (Object.prototype.toString.call(data[i]) !== '[object Object]') {
        obj[i] = data[i];
      } else if (typeof data[i] === 'object') {
        obj[i] = obj[i] || {};
        obj[i] = mergeObjects(obj[i], data[i]);
      }
    } 
  }
  return obj;
};
  
eventData.push = function(...args) {
  args.forEach(function(arg) {
    // debugger;
    eventDataModel = mergeObjects(eventDataModel, arg);
  });
  return Array.prototype.push.apply(this,args);
};

eventData.push({
  event: 'start',
  page: {
    host: document.location.hostname,
    path: document.location.pathname
  }
});

eventData.push({
  event: 'load',
  page: {
    name: 'event-driven-data-layer'
  }
});

eventData.push({
  event: 'click',
  interaction: {
    label: 'contact',
    position: 'body',
    type: 'button'
  }
});

          

After these pushes this is the state of the data layer:

State of the data layer after three pushes

I'm happy with how an eventData.push updates eventDataModel and would now like to feed eventDataModel to my actions. I have all the components to reach my main goal. Now I want to trigger actions on a data layer push.

Triggering actions from my event driven data layer

As the EDDL work already resulted in a modified push method for eventData, combining it with my actions object is a small step. Since I want to trigger actions on a push with the state of the data layer as contextual data, I only have to add a call of the trigger function after I update eventDataModel.


const actions = {
  add: (eventName, callback) => {
    actions[eventName] = actions[eventName] || [];
    actions[eventName].push(callback);
  },
  trigger: (eventName, data) => {
    if (actions[eventName]) {
      actions[eventName].forEach(action => {
        action(data);
      });
    }
  }
};

window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};
  
const mergeObjects = (obj, data) => {
  if (Object.prototype.toString.call(data) === '[object Object]') {
    for(var i in data) {
      if (Object.prototype.toString.call(data[i]) !== '[object Object]') {
        obj[i] = data[i];
      } else if (typeof data[i] === 'object') {
        obj[i] = obj[i] || {};
        obj[i] = mergeObjects(obj[i], data[i]);
      }
    } 
  }
  return obj;
};
  
eventData.push = function(...args) {
  args.forEach(function(arg) {
    eventDataModel = mergeObjects(eventDataModel, arg);
    actions.trigger(arg.event, eventDataModel);
  });
  return Array.prototype.push.apply(this,args);
};

          

If I add an action for a specific event, then push that event to the data layer, the action is triggered. A simple example could be adding a console.log of the page path when a pageview event happens, then pushing a pageview event including the pagepath to the data layer. When I perform the eventData.push(), the action triggers immediately. This is a way of triggering actions on a data layer push.


const actions = {
  add: (eventName, callback) => {
    actions[eventName] = actions[eventName] || [];
    actions[eventName].push(callback);
  },
  trigger: (eventName, data) => {
    if (actions[eventName]) {
      actions[eventName].forEach(action => {
        action(data);
      });
    }
  }
};

window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};
  
const mergeObjects = (obj, data) => {
  if (Object.prototype.toString.call(data) === '[object Object]') {
    for(var i in data) {
      if (Object.prototype.toString.call(data[i]) !== '[object Object]') {
        obj[i] = data[i];
      } else if (typeof data[i] === 'object') {
        obj[i] = obj[i] || {};
        obj[i] = mergeObjects(obj[i], data[i]);
      }
    } 
  }
  return obj;
};
  
eventData.push = function(...args) {
  args.forEach(function(arg) {
    eventDataModel = mergeObjects(eventDataModel, arg);
    actions.trigger(arg.event, eventDataModel);
  });
  return Array.prototype.push.apply(this,args);
};

actions.add('pageview', data => {
  console.log('Pageview recorded for page: ', data.page.path);
});

eventData.push({event: 'pageview', page: {path: document.location.pathname}});

          

The base code for my simple EDDL is just 38 lines. And one could argue the actions object and its functions are not even part of the data layer, because triggers and executing actions - or tags in GTM terms - are separate parts of a tag manager. So what I have here is a starting point for a tag management system. There are many ways to expand from here; as a final excercise for this article I choose to make the actions more interesting. I want to load Google Analytics (GA) via my code and send it some data.

Sending data to Google Analytics

To clearly separate this code from other data collection on the page, I use Universal Analytics (UA, also known as Google Analytics 3) in this example. Actual data collection on this page does not use UA.


const actions = {
  add: (eventName, callback) => {
    actions[eventName] = actions[eventName] || [];
    actions[eventName].push(callback);
  },
  trigger: (eventName, data) => {
    if (actions[eventName]) {
      actions[eventName].forEach(action => {
        action(data);
      });
    }
  }
};

window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};
  
const mergeObjects = (obj, data) => {
  if (Object.prototype.toString.call(data) === '[object Object]') {
    for(var i in data) {
      if (Object.prototype.toString.call(data[i]) !== '[object Object]') {
        obj[i] = data[i];
      } else if (typeof data[i] === 'object') {
        obj[i] = obj[i] || {};
        obj[i] = mergeObjects(obj[i], data[i]);
      }
    } 
  }
  return obj;
};
  
eventData.push = function(...args) {
  args.forEach(function(arg) {
    eventDataModel = mergeObjects(eventDataModel, arg);
    actions.trigger(arg.event, eventDataModel);
  });
  return Array.prototype.push.apply(this,args);
};

actions.add('loadga', () => {
  if (typeof ga !== 'function') {
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
      })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
    
    ga('create', 'UA-12345-AB', 'auto');
    ga('set', {
      'anonymizeIp': true,
      'allowAdFeatures': false,
      'transport': 'beacon'
    });
  }    
});

actions.add('pageview', data => {
  actions.trigger('loadga');
  
  ga('set', {'dimension1': data && data.page && data.page.category || 'no value'})
  ga('send', 'pageview');
});

actions.add('click', data => {
  actions.trigger('loadga');

  const novalue = 'no value';
  const interaction = data.interaction;
  const position = interaction && interaction.position || novalue;
  const type = interaction && interaction.type || novalue;
  const label = interaction && interaction.label || novalue;
  const eventAction = `${position}:${type}:${label}`;
  const navigation = interaction.navigation;
  const eventLabel = navigation || 'no navigation';
  ga('send', 'event', 'user interaction', eventAction, eventLabel);
});

          

To be able to send anything to Google Analytics, I need to add its library to the page. I choose to do that in a separate event, that only executes if the 'ga' object on the page does not yet exist. Now I can trigger the load event every time I want to send something to GA and I can be sure the library is loaded when the first GA event fires. Note that when I load the library I'm also setting some data on the tracker object (the object that builds the hit to GA), to customise my hits.

I have two actions that send data to GA, one for a pageview and one for a click event. Both expect specific information from the data object to build the hit as intended. I can provide everything in a data layer push.


const actions = {
  add: (eventName, callback) => {
    actions[eventName] = actions[eventName] || [];
    actions[eventName].push(callback);
  },
  trigger: (eventName, data) => {
    if (actions[eventName]) {
      actions[eventName].forEach(action => {
        action(data);
      });
    }
  }
};

window.eventData = window.eventData || [];
window.eventDataModel = window.eventDataModel || {};
  
const mergeObjects = (obj, data) => {
  if (Object.prototype.toString.call(data) === '[object Object]') {
    for(var i in data) {
      if (Object.prototype.toString.call(data[i]) !== '[object Object]') {
        obj[i] = data[i];
      } else if (typeof data[i] === 'object') {
        obj[i] = obj[i] || {};
        obj[i] = mergeObjects(obj[i], data[i]);
      }
    } 
  }
  return obj;
};
  
eventData.push = function(...args) {
  args.forEach(function(arg) {
    eventDataModel = mergeObjects(eventDataModel, arg);
    actions.trigger(arg.event, eventDataModel);
  });
  return Array.prototype.push.apply(this,args);
};

actions.add('loadga', () => {
  if (typeof ga !== 'function') {
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
      })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
    
    ga('create', 'UA-12345-AB', 'auto');
    ga('set', {
      'anonymizeIp': true,
      'allowAdFeatures': false,
      'transport': 'beacon'
    });
  }    
});

actions.add('pageview', data => {
  actions.trigger('loadga');
  
  ga('set', {'dimension1': data && data.page && data.page.category || 'no value'})
  ga('send', 'pageview');
});

actions.add('click', data => {
  actions.trigger('loadga');

  const novalue = 'no value';
  const interaction = data.interaction;
  const position = interaction && interaction.position || novalue;
  const type = interaction && interaction.type || novalue;
  const label = interaction && interaction.label || novalue;
  const eventAction = `${position}:${type}:${label}`;
  const navigation = interaction.navigation;
  const eventLabel = navigation || 'no navigation';
  ga('send', 'event', 'user interaction', eventAction, eventLabel);
});

eventData.push({
  event: 'pageview', 
  page: {
    category: 'blog'
  }
});

eventData.push({
  event: 'click',
  interaction: {
    navigation: 'https://antonbies.com/',
    position: 'header',
    type: 'topnav',
    label: 'home'
  }
});

          

And when I push to eventData, the data is sent off to Google's servers:

A data layer push leads to a hit to Google Analytics

And that's enough for now. The question is answered. I've created a simple EDDL that allows me to trigger actions on a data layer push and I've even used it to load GA and send data to Google. This excercise has helped me understand how an EDDL works. By extension, I have a better understanding of the benefits event driven data layers which will allow me to better help organisations with their analytics implementations.

Here's to hoping reading this post - and maybe following along with the code in the console - has helped you understand the event driven data layer, too.