Tracking YouTube videos in Google Tag Manager is one of the more useful things you can do in terms of tracking. YouTube has a wonderful API that you can tap into and convert the detected events into dataLayer messages.

There are some really good solutions out there for tracking YouTube videos in GTM:

Both do a great job of tracking videos that have been loaded with the page. However, both have difficulties with tracking dynamically loaded videos. That means videos which are loaded lazily, or in pop-ups, or when some content link is clicked.

In this article, I’m going to show how you can track dynamically loaded videos using LunaMetrics’ solution with slight modifications. LunaMetrics is my favorite group of geeks in North America, and they bribed me with their awesome book just before Christmas, which would already be reason enough to use their code. However, their solution is also all kinds of robust, and it really lends itself to tracking dynamic stuff as well.

What you’ll need

The modifications will be done to the JavaScript code in the Custom HTML Tag. In other words, you can’t use LunaMetrics’ CDN for this. You will have to create a Custom HTML Tag, and copy-paste the code in the next chapter within.

As for the rest of the stuff, just follow LunaMetrics’ guide. All this does is add a method you can trigger when videos have been loaded dynamically. Don’t worry, I’ll explain the method later on as well.

The modified Custom HTML Tag

So, follow the LunaMetrics guide, avoiding the CDN, and when you come to the chapter titled Google Tag Manager Installation, read the following instead.

Create a new Custom HTML Tag, and give it some cool name like Utility - Video Tracking Bomb Overload Christmas Awesome.

Sorry for that.

Next, add the following code.

