Merge pull request #5591 from crookedneighbor/analytics-service-server-side

Analytics service - server side
This commit is contained in:
Blade Barringer 2015-07-24 15:28:24 -05:00
commit 4c316cab72
12 changed files with 903 additions and 98 deletions

View file

@ -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

View file

@ -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",

View file

@ -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'

View file

@ -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'
);
});
});
});
});

View file

@ -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();

View file

@ -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);
};

190
website/src/analytics.js Normal file
View file

@ -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;

View file

@ -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});

View file

@ -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);

View file

@ -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;

View file

@ -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);
}
}
})

View file

@ -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';