From 2e59260149c990c779751351827544e16749482f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 9 Nov 2020 11:34:20 +0100 Subject: [PATCH] Revert "Revert "Analytics: track generic events through the server (#12735)"" This reverts commit 9d6fb2ca2650f680faaa24a872aa07abba4de46c. --- .../POST-analytics_track_eventName.test.js | 28 +++++++++++ test/helpers/api-integration/requester.js | 1 + .../achievements/dropCapReached.vue | 4 +- .../header/notifications/dropCapReached.vue | 2 +- .../src/components/header/userDropdown.vue | 2 +- website/client/src/libs/analytics.js | 13 +++-- website/client/src/store/actions/analytics.js | 7 +++ website/client/src/store/actions/index.js | 2 + website/client/vue.config.js | 4 ++ .../server/controllers/api-v3/challenges.js | 5 ++ website/server/controllers/api-v3/quests.js | 8 ++-- .../server/controllers/top-level/analytics.js | 47 +++++++++++++++++++ .../controllers/top-level/payments/paypal.js | 2 +- .../controllers/top-level/payments/stripe.js | 2 +- website/server/libs/analyticsService.js | 8 ++-- website/server/libs/cron.js | 2 +- 16 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 test/api/v3/integration/analytics/POST-analytics_track_eventName.test.js create mode 100644 website/client/src/store/actions/analytics.js create mode 100644 website/server/controllers/top-level/analytics.js diff --git a/test/api/v3/integration/analytics/POST-analytics_track_eventName.test.js b/test/api/v3/integration/analytics/POST-analytics_track_eventName.test.js new file mode 100644 index 0000000000..eb00ee8927 --- /dev/null +++ b/test/api/v3/integration/analytics/POST-analytics_track_eventName.test.js @@ -0,0 +1,28 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService'; + +describe('POST /analytics/track/:eventName', () => { + it('requires authentication', async () => { + await expect(requester().post('/analytics/track/event')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthHeaders'), + }); + }); + + it('calls res.analytics', async () => { + const user = await generateUser(); + sandbox.spy(analytics, 'track'); + + const requestWithHeaders = requester(user, { 'x-client': 'habitica-web' }); + await requestWithHeaders.post('/analytics/track/eventName', { data: 'example' }, { 'x-client': 'habitica-web' }); + expect(analytics.track).to.be.calledOnce; + expect(analytics.track).to.be.calledWith('eventName', sandbox.match({ data: 'example' })); + + sandbox.restore(); + }); +}); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a01aa1e227..f5c63b9d14 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -41,6 +41,7 @@ function _requestMaker (user, method, additionalSets = {}) { || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0 || route.indexOf('/qr-code') === 0 + || route.indexOf('/analytics') === 0 ) { url += `${route}`; } else { diff --git a/website/client/src/components/achievements/dropCapReached.vue b/website/client/src/components/achievements/dropCapReached.vue index a804fd78fa..0c01057752 100644 --- a/website/client/src/components/achievements/dropCapReached.vue +++ b/website/client/src/components/achievements/dropCapReached.vue @@ -232,7 +232,7 @@ export default { eventCategory: 'drop-cap-reached', eventAction: 'click', eventLabel: 'Drop Cap Reached > Modal > Wiki', - }); + }, { trackOnServer: true }); }, toLearnMore () { Analytics.track({ @@ -240,7 +240,7 @@ export default { eventCategory: 'drop-cap-reached', eventAction: 'click', eventLabel: 'Drop Cap Reached > Modal > Subscriptions', - }); + }, { trackOnServer: true }); this.close(); this.$router.push('/user/settings/subscription'); diff --git a/website/client/src/components/header/notifications/dropCapReached.vue b/website/client/src/components/header/notifications/dropCapReached.vue index 34a133b64e..6fcab511d2 100644 --- a/website/client/src/components/header/notifications/dropCapReached.vue +++ b/website/client/src/components/header/notifications/dropCapReached.vue @@ -36,7 +36,7 @@ export default { eventCategory: 'drop-cap-reached', eventAction: 'click', eventLabel: 'Drop Cap Reached > Notification Click', - }); + }, { trackOnServer: true }); }, }, }; diff --git a/website/client/src/components/header/userDropdown.vue b/website/client/src/components/header/userDropdown.vue index 8434defbef..cb058247b0 100644 --- a/website/client/src/components/header/userDropdown.vue +++ b/website/client/src/components/header/userDropdown.vue @@ -183,7 +183,7 @@ export default { eventCategory: 'button', eventAction: 'click', eventLabel: 'User Dropdown > Subscriptions', - }); + }, { trackOnServer: true }); this.$router.push({ name: 'subscription' }); }, diff --git a/website/client/src/libs/analytics.js b/website/client/src/libs/analytics.js index d7af60b8a1..3b2bed28ec 100644 --- a/website/client/src/libs/analytics.js +++ b/website/client/src/libs/analytics.js @@ -82,14 +82,21 @@ export function setUser () { window.ga('set', { userId: user._id }); } -export function track (properties) { +export function track (properties, options = {}) { // Use nextTick to avoid blocking the UI Vue.nextTick(() => { if (_doesNotHaveRequiredFields(properties)) return; if (_doesNotHaveAllowedHitType(properties)) return; - amplitude.getInstance().logEvent(properties.eventAction, properties); - window.ga('send', properties); + const trackOnServer = options && options.trackOnServer === true; + if (trackOnServer === true) { + // Track an event on the server + const store = getStore(); + store.dispatch('analytics:trackEvent', properties); + } else { + amplitude.getInstance().logEvent(properties.eventAction, properties); + window.ga('send', properties); + } }); } diff --git a/website/client/src/store/actions/analytics.js b/website/client/src/store/actions/analytics.js new file mode 100644 index 0000000000..1d104d749c --- /dev/null +++ b/website/client/src/store/actions/analytics.js @@ -0,0 +1,7 @@ +import axios from 'axios'; + +export async function trackEvent (store, params) { + const url = `/analytics/track/${params.eventAction}`; + + await axios.post(url, params); +} diff --git a/website/client/src/store/actions/index.js b/website/client/src/store/actions/index.js index 947be42878..adc7a38fb2 100644 --- a/website/client/src/store/actions/index.js +++ b/website/client/src/store/actions/index.js @@ -17,6 +17,7 @@ import * as shops from './shops'; import * as snackbars from './snackbars'; import * as worldState from './worldState'; import * as news from './news'; +import * as analytics from './analytics'; // Actions should be named as 'actionName' and can be accessed as 'namespace:actionName' // Example: fetch in user.js -> 'user:fetch' @@ -39,6 +40,7 @@ const actions = flattenAndNamespace({ snackbars, worldState, news, + analytics, }); export default actions; diff --git a/website/client/vue.config.js b/website/client/vue.config.js index aae8725c33..6d446cb1f6 100644 --- a/website/client/vue.config.js +++ b/website/client/vue.config.js @@ -148,6 +148,10 @@ module.exports = { target: DEV_BASE_URL, changeOrigin: true, }, + '^/analytics': { + target: DEV_BASE_URL, + changeOrigin: true, + }, }, }, }; diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index 3e8bea0a6c..84801e28a5 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -221,6 +221,7 @@ api.createChallenge = { groupID: group._id, groupName: group.privacy === 'private' ? null : group.name, groupType: group._id === TAVERN_ID ? 'tavern' : group.type, + headers: req.headers, }); res.respond(201, response); @@ -286,6 +287,7 @@ api.joinChallenge = { groupID: group._id, groupName: group.privacy === 'private' ? null : group.name, groupType: group._id === TAVERN_ID ? 'tavern' : group.type, + headers: req.headers, }); res.respond(200, response); @@ -335,6 +337,7 @@ api.leaveChallenge = { groupID: challenge.group._id, groupName: challenge.group.privacy === 'private' ? null : challenge.group.name, groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type, + headers: req.headers, }); res.respond(200, {}); @@ -748,6 +751,7 @@ api.deleteChallenge = { groupID: challenge.group._id, groupName: challenge.group.privacy === 'private' ? null : challenge.group.name, groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type, + headers: req.headers, }); res.respond(200, {}); @@ -798,6 +802,7 @@ api.selectChallengeWinner = { groupID: challenge.group._id, groupName: challenge.group.privacy === 'private' ? null : challenge.group.name, groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type, + headers: req.headers, }); res.respond(200, {}); diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index dd1105e58d..b0e2c08e6c 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -156,7 +156,7 @@ api.inviteToQuest = { questName: questKey, uuid: user._id, headers: req.headers, - }); + }, true); }, }; @@ -217,7 +217,7 @@ api.acceptQuest = { questName: group.quest.key, uuid: user._id, headers: req.headers, - }); + }, true); }, }; @@ -278,7 +278,7 @@ api.rejectQuest = { questName: group.quest.key, uuid: user._id, headers: req.headers, - }); + }, true); }, }; @@ -338,7 +338,7 @@ api.forceStart = { questName: group.quest.key, uuid: user._id, headers: req.headers, - }); + }, true); }, }; diff --git a/website/server/controllers/top-level/analytics.js b/website/server/controllers/top-level/analytics.js new file mode 100644 index 0000000000..f663a10dcb --- /dev/null +++ b/website/server/controllers/top-level/analytics.js @@ -0,0 +1,47 @@ +import { + NotAuthorized, +} from '../../libs/errors'; +import { + authWithHeaders, +} from '../../middlewares/auth'; + +const api = {}; + +/** + * @apiIgnore Analytics are considered part of the private API + * @api {post} /analytics/track/:eventName Track a generic analytics event + * @apiName AnalyticsTrack + * @apiGroup Analytics + * + * @apiSuccess {Object} data An empty object + * */ +api.trackEvent = { + method: 'POST', + url: '/analytics/track/:eventName', + // we authenticate these requests to make sure they actually came from a real user + middlewares: [authWithHeaders()], + async handler (req, res) { + // As of now only web can track events using this route + if (req.headers['x-client'] !== 'habitica-web') { + throw new NotAuthorized('Only habitica.com is allowed to track analytics events.'); + } + + const { user } = res.locals; + const eventProperties = req.body; + + res.analytics.track(req.params.eventName, { + uuid: user._id, + headers: req.headers, + category: 'behaviour', + gaLabel: 'local', + // hitType: 'event', sent from the client + ...eventProperties, + }); + + // not using res.respond + // because we don't want to send back notifications and other user-related data + res.status(200).send({}); + }, +}; + +export default api; diff --git a/website/server/controllers/top-level/payments/paypal.js b/website/server/controllers/top-level/payments/paypal.js index ba3eaf64b3..1432ceea90 100644 --- a/website/server/controllers/top-level/payments/paypal.js +++ b/website/server/controllers/top-level/payments/paypal.js @@ -63,7 +63,7 @@ api.checkoutSuccess = { if (!customerId) throw new BadRequest(apiError('missingCustomerId')); await paypalPayments.checkoutSuccess({ - user, gemsBlock, gift, paymentId, customerId, + user, gemsBlock, gift, paymentId, customerId, headers: req.headers, }); if (req.query.noRedirect) { diff --git a/website/server/controllers/top-level/payments/stripe.js b/website/server/controllers/top-level/payments/stripe.js index 4a01108306..fec9230524 100644 --- a/website/server/controllers/top-level/payments/stripe.js +++ b/website/server/controllers/top-level/payments/stripe.js @@ -35,7 +35,7 @@ api.checkout = { const { groupId, coupon, gemsBlock } = req.query; await stripePayments.checkout({ - token, user, gemsBlock, gift, sub, groupId, coupon, + token, user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers, }); res.respond(200, {}); diff --git a/website/server/libs/analyticsService.js b/website/server/libs/analyticsService.js index 45de7fef6c..f990b60a9d 100644 --- a/website/server/libs/analyticsService.js +++ b/website/server/libs/analyticsService.js @@ -174,7 +174,7 @@ function _formatDataForAmplitude (data) { return ampData; } -function _sendDataToAmplitude (eventType, data) { +function _sendDataToAmplitude (eventType, data, loggerOnly) { const amplitudeData = _formatDataForAmplitude(data); amplitudeData.event_type = eventType; @@ -183,6 +183,8 @@ function _sendDataToAmplitude (eventType, data) { logger.info('Amplitude Event', amplitudeData); } + if (loggerOnly) return Promise.resolve(null); + return amplitude .track(amplitudeData) .catch(err => logger.error(err, 'Error while sending data to Amplitude.')); @@ -312,9 +314,9 @@ function _setOnce (dataToSetOnce, uuid) { } // There's no error handling directly here because it's handled inside _sendDataTo{Amplitude|Google} -async function track (eventType, data) { +async function track (eventType, data, loggerOnly = false) { const promises = [ - _sendDataToAmplitude(eventType, data), + _sendDataToAmplitude(eventType, data, loggerOnly), _sendDataToGoogle(eventType, data), ]; if (data.user && data.user.registeredThrough) { diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 67804c705e..f8755f7337 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -225,7 +225,7 @@ function trackCronAnalytics (analytics, user, _progress, options) { user, questName: user.party.quest.key, headers: options.headers, - }); + }, true); } }