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..d7a0b6c316 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -36,6 +36,7 @@ module.exports = function(config) { 'common/dist/scripts/habitrpg-shared.js', "test/spec/mocks/translations.js", + "test/spec/mocks/sandbox.js", "website/public/js/env.js", @@ -45,6 +46,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/controllers/authCtrlSpec.js b/test/spec/controllers/authCtrlSpec.js index 70e985d76d..20706cf9c8 100644 --- a/test/spec/controllers/authCtrlSpec.js +++ b/test/spec/controllers/authCtrlSpec.js @@ -5,16 +5,22 @@ describe('Auth Controller', function() { describe('AuthCtrl', function(){ var scope, ctrl, user, $httpBackend, $window; - beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) { - $httpBackend = _$httpBackend_; - scope = $rootScope.$new(); - scope.loginUsername = 'user'; - scope.loginPassword = 'pass'; - $window = { location: { href: ""}, alert: sandbox.spy() }; - user = { user: {}, authenticate: sandbox.spy() }; + beforeEach(function(){ + module(function($provide) { + $provide.value('Analytics', analyticsMock); + }); - ctrl = $controller('AuthCtrl', {$scope: scope, $window: $window, User: user}); - })); + inject(function(_$httpBackend_, $rootScope, $controller) { + $httpBackend = _$httpBackend_; + scope = $rootScope.$new(); + scope.loginUsername = 'user'; + scope.loginPassword = 'pass'; + $window = { location: { href: ""}, alert: sandbox.spy() }; + user = { user: {}, authenticate: sandbox.spy() }; + + ctrl = $controller('AuthCtrl', {$scope: scope, $window: $window, User: user}); + }) + }); it('should log in users with correct uname / pass', function() { $httpBackend.expectPOST('/api/v2/user/auth/local').respond({id: 'abc', token: 'abc'}); diff --git a/test/spec/mocks/analyticsMock.js b/test/spec/mocks/analyticsMock.js new file mode 100644 index 0000000000..9844c6d702 --- /dev/null +++ b/test/spec/mocks/analyticsMock.js @@ -0,0 +1,8 @@ +'use strict' + +var analyticsMock = { + login: sandbox.spy(), + register: sandbox.spy(), + updateUser: sandbox.spy(), + track: sandbox.spy() +}; diff --git a/test/spec/mocks/sandbox.js b/test/spec/mocks/sandbox.js index f095eefd09..b084a232a4 100644 --- a/test/spec/mocks/sandbox.js +++ b/test/spec/mocks/sandbox.js @@ -1,8 +1,4 @@ -var sandbox; - -beforeEach(function() { - sandbox = sinon.sandbox.create(); -}); +var sandbox = sinon.sandbox.create(); afterEach(function() { sandbox.restore(); diff --git a/test/spec/services/analyticsServicesSpec.js b/test/spec/services/analyticsServicesSpec.js new file mode 100644 index 0000000000..935ace5900 --- /dev/null +++ b/test/spec/services/analyticsServicesSpec.js @@ -0,0 +1,236 @@ +'use strict'; + +describe('Analytics Service', function () { + var analytics, user; + + beforeEach(function() { + user = specHelper.newUser(); + user.contributor = {}; + user.purchased = { plan: {} }; + + module(function($provide) { + $provide.value('User', {user: user}); + }); + + inject(function(Analytics) { + analytics = Analytics; + }); + }); + + context('functions', function() { + + describe('register', function() { + + beforeEach(function() { + sandbox.stub(amplitude, 'setUserId'); + sandbox.stub(window, 'ga'); + }); + + it('sets up user with Amplitude', function() { + analytics.register(); + expect(amplitude.setUserId).to.have.been.calledOnce; + expect(amplitude.setUserId).to.have.been.calledWith(user._id); + }); + + it('sets up user with Google Analytics', function() { + analytics.register(); + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set', {userId: user._id}); + }); + }); + + describe('login', function() { + + beforeEach(function() { + sandbox.stub(amplitude, 'setUserId'); + sandbox.stub(window, 'ga'); + }); + + it('sets up tracking for amplitude', function() { + analytics.login(); + + expect(amplitude.setUserId).to.have.been.calledOnce; + expect(amplitude.setUserId).to.have.been.calledWith(user._id); + }); + + it('sets up tracking for google analytics', function() { + analytics.login(); + + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set', {userId: user._id}); + }); + }); + + describe('track', function() { + + beforeEach(function() { + sandbox.stub(amplitude, 'logEvent'); + sandbox.stub(window, 'ga'); + }); + + context('successful tracking', function() { + + it('tracks a simple user action with Amplitude', function() { + var properties = {'hitType':'event','eventCategory':'behavior','eventAction':'cron'}; + analytics.track(properties); + + expect(amplitude.logEvent).to.have.been.calledOnce; + expect(amplitude.logEvent).to.have.been.calledWith('cron', properties); + }); + + it('tracks a simple user action with Google Analytics', function() { + var properties = {'hitType':'event','eventCategory':'behavior','eventAction':'cron'}; + analytics.track(properties); + + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('send', properties); + }); + + it('tracks a user action with additional properties in Amplitude', function() { + var properties = {'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}; + analytics.track(properties); + + expect(amplitude.logEvent).to.have.been.calledOnce; + expect(amplitude.logEvent).to.have.been.calledWith('cron', properties); + }); + + it('tracks a user action with additional properties in google analytics', function() { + var properties = {'hitType':'event','eventCategory':'behavior','eventAction':'cron','booleanProperty':true,'numericProperty':17,'stringProperty':'bagel'}; + analytics.track(properties); + + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('send', properties); + }); + }); + + context('unsuccessful tracking', function() { + + beforeEach(function() { + sandbox.stub(console, 'log'); + }); + + context('events without required properties', function() { + beforeEach(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'}); + }); + + it('logs errors to console', function() { + expect(console.log.callCount).to.eql(7); + }); + + it('does not call out to Amplitude', function() { + expect(amplitude.logEvent).to.not.be.called; + }); + + it('does not call out to Google Analytics', function() { + expect(ga).to.not.be.called; + }); + }); + + context('incorrect hit type', function() { + beforeEach(function() { + analytics.track({'hitType':'moogly','eventCategory':'green','eventAction':'eat'}); + }); + + it('logs error to console', function () { + expect(console.log).to.have.been.calledOnce; + }); + + it('does not call out to Amplitude', function() { + expect(amplitude.logEvent).to.not.be.called; + }); + + it('does not call out to Google Analytics', function() { + expect(ga).to.not.be.called; + }); + }); + }); + }); + + describe('updateUser', function() { + + beforeEach(function() { + sandbox.stub(amplitude, 'setUserProperties'); + sandbox.stub(window, 'ga'); + }); + + context('properties argument provided', function(){ + var properties = {'userBoolean': false, 'userNumber': -8, 'userString': 'Enlightened'}; + var expectedProperties = _.cloneDeep(properties); + expectedProperties.UUID = 'unique-user-id'; + expectedProperties.Class = 'wizard'; + expectedProperties.Experience = 35; + expectedProperties.Gold = 43; + expectedProperties.Health = 48; + expectedProperties.Level = 24; + expectedProperties.Mana = 41; + + beforeEach(function() { + user._id = 'unique-user-id'; + user.stats.class = 'wizard'; + user.stats.exp = 35.7; + user.stats.gp = 43.2; + user.stats.hp = 47.8; + user.stats.lvl = 24; + user.stats.mp = 41; + + analytics.updateUser(properties); + }); + + it('calls Amplitude with provided properties and select user info', function() { + expect(amplitude.setUserProperties).to.have.been.calledOnce; + expect(amplitude.setUserProperties).to.have.been.calledWith(expectedProperties); + }); + + it('calls Google Analytics with provided properties and select user info', function() { + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set', expectedProperties); + }); + }); + + context('no properties argument provided', function() { + var expectedProperties = { + UUID: 'unique-user-id', + Class: 'wizard', + Experience: 35, + Gold: 43, + Health: 48, + Level: 24, + Mana: 41, + contributorLevel: 1, + subscription: 'unique-plan-id' + }; + + beforeEach(function() { + user._id = 'unique-user-id'; + user.stats.class = 'wizard'; + user.stats.exp = 35.7; + user.stats.gp = 43.2; + user.stats.hp = 47.8; + user.stats.lvl = 24; + user.stats.mp = 41; + user.contributor.level = 1; + user.purchased.plan.planId = 'unique-plan-id'; + + analytics.updateUser(); + }); + + it('calls Amplitude with select user info', function() { + expect(amplitude.setUserProperties).to.have.been.calledOnce; + expect(amplitude.setUserProperties).to.have.been.calledWith(expectedProperties); + }); + + it('calls Google Analytics with select user info', function() { + expect(ga).to.have.been.calledOnce; + expect(ga).to.have.been.calledWith('set', expectedProperties); + }); + }); + }); + }); +}); diff --git a/website/public/js/app.js b/website/public/js/app.js index fae2f42e42..822ce9601d 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -243,7 +243,7 @@ window.habitrpg = angular.module('habitrpg', .state('options.settings.notifications', { url: "/notifications", templateUrl: "partials/options.settings.notifications.html" - }) + }); var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); if (settings && settings.auth) { @@ -251,4 +251,4 @@ window.habitrpg = angular.module('habitrpg', $httpProvider.defaults.headers.common['x-api-user'] = settings.auth.apiId; $httpProvider.defaults.headers.common['x-api-key'] = settings.auth.apiToken; } - }]) + }]); diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index 48cf71cbe7..eb62d87d52 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -5,8 +5,9 @@ */ angular.module('habitrpg') - .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrl', '$modal', - function($scope, $rootScope, User, $http, $location, $window, ApiUrl, $modal) { + .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrl', '$modal', 'Analytics', + function($scope, $rootScope, User, $http, $location, $window, ApiUrl, $modal, Analytics) { + $scope.Analytics = Analytics; $scope.logout = function() { localStorage.clear(); @@ -47,14 +48,14 @@ angular.module('habitrpg') $http.post(url, scope.registerVals).success(function(data, status, headers, config) { runAuth(data.id, data.apiToken); if (status == 200) { - mixpanel.alias(data._id); + Analytics.register(); if (data.auth.facebook) { - mixpanel.register({'authType':'facebook','email':data.auth.facebook._json.email}) + Analytics.updateUser({'email':data.auth.facebook._json.email,'language':data.preferences.language}); + Analytics.track({'hitType':'event','eventCategory':'acquisition','eventAction':'register','authType':'facebook'}); } else { - mixpanel.register({'authType':'email','email':data.auth.local.email}) + Analytics.updateUser({'email':data.auth.local.email,'language':data.preferences.language}); + Analytics.track({'hitType':'event','eventCategory':'acquisition','eventAction':'register','authType':'email'}); } - mixpanel.register({'UUID':data._id,'language':data.preferences.language}); - mixpanel.track('Registration'); } }).error(errorAlert); }; @@ -68,15 +69,15 @@ angular.module('habitrpg') .success(function(data, status, headers, config) { runAuth(data.id, data.token); if (status == 200) { - mixpanel.identify(data.id); - mixpanel.register({'UUID':data._id}); - mixpanel.track('Login'); + Analytics.login(); + Analytics.updateUser(); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'login'}); } }).error(errorAlert); }; $scope.playButtonClick = function(){ - window.ga && ga('send', 'event', 'button', 'click', 'Play'); + Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Play'}) if (User.authenticated()) { window.location.href = ('/' + window.location.hash); } else { @@ -144,9 +145,9 @@ angular.module('habitrpg') $http.post(ApiUrl.get() + "/api/v2/user/auth/social", auth) .success(function(data, status, headers, config) { if (status == 200) { - mixpanel.identify(data.id); - mixpanel.register({'UUID':data._id}); - mixpanel.track('Login'); + Analytics.login(); + Analytics.updateUser(); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'login'}); } runAuth(data.id, data.token); }).error(errorAlert); diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js index f7fda11580..32c3055e6c 100644 --- a/website/public/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -25,20 +25,11 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl) { // Stripe $.getScript('//checkout.stripe.com/v2/checkout.js'); - // Google Analytics, only in production + // Google Content Experiments if (window.env.NODE_ENV === 'production') { - // Get experiments API $.getScript('//www.google-analytics.com/cx/api.js?experiment=t-AFggRWQnuJ6Teck_x1-Q', function(){ $rootScope.variant = cxApi.chooseVariation(); $rootScope.$apply(); - - (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','//www.google-analytics.com/analytics.js','ga'); - ga('create', window.env.GA_ID, {userId:User.user._id}); - ga('require', 'displayfeatures'); - ga('send', 'pageview'); }) } diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js index eaf23df4d3..dc1329c981 100644 --- a/website/public/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -286,7 +286,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' }); }]) - .controller('ChatCtrl', ['$scope', 'Groups', 'User', '$http', 'ApiUrl', 'Notification', 'Members', '$rootScope', function($scope, Groups, User, $http, ApiUrl, Notification, Members, $rootScope){ + .controller('ChatCtrl', ['$scope', 'Groups', 'User', '$http', 'ApiUrl', 'Notification', 'Members', '$rootScope', 'Analytics', + function($scope, Groups, User, $http, ApiUrl, Notification, Members, $rootScope, Analytics){ $scope.message = {content:''}; $scope._sending = false; @@ -321,9 +322,9 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.message.content = ''; $scope._sending = false; if (group.privacy == 'public'){ - mixpanel.track('Group Chat',{'groupType':group.type,'privacy':group.privacy,'groupName':group.name,'message':message}) + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy,'groupName':group.name,'message':message}); } else { - mixpanel.track('Group Chat',{'groupType':group.type,'privacy':group.privacy}) + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy}); } }, function(err){ $scope._sending = false; @@ -417,8 +418,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' }]) - .controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', - function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile) { + .controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', + function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) { $scope.groups = { guilds: Groups.myGuilds(), "public": Groups.publicGuilds() @@ -436,8 +437,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' if (confirm(window.env.t('confirmGuild'))) { group.$save(function(saved){ - if (saved.privacy == 'public') {mixpanel.track('Join Group',{'owner':true,'groupType':'guild','privacy':saved.privacy,'groupName':saved.name})} - else {mixpanel.track('Join Group',{'owner':true,'groupType':'guild','privacy':saved.privacy})} + if (saved.privacy == 'public') {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'guild','privacy':saved.privacy,'groupName':saved.name})} + else {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'guild','privacy':saved.privacy})} $rootScope.hardRedirect('/#/options/groups/guilds/' + saved._id); }); } @@ -452,8 +453,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } group.$join(function(joined){ - if (joined.privacy == 'public') {mixpanel.track('Join Group',{'owner':false,'groupType':'guild','privacy':joined.privacy,'groupName':joined.name})} - else {mixpanel.track('Join Group',{'owner':false,'groupType':'guild','privacy':joined.privacy})} + if (joined.privacy == 'public') {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'guild','privacy':joined.privacy,'groupName':joined.name})} + else {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'guild','privacy':joined.privacy})} $rootScope.hardRedirect('/#/options/groups/guilds/' + joined._id); }) } @@ -508,8 +509,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } ]) - .controller("PartyCtrl", ['$rootScope','$scope', 'Groups', 'User', 'Challenges', '$state', '$compile', - function($rootScope,$scope, Groups, User, Challenges, $state, $compile) { + .controller("PartyCtrl", ['$rootScope','$scope', 'Groups', 'User', 'Challenges', '$state', '$compile', 'Analytics', + function($rootScope,$scope, Groups, User, Challenges, $state, $compile, Analytics) { $scope.type = 'party'; $scope.text = window.env.t('party'); $scope.group = $rootScope.party = Groups.party(); @@ -519,7 +520,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.create = function(group){ group.$save(function(){ - mixpanel.track('Join Group',{'owner':true,'groupType':'party','privacy':'private'}); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'party','privacy':'private'}); $rootScope.hardRedirect('/#/options/groups/party'); }); } @@ -527,7 +528,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.join = function(party){ var group = new Groups.Group({_id: party.id, name: party.name}); group.$join(function(){ - mixpanel.track('Join Group',{'owner':false,'groupType':'party','privacy':'private'}); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); $rootScope.hardRedirect('/#/options/groups/party'); }); } diff --git a/website/public/js/controllers/inventoryCtrl.js b/website/public/js/controllers/inventoryCtrl.js index deae46e618..afd85b625b 100644 --- a/website/public/js/controllers/inventoryCtrl.js +++ b/website/public/js/controllers/inventoryCtrl.js @@ -1,6 +1,6 @@ habitrpg.controller("InventoryCtrl", - ['$rootScope', '$scope', 'Shared', '$window', 'User', 'Content', - function($rootScope, $scope, Shared, $window, User, Content) { + ['$rootScope', '$scope', 'Shared', '$window', 'User', 'Content', 'Analytics', + function($rootScope, $scope, Shared, $window, User, Content, Analytics) { var user = User.user; @@ -180,7 +180,7 @@ habitrpg.controller("InventoryCtrl", $rootScope.selectedQuest = undefined; } $scope.questInit = function(){ - mixpanel.track("Quest",{"owner":true,"response":"accept","questName":$scope.selectedQuest.key}); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'quest','owner':true,'response':'accept','questName':$scope.selectedQuest.key}); $rootScope.party.$questAccept({key:$scope.selectedQuest.key}, function(){ $rootScope.party.$get(); }); diff --git a/website/public/js/controllers/notificationCtrl.js b/website/public/js/controllers/notificationCtrl.js index 578654afb0..2ff163240e 100644 --- a/website/public/js/controllers/notificationCtrl.js +++ b/website/public/js/controllers/notificationCtrl.js @@ -1,8 +1,8 @@ 'use strict'; habitrpg.controller('NotificationCtrl', - ['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', - function ($scope, $rootScope, Shared, Content, User, Guide, Notification) { + ['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', 'Analytics', + function ($scope, $rootScope, Shared, Content, User, Guide, Notification, Analytics) { $rootScope.$watch('user.stats.hp', function (after, before) { if (after <= 0){ @@ -87,7 +87,7 @@ habitrpg.controller('NotificationCtrl', Notification.drop(User.user._tmp.drop.dialog); } $rootScope.playSound('Item_Drop'); - mixpanel.track("Acquire Item",{'itemName':after.key,'acquireMethod':'Drop'}) + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'acquire item','itemName':after.key,'acquireMethod':'Drop'}); }); $rootScope.$watch('user.achievements.streak', function(after, before){ diff --git a/website/public/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js index 4f6d04f630..ef5c5aa5e8 100644 --- a/website/public/js/controllers/rootCtrl.js +++ b/website/public/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics) { var user = User.user; var initSticky = _.once(function(){ @@ -15,7 +15,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams){ - if (!!fromState.name) window.ga && ga('send', 'pageview', {page: '/#/'+toState.name}); + if (!!fromState.name) Analytics.track({'hitType':'pageview','eventCategory':'navigation','eventAction':'navigate','page':'/#/'+toState.name}); // clear inbox when entering or exiting inbox tab if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { User.user.ops.update && User.set({'inbox.newMessages':0}); @@ -29,6 +29,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.settings = User.settings; $rootScope.Shared = Shared; $rootScope.Content = Content; + $rootScope.Analytics = Analytics; $rootScope.env = window.env; $rootScope.Math = Math; $rootScope.Groups = Groups; @@ -126,7 +127,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ // Otherwise use the proper $modal.open $rootScope.openModal = function(template, options){//controller, scope, keyboard, backdrop){ if (!options) options = {}; - if (options.track) window.ga && ga('send', 'event', 'button', 'click', options.track); + if (options.track) Analytics.track(_.merge(options.track,{'hitType':'event','eventCategory':'button','eventAction':'click'})); if(template === 'newStuff') return forceLoadBailey(template, options); return $modal.open({ templateUrl: 'modals/' + template + '.html', diff --git a/website/public/js/controllers/tasksCtrl.js b/website/public/js/controllers/tasksCtrl.js index 9f0b031e01..58a992c56d 100644 --- a/website/public/js/controllers/tasksCtrl.js +++ b/website/public/js/controllers/tasksCtrl.js @@ -1,7 +1,7 @@ "use strict"; -habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','Notification', '$http', 'ApiUrl', '$timeout', 'Shared', 'Guide', 'Tasks', - function($scope, $rootScope, $location, User, Notification, $http, ApiUrl, $timeout, Shared, Guide, Tasks) { +habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','Notification', '$http', 'ApiUrl', '$timeout', 'Shared', 'Guide', 'Tasks', 'Analytics', + function($scope, $rootScope, $location, User, Notification, $http, ApiUrl, $timeout, Shared, Guide, Tasks, Analytics) { $scope.obj = User.user; // used for task-lists $scope.user = User.user; @@ -25,8 +25,8 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N else if (direction === 'up') $rootScope.playSound('Plus_Habit'); } User.user.ops.score({params:{id: task.id, direction:direction}}); - mixpanel.register({'Gold':Math.floor(User.user.stats.gp),'Health':Math.ceil(User.user.stats.hp),'Experience':Math.floor(User.user.stats.exp),'Level':User.user.stats.lvl,'Mana':Math.floor(User.user.stats.mp),'Class':User.user.stats.class,'subscription':User.user.purchased.plan.planId,'contributorLevel':User.user.contributor.level,'UUID':User.user._id}); - mixpanel.track('Score Task',{'taskType':task.type,'direction':direction}); + Analytics.updateUser(); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'score task','taskType':task.type,'direction':direction}); }; function addTask(addTo, listDef, task) { diff --git a/website/public/js/services/analyticsServices.js b/website/public/js/services/analyticsServices.js new file mode 100644 index 0000000000..462397954c --- /dev/null +++ b/website/public/js/services/analyticsServices.js @@ -0,0 +1,118 @@ +'use strict'; + +(function(){ + var REQUIRED_FIELDS = ['hitType','eventCategory','eventAction']; + var ALLOWED_HIT_TYPES = ['pageview','screenview','event','transaction','item','social','exception','timing']; + + 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'); + + 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); + } + + function register() { + amplitude.setUserId(user._id); + ga('set', {'userId':user._id}); + } + + function login() { + amplitude.setUserId(user._id); + ga('set', {'userId':user._id}); + } + + function track(properties) { + if(_doesNotHaveRequiredFields(properties)) { return false; } + if(_doesNotHaveAllowedHitType(properties)) { return false; } + + amplitude.logEvent(properties.eventAction,properties); + ga('send',properties); + } + + function updateUser(properties) { + properties = properties || {}; + + _gatherUserStats(user, properties); + + amplitude.setUserProperties(properties); + ga('set',properties); + } + + if (window.env.NODE_ENV === 'production') loadScripts(); + + return { + loadScripts: loadScripts, + register: register, + login: login, + track: track, + updateUser: updateUser + }; + } + + function _gatherUserStats(user, properties) { + if (user._id) properties.UUID = user._id; + if (user.stats) { + properties.Class = user.stats.class; + properties.Experience = Math.floor(user.stats.exp); + properties.Gold = Math.floor(user.stats.gp); + properties.Health = Math.ceil(user.stats.hp); + properties.Level = user.stats.lvl; + properties.Mana = Math.floor(user.stats.mp); + } + if (user.contributor && user.contributor.level) properties.contributorLevel = user.contributor.level; + if (user.purchased && user.purchased.plan.planId) properties.subscription = user.purchased.plan.planId; + } + + function _doesNotHaveRequiredFields(properties) { + if (!_.isEqual(_.keys(_.pick(properties, REQUIRED_FIELDS)), REQUIRED_FIELDS)) { + console.log('Analytics tracking calls must include the following properties: ' + JSON.stringify(REQUIRED_FIELDS)); + return true; + } + } + + function _doesNotHaveAllowedHitType(properties) { + if (!_.contains(ALLOWED_HIT_TYPES, properties.hitType)) { + console.log('Hit type of Analytics event must be one of the following: ' + JSON.stringify(ALLOWED_HIT_TYPES)); + return true; + } + } +}()); + diff --git a/website/public/js/services/guideServices.js b/website/public/js/services/guideServices.js index 5ba95de2e0..c387414fed 100644 --- a/website/public/js/services/guideServices.js +++ b/website/public/js/services/guideServices.js @@ -5,8 +5,8 @@ */ angular.module('habitrpg').factory('Guide', -['$rootScope', 'User', '$timeout', '$state', -function($rootScope, User, $timeout, $state) { +['$rootScope', 'User', '$timeout', '$state', 'Analytics', +function($rootScope, User, $timeout, $state, Analytics) { var chapters = { intro: [ @@ -184,14 +184,13 @@ function($rootScope, User, $timeout, $state) { $state.go(step.state); return $timeout(function(){}); } - window.ga && ga('send', 'event', 'behavior', 'tour', k, i+1); - mixpanel.track('Tutorial',{'tour':k+'-web','step':i+1,'complete':false}); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'tutorial','eventLabel':k+'-web','eventValue':i+1,'complete':false}) } step.onHide = function(){ if (step.final) { // -2 indicates complete var ups={};ups['flags.tour.'+k] = -2; User.set(ups); - mixpanel.track('Tutorial',{'tour':k+'-web','step':i+1,'complete':true}); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'tutorial','eventLabel':k+'-web','eventValue':i+1,'complete':true}) } } }) diff --git a/website/public/js/static.js b/website/public/js/static.js index ae355a4b17..6494602e45 100644 --- a/website/public/js/static.js +++ b/website/public/js/static.js @@ -22,10 +22,10 @@ window.habitrpg = angular.module('habitrpg', ['chieffancypants.loadingBar', 'ui. $scope.Math = window.Math; }]) -.controller("PlansCtrl", ['$rootScope', - function($rootScope) { +.controller("PlansCtrl", ['$rootScope','Analytics', + function($rootScope,Analytics) { $rootScope.clickContact = function(){ - window.ga && ga('send', 'event', 'button', 'click', 'Contact Us (Plans)'); + Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Contact Us (Plans)'}) } } ]) diff --git a/website/public/manifest.json b/website/public/manifest.json index 73b7299a82..b379990191 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", @@ -105,6 +106,7 @@ "bower_components/angular-loading-bar/build/loading-bar.js", "js/env.js", "js/static.js", + "js/services/analyticsServices.js", "js/services/notificationServices.js", "common/script/public/userServices.js", "js/controllers/authCtrl.js", @@ -131,6 +133,7 @@ "bower_components/angular-loading-bar/build/loading-bar.js", "js/env.js", "js/static.js", + "js/services/analyticsServices.js", "js/services/notificationServices.js", "common/script/public/userServices.js", "js/controllers/authCtrl.js", diff --git a/website/src/middleware.js b/website/src/middleware.js index abd98ea048..05e755e77f 100644 --- a/website/src/middleware.js +++ b/website/src/middleware.js @@ -180,7 +180,7 @@ module.exports.locals = function(req, res, next) { language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); var tavern = require('./models/group').tavern; - var envVars = _.pick(nconf.get(), 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY'.split(' ')); + var envVars = _.pick(nconf.get(), 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ')); res.locals.habitrpg = _.merge(envVars, { IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')), getManifestFiles: getManifestFiles, diff --git a/website/views/index.jade b/website/views/index.jade index 52d464c1b0..504449ecc3 100644 --- a/website/views/index.jade +++ b/website/views/index.jade @@ -21,11 +21,6 @@ html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":a script(type='text/javascript'). window.env = !{JSON.stringify(env)}; - script(type='text/javascript'). - (function(f,b){if(!b.__SV){var a,e,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