<script>
// Respectfully copied and modified from 
// https://github.com/lunametrics/youtube-google-analytics
// 
// Original implementation by LunaMetrics
var ytTracker = (function( document, window, config ) {

  'use strict';

  window.onYouTubeIframeAPIReady = (function() {
    
    var cached = window.onYouTubeIframeAPIReady;

    return function() {
        
      if( cached ) {

        cached.apply(this, arguments);

      }

      // This script won't work on IE 6 or 7, so we bail at this point if we detect that UA
      if( !navigator.userAgent.match( /MSIE [67]\./gi ) ) {

        init(); 
    
      }

    };

  })();
  
  var _config = config || {};
  var forceSyntax = _config.forceSyntax || 0;
  var dataLayerName = _config.dataLayerName || 'dataLayer';
  // Default configuration for events
  var eventsFired = {
    'Play'        : true,
    'Pause'       : true,
    'Watch to End': true
  };
  
  // Overwrites defaults with customizations, if any
  var key;
  for( key in _config.events ) {

    if( _config.events.hasOwnProperty( key ) ) {

      eventsFired[ key ] = _config.events[ key ];

    }

  }
  
  //*****//
  // DO NOT EDIT ANYTHING BELOW THIS LINE EXCEPT CONFIG AT THE BOTTOM
  //*****//

  // Invoked by the YouTube API when it's ready
  function init() {

    var iframes = document.getElementsByTagName( 'iframe' );
    var embeds  = document.getElementsByTagName( 'embed' );

    digestPotentialVideos( iframes );
    digestPotentialVideos( embeds );

  }

  var tag            = document.createElement( 'script' );
  tag.src            = '//www.youtube.com/iframe_api';
  var firstScriptTag = document.getElementsByTagName( 'script' )[0];
  firstScriptTag.parentNode.insertBefore( tag, firstScriptTag );

  // Take our videos and turn them into trackable videos with events
  function digestPotentialVideos( potentialVideos ) {

    var i;

    for( i = 0; i < potentialVideos.length; i++ ) {       
      var isYouTubeVideo = checkIfYouTubeVideo( potentialVideos[ i ] );       
      if( isYouTubeVideo ) {
        var normalizedYouTubeIframe = normalizeYouTubeIframe( potentialVideos[ i ] );
        addYouTubeEvents( normalizedYouTubeIframe );
      }
    }

  }   

  // Determine if the element is a YouTube video or not   
  function checkIfYouTubeVideo( potentialYouTubeVideo ) {
    // Exclude already decorated videos     
    if (potentialYouTubeVideo.getAttribute('data-gtm-yt')) {       
      return false;
    }

    var potentialYouTubeVideoSrc = potentialYouTubeVideo.src || '';     
    if( potentialYouTubeVideoSrc.indexOf( 'youtube.com/embed/' ) > -1 || potentialYouTubeVideoSrc.indexOf( 'youtube.com/v/' ) > -1 ) {

      return true;

    }

    return false;

  }

  // Turn embed objects into iframe objects and ensure they have the right parameters
  function normalizeYouTubeIframe( youTubeVideo ) {
    
    var a           = document.createElement( 'a' );
        a.href      = youTubeVideo.src;
        a.hostname  = 'www.youtube.com';
        a.protocol  = document.location.protocol;
    var tmpPathname = a.pathname.charAt( 0 ) === '/' ? a.pathname : '/' + a.pathname;  // IE10 shim
    
    // For security reasons, YouTube wants an origin parameter set that matches our hostname
    var origin = window.location.protocol + '%2F%2F' + window.location.hostname + ( window.location.port ? ':' + window.location.port : '' );

    if( a.search.indexOf( 'enablejsapi' ) === -1 ) {

      a.search = ( a.search.length > 0 ? a.search + '&' : '' ) + 'enablejsapi=1';

    }

    // Don't set if testing locally
    if( a.search.indexOf( 'origin' ) === -1  && window.location.hostname.indexOf( 'localhost' ) === -1 ) {

      a.search = a.search + '&origin=' + origin;

    }

    if( youTubeVideo.type === 'application/x-shockwave-flash' ) {

      var newIframe     = document.createElement( 'iframe' );
      newIframe.height  = youTubeVideo.height;
      newIframe.width   = youTubeVideo.width;
      tmpPathname = tmpPathname.replace('/v/', '/embed/');

      youTubeVideo.parentNode.parentNode.replaceChild( newIframe, youTubeVideo.parentNode );

      youTubeVideo = newIframe;

    }

    a.pathname       = tmpPathname;
    if(youTubeVideo.src !== a.href + a.hash) {
    
      youTubeVideo.src = a.href + a.hash;

    }

    youTubeVideo.setAttribute('data-gtm-yt', 'true');

    return youTubeVideo;

  }

  // Add event handlers for events emitted by the YouTube API
  function addYouTubeEvents( youTubeIframe ) {

    youTubeIframe.pauseFlag  = false;

    new YT.Player( youTubeIframe, {

      events: {

        onStateChange: function( evt ) {

          onStateChangeHandler( evt, youTubeIframe );

        }

      }

    } );

  }

  // Returns key/value pairs of percentages: number of seconds to achieve
  function getMarks(duration) {

    var marks = {}; 

    // For full support, we're handling Watch to End with percentage viewed
    if (_config.events[ 'Watch to End' ] ) {

      marks[ 'Watch to End' ] = duration * 99 / 100;

    }

    if( _config.percentageTracking ) {

      var points = [];
      var i;

      if( _config.percentageTracking.each ) {

        points = points.concat( _config.percentageTracking.each );

      }

      if( _config.percentageTracking.every ) {

        var every = parseInt( _config.percentageTracking.every, 10 );
        var num = 100 / every;
        
        for( i = 1; i < num; i++ ) {
      
          points.push(i * every);

        }

      }

      for(i = 0; i < points.length; i++) {

        var _point = points[i];
        var _mark = _point + '%';
        var _time = duration * _point / 100;
        
        marks[_mark] = Math.floor( _time );

      }

    }

    return marks;

  }

  function checkCompletion(player, marks, videoId) {

    var duration     = player.getDuration();
    var currentTime  = player.getCurrentTime();
    var playbackRate = player.getPlaybackRate();
    player[videoId] = player[videoId] || {};
    var key;

    for( key in marks ) {

      if( marks[key] <= currentTime && !player[videoId][key] ) {

        player[videoId][key] = true;
        fireAnalyticsEvent( videoId, key );

      }

    }

  }

  // Event handler for events emitted from the YouTube API
  function onStateChangeHandler( evt, youTubeIframe ) {
 
    var stateIndex     = evt.data;
    var player         = evt.target;
    var targetVideoUrl = player.getVideoUrl();
    var targetVideoId  = targetVideoUrl.match( /[?&]v=([^&#]*)/ )[ 1 ];  // Extract the ID    
    var playerState    = player.getPlayerState();
    var duration       = player.getDuration();
    var marks          = getMarks(duration);
    var playerStatesIndex = {
      '1' : 'Play',
      '2' : 'Pause'
    };
    var state = playerStatesIndex[ stateIndex ]; 

    youTubeIframe.playTracker = youTubeIframe.playTracker || {};

    if( playerState === 1 && !youTubeIframe.timer ) {

      clearInterval(youTubeIframe.timer);

      youTubeIframe.timer = setInterval(function() {

        // Check every second to see if we've hit any of our percentage viewed marks
        checkCompletion(player, marks, youTubeIframe.videoId);

      }, 1000);

    } else {

      clearInterval(youTubeIframe.timer);
      youTubeIframe.timer = false;

    }

    // Playlist edge-case handler
    if( stateIndex === 1 ) {

      youTubeIframe.playTracker[ targetVideoId ] = true;
      youTubeIframe.videoId = targetVideoId;
      youTubeIframe.pauseFlag = false;

    }

    if( !youTubeIframe.playTracker[ youTubeIframe.videoId ] ) {

      // This video hasn't started yet, so this is spam
      return false;

    }

    if( stateIndex === 2 ) {

      if( !youTubeIframe.pauseFlag ) { 
      
        youTubeIframe.pauseFlag = true;

      } else {

        // We don't want to fire consecutive pause events
        return false;

      }

    }

    // If we're meant to track this event, fire it
    if( eventsFired[ state ] ) {
    
      fireAnalyticsEvent( youTubeIframe.videoId, state );

    }

  }

  // Fire an event to Google Analytics or Google Tag Manager
  function fireAnalyticsEvent( videoId, state ) {

    var videoUrl = 'https://www.youtube.com/watch?v=' + videoId;
    var _ga = window.GoogleAnalyticsObject;

    if( typeof window[ dataLayerName ] !== 'undefined' && !_config.forceSyntax ) { 
      
      window[ dataLayerName ].push( {

        'event'     : 'youTubeTrack',
        'attributes': {

          'videoUrl': videoUrl,
          'videoAction': state

        }

      } );

    } else if( typeof window[ _ga ] === 'function' && 
               typeof window[ _ga ].getAll === 'function' && 
               _config.forceSyntax !== 2 ) 
    {

      window[ _ga ]( 'send', 'event', 'Videos', state, videoUrl );

    } else if( typeof window._gaq !== 'undefined' && forceSyntax !== 1 ) {

      window._gaq.push( [ '_trackEvent', 'Videos', state, videoUrl ] );

    }

  }
  
  return {
    init : init,
    digestPotentialVideos : digestPotentialVideos
  }
    
})(document, window, {
  'events': {
    'Play': true,
    'Pause': true,
    'Watch to End': true
  },
  'percentageTracking': {
    'every': 25,
    'each': [ 10, 90 ]
  }
});
/*
 * Configuration Details
 *
 * @property events object
 * Defines which events emitted by YouTube API
 * will be turned into Google Analytics or GTM events
 *
 * @property percentageTracking object
 * Object with configurations for percentage viewed events
 *
 *   @property each array
 *   Fires an event once each percentage ahs been reached
 *
 *   @property every number
 *   Fires an event for every n% viewed
 *
 * @property forceSyntax int 0, 1, or 2
 * Forces script to use Classic (2) or Universal(1)
 *
 * @property dataLayerName string
 * Tells script to use custom dataLayer name instead of default
 */
</script>

Yes, that is a LOT of stuff. Bear with me, though.

The modifications to LunaMetrics’ solution are few but significant.

First of all, instead of using an anonymous function, we’re actually reserving a slot in the global namespace, and exposing the function in a variable named ytTracker.

We do this because we want to invoke certain methods in the setup after the initial execution. If we didn’t expose a public method for it, we’d need to run the whole thing again and again, each time a video is dynamically loaded, and that’s just a huge strain on performance.

Next thing we’re doing is adding a single line into the normalizeYouTubeIframe() method:

function normalizeYouTubeIframe( youTubeVideo ) {
  ...
  youTubeVideo.setAttribute('data-gtm-yt', 'true'); // <--- NEW

  return youTubeVideo;
}

The youTubeVideo.setAttribute() command is used to add a data attribute to all iframes which have already been treated by the script. This is because we want to avoid running the initialization methods again and again for videos which have already been configured for tracking. In my testing, if a video was tracked multiple times it led to runtime conflicts.

Now that the data attribute is there, we need to check for its existence. In the checkIfYouTubeVideo() method, we’ll add the check like this:

function checkIfYouTubeVideo( potentialYouTubeVideo ) {    
  if (potentialYouTubeVideo.getAttribute('data-gtm-yt')) {
    return false;
  }
  ...
}

This check just looks for the data-gtm-yt attribute we added in the previous step, and if it’s found, the initialization is skipped for this particular video.

This way only videos which have not been treated yet will be processed.

Finally, we need to expose two methods in the ytTracker interface. These will let us handle dynamically added videos. Lets do that in the very end of the function expression.

var ytTracker = (function(...) {
  ...
  return {
    init : init,
    digestPotentialVideos : digestPotentialVideos
  }
})(...);

We return an object with two members: init and digestPotentialVideos. So, when we call ytTracker.init(), the script is basically run again, and all newly added, yet untreated YouTube iframe embeds will be processed and decorated with event trackers.

If you want it to be a bit more robust, you can use ytTracker.digestPotentialVideos(iframe) instead. You pass an iframe object as its parameter, and the script will only treat the one video. This is better since it won’t loop through all the iframes on the page, and instead just decorates the one you want.

Finally, set the Custom HTML Tag to fire on a Page View / DOM Ready Trigger.

That’s the changes right there, ladies and gentlemen.

How to use it

As said, there’s two new methods in town:

ytTracker.init(); // <-- Decorate all new videos on the page
ytTracker.digestPotentialVideos(iframe); // <-- Only decorate the iframe that was passed as a parameter

Making the solution work will still most likely require developer help. If your developers have added some complex, dynamic content loaders, which add the videos after some awesome jQuery fade-in has finished, you’ll need to cooperate with them to add the ytTracker.init() or ytTracker.digestPotentialVideos() command in the right place.

Here’s an example of what a modified content loader might look like:

function processVideoClick(ytEmbedSrc) {
  var video = document.createElement('iframe');
  video.src = ytEmbedSrc;
  var content = document.querySelector('#content');
  content.appendChild(video);
  window.ytTracker.digestPotentialVideos(video);
}

After the video is injected, you call ytTracker.digestPotentialVideos(). You could use the init() method without any parameters, but since you already have the iframe element at hand, it’s better to use the more direct method.

Summary

Since loading content dynamically is notoriously standard-free, it’s difficult to give a generic solution for managing dynamically loaded content in your analytics or tag management platform. However, LunaMetrics’ original solution has a very nice set of tools to tackle dynamically loaded YouTube videos as well, without having to make drastic modifications to the code.

LunaMetrics’ solution is so flexible and useful. Poking some holes into its interface to let some light out just makes it even better, in my own, humble opinion.

The optimal way of working with it is to cooperate with your developers. I hope we’ve all matured past the notion of GTM being developer-independent. So, communicate with your developers, ask them to modify the JavaScript controlling the dynamic video injection, and request that they’ll add the simple ytTracker methods to the handlers they create.