Republished from https://medium.com/@stuart.izon/dynamically-loading-google-tag-manager-environments-in-a-single-page-application-a9ae7550d042
Here’s the problem: you’re using Google Tag Manager on your single page application and you want it setup independently for your UAT and your production environments.
TL;DR - Check out the approach in this demo Github project
Having separate GTM environments is a good thing — it means that you can properly test changes to your tags outside of preview mode, and also that you can add tags/triggers on features that haven’t been deployed to production yet. But, different environments means loading a different script for each environment... The GTM documentation tells us:
Place the
<script>
code snippet in the<head>
of your web page's HTML output, preferably as close to the opening<head>
tag as possible, but below any dataLayer declarations.
And that script looks something like:
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'>m_auth=gsfg876sbgsfg7896g9876gs>m_preview=env-3>m_cookies_win=x';f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-ABCDEFGH');</script>
So how can you add it to your index.html, without maintaining a separate index-uat.html and index-prod.html? That doesn’t seem very in tune with the 12 factor app guidelines of keeping your environment specific config separate from your code.
Asynchrony, asynchrony
When we actually look at what the script above is doing, we see that it is dynamically creating a script
tag and injecting it into the DOM. (For HTML5 browsers there is the async attribute for asynchronously loading JavaScript, but for supporting older browsers this programmatic method is still pretty common). The script tag that actually gets added to the DOM when this script executes looks like this:
<script async=true src="https://www.googletagmanager.com/gtm.js
?id=GTM-ABCDEFGH
>m_auth=gsfg876sbgsfg7896g9876gs
>m_preview=env-3
>m_cookies_win=x">
</script>
Given we are using a single page app framework, we already have a JavaScript bundle included on the page. Could we do something similar programatically within our bundle to create that DOM element? A useful tool is loadjs, a “tiny async loading library for modern browsers”. To add this script, we could call:
loadjs("https://www.googletagmanager.com/gtm.js
?id=GTM-ABCDEFGH
>m_auth=gsfg876sbgsfg7896g9876gs
>m_preview=env-3
>m_cookies_win=x");
The original GTM snippet above also creates and initialises the dataLayer
array so let’s also add that to our code:
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({'gtm.start': new Date().getTime(), event: 'gtm.js'});
Blocking the render path… or not
If we wanted to block the render path, i.e. not show any meaningful content (beyond the main skeleton and a spinner) then we can wait until the asynchronous load has completed. e.g. in my AngularJS app, I have a service which wraps the loadjs functionality to return a promise:
export class ScriptLoader {
constructor(private $q: angular.IQService) { }
public load(paths: string | string[]) {
const done = this.$q.defer<void>();
loadjs(paths, {
error: pathsMissing => done.reject(pathsMissing),
success: () => done.resolve()
});
return done.promise;
}
}
And we could put a call to load("https://www.googletagmanager.com/gtm.js?id=GTM-ABCDEFGH>m_auth=gsfg876sbgsfg7896g9876gs>m_preview=env-3>m_cookies_win=x")
inside the resolve block in our routing config, so that this promise must resolve before the view/controller runs.
However, while this is a useful pattern for some other scripts, it turns out to be potentially unnecessary in the GTM case. Going back to the original snippet, we see that the dataLayer
initialisation code is separate from the GTM script inclusion. And therefore it seems that it is not required that this asynchronous script has loaded yet before calling dataLayer.push
. You can see this with the gtm.js
event which is part of this script:
w[l].push({'gtm.start': new Date().getTime(), event:'gtm.js'});
Whilst gtm.js
is a special event type within GTM, it functions in the same way as other data layer events i.e. it will only get instrumented after the async script has loaded and run, and any tags defined on this event will fire accordingly. Therefore we can totally divorce the inclusion of the GTM script from any code that pushes to the dataLayer
array. e.g. Let’s say you have code which pushes an event to the dataLayer
when the first “page” of the application loads:
dataLayer.push('event': 'ViewLandingPage');
Then you can run that independently from the asynchronous inclusion of the GTM script. If this code runs prior to the asynchronous script loading then any tags with this ViewLandingPage
as a trigger will fire when the script has loaded.
If the tags you have triggering on this event are primarily for reporting, this tiny delay will be fine. However if you have tags which display something to the user, or manipulate the DOM in any way, this delay may be more problematic. Whether you choose to block the render path or not will depend on your specific use case.
But, the environment, man…
This all makes for great conversation, but we haven’t actually solved the problem of making this script work with multiple environments yet. The following solution is specific for an AngularJS application, however similar principles will work regardless of your framework choice.
We need a way of injecting environment specific variables into our code — this could be done in all sorts of ways, and probably this is something you have already figured out for your use case. Here our approach is to add another script in the <head>
of our index.html containing a separate angular module which the main bundle is dependent on.
angular.module('myApp.config', [])
.constant('appConfig', {
gtmSource: 'id=GTM-ABCDEFGH>m_auth=gsfg876sbgsfg7896g9876gs>m_preview=env-3'
});
This module will be generated from a separate repo and there is a separate config file for the UAT and production environments. We add this config module as a dependency to our main module and inject the appConfig
into the function which initialises GTM:
export class DataLayerService {
constructor(private $window: angular.IWindowService,
appConfig: AppConfig,
scriptLoader: ScriptLoader) {
$window.dataLayer = $window.dataLayer || [];
$window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
scriptLoader.load(`https://www.googletagmanager.com/gtm.js?${appConfig.gtmSource}`);
}
angular.module('myApp', ['myApp.config'])
.service('dataLayer', DataLayerService)
.service('scriptLoader', ScriptLoader);
And now we can change the GTM snippet independently of our main application codebase.
When it comes to using Environments within GTM, typically the Live (production environment) does not have the gtm_auth
or gtm_preview
parameters set, and the id
parameter will have the same value across all the environments in the container. However the approach in this article does not even force you to use GTM environments — e.g. it is also possible to set your UAT up as a completely separate GTM container if you so choose (which was the recommended method pre-2016, prior to GTM environments). However you do it, this approach gives you the flexibility to handle all the params that make up the GTM URL.
Supporting the Luddites
One detail I glossed over is that the snippet Google tells you to install, also says:
Additionally, paste this code immediately after the opening
<body>
tag:
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-ABCDEFGH>m_auth=gsfg876sbgsfg7896g9876gs>m_preview=env-3>m_cookies_win=x"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
Clearly this is for any browsers which do not support JavaScript. I’m not going to go into this case in any detail, except to say that if your user has no JavaScript support then you are not going to be showing them much of your single page application! Depending on how you treat these customers, you may be sending them into a non JavaScript version of your app or possibly rendering something simplistic within your index.html. In any case there is no way to dynamically include GTM in the way I’ve described until now without JavaScript, and I have not bothered to add this <noscript>
snippet in the projects using this methodology.
To see this methodology in action, check out this demo Github project.