diff --git a/common/script/index.coffee b/common/script/index.coffee index d5a96cafc7..2620a9043c 100644 --- a/common/script/index.coffee +++ b/common/script/index.coffee @@ -442,7 +442,7 @@ api.wrap = (user, main=true) -> user.preferences.sleep = !user.preferences.sleep cb? null, {} - revive: (req, cb) -> + revive: (req, cb, analytics) -> return cb?({code:400, message: "Cannot revive if not dead"}) unless user.stats.hp <= 0 # Reset stats after death @@ -471,7 +471,15 @@ api.wrap = (user, main=true) -> user.items.gear.equipped[item.type] = "#{item.type}_base_0" if user.items.gear.equipped[item.type] is lostItem user.items.gear.costume[item.type] = "#{item.type}_base_0" if user.items.gear.costume[item.type] is lostItem user.markModified? 'items.gear' - mixpanel?.track('Death',{'lostItem':lostItem}) + + analyticsData = { + uuid: user._id, + lostItem: lostItem, + gaLabel: lostItem, + category: 'behavior' + } + analytics?.track('Death', analyticsData) + cb? (if item then {code:200,message: i18n.t('messageLostItem', {itemText: item.text(req.language)}, req.language)} else null), user reset: (req, cb) -> @@ -497,7 +505,7 @@ api.wrap = (user, main=true) -> user.preferences.costume = false cb? null, user - reroll: (req, cb, ga) -> + reroll: (req, cb, analytics) -> if user.balance < 1 return cb? {code:401,message: i18n.t('notEnoughGems', req.language)} user.balance-- @@ -505,19 +513,37 @@ api.wrap = (user, main=true) -> unless task.type is 'reward' task.value = 0 user.stats.hp = 50 - cb? null, user - mixpanel?.track("Acquire Item",{'itemName':'Fortify','acquireMethod':'Gems','gemCost':4}) - ga?.event('behavior', 'gems', 'reroll').send() - rebirth: (req, cb, ga) -> + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + } + analytics?.track('Fortify Potion', analyticsData) + + cb? null, user + + rebirth: (req, cb, analytics) -> # Cost is 8 Gems ($2) if (user.balance < 2 && user.stats.lvl < api.maxLevel) return cb? {code:401,message: i18n.t('notEnoughGems', req.language)} + + analyticsData = { + uuid: user._id, + category: 'behavior' + } # only charge people if they are under the max level - ryan if user.stats.lvl < api.maxLevel user.balance -= 2 - mixpanel?.track("Acquire Item",{'itemName':'Rebirth','acquireMethod':'Gems','gemCost':8}) - ga?.event('behavior', 'gems', 'rebirth').send() + analyticsData.acquireMethod = 'Gems' + analyticsData.gemCost = 8 + else + analyticsData.gemCost = 0 + analyticsData.acquireMethod = '> 100' + + analytics?.track('Rebirth', analyticsData) + # Save off user's level, for calculating achievement eligibility later lvl = api.capByLevel(user.stats.lvl) # Turn tasks yellow, zero out streaks @@ -766,7 +792,7 @@ api.wrap = (user, main=true) -> cb? {code:200,message}, _.pick(user,$w 'items stats') # buy is for using Gold, purchase is for Gems (I know, I know...) - purchase: (req, cb, ga) -> + purchase: (req, cb, analytics) -> {type,key} = req.params if type is 'gems' and key is 'gem' @@ -778,7 +804,16 @@ api.wrap = (user, main=true) -> user.balance += .25 user.purchased.plan.gemsBought++ user.stats.gp -= convRate - mixpanel?.track("Acquire Item",{'itemName':key,'acquireMethod':'Gold','goldCost':convRate}) + + analyticsData = { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: convRate, + category: 'behavior' + } + analytics?.track('purchase gems', analyticsData) + return cb? {code:200,message:"+1 Gem"}, _.pick(user,$w 'stats balance') return cb?({code:404,message:":type must be in [eggs,hatchingPotions,food,quests,gear]"},req) unless type in ['eggs','hatchingPotions','food','quests','gear'] @@ -796,11 +831,20 @@ api.wrap = (user, main=true) -> else user.items[type][key] = 0 unless user.items[type][key] > 0 user.items[type][key]++ - mixpanel?.track("Acquire Item",{'itemName':key,'acquireMethod':'Gems','gemCost':item.value}) - cb? null, _.pick(user,$w 'items balance') - ga?.event('behavior', 'gems', key).send() - releasePets: (req, cb) -> + analyticsData = { + uuid: user._id, + itemKey: key, + itemType: 'Market', + acquireMethod: 'Gems', + gemCost: item.value, + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) + + cb? null, _.pick(user,$w 'items balance') + + releasePets: (req, cb, analytics) -> if user.balance < 1 return cb? {code:401,message: i18n.t('notEnoughGems', req.language)} else @@ -811,10 +855,17 @@ api.wrap = (user, main=true) -> user.achievements.beastMasterCount = 0 user.achievements.beastMasterCount++ user.items.currentPet = "" - cb? null, user - mixpanel?.track("Acquire Item",{'itemName':'Kennel Key','acquireMethod':'Gems','gemCost':4}) - releaseMounts: (req, cb) -> + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + } + analytics?.track('release pets', analyticsData) + cb? null, user + + releaseMounts: (req, cb, analytics) -> if user.balance < 1 return cb? {code:401,message: i18n.t('notEnoughGems', req.language)} else @@ -825,8 +876,16 @@ api.wrap = (user, main=true) -> if not user.achievements.mountMasterCount user.achievements.mountMasterCount = 0 user.achievements.mountMasterCount++ + + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + } + analytics?.track('release mounts', analyticsData) + cb? null, user - mixpanel?.track("Acquire Item",{'itemName':'Kennel Key','acquireMethod':'Gems','gemCost':4}) releaseBoth: (req, cb) -> if user.balance < 1.5 and not user.achievements.triadBingo @@ -834,7 +893,13 @@ api.wrap = (user, main=true) -> else giveTriadBingo = true if not user.achievements.triadBingo - mixpanel?.track("Acquire Item",{'itemName':'Kennel Key','acquireMethod':'Gems','gemCost':6}) + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 6, + category: 'behavior' + } + analytics?.track('release pets & mounts', analyticsData) user.balance -= 1.5 user.items.currentMount = "" user.items.currentPet = "" @@ -855,7 +920,7 @@ api.wrap = (user, main=true) -> cb? null, user # buy is for gear, purchase is for gem-purchaseables (i know, I know...) - buy: (req, cb) -> + buy: (req, cb, analytics) -> {key} = req.params item = if key is 'potion' then content.potion @@ -896,10 +961,19 @@ api.wrap = (user, main=true) -> message ?= i18n.t('messageBought', {itemText: item.text(req.language)}, req.language) if item.last then user.fns.ultimateGear() user.stats.gp -= item.value - mixpanel?.track("Acquire Item",{'itemName':key,'acquireMethod':'Gold','goldCost':item.value}) + + analyticsData = { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: item.value, + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) + cb? {code:200, message}, _.pick(user,$w 'items achievements stats flags') - buyQuest: (req, cb) -> + buyQuest: (req, cb, analytics) -> {key} = req.params item = content.quests[key] return cb?({code:404, message:"Quest '#{key} not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content.coffee)"}) unless item @@ -909,9 +983,18 @@ api.wrap = (user, main=true) -> user.items.quests[item.key] ?= 0 user.items.quests[item.key] += 1 user.stats.gp -= item.goldValue + analyticsData = { + uuid: user._id, + itemKey: item.key, + itemType: 'Market', + goldCost: item.goldValue, + acquireMethod: 'Gold', + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) cb? {code:200, message}, user.items.quests - buyMysterySet: (req, cb)-> + buyMysterySet: (req, cb, analytics)-> return cb?({code:401, message:"You don't have enough Mystic Hourglasses"}) unless user.purchased.plan.consecutive.trinkets>0 mysterySet = content.timeTravelerStore(user.items.gear.owned)?[req.params.key] if window?.confirm? @@ -919,7 +1002,15 @@ api.wrap = (user, main=true) -> return cb?({code:404, message:"Mystery set not found, or set already owned"}) unless mysterySet _.each mysterySet.items, (i)-> user.items.gear.owned[i.key]=true - mixpanel?.track("Acquire Item",{'itemName':i.key,'acquireMethod':'Hourglass'}) + analyticsData = { + uuid: user._id, + itemKey: i.key, + itemType: 'Subscriber Gear', + acquireMethod: 'Hourglass', + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) + user.purchased.plan.consecutive.trinkets-- cb? null, _.pick(user,$w 'items purchased.plan.consecutive') @@ -963,7 +1054,7 @@ api.wrap = (user, main=true) -> user.items.hatchingPotions[hatchingPotion]-- cb? {code:200, message:i18n.t('messageHatched', req.language)}, user.items - unlock: (req, cb, ga) -> + unlock: (req, cb, analytics) -> {path} = req.query fullSet = ~path.indexOf(",") cost = @@ -990,17 +1081,36 @@ api.wrap = (user, main=true) -> user.fns.dotSet "purchased." + path, true user.balance -= cost if ~path.indexOf('gear.') then user.markModified? 'gear.owned' else user.markModified? 'purchased' + + analyticsData = { + uuid: user._id, + itemKey: path, + itemType: 'customization', + acquireMethod: 'Gems', + gemCost: (cost/.25), + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) + cb? null, _.pick(user,$w 'purchased preferences items') - mixpanel?.track("Acquire Item",{'itemName':'Customizations','acquireMethod':'Gems','gemCost':(cost / .25)}) - ga?.event('behavior', 'gems', path).send() # ------ # Classes # ------ - changeClass: (req, cb, ga) -> + changeClass: (req, cb, analytics) -> klass = req.query?.class if klass in ['warrior','rogue','wizard','healer'] + + analyticsData = { + uuid: user._id, + class: klass, + acquireMethod: 'Gems', + gemCost: 3, + category: 'behavior' + } + analytics?.track('change class', analyticsData) + user.stats.class = klass user.flags.classSelected = true # Clear their gear and equip their new class's gear (can still equip old gear from inventory) @@ -1031,8 +1141,6 @@ api.wrap = (user, main=true) -> user.balance -= .75 _.merge user.stats, {str: 0, con: 0, per: 0, int: 0, points: api.capByLevel(user.stats.lvl)} user.flags.classSelected = false - mixpanel?.track("Acquire Item",{'itemName':klass,'acquireMethod':'Gems','gemCost':3}) - ga?.event('behavior', 'gems', 'changeClass').send() #'stats.points': this is handled on the server cb? null, _.pick(user,$w 'stats flags items preferences') @@ -1058,13 +1166,21 @@ api.wrap = (user, main=true) -> user.markModified? 'items.special.valentineReceived' cb? null, 'items.special' - openMysteryItem: (req,cb,ga) -> + openMysteryItem: (req,cb,analytics) -> item = user.purchased.plan?.mysteryItems?.shift() return cb?(code:400,message:"Empty") unless item item = content.gear.flat[item] user.items.gear.owned[item.key] = true user.markModified? 'purchased.plan.mysteryItems' item.type = 'Mystery' + analyticsData = { + uuid: user._id, + itemKey: item, + itemType: 'Subscriber Gear', + acquireMethod: 'Subscriber', + category: 'behavior' + } + analytics?.track('open mystery item', analyticsData) (user._tmp?={}).drop = item if typeof window != 'undefined' cb? null, user.items.gear.owned @@ -1460,7 +1576,7 @@ api.wrap = (user, main=true) -> else "str" # if all else fails, dump into STR )()]++ - updateStats: (stats, req) -> + updateStats: (stats, req, analytics) -> # Game Over (death) return user.stats.hp=0 if stats.hp <= 0 @@ -1514,7 +1630,13 @@ api.wrap = (user, main=true) -> user.items.quests[k]++ (user.flags.levelDrops ?= {})[k] = true user.markModified? 'flags.levelDrops' - mixpanel?.track("Acquire Item",{'itemName':k,'acquireMethod':'Drop'}) + analyticsData = { + uuid: user._id, + itemKey: k, + acquireMethod: 'Level Drop', + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) user._tmp.drop = {type: 'Quest', key: k} if !user.flags.rebirthEnabled and (user.stats.lvl >= 50 or user.achievements.beastMaster) user.flags.rebirthEnabled = true @@ -1695,8 +1817,17 @@ api.wrap = (user, main=true) -> # Analytics user.flags.cronCount?=0 user.flags.cronCount++ - options.mixpanel?.track('Cron',{'distinct_id':user._id,'resting':user.preferences.sleep}) - options.ga?.event('behavior', 'cron', 'cron', user.flags.cronCount).send(); #TODO userId for cohort + + analyticsData = { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user: user, + resting: user.preferences.sleep, + cronCount: user.flags.cronCount + } + options.analytics?.track('Cron', analyticsData) # After all is said and done, progress up user's effect on quest, return those values & reset the user's progress = user.party.quest.progress; _progress = _.cloneDeep progress diff --git a/package.json b/package.json index 3190070bc0..b387c0f638 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "./website/src/server.js", "dependencies": { "amazon-payments": "0.0.4", + "amplitude": "^1.0.6", "async": "~0.9.0", "aws-sdk": "^2.0.25", "babel": "^5.5.4", @@ -40,7 +41,6 @@ "lodash": "~2.4.1", "loggly": "~1.0.8", "method-override": "~2.2.0", - "mixpanel": "^0.2.1", "moment": "~2.8.3", "mongoose": "~3.8.23", "mongoose-id-autoinc": "~2013.7.14-4", diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 14953c8559..bf81b34838 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -70,6 +70,31 @@ gulp.task('test:common:safe', ['test:prepare:build'], (cb) => { pipe(runner); }); +gulp.task('test:server_side', ['test:prepare:build'], (cb) => { + let runner = exec( + testBin('mocha test/server_side'), + (err, stdout, stderr) => { + cb(err); + } + ); + pipe(runner); +}); + +gulp.task('test:server_side:safe', ['test:prepare:build'], (cb) => { + let runner = exec( + testBin('mocha test/server_side'), + (err, stdout, stderr) => { + testResults.push({ + suite: 'Servser Side Specs\t', + pass: testCount(stdout, /(\d+) passing/), + fail: testCount(stderr, /(\d+) failing/), + pend: testCount(stdout, /(\d+) pending/) + }); + cb(); + } + ); + pipe(runner); +}); gulp.task('test:api', ['test:prepare:mongo'], (cb) => { let runner = exec( @@ -193,6 +218,7 @@ gulp.task('test:e2e:safe', ['test:prepare'], (cb) => { gulp.task('test', [ 'test:common:safe', + 'test:server_side:safe', 'test:karma:safe', 'test:api:safe', 'test:e2e:safe' diff --git a/test/server_side/analytics.test.js b/test/server_side/analytics.test.js new file mode 100644 index 0000000000..2c421f9dab --- /dev/null +++ b/test/server_side/analytics.test.js @@ -0,0 +1,435 @@ +var sinon = require('sinon'); +var chai = require("chai") +chai.use(require("sinon-chai")) +var expect = chai.expect +var rewire = require('rewire'); + +describe('analytics', function() { + // Mocks + var amplitudeMock = sinon.stub(); + var googleAnalyticsMock = sinon.stub(); + var amplitudeTrack = sinon.stub(); + var googleEvent = sinon.stub().returns({ + send: function() { } + }); + var googleItem = sinon.stub().returns({ + send: function() { } + }); + var googleTransaction = sinon.stub().returns({ + item: googleItem + }); + + afterEach(function(){ + amplitudeMock.reset(); + amplitudeTrack.reset(); + googleEvent.reset(); + googleTransaction.reset(); + googleItem.reset(); + }); + + describe('init', function() { + var analytics = rewire('../../website/src/analytics'); + + it('throws an error if no options are passed in', function() { + expect(analytics).to.throw('No options provided'); + }); + + it('registers amplitude with token', function() { + analytics.__set__('Amplitude', amplitudeMock); + var options = { + amplitudeToken: 'token' + }; + analytics(options); + + expect(amplitudeMock).to.be.calledOnce; + expect(amplitudeMock).to.be.calledWith('token'); + }); + + it('registers google analytics with token', function() { + analytics.__set__('googleAnalytics', googleAnalyticsMock); + var options = { + googleAnalytics: 'token' + }; + analytics(options); + + expect(googleAnalyticsMock).to.be.calledOnce; + expect(googleAnalyticsMock).to.be.calledWith('token'); + }); + }); + + describe('track', function() { + + var analyticsData, event_type; + var analytics = rewire('../../website/src/analytics'); + var initializedAnalytics; + + beforeEach(function() { + analytics.__set__('Amplitude', amplitudeMock); + initializedAnalytics = analytics({amplitudeToken: 'token'}); + analytics.__set__('amplitude.track', amplitudeTrack); + analytics.__set__('ga.event', googleEvent); + + event_type = 'Cron'; + analyticsData = { + category: 'behavior', + uuid: 'unique-user-id', + resting: true, + cronCount: 5 + } + }); + + context('Amplitude', function() { + it('tracks event in amplitude', function() { + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends english item name for gear if itemKey is provided', function() { + analyticsData.itemKey = 'headAccessory_special_foxEars' + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + itemKey: 'headAccessory_special_foxEars', + itemName: 'Fox Ears', + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends english item name for egg if itemKey is provided', function() { + analyticsData.itemKey = 'Wolf' + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + itemKey: 'Wolf', + itemName: 'Wolf Egg', + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends english item name for food if itemKey is provided', function() { + analyticsData.itemKey = 'Cake_Skeleton' + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + itemKey: 'Cake_Skeleton', + itemName: 'Bare Bones Cake', + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends english item name for hatching potion if itemKey is provided', function() { + analyticsData.itemKey = 'Golden' + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + itemKey: 'Golden', + itemName: 'Golden Hatching Potion', + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends english item name for quest if itemKey is provided', function() { + analyticsData.itemKey = 'atom1' + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + itemKey: 'atom1', + itemName: 'Attack of the Mundane, Part 1: Dish Disaster!', + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends english item name for purchased spell if itemKey is provided', function() { + analyticsData.itemKey = 'seafoam' + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + itemKey: 'seafoam', + itemName: 'Seafoam', + category: 'behavior', + resting: true, + cronCount: 5 + } + }); + }); + + it('sends user data if provided', function() { + var stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; + var user = { + stats: stats, + contributor: { level: 1 }, + purchased: { plan: { planId: 'foo-plan' } } + }; + + analyticsData.user = user; + + initializedAnalytics.track(event_type, analyticsData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'Cron', + user_id: 'unique-user-id', + platform: 'server', + event_properties: { + category: 'behavior', + resting: true, + cronCount: 5 + }, + user_properties: { + Class: 'wizard', + Experience: 5, + Gold: 23, + Health: 10, + Level: 4, + Mana: 30, + contributorLevel: 1, + subscription: 'foo-plan' + } + }); + }); + }); + + context('Google Analytics', function() { + it('tracks event in google analytics', function() { + initializedAnalytics.track(event_type, analyticsData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'behavior', + ea: 'Cron' + }); + }); + + it('if itemKey property is provided, use as label', function() { + analyticsData.itemKey = 'some item'; + + initializedAnalytics.track(event_type, analyticsData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'behavior', + ea: 'Cron', + el: 'some item' + }); + }); + + it('if gaLabel property is provided, use as label (overrides itemKey)', function() { + analyticsData.value = 'some value'; + analyticsData.itemKey = 'some item'; + analyticsData.gaLabel = 'some label'; + + initializedAnalytics.track(event_type, analyticsData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'behavior', + ea: 'Cron', + el: 'some label' + }); + }); + + it('if goldCost property is provided, use as value', function() { + analyticsData.goldCost = 5; + + initializedAnalytics.track(event_type, analyticsData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'behavior', + ea: 'Cron', + ev: 5 + }); + }); + + it('if gemCost property is provided, use as value (overrides goldCost)', function() { + analyticsData.gemCost = 7; + analyticsData.goldCost = 5; + + initializedAnalytics.track(event_type, analyticsData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'behavior', + ea: 'Cron', + ev: 7 + }); + }); + + it('if gaValue property is provided, use as value (overrides gemCost)', function() { + analyticsData.gemCost = 7; + analyticsData.gaValue = 5; + + initializedAnalytics.track(event_type, analyticsData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'behavior', + ea: 'Cron', + ev: 5 + }); + }); + }); + }); + + describe('trackPurchase', function() { + + var purchaseData; + + var analytics = rewire('../../website/src/analytics'); + var initializedAnalytics; + + beforeEach(function() { + analytics.__set__('Amplitude', amplitudeMock); + initializedAnalytics = analytics({amplitudeToken: 'token', googleAnalytics: 'token'}); + analytics.__set__('amplitude.track', amplitudeTrack); + analytics.__set__('ga.event', googleEvent); + analytics.__set__('ga.transaction', googleTransaction); + + purchaseData = { + uuid: 'user-id', + sku: 'paypal-checkout', + paymentMethod: 'PayPal', + itemPurchased: 'Gems', + purchaseValue: 8, + purchaseType: 'checkout', + gift: false, + quantity: 1 + } + + }); + + context('Amplitude', function() { + + it('calls amplitude.track', function() { + initializedAnalytics.trackPurchase(purchaseData); + + expect(amplitudeTrack).to.be.calledOnce; + expect(amplitudeTrack).to.be.calledWith({ + event_type: 'purchase', + user_id: 'user-id', + platform: 'server', + event_properties: { + paymentMethod: 'PayPal', + sku: 'paypal-checkout', + gift: false, + itemPurchased: 'Gems', + purchaseType: 'checkout', + quantity: 1 + }, + revenue: 8 + }); + }); + }); + + context('Google Analytics', function() { + + it('calls ga.event', function() { + initializedAnalytics.trackPurchase(purchaseData); + + expect(googleEvent).to.be.calledOnce; + expect(googleEvent).to.be.calledWith({ + ec: 'commerce', + ea: 'checkout', + el: 'PayPal', + ev: 8 + }); + }); + + it('calls ga.transaction', function() { + initializedAnalytics.trackPurchase(purchaseData); + + expect(googleTransaction).to.be.calledOnce; + expect(googleTransaction).to.be.calledWith( + 'user-id', + 8 + ); + expect(googleItem).to.be.calledOnce; + expect(googleItem).to.be.calledWith( + 8, + 1, + 'paypal-checkout', + 'Gems', + 'checkout' + ); + }); + + it('appends gift to variation of ga.transaction.item if gift is true', function() { + + purchaseData.gift = true; + initializedAnalytics.trackPurchase(purchaseData); + + expect(googleItem).to.be.calledOnce; + expect(googleItem).to.be.calledWith( + 8, + 1, + 'paypal-checkout', + 'Gems', + 'checkout - Gift' + ); + }); + }); + }); +}); diff --git a/test/spec/mocks/mixpanelMock.js b/test/spec/mocks/mixpanelMock.js deleted file mode 100644 index 2cb7300f73..0000000000 --- a/test/spec/mocks/mixpanelMock.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict' -//Adapted from http://stackoverflow.com/questions/23785603/angularjs-testing-with-jasmine-and-mixpanel -// @TODO: replace with an injectable mixpanel instance for testing - -var MixpanelMock; - -MixpanelMock = (function() { - function MixpanelMock() {} - - MixpanelMock.prototype.track = function() { - return console.log("mixpanel.track", arguments); - }; - - MixpanelMock.prototype.register_once = function() { - return console.log("mixpanel.register_once", arguments); - }; - - MixpanelMock.prototype.identify = function() { - return console.log("mixpanel.identify", arguments); - }; - - MixpanelMock.prototype.register = function() { - return console.log("mixpanel.register", arguments); - }; - - return MixpanelMock; - -})(); - -window.mixpanel = new MixpanelMock(); diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index ce49fdcfc6..7dced7ce00 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -50,15 +50,6 @@ angular.module('habitrpg') if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; $http.post(url, scope.registerVals).success(function(data, status, headers, config) { runAuth(data.id, data.apiToken); - if (status == 200) { - if (data.auth.facebook) { - Analytics.updateUser({'email':data.auth.facebook._json.email,'language':data.preferences.language}); - Analytics.track({'hitType':'event','eventCategory':'acquisition','eventAction':'register','authType':'facebook'}); - } else { - Analytics.updateUser({'email':data.auth.local.email,'language':data.preferences.language}); - Analytics.track({'hitType':'event','eventCategory':'acquisition','eventAction':'register','authType':'email'}); - } - } }).error(errorAlert); }; diff --git a/website/src/analytics.js b/website/src/analytics.js new file mode 100644 index 0000000000..ecae3768d5 --- /dev/null +++ b/website/src/analytics.js @@ -0,0 +1,190 @@ +var _ = require('lodash'); +require('coffee-script'); // remove this once we've fully converted over +var i18n = require('./i18n'); +var Content = require('../../common/script/content'); +var Amplitude = require('amplitude'); +var googleAnalytics = require('universal-analytics'); + +var ga; +var amplitude; + +var analytics = { + trackPurchase: trackPurchase, + track: track +} + +function init(options) { + if(!options) { throw 'No options provided' } + + amplitude = new Amplitude(options.amplitudeToken); + ga = googleAnalytics(options.googleAnalytics); + + return analytics; +} + +function track(eventType, data) { + _sendDataToAmplitude(eventType, data); + _sendDataToGoogle(eventType, data); +} + +function _sendDataToAmplitude(eventType, data) { + var amplitudeData = _formatDataForAmplitude(data); + amplitudeData.event_type = eventType; + amplitude.track(amplitudeData); +} + +function _sendDataToGoogle(eventType, data) { + var eventData = { + ec: data.category, + ea: eventType + } + + var label = _generateLabelForGoogleAnalytics(data); + if(label) { eventData.el = label; } + + var value = _generateValueForGoogleAnalytics(data); + if(value) { eventData.ev = value; } + + ga.event(eventData).send(); +} + +function _generateLabelForGoogleAnalytics(data) { + var label; + var POSSIBLE_LABELS = ['gaLabel', 'itemKey']; + + _(POSSIBLE_LABELS).each(function(key) { + if(data[key]) { + label = data[key]; + return false; // exit _.each early + } + }); + + return label; +} + +function _generateValueForGoogleAnalytics(data) { + var value; + var POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost']; + + _(POSSIBLE_VALUES).each(function(key) { + if(data[key]) { + value = data[key]; + return false; // exit _.each early + } + }); + + return value; +} + +function trackPurchase(data) { + _sendPurchaseDataToAmplitude(data); + _sendPurchaseDataToGoogle(data); +} + +function _sendPurchaseDataToAmplitude(data) { + var amplitudeData = _formatDataForAmplitude(data); + amplitudeData.event_type = 'purchase'; + amplitudeData.revenue = data.purchaseValue; + + amplitude.track(amplitudeData) +} + +function _formatDataForAmplitude(data) { + var PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue']; + var event_properties = _.omit(data, PROPERTIES_TO_SCRUB); + + var ampData = { + user_id: data.uuid, + platform: 'server', + event_properties: event_properties + } + + if(data.user) { + ampData.user_properties = _formatUserData(data.user); + } + + var itemName = _lookUpItemName(data.itemKey); + if(itemName) { + event_properties.itemName = itemName; + } + + return ampData; +} + +function _lookUpItemName(itemKey) { + if (!itemKey) return; + + var gear = Content.gear.flat[itemKey]; + var egg = Content.eggs[itemKey]; + var food = Content.food[itemKey]; + var hatchingPotion = Content.hatchingPotions[itemKey]; + var quest = Content.quests[itemKey]; + var spell = Content.special[itemKey]; + + var itemName; + + if (gear) { + itemName = gear.text(); + } else if (egg) { + itemName = egg.text() + ' Egg'; + } else if (food) { + itemName = food.text(); + } else if (hatchingPotion) { + itemName = hatchingPotion.text() + " Hatching Potion"; + } else if (quest) { + itemName = quest.text(); + } else if (spell) { + itemName = spell.text(); + } + + return itemName; +} + +function _formatUserData(user) { + var properties = {}; + + 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; + } + + return properties; +} + +function _sendPurchaseDataToGoogle(data) { + var label = data.paymentMethod; + var type = data.purchaseType; + var price = data.purchaseValue; + var qty = data.quantity; + var sku = data.sku; + var itemKey = data.itemPurchased; + var variation = type; + if(data.gift) variation += ' - Gift'; + + var eventData = { + ec: 'commerce', + ea: type, + el: label, + ev: price + }; + + ga.event(eventData).send(); + + ga.transaction(data.uuid, price) + .item(price, qty, sku, itemKey, variation) + .send(); +} + +module.exports = init; diff --git a/website/src/controllers/auth.js b/website/src/controllers/auth.js index e861daab19..fc5e5dafbe 100644 --- a/website/src/controllers/auth.js +++ b/website/src/controllers/auth.js @@ -8,7 +8,7 @@ var nconf = require('nconf'); var request = require('request'); var User = require('../models/user').model; var EmailUnsubscription = require('../models/emailUnsubscription').model; -var ga = require('./../utils').ga; +var analytics = utils.analytics; var i18n = require('./../i18n'); var isProd = nconf.get('NODE_ENV') === 'production'; @@ -110,7 +110,14 @@ api.registerUser = function(req, res, next) { newUser.preferences = newUser.preferences || {}; newUser.preferences.language = req.language; // User language detected from browser, not saved var user = new User(newUser); - ga.event('acquisition', 'register', 'local').send(); + + var analyticsData = { + category: 'acquisition', + type: 'local', + gaLabel: 'local' + }; + analytics.track('register', analyticsData) + user.save(function(err, savedUser){ // Clean previous email preferences EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){ @@ -194,7 +201,12 @@ api.loginSocial = function(req, res, next) { cb.apply(cb, arguments); }); - ga.event('acquisition', 'register', network).send(); + var analyticsData = { + category: 'acquisition', + type: network, + gaLabel: network + }; + analytics.track('register', analyticsData) }] }, function(err, results){ if (err) return res.json(401, {err: err.toString ? err.toString() : err}); diff --git a/website/src/controllers/groups.js b/website/src/controllers/groups.js index 982383205c..00985aff5b 100644 --- a/website/src/controllers/groups.js +++ b/website/src/controllers/groups.js @@ -17,6 +17,7 @@ var EmailUnsubscription = require('./../models/emailUnsubscription').model; var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; var pushNotify = require('./pushNotifications'); +var analytics = utils.analytics; /* ------------------------------------------------------------------------ @@ -925,6 +926,14 @@ api.questAccept = function(req, res, next) { // or everyone has either accepted/rejected, then we store quest key in user object. _.each(group.members, function(m){ if (m == user._id) { + var analyticsData = { + category: 'behavior', + owner: true, + response: 'accept', + gaLabel: 'accept', + questName: key + }; + analytics.track('quest',analyticsData); group.quest.members[m] = true; group.quest.leader = user._id; } else { @@ -963,6 +972,14 @@ api.questAccept = function(req, res, next) { // Party member accepting the invitation } else { if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'}); + var analyticsData = { + category: 'behavior', + owner: false, + response: 'accept', + gaLabel: 'accept', + questName: group.quest.key + }; + analytics.track('quest',analyticsData); group.quest.members[user._id] = true; User.update({_id:user._id}, {$set: {'party.quest.RSVPNeeded': false}}).exec(); questStart(req,res,next); @@ -974,6 +991,14 @@ api.questReject = function(req, res, next) { var user = res.locals.user; if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'}); + var analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key + }; + analytics.track('quest',analyticsData); group.quest.members[user._id] = false; User.update({_id:user._id}, {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}).exec(); questStart(req,res,next); diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index 6206bfe187..b1b444006f 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -74,9 +74,18 @@ exports.createSubscription = function(data, cb) { revealMysteryItems(recipient); if(isProduction) { if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); - utils.ga.event('commerce', 'subscribe', data.paymentMethod, block.price).send(); - utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); - utils.mixpanel.track('purchase',{'distinct_id':data.user._id,'itemPurchased':block.key,'purchaseValue':block.price}) + + var analyticsData = { + uuid: data.user._id, + itemPurchased: 'Subscription', + sku: data.paymentMethod.toLowerCase() + '-subscription', + purchaseType: 'subscribe', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: !!data.gift, // coerced into a boolean + purchaseValue: block.price + } + utils.analytics.trackPurchase(analyticsData); } data.user.purchased.txnCount++; if (data.gift){ @@ -118,7 +127,13 @@ exports.cancelSubscription = function(data, cb) { data.user.save(cb); utils.txnEmail(data.user, 'cancel-subscription'); - utils.ga.event('commerce', 'unsubscribe', data.paymentMethod).send(); + var analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod + } + utils.analytics.track('unsubscribe', analyticsData); } exports.buyGems = function(data, cb) { @@ -127,11 +142,20 @@ exports.buyGems = function(data, cb) { data.user.purchased.txnCount++; if(isProduction) { if (!data.gift) utils.txnEmail(data.user, 'donation'); - utils.ga.event('commerce', 'checkout', data.paymentMethod, amt).send(); - utils.mixpanel.track('purchase',{'distinct_id':data.user._id,'itemPurchased':'Gems','purchaseValue':amt}) - //TODO ga.transaction to reflect whether this is gift or self-purchase - utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); + + var analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: data.paymentMethod.toLowerCase() + '-checkout', + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: !!data.gift, // coerced into a boolean + purchaseValue: amt + } + utils.analytics.trackPurchase(analyticsData); } + if (data.gift){ var byUsername = utils.getUserInfo(data.user, ['name']).name; var gemAmount = data.gift.gems.amount || 20; diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index 00638f7007..8ad45d5e6d 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -8,7 +8,7 @@ var async = require('async'); var shared = require('../../../common'); var User = require('./../models/user').model; var utils = require('./../utils'); -var ga = utils.ga; +var analytics = utils.analytics; var Group = require('./../models/group').model; var Challenge = require('./../models/challenge').model; var moment = require('moment'); @@ -102,7 +102,7 @@ api.score = function(req, res, next) { if (task.type === 'daily' || task.type === 'todo') task.completed = direction === 'up'; - + task = user.ops.addTask({body:task}); } var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); @@ -336,7 +336,7 @@ api.update = function(req, res, next) { api.cron = function(req, res, next) { var user = res.locals.user, - progress = user.fns.cron({ga:ga, mixpanel:utils.mixpanel}), + progress = user.fns.cron({analytics:utils.analytics}), ranCron = user.isModified(), quest = shared.content.quests[user.party.quest.key]; @@ -535,7 +535,7 @@ _.each(shared.wrap({}).ops, function(op,k){ if (err) return next(err); res.json(200,response); }) - }, ga); + }, analytics); } } }) diff --git a/website/src/utils.js b/website/src/utils.js index 96f5f04645..5d1e4ac2ba 100644 --- a/website/src/utils.js +++ b/website/src/utils.js @@ -7,8 +7,6 @@ var request = require('request'); // Set when utils.setupConfig is run var isProd, baseUrl; -module.exports.ga = undefined; // set Google Analytics on nconf init - module.exports.sendEmail = function(mailData) { var smtpTransport = nodemailer.createTransport("SMTP",{ service: nconf.get('SMTP_SERVICE'), @@ -178,12 +176,15 @@ module.exports.setupConfig = function(){ isProd = nconf.get('NODE_ENV') === 'production'; baseUrl = nconf.get('BASE_URL'); - module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); + var analytics = isProd && require('./analytics'); + var analyticsTokens = { + amplitudeToken: nconf.get('AMPLITUDE_KEY'), + googleAnalytics: nconf.get('GA_ID') + } - var mixpanel = isProd && require('mixpanel'); - module.exports.mixpanel = mixpanel - ? mixpanel.init(nconf.get('MP_ID')) - : { track: function() {} }; + module.exports.analytics = analytics + ? analytics(analyticsTokens) + : { track: function() { }, trackPurchase: function() { } }; }; var algorithm = 'aes-256-ctr';