diff --git a/package.json b/package.json index 6174bf5f52..3117ca87b9 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "mocha": "^2.3.3", "mongodb": "^2.0.46", "mongoskin": "~0.6.1", + "nock": "^2.17.0", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js new file mode 100644 index 0000000000..99657b270a --- /dev/null +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -0,0 +1,309 @@ +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; + +import nock from 'nock'; + +describe('analyticsService', () => { + let amplitudeNock, gaNock; + + beforeEach(() => { + amplitudeNock = nock( 'https://api.amplitude.com') + .filteringPath(/httpapi.*/g, '') + .post('/') + .reply(200, {status: 'OK'}); + + gaNock = nock( 'http://www.google-analytics.com'); + }); + + describe('#track', () => { + let eventType, data; + + beforeEach(() => { + eventType = 'Cron'; + data = { + category: 'behavior', + uuid: 'unique-user-id', + resting: true, + cronCount: 5 + }; + }); + + context('Amplitude', () => { + it('calls out to amplitude', () => { + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('uses a dummy user id if none is provided', () => { + delete data.uuid; + + amplitudeNock + .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sets platform as server', () => { + amplitudeNock + .filteringPath(/httpapi.*platform.*server.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends details about event', () => { + amplitudeNock + .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for gear if itemKey is provided', () => { + data.itemKey = 'headAccessory_special_foxEars' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for egg if itemKey is provided', () => { + data.itemKey = 'Wolf' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for food if itemKey is provided', () => { + data.itemKey = 'Cake_Skeleton' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for hatching potion if itemKey is provided', () => { + data.itemKey = 'Golden' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for quest if itemKey is provided', () => { + data.itemKey = 'atom1' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for purchased spell if itemKey is provided', () => { + data.itemKey = 'seafoam' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends user data if provided', () => { + let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; + let user = { + stats: stats, + contributor: { level: 1 }, + purchased: { plan: { planId: 'foo-plan' } }, + flags: {tour: {intro: -2}}, + habits: [{_id: 'habit'}], + dailys: [{_id: 'daily'}], + todos: [{_id: 'todo'}], + rewards: [{_id: 'reward'}] + }; + + data.user = user; + + amplitudeNock + .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + }); + + context('GA', () => { + it('calls out to GA', () => { + gaNock + .post('/collect') + .reply(200, {status: 'OK'}); + + return analyticsService.track(eventType, data) + .then((res) => { + gaNock.done(); + }); + }); + + it('sends details about event', () => { + gaNock + .post('/collect', /ec=behavior&ea=Cron&v=1&tid=GA_ID&cid=.*&t=event/) + .reply(200, {status: 'OK'}); + + return analyticsService.track(eventType, data) + .then((res) => { + gaNock.done(); + }); + }); + }); + }); + + describe('#trackPurchase', () => { + let data; + + beforeEach(() => { + data = { + uuid: 'user-id', + sku: 'paypal-checkout', + paymentMethod: 'PayPal', + itemPurchased: 'Gems', + purchaseValue: 8, + purchaseType: 'checkout', + gift: false, + quantity: 1 + }; + }); + + context('Amplitude', () => { + it('calls out to amplitude', () => { + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('uses a dummy user id if none is provided', () => { + delete data.uuid; + + amplitudeNock + .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sets platform as server', () => { + amplitudeNock + .filteringPath(/httpapi.*platform.*server.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends details about purchase', () => { + amplitudeNock + .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends user data if provided', () => { + let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; + let user = { + stats: stats, + contributor: { level: 1 }, + purchased: { plan: { planId: 'foo-plan' } }, + flags: {tour: {intro: -2}}, + habits: [{_id: 'habit'}], + dailys: [{_id: 'daily'}], + todos: [{_id: 'todo'}], + rewards: [{_id: 'reward'}] + }; + + data.user = user; + + amplitudeNock + .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + }); + + context('GA', () => { + it('calls out to GA', () => { + gaNock + .post('/collect') + .reply(200, {status: 'OK'}); + + return analyticsService.trackPurchase(data) + .then((res) => { + gaNock.done(); + }); + }); + + it('sends details about purchase', () => { + gaNock + .post('/collect', /ti=user-id&tr=8&v=1&tid=GA_ID&cid=.*&t=transaction/) + .reply(200, {status: 'OK'}) + .post('/collect', /ec=commerce&ea=checkout&el=PayPal&ev=8&v=1&tid=GA_ID&cid=.*&t=event/) + .reply(200, {status: 'OK'}); + + return analyticsService.trackPurchase(data) + .then((res) => { + gaNock.done(); + }); + }); + }); + }); + + describe('mockAnalyticsService', () => { + it('has stubbed track method', () => { + expect(analyticsService.mockAnalyticsService).to.respondTo('track'); + }); + + it('has stubbed trackPurchase method', () => { + expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase'); + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js new file mode 100644 index 0000000000..75df6c72dc --- /dev/null +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -0,0 +1,32 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService' +import nconf from 'nconf'; +import attachAnalytics from '../../../../../website/src/middlewares/api-v3/analytics'; + +describe('analytics middleware', function() { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + it('attaches analytics object res.locals', function() { + attachAnalytics(req, res, next); + + expect(res.analytics).to.exist; + }); + + it('attaches stubbed methods for non-prod environments', () => { + attachAnalytics(req, res, next); + + expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track); + expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase); + }); +}); + diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js new file mode 100644 index 0000000000..6e460097eb --- /dev/null +++ b/website/src/libs/api-v3/analyticsService.js @@ -0,0 +1,240 @@ +/* eslint-disable camelcase */ +import nconf from 'nconf'; +import Amplitude from 'amplitude'; +import Q from 'q'; +import googleAnalytics from 'universal-analytics'; +import { + each, + omit, +} from 'lodash'; +import { content as Content } from '../../../../common'; + +require('coffee-script'); +require('../../libs/i18n'); + +const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY'); +const GA_TOKEN = nconf.get('GA_ID'); +const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey']; +const GA_POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost']; +const AMPLITUDE_PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue']; + +let amplitude = new Amplitude(AMPLIUDE_TOKEN); +let ga = googleAnalytics(GA_TOKEN); + +let _lookUpItemName = (itemKey) => { + if (!itemKey) return; + + let gear = Content.gear.flat[itemKey]; + let egg = Content.eggs[itemKey]; + let food = Content.food[itemKey]; + let hatchingPotion = Content.hatchingPotions[itemKey]; + let quest = Content.quests[itemKey]; + let spell = Content.special[itemKey]; + + let 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; +}; + +let _formatUserData = (user) => { + let 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); + } + + properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2; + + if (user.habits && user.dailys && user.todos && user.rewards) { + properties['Number Of Tasks'] = { + habits: user.habits.length, + dailys: user.dailys.length, + todos: user.todos.length, + rewards: user.rewards.length, + }; + } + + 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; +}; + + +let _formatDataForAmplitude = (data) => { + let event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB); + + let ampData = { + user_id: data.uuid || 'no-user-id-was-provided', + platform: 'server', + event_properties, + }; + + if (data.user) { + ampData.user_properties = _formatUserData(data.user); + } + + let itemName = _lookUpItemName(data.itemKey); + + if (itemName) { + event_properties.itemName = itemName; + } + + return ampData; +}; + +let _sendDataToAmplitude = (eventType, data) => { + let amplitudeData = _formatDataForAmplitude(data); + + amplitudeData.event_type = eventType; + + return Q.promise((resolve, reject) => { + amplitude.track(amplitudeData) + .then(resolve) + .catch(reject); + }); +}; + +let _generateLabelForGoogleAnalytics = (data) => { + let label; + + each(GA_POSSIBLE_LABELS, (key) => { + if (data[key]) { + label = data[key]; + return false; // exit each early + } + }); + + return label; +}; + +let _generateValueForGoogleAnalytics = (data) => { + let value; + + each(GA_POSSIBLE_VALUES, (key) => { + if (data[key]) { + value = data[key]; + return false; // exit each early + } + }); + + return value; +}; + +let _sendDataToGoogle = (eventType, data) => { + let eventData = { + ec: data.category, + ea: eventType, + }; + + let label = _generateLabelForGoogleAnalytics(data); + + if (label) { + eventData.el = label; + } + + let value = _generateValueForGoogleAnalytics(data); + + if (value) { + eventData.ev = value; + } + + return Q.promise((resolve, reject) => { + ga.event(eventData, (err) => { + if (err) return reject(err); + resolve(); + }); + }); +}; + +let _sendPurchaseDataToAmplitude = (data) => { + let amplitudeData = _formatDataForAmplitude(data); + + amplitudeData.event_type = 'purchase'; + amplitudeData.revenue = data.purchaseValue; + + return Q.promise((resolve, reject) => { + amplitude.track(amplitudeData) + .then(resolve) + .catch(reject); + }); +}; + +let _sendPurchaseDataToGoogle = (data) => { + let label = data.paymentMethod; + let type = data.purchaseType; + let price = data.purchaseValue; + let qty = data.quantity; + let sku = data.sku; + let itemKey = data.itemPurchased; + let variation = type; + + if (data.gift) variation += ' - Gift'; + + let eventData = { + ec: 'commerce', + ea: type, + el: label, + ev: price, + }; + + return Q.promise((resolve) => { + ga.event(eventData).send(); + + ga.transaction(data.uuid, price) + .item(price, qty, sku, itemKey, variation) + .send(); + + resolve(); + }); +}; + +function track (eventType, data) { + return Q.all([ + _sendDataToAmplitude(eventType, data), + _sendDataToGoogle(eventType, data), + ]); +} + +function trackPurchase (data) { + return Q.all([ + _sendPurchaseDataToAmplitude(data), + _sendPurchaseDataToGoogle(data), + ]); +} + +// Stub for non-prod environments +let mockAnalyticsService = { + track: () => { }, + trackPurchase: () => { }, +}; + +export default { + track, + trackPurchase, + mockAnalyticsService, +}; diff --git a/website/src/middlewares/api-v3/analytics.js b/website/src/middlewares/api-v3/analytics.js new file mode 100644 index 0000000000..e4872fad56 --- /dev/null +++ b/website/src/middlewares/api-v3/analytics.js @@ -0,0 +1,23 @@ +import nconf from 'nconf'; +import { + track, + trackPurchase, + mockAnalyticsService, +} from '../../libs/api-v3/analyticsService'; + +let service; + +if (nconf.get('IS_PROD')) { + service = { + track, + trackPurchase, + }; +} else { + service = mockAnalyticsService; +} + +export default function attachAnalytics (req, res, next) { + res.analytics = service; + + next(); +} diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 4d2053fe2b..82b85eee11 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,5 +1,6 @@ // This module is only used to attach middlewares to the express app +import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; @@ -11,6 +12,8 @@ export default function attachMiddlewares (app) { })); app.use(bodyParser.json()); + app.use(analytics); + // Error handler middleware, define as the last one app.use(errorHandler); }