diff --git a/package-lock.json b/package-lock.json index 6ea4acc3a0..04540a4550 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "habitica", - "version": "4.166.0", + "version": "4.166.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b3619c37a8..32fafa4b83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "habitica", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "4.166.0", + "version": "4.166.2", "main": "./website/server/index.js", "dependencies": { "@babel/core": "^7.12.3", 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/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;