From 3c5ae80e0b5f393926db3e51a5fea21366076c2c Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 18 Jun 2015 15:43:57 -0500 Subject: [PATCH] WIP(analytics): Move client tracking to service --- config.json.example | 3 +- karma.conf.js | 1 + test/spec/services/analyticsServicesSpec.js | 191 ++++++++++++++++++ .../public/js/services/analyticsServices.js | 146 +++++++++++++ website/public/manifest.json | 1 + 5 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 test/spec/services/analyticsServicesSpec.js create mode 100644 website/public/js/services/analyticsServices.js diff --git a/config.json.example b/config.json.example index 5bc26812d0..7af203a71e 100644 --- a/config.json.example +++ b/config.json.example @@ -21,7 +21,8 @@ "NEW_RELIC_APPLICATION_ID":"NEW_RELIC_APPLICATION_ID", "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", - "MP_ID": "MP_ID", + "MIXPANEL_TOKEN": "MIXPANEL_TOKEN", + "AMPLITUDE_KEY": "AMPLITUDE_KEY", "FLAG_REPORT_EMAIL": ["email@mod.com"], "EMAIL_SERVER": { "url": "http://example.com", diff --git a/karma.conf.js b/karma.conf.js index fcf3f9eb31..c77f1dea5c 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -45,6 +45,7 @@ module.exports = function(config) { "website/public/js/services/notificationServices.js", "common/script/public/userServices.js", "common/script/public/directives.js", + "website/public/js/services/analyticsServices.js", "website/public/js/services/groupServices.js", "website/public/js/services/memberServices.js", "website/public/js/services/guideServices.js", diff --git a/test/spec/services/analyticsServicesSpec.js b/test/spec/services/analyticsServicesSpec.js new file mode 100644 index 0000000000..fa89b06ecb --- /dev/null +++ b/test/spec/services/analyticsServicesSpec.js @@ -0,0 +1,191 @@ +/** + * Created by Sabe on 6/11/2015. + */ +'use strict'; + +describe('Analytics Service', function () { + var analytics; + + beforeEach(function() { + inject(function(Analytics) { + analytics = Analytics; + }); + }); + + context('error handling', function() { + + before(function() { + sinon.stub(console, 'log'); + }); + + afterEach(function() { + console.log.reset(); + }); + + after(function() { + console.log.restore(); + }); + + it('does not accept tracking events without required properties', function() { + analytics.track('action'); + analytics.track({'hitType':'pageview','eventCategory':'green'}); + analytics.track({'hitType':'pageview','eventAction':'eat'}); + analytics.track({'eventCategory':'green','eventAction':'eat'}); + analytics.track({'hitType':'pageview'}); + analytics.track({'eventCategory':'green'}); + analytics.track({'eventAction':'eat'}); + expect(console.log.callCount).to.eql(7); + }); + + it('does not accept tracking events with incorrect hit type', function () { + analytics.track({'hitType':'moogly','eventCategory':'green','eventAction':'eat'}); + expect(console.log).to.have.been.calledOnce; + }); + }); + + context('Amplitude', function() { + + before(function() { + sinon.stub(amplitude, 'setUserId'); + sinon.stub(amplitude, 'logEvent'); + sinon.stub(amplitude, 'setUserProperties'); + }); + + afterEach(function() { + amplitude.setUserId.reset(); + amplitude.logEvent.reset(); + amplitude.setUserProperties.reset(); + }); + + after(function() { + amplitude.setUserId.restore(); + amplitude.logEvent.restore(); + amplitude.setUserProperties.restore(); + }); + + it('sets up tracking when user registers', function() { + analytics.register(); + expect(amplitude.setUserId).to.have.been.calledOnce; + }); + + it('sets up tracking when user logs in', function() { + analytics.login(); + expect(amplitude.setUserId).to.have.been.calledOnce; + }); + + it('tracks a simple user action', function() { + analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'cron'}); + expect(amplitude.logEvent).to.have.been.calledOnce; + expect(amplitude.logEvent).to.have.been.calledWith('cron',{'hitType':'event','eventCategory':'behavior','eventAction':'cron'}); + }); + + it('tracks a user action with additional properties', function() { + analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}); + expect(amplitude.logEvent).to.have.been.calledOnce; + expect(amplitude.logEvent).to.have.been.calledWith('cron',{'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}); + }); + + it('updates user-level properties', function() { + analytics.updateUser({'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}); + expect(amplitude.setUserProperties).to.have.been.calledOnce; + expect(amplitude.setUserProperties).to.have.been.calledWith({'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}); + }); + }); + + context('Google Analytics', function() { + + before(function() { + sinon.stub(ga); + }); + + afterEach(function() { + ga.reset(); + }); + + after(function() { + ga.restore(); + }); + + it('sets up tracking when user registers', function() { + analytics.register(); + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set'); + }); + + it('sets up tracking when user logs in', function() { + analytics.login(); + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set'); + }); + + it('tracks a simple user action', function() { + analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'cron'}); + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('send',{'hitType':'event','eventCategory':'behavior','eventAction':'cron'}); + }); + + it('tracks a user action with additional properties', function() { + analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}); + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('send',{'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}); + }); + + it('updates user-level properties', function() { + analytics.updateUser({'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}); + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set',{'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}); + }); + }); + + context('Mixpanel', function() { + + before(function() { + sinon.stub(mixpanel, 'alias'); + sinon.stub(mixpanel, 'identify'); + sinon.stub(mixpanel, 'track'); + sinon.stub(mixpanel, 'register'); + }); + + afterEach(function() { + mixpanel.alias.reset(); + mixpanel.identify.reset(); + mixpanel.track.reset(); + mixpanel.register.reset(); + }); + + after(function() { + mixpanel.alias.restore(); + mixpanel.identify.restore(); + mixpanel.track.restore(); + mixpanel.register.restore(); + }); + + it('sets up tracking when user registers', function() { + analytics.register(); + expect(mixpanel.alias).to.have.been.calledOnce; + }); + + it('sets up tracking when user logs in', function() { + analytics.login(); + expect(mixpanel.identify).to.have.been.calledOnce; + }); + + it('tracks a simple user action', function() { + analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'cron'}); + expect(mixpanel.track).to.have.been.calledOnce; + expect(mixpanel.track).to.have.been.calledWith('cron',{'hitType':'event','eventCategory':'behavior','eventAction':'cron'}); + }); + + it('tracks a user action with additional properties', function() { + analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}); + expect(mixpanel.track).to.have.been.calledOnce; + expect(mixpanel.track).to.have.been.calledWith('cron',{'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}); + }); + + it('updates user-level properties', function() { + analytics.updateUser({'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}); + expect(mixpanel.register).to.have.been.calledOnce; + expect(mixpanel.register).to.have.been.calledWith({'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}); + }); + }); +}); diff --git a/website/public/js/services/analyticsServices.js b/website/public/js/services/analyticsServices.js new file mode 100644 index 0000000000..050fa513b4 --- /dev/null +++ b/website/public/js/services/analyticsServices.js @@ -0,0 +1,146 @@ +/** + * Created by Sabe on 6/15/2015. + */ +'use strict'; + +angular + .module('habitrpg') + .factory('Analytics', analyticsFactory); + +analyticsFactory.$inject = [ + 'User' +]; + +function analyticsFactory(User) { + + var user = User.user; + + // Amplitude + var r = window.amplitude || {}; + r._q = []; + function a(window) {r[window] = function() {r._q.push([window].concat(Array.prototype.slice.call(arguments, 0)));}} + var i = ["init", "logEvent", "logRevenue", "setUserId", "setUserProperties", "setOptOut", "setVersionName", "setDomain", "setDeviceId", "setGlobalUserProperties"]; + for (var o = 0; o < i.length; o++) {a(i[o])} + window.amplitude = r; + amplitude.init(window.env.AMPLITUDE_KEY); + + // Google Analytics (aka Universal Analytics) + window['GoogleAnalyticsObject'] = 'ga'; + window['ga'] = window['ga'] || function() { + (window['ga'].q = window['ga'].q || []).push(arguments) + }, window['ga'].l = 1 * new Date(); + ga('create', window.env.GA_ID, 'auto'); + + // Mixpanel + (function(b) { + if (!b.__SV) { + var i, g; + window.mixpanel = b; + b._i = []; + b.init = function(a, e, d) { + function f(b, h) { + var a = h.split("."); + 2 == a.length && (b = b[a[0]], h = a[1]); + b[h] = function() { + b.push([h].concat(Array.prototype.slice.call(arguments, 0))) + } + } + var c = b; + "undefined" !== typeof d ? c = b[d] = [] : d = "mixpanel"; + c.people = c.people || []; + c.toString = function(b) { + var a = "mixpanel"; + "mixpanel" !== d && (a += "." + d); + b || (a += " (stub)"); + return a + }; + c.people.toString = function() { + return c.toString(1) + ".people (stub)" + }; + i = "disable track track_pageview track_links track_forms register register_once alias unregister identify name_tag set_config people.set people.set_once people.increment people.append people.union people.track_charge people.clear_charges people.delete_user".split(" "); + for (g = 0; g < i.length; g++) f(c, i[g]); + b._i.push([a, e, d]) + }; + b.__SV = 1.2; + } + })(window.mixpanel || []); + mixpanel.init(window.env.MIXPANEL_TOKEN); + + function loadScripts() { + // Amplitude + var n = document.createElement("script"); + var s = document.getElementsByTagName("script")[0]; + n.type = "text/javascript"; + n.async = true; + n.src = "https://d24n15hnbwhuhn.cloudfront.net/libs/amplitude-2.2.0-min.gz.js"; + s.parentNode.insertBefore(n, s); + + // Google Analytics + var a = document.createElement('script'); + var m = document.getElementsByTagName('script')[0]; + a.async = 1; + a.src = '//www.google-analytics.com/analytics.js'; + m.parentNode.insertBefore(a, m); + + // Mixpanel + var g = document.createElement("script"); + var e = document.getElementsByTagName("script")[0]; + g.type = "text/javascript"; + g.async = !0; + g.src = "undefined" !== typeof MIXPANEL_CUSTOM_LIB_URL ? MIXPANEL_CUSTOM_LIB_URL : "//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js"; + e.parentNode.insertBefore(g, e); + } + + function register() { + amplitude.setUserId(user._id); + ga('set', {'userId':user._id}); + mixpanel.alias(user._id); + } + + function login() { + amplitude.setUserId(user._id); + ga('set', {'userId':user._id}); + mixpanel.identify(user._id); + } + + function track(properties) { + var REQUIRED_FIELDS = ['hitType','eventCategory','eventAction']; + var ALLOWED_HIT_TYPES = ['pageview','screenview','event','transaction','item','social','exception','timing']; + if (!_.isEqual(_.keys(_.pick(properties, REQUIRED_FIELDS)), REQUIRED_FIELDS)) { + return console.log('Analytics tracking calls must include the following properties: ' + JSON.stringify(REQUIRED_FIELDS)); + } + if (!_.contains(ALLOWED_HIT_TYPES, properties.hitType)) { + return console.log('Hit type of Analytics event must be one of the following: ' + JSON.stringify(ALLOWED_HIT_TYPES)); + } + + amplitude.logEvent(properties.eventAction,properties); + mixpanel.track(properties.eventAction,properties); + ga('send',properties); + } + + function updateUser(properties) { + if (typeof properties === 'undefined') properties = {}; + + if (typeof user._id !== 'undefined') properties.UUID = user._id; + if (typeof user.stats.class !== 'undefined') properties.Class = user.stats.class; + if (typeof user.stats.exp !== 'undefined') properties.Experience = Math.floor(user.stats.exp); + if (typeof user.stats.gp !== 'undefined') properties.Gold = Math.floor(user.stats.gp); + if (typeof user.stats.hp !== 'undefined') properties.Health = Math.ceil(user.stats.hp); + if (typeof user.stats.lvl !== 'undefined') properties.Level = user.stats.lvl; + if (typeof user.stats.mp !== 'undefined') properties.Mana = Math.floor(user.stats.mp); + if (typeof user.contributor.level !== 'undefined') properties.contributorLevel = user.contributor.level; + if (typeof user.purchased.plan.planId !== 'undefined') properties.subscription = user.purchased.plan.planId; + + amplitude.setUserProperties(properties); + ga('set',properties); + mixpanel.register(properties); + } + + return { + loadScripts: loadScripts, + register: register, + login: login, + track: track, + updateUser: updateUser + }; +} diff --git a/website/public/manifest.json b/website/public/manifest.json index 73b7299a82..d1816718a3 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -43,6 +43,7 @@ "js/services/notificationServices.js", "common/script/public/userServices.js", "common/script/public/directives.js", + "js/services/analyticsServices.js", "js/services/groupServices.js", "js/services/memberServices.js", "js/services/guideServices.js",