Tracking snippets under control

You’ve just finished the cleanest code of the new feature. You’re really proud of it but then marketing team needs just one tiny snippet to remarket a thing. Or to track its conversion. Or to automate lead generation. Whatever it is you end up with ugly JS snippet in the code. Oh, did I forget about GDPR and visitor consents? Yeah, you need them first to actually fire the tracking.

I must say I always thought of tracking snippets as a dirty secret of every site. I mean “here is nice React component to show the feature”, “there is the clean business logic of the feature” and “don’t look at this piece, it’s just aweful tracking code”. The truth is marketing or analytics snippets may be key money makers of the business, so why would you have them its dirty secret.

Why is it usually hard to maintain tracking snippets?

What can we do to overcome these issues? Encapsulation, my friend!

Suppose you have a snippet:

1
2
3
4
5
6
7
8
9
!function(m,s,e,v,n,t,x)
{if(m.msq)return;n=m.msq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!m._msq)m._msq=n;n.push=n;n.loaded=!0;
n.queue=[];t=s.createElement(e);t.async=!0;
t.src=v;x=s.getElementsByTagName(e)[0];
x.parentNode.insertBefore(t,s)}(window, document,'script',
'https://marketing-service-events.com/events.js');
msq('init', '<CODE>');

And use it like this:

1
2
3
// code and stuff
msq('track', 'View', { stuff: 'things' });
// stuff and code

(usually in at least few JS files)

The goal is to make it maintainable, deferable and testable.

Maintainable

Let’s introduce new JS module - MarketingService.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const trackEvent = function(event, properties) {
  window.msq('track', event, properties)
}

export const initialize = function() {
  !function(m,s,e,v,n,t,x)
  {if(m.msq)return;n=m.msq=function(){n.callMethod?
  n.callMethod.apply(n,arguments):n.queue.push(arguments)};
  if(!m._msq)m._msq=n;n.push=n;n.loaded=!0;
  n.queue=[];t=s.createElement(e);t.async=!0;
  t.src=v;x=s.getElementsByTagName(e)[0];
  x.parentNode.insertBefore(t,s)}(window, document,'script',
  'https://marketing-service-events.com/events.js');
  msq('init', '<CODE>');
}

And replace old code with these function calls accordingly. No big deal, just function extraction refactoring.

Deferable

Suppose you don’t know the order of msq calls versus initialization - sometimes it will be initalized first and then used, sometimes the other way - first used, then initialized. It’s actually pretty common, especially if you really care about GDPR. Let’s change both function to provide a way to defer real calls after initalization.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
let initialized = false
let queue = []

const sendEvent = function({ event, properties }) {
  window.msq('track', event, properties)
}

export const trackEvent = function(event, properties) {
  if (initalized) {
    sendEvent({ event, properties })
  } else {
    queue.push({ event, properties })
  }
}

export const initialize = function() {
  !function(m,s,e,v,n,t,x)
  {if(m.msq)return;n=m.msq=function(){n.callMethod?
  n.callMethod.apply(n,arguments):n.queue.push(arguments)};
  if(!m._msq)m._msq=n;n.push=n;n.loaded=!0;
  n.queue=[];t=s.createElement(e);t.async=!0;
  t.src=v;x=s.getElementsByTagName(e)[0];
  x.parentNode.insertBefore(t,s)}(window, document,'script',
  'https://marketing-service-events.com/events.js');
  msq('init', '<CODE>');

  initalized = true
  queue.forEach(sendEvent)
  queue = []
}

As you can see we introduce 2 module variables - initialized flag to know whether tracker is ready to receive events and queue to store the events before tracker is ready. The side effect is you may decide not to initialize the tracker at all (when visitor makes that choice) so none of the events will be sent. You don’t need any other fallback.

Testable

Ideally tests would send real events and use API’s endpoint to know whether it was sent - like list of recent events. Let’s be honest though - it’s nearly impossible to have it performant, if the tracker service provide the endpoint. What can we do? Mock the tracker.

As you see in the snippet the script body can be found here https://marketing-service-events.com/events.js, so what if we replace this with our implementation?

1
2
3
4
5
6
7
8
9
if (window.msq && window.msq.queue) {
  window.msq.queue.forEach(callMethod)
}

const callMethod = function(args) {
  ajaxCall('/track_test', args)
}

window.msq.callMethod = callMethod

The mock provides the callMethod method which is used in 3rd line of tracker snippet - as you see the msq tests if callMethod exists and decides what to do on msq() call - whether to call it or enqueue for later. The mock supports both - enqueued events and new, emitted after tracker initialization, so /track_test endpoint will get all events. That way you can test if all trackers were sent. I usually store all such mock requests in Redis and checks if all necessary data is being sent.

What can go wrong?

I’ve implemented my tracking code this way lately and a test suite which worked for previous implementation started to fail. I thought I had all of this sorted out - webpack makes the tracking module to load only once so all module variables will persist across multiple tracking calls. I was wrong - somehow I had the same module loaded multiple times in the same JS bundle. I didn’t have much time to debug why is it happening so I accepted the current state and made small change - I started to use global variables. I actually removed the initialized variable and replaced the queue.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const sendEvent = function({ event, properties }) {
  window.msq('track', event, properties)
}

const isLoaded = () => !!window.msq

const enqueue = function(event) {
  window.queue = window.queue || []
  window.queue.push(event)
}

export const trackEvent = function(event, properties) {
  if (isLoaded()) {
    sendEvent({ event, properties })
  } else {
    enqueue({ event, properties })
  }
}

export const initialize = function() {
  !function(m,s,e,v,n,t,x)
  {if(m.msq)return;n=m.msq=function(){n.callMethod?
  n.callMethod.apply(n,arguments):n.queue.push(arguments)};
  if(!m._msq)m._msq=n;n.push=n;n.loaded=!0;
  n.queue=[];t=s.createElement(e);t.async=!0;
  t.src=v;x=s.getElementsByTagName(e)[0];
  x.parentNode.insertBefore(t,s)}(window, document,'script',
  'https://marketing-service-events.com/events.js');
  msq('init', '<CODE>');

  if (window.queue) {
    window.queue.forEach(sendEvent)
    window.queue = []
  }
}

The code no longer needs the initialized flag, as it detects whether tracking code is loaded with window.msq existance. The enqueuing part is a bit tricky, because you can’t initialize the window.queue once, you have to think about other possible module executions, so window.queue = window.queue || [] fallback is introduced. I extracted new functions to name some abstractions - it should be easier to read.

Higher-level picture - what happened?

The first step of this change was to put important concepts in functions and make this public interface of the tracking module. This way in real tracking calls you had to use single function call and forget about its implementation details. Then functions’ implementation was replaced with more bullet-proof implementation that should support enqueuing events before the snippet is loaded and sending events when after the snippet loads. It didn’t work, but it was super simple to fix it with “local globals” - globals introduced only for given module. That’s technical debt, as you will have to think twice when changing this globals semantics, so ideally it would have really complicated and unique name - the queue is too generic, it should rather be something like msqPreloadQueue. Still it’s cheap because public API remains the same.