Cross Domain Tracking with Clean Urls

Cross domain tracking in Google Analytics is a way to track a visit on multiple domains within a single session.

The Problem

GA uses a client id to assemble sessions from individual pageviews; all pageviews and other interactions within a certain timeframe that share a clientid are part of one session. HTTP is a stateless protocol, meaning that individuals hits are not connected to each other. So GA persists an id within the “_ga” cookie on the client and sends it to the Analytics server with every tracking call. Cookies are domain specific, to if a single web property uses multiple domains the client id is lost as the cookie itself cannot be transferred between domains. So GA uses a workaround to overcome that particular problem.

Cross domain tracking works by appending a parameter with the client id to all the links that lead to and fro between the domains of your web property. The receiving domain then takes the parameter on the landingpage and sets the client id accordingly. Now pageviews on all domains share a client id, and can be connected on the Google Analytics Servers. This is important for example if your checkout is on a different domain than your product portfolio and you do not want to lose campaign attribution as the user progresses from product views to the checkout.

GA uses a “decorator” function to append the linker parameter to the clicked url. The parameter is added only at the moment of the click; the decorator function inspects the link href, decides if the target is a linked domain according to the configuration and if so adds the linker parameter. The parameter comprises the client id and some timing information (the parameter is valid for two minutes only ).

The small caveat with that approach is that there is a funny looking parameter in the url (which might be cut of by server side redirects, which would break cross domain tracking).  There are frequent requests on sites like stackoverflow for a way to dispense with the parameter, so I decided to give it a go.

POC for a possible solution

This is very much a proof of concept, and if you want to use the idea you should thoroughly test it for your use case (and the actual code is not really refined enough to use it in production).

My approach was to write a Google Analytics plugin that adds its own decorator function. The idea is rather simple: instead of adding the linker parameter to the outgoing link the plugin creates an iframe that points to the linked domain. It transmits the linker parameter via the iframe source, and then user is redirected to the link destination. The cookie with the client id has been set via the iframe call, so both domains have the same id, and the session proceeds from one domain to the other. Also the plugin suppresses the tracking call from the iframe (else you’d have a row with the iframe url in your reports which you probably do not want or need).

For this to work you need to call the plugin with some options in your GA tracking code. Also since this uses the same linker parameter as the official autolink plugin you need to enable autolinking. You also need to save the plugin code to a file (iframelinker.js – iframelinker being the name of the plugin, as I am not very good with names). And finally you need to add all linked domains to the referral exclusion list, just as you would do with regular cross domain tracking.

<script>
 (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-XXXXXXXX-1', { 'cookieDomain':'auto' , 'allowLinker':true} );
 ga('require', 'iFrameLinker', {
 'debug': true,
 'domains' : ['domainA.com','domainB.com']
 });
 ga('send', 'pageview');

</script>
<script async src="http://www.domainA.com/path/to/iframelinker.js"></script>

And finally here is the actual plugin code. Look at the comments to see what it does.

(function() {
 function providePlugin(pluginName, pluginConstructor) {
 var ga = window[window['GoogleAnalyticsObject'] || 'ga'];
 if (typeof ga == 'function') {
 ga('provide', pluginName, pluginConstructor);
 }
 }

 /*
 * Evaluate configuration options 
 */
 var iFrameLinker = function(tracker, config) {
 this.isDebug = config.debug || false;
 if (!window.console) {
 this.isDebug = false;
 }
 this.tracker = tracker;
 this.domains = config.domains || false;
 this.log("Domains:" + this.domains);
 
 // if the url contains the parameter "isLinker" this is the iframe that 
 // transmits the cid; we abort the sendHitTask so this hit is not tracked
 if (this.getUrlParam('isLinker') == "true") {
 tracker.set('sendHitTask', false);
 this.log('debug','linker aborted');
 }
 this.linkerParam = tracker.get('linkerParam');
 this.decorate();
 }

 /*
 * Looks at all the links in the document; if they point to a linked domain 
 * the function to create the iframe that transmits the cid is added to the
 * link. 
 */ 
 iFrameLinker.prototype.decorate = function() {
 var hrefs = document.querySelectorAll('a');
 that = this; // clumsy but then this is a proof of concept
 hrefs.forEach(function(a) {
 a.addEventListener("click", function(e) {
 e.preventDefault();
 var targetUrlParts = e.target.hostname.split(".");
 var targetUrlTld = targetUrlParts.pop();
 var targetUrlDomain = targetUrlParts.pop();
 var targetUrlHostname = [targetUrlDomain, targetUrlTld].join(".");
 if (that.domains.indexOf(targetUrlHostname) > -1) {
 ifr = document.createElement('iframe');
 ifr.setAttribute("style", "width:1px;height:1px;");
 var delim = e.target.href.indexOf('?') > -1 ? "&" : "?";
 ifr.src = e.target.href + delim + ["isLinker=true",that.linkerParam].join("&");
 ifr.onload = function() {
 window.location = e.target.href;
 }
 document.body.appendChild(ifr);
 that.log('info','linker iframe source is' + ifr.src);
 }
 });
 });
 }

 /**
 * Utility function to extract a URL parameter value.
 */
 iFrameLinker.prototype.getUrlParam = function(param) {
 var match = document.location.search.match('(?:\\?|&)' + param + '=([^&#]*)');
 return (match && match.length == 2) ? decodeURIComponent(match[1]) : '';
 }

 /**
 * Displays a debug message in the console, if debugging is enabled.
 */
 iFrameLinker.prototype.log = function(type, message) {
 if (!this.isDebug) return;
 if (arguments.length == 1) {
 message = arguments[0];
 type = "debug";
 }
 console[type]('[iFrameLinker]', message);
 };

 providePlugin('iFrameLinker', iFrameLinker);
 })();

This has worked for me. I do not guarantee that it will work for anyone else, but I did test enough to be fairly sure that the principle is sound. It does not work for forms, but if you wanted to implement that it would work just the like the link decorator, only you’d have to attach it to forms, use the form action and the submit event.

One thought on “Cross Domain Tracking with Clean Urls

  1. I love this! This solves a problem I dealt with recently, where a client’s booking system is presented in an iframe. I adapted Google’s code for doing this (https://developers.google.com/analytics/devguides/collection/analyticsjs/cross-domain) – worked great until the client started using window.postMessage() to talk to the iframe too, and overwrote my message. Your approach is so much better! Every shopping cart, booking system, etc. that relies on iframes should provide a snippet of code like yours. Brilliant.

Leave a Reply

Your email address will not be published. Required fields are marked *