Jacob Lowe

Published ● 4 min read

Tracking link exits


Here at Hone, we have been strengthening our analytics and conversion metrics to give our customers the most insightful information about their audience. One useful event is that we track clicks to external sites. After searching for a good way to track our links, and not finding an elegant solution we decided to roll our own. We would like to share what we implemented.

The issue

On the surface, it sounds super simple to just listen to all the click events on a given page and try to log them to a rest API. It’s a bit trickier than that. The issue is that all the XHR calls will be canceled once the page changes.

The first solution we found is to grab the event, and call event.preventDefault then set window.location after we have tracked the event.

window.addEventListener('click', function (e) {
  var href = e.target.href

  // this makes the xhr call
  trackEvent('link exit', e, function (err, res) {
    window.location = href
  })
})

We were not a huge fan of this because it would take shift + clicks and ctrl + clicks and make them work the same way a normal click does when clearly that was not the user’s intent.

Our solution

We decided that the event needed to stay an event and dispatch again once we have tracked it.

window.addEventListener('click', function (e) {
  if (e.tracked) return

  var mockEvent = new e.constructor(e.type, e)
  mockEvent.tracked = true

  trackEvent('link exit', e, function (err, res) {
    e.target.dispatchEvent(mockEvent)
  })
})

What is nice about this approach is that now the click events will work as intended. Whenever a user ctrl + clicks it still works because with this approach we copy the event via new e.constructor( e.type, e ), so all the modifier keys are still intact when dispatching the event again.

Going further.

This is better but not a complete solution. There are a few things that would make this script a lot better.

Since we are deferring the navigation of the link there is a higher chance the user will click the link again. We do not want two events just because of a slow connection. So we temporary disable the link and re-enable it once we have dispatched the event.

Timeout

Sometimes an HTTP request can take more than 2 seconds. That is way too long for a user to wait after they have clicked a link. So instead of making the users wait, we will give the call a smaller amount of time to complete. It can be done by simply setting a short XHR timeout and then make sure xhr.ontimeout is handled. eg xhr.timeout = 1000

With the window click events we will get a lot more events that are not anchor tag clicks so we filter all those out. We also track internal navigation a bit differently than external linking so we filter out those events.

Modifier handling

When a user is using a modifier key, lets say ctrl, tracking is not a problem because the page will not change. So instead of waiting we just dispatch those events right away.

Here is the full code:

window.addEventListener('click', function (e) {
  // this means the click has already been canceled
  if (e.defaultPrevented) return

  // ensure link
  var el = e.target,
    origin,
    mockEvent,
    dispathed

  // checking parents to see if we are nested in an anchor tag
  while (el && 'A' !== el.nodeName) {
    el = el.parentNode
  }

  // check the el is an achor tag
  if (!el || 'A' !== el.nodeName) {
    return
  }

  // getting origin
  origin = location.protocol + '//' + location.hostname

  if (location.port) {
    origin += ':' + location.port // add port to origin
  }

  // same origin
  if (el.href && 0 === el.href.indexOf(origin)) {
    return
  }

  // check to see if we already tracked the event
  if (e.tracked) {
    return
  }

  mockEvent = new e.constructor(e.type, e)
  mockEvent.tracked = true

  if (e.metaKey || e.ctrlKey || e.shiftKey) {
    dispatched = true // set this so we dont send this out twice
  } else {
    e.preventDefault()
  }

  trackEvent(
    'link exit',
    e,
    function (err, res) {
      if (!dispatched) {
        e.target.dispatchEvent(mockEvent)
      }
    },
    1500 /* this sets the timeout */
  )
})
Discuss it on Twitter Edit on Github