By Oleksii Rudenko September 30, 2015 9:47 PM
Adding Analytics to Your Ember Apps Once and for all Using Segment's Analytics.js

Adding Google Analytics script to your project for page view tracking is easy. Just add a script tag to the index.html and place ga.send calls on transitions. But imagine that after you did that you need to track E-Commerce events - you add proper ga.send calls where it’s needed. Great. But then you are told to replace Google Analytics with Piwik or something else. So what do you do? Replace ga calls with piwik calls everywhere or look for a generic solution?

In this post I would like to discuss a generic solution for Ember apps that uses Analytics.js by Segment. Analytics.js is an open source library that provides a single API to many analytics tools so that you exchange them by simply configuring the library.

Getting Analytics.js

Analytics.js is not hosted on CDNs (at least I have not found a hosted version) and its development is happening on Github. https://github.com/segmentio/analytics.js. It’s possible to install the library via npm, bower or component. It’s not available as an ember-cli addon.

We will not bundle Analytics.js into the app. Instead, we will load it dynamically after the application starts. By doing so we reduce the waiting time for the app users.

First, let’s create a new ember-cli app:

ember new ember-analytics

Next, let’s install Analytics.js using Bower:

cd ember-analytics
cd public
bower install analytics

Note that we install analytics while being in the public directory. It makes the library available as a static file so it will not be added to the build file.

Using Analytic.js in the Ember way

To work comfortably with this library we introduce a new service that is called Tracking:

ember generate service tracking

It will be responsible for the following:

  • it will fetch and configure Analytics.js
  • it will track page views automatically
  • it will expose an API to track custom events
// app/services/tracking.js

/* globals analytics */
import Ember from 'ember';

function getUncachedScript(url, options) {
  options = Ember.$.extend(options || {}, {
    dataType: 'script',
    cache: true, // true to append "_={timestamp}", false - not to append
                // when used in combination with HTML5 Application Cache
                // false is handy
    url: url
  });
  return Ember.$.ajax(options);
}

var TrackingService = Ember.Service.extend({
  log: console, // can use a logging service instead
  config: {   // Google Analytics params. See Analytics.js docs for more options
    'Google Analytics': {
      'trackingId': 'UA-64554054-7'
    }
  },
  router: Ember.inject.service('router'), // router service

  /**
  * a queue for events happening before analytics.js is loaded
  */
  queue: [],

  /**
  * whether page view was tracked before analytics.js is loaded
  */
  pageviewBeforeReady: false,

  /**
  * whether analytics.js is ready
  */
  ready: false,

  /**
  * analytics.js instance
  */
  ax: null,

  /**
  * supported custom events
  */
  supportedEvents: {
    'Added Product': ['id', 'sku', 'name', 'price', 'quantity', 'category'],
    'Removed Product': ['id', 'sku', 'name', 'price', 'quantity', 'category'],
    'Completed Order': ['orderId', 'total', 'revenue', 'shipping', 'tax',
    'discount', 'coupon', 'currency', 'products']
  },

  prepare(libUrl) {
    const log = this.get('log');

    // don't return or store promise so that it's not possible
    // to block routing
    new Ember.RSVP.Promise((resolve, reject) => {
      log.info(`Loading a lib async: ${libUrl}`);
      getUncachedScript(libUrl).done(resolve).fail(reject);
    }).then(() => {
      // analytics is global and available at this point
      let ax = analytics.initialize(this.get('config'));
      this.set('ax', ax);

      // set ready once loaded
      this.set('ready', true);
      log.info('Tracking service: initialized');
    })
    .catch(function(err) {
      log.error('Tracking service: failed to load analytics.js', err);
    });
  },

  /**
  * Normal events are queued
  */
  track: function(eventName, params) {
    var events = [];
    if (!this.get('ready')) {
      this.get('queue').push(this._getEvent(eventName, params));
    } else {
      let event = this._getEvent(eventName, params);
      events = this.get('queue').concat([event]);
      this.set('queue', []); // clear queue
    }

    this._trackEvents(events);
  },

  trackPageView() {
    this.get('log').info('Tracking service: pageview');
    this.get('ax').page(); // analytics.js handles everything
  },

  /**
  * On init subscribe to the `currentPathDidChange` events
  */
  init(...args) {
    this._super(...args);
    this.get('router').on('currentPathDidChange',
      path => this.onPathChange(path));
  },

  /**
  * Page view events are not queued.
  * But if ax is ready before another transition
  * it still tracks the page view based on whether
  * `pageviewBeforeReady` happened
  */
  onPathChange(path) {
   if (!this.get('ready')) {
     this.set('pageviewBeforeReady', true);
   } else {
     this.set('pageviewBeforeReady', false);
     this.trackPageView();
   }
 },

  /**
  * when ready is true, apply all events from the queue and the missed page view
  */
  _onReady: Ember.observer('ready', function() {
    if (this.get('ready')) {
      this._trackEvents(this.get('queue'));
      if (this.get('pageviewBeforeReady')) {
        this.trackPageView();
      }
    }
  }),

  _trackEvents(events) {
    events.forEach(e => this.get('ax').track(e.eventName, e.params));
  },

  _getEvent(eventName, params) {
    const log = this.get('log');
    const supported = this.get('supportedEvents');
    const attrs = supported[eventName];

    log.info('Tracking service: sending ' + eventName, params);
    for (let i = 0; i < attrs.length; i++) {
      let attr = attrs[i];
      if (!(attr in params)) {
        log.warn(`Tracking service: event ${eventName}
          does not have a param called ${attr}`);
      }
    }
    return {eventName: eventName, params: params};
  }
});

export default TrackingService;

The service provides 3 public methods:

  • track to track any event with params
  • trackPageView to track the current page view. All params are automatically assembled by the analytics.js or the underlying provider
  • prepare to fetch analytics.js and configure it

Prepare method has to be called somewhere and a good place for it, in my opinion, is the beforeModel hook:

  ember generate route application
//app/routes/application.js

import Ember from 'ember';

export default Ember.Route.extend({
  tracking: Ember.inject.service(),

  beforeModel() {
    return this.get('tracking')
      .prepare('/bower_components/analytics/analytics.min.js');
  }
});

Now just add some content to your application.hbs to see what’s ready first - analytics.js or content:

<h2 id="title">Welcome to Ember</h2>

{{outlet}}

Content {{log 'content'}}

Additionally, one has to configure ember-cli content security policy by adding the following to the configuration:

contentSecurityPolicy: {
  'default-src': "'none'",
  'script-src': "'self' 'unsafe-eval' http://www.google-analytics.com",
  'font-src': "'self'",
  'connect-src': "'self'",
  'img-src': "'self' http://www.google-analytics.com",
  'style-src': "'self'",
  'media-src': "'self'"
}

Now everything should work fine if you have a router service in your app. Check out a working non-ember-cli version here: http://emberjs.jsbin.com/sajego/1/edit?output

The TrackingService relies on a RouterService that is a wrapper over the private router of Ember + some additional features. More details about it in the blog post: Router Service for Ember Apps or in the jsbin provided.

Let’s add some route to see how it works on transitions and custom events:

ember generate route page2
//app/routes/page2.js
import Ember from 'ember';

export default Ember.Route.extend({
  tracking: Ember.inject.service(),

  beforeModel() {
    // track Completed Order
    return this.get('tracking').track('Completed Order', {
      id: 'orderId'
    });
  }
});

Let’s add a link-to to application.hbs:

{{#link-to 'page2'}}Navigate{{/link-to}}

Now watch the Network tab of your browser dev tools to see how requests collecting events are being sent to Google Analytics servers.

Some notes

Thanks for reading.