diff --git a/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.js b/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.js index bbe9058e5c..ed79e1f17d 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.js +++ b/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.js @@ -1,6 +1,5 @@ -const RefererParser = require('referer-parser') -const { URL } = require('url') const AnalyticsManager = require('./AnalyticsManager') +const RequestHelper = require('./RequestHelper') function clearSource(session) { if (session) { @@ -9,57 +8,10 @@ function clearSource(session) { } } -const UTM_KEYS = [ - 'utm_campaign', - 'utm_source', - 'utm_term', - 'utm_medium', - 'utm_count', -] - -function parseUtm(query) { - const utmValues = {} - for (const utmKey of UTM_KEYS) { - if (query[utmKey]) { - utmValues[utmKey] = query[utmKey] - } - } - return Object.keys(utmValues).length > 0 ? utmValues : null -} - -function parseReferrer(referrer, url) { - if (!referrer) { - return { - medium: 'direct', - } - } - - const parsedReferrer = new RefererParser(referrer, url) - - const referrerValues = { - medium: parsedReferrer.medium, - source: parsedReferrer.referer || 'other', - } - - if (referrerValues.medium === 'unknown') { - try { - const referrerHostname = new URL(referrer).hostname - if (referrerHostname) { - referrerValues.medium = 'link' - referrerValues.source = referrerHostname - } - } catch (error) { - // ignore referrer parsing errors - } - } - - return referrerValues -} - function setInbound(session, url, query, referrer) { const inboundSession = { - referrer: parseReferrer(referrer, url), - utm: parseUtm(query), + referrer: RequestHelper.parseReferrer(referrer, url), + utm: RequestHelper.parseUtm(query), } if (inboundSession.referrer || inboundSession.utm) { @@ -123,7 +75,7 @@ function addUserProperties(userId, session) { } if (session.inbound.utm) { - for (const utmKey of UTM_KEYS) { + for (const utmKey of RequestHelper.UTM_KEYS) { if (session.inbound.utm[utmKey]) { AnalyticsManager.setUserPropertyForUser( userId, diff --git a/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js b/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js index b1c0571355..33116579ea 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js +++ b/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js @@ -29,7 +29,7 @@ function setInbound() { } if (SessionManager.isUserLoggedIn(req.session)) { - return next() // don't store referrer if user is alread logged in + return next() // don't store referrer if user is already logged in } const referrer = req.header('referrer') diff --git a/services/web/app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware.js b/services/web/app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware.js new file mode 100644 index 0000000000..3bef84130f --- /dev/null +++ b/services/web/app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware.js @@ -0,0 +1,61 @@ +const _ = require('lodash') +const RequestHelper = require('./RequestHelper') +const AnalyticsManager = require('./AnalyticsManager') +const querystring = require('querystring') +const { URL } = require('url') +const Settings = require('@overleaf/settings') +const OError = require('@overleaf/o-error') +const logger = require('logger-sharelatex') + +function recordUTMTags() { + return function (req, res, next) { + const query = req.query + + try { + const utmValues = RequestHelper.parseUtm(query) + + if (utmValues) { + const path = new URL(req.url, Settings.siteUrl).pathname + + AnalyticsManager.recordEventForSession(req.session, 'page-view', { + path, + ...utmValues, + }) + + const propertyValue = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + ] + .map(tag => utmValues[tag] || 'N/A') + .join(';') + AnalyticsManager.setUserPropertyForSession( + req.session, + 'utm-tags', + propertyValue + ) + + // redirect to URL without UTM query params + const queryWithoutUtm = _.omit(query, RequestHelper.UTM_KEYS) + const queryString = + Object.keys(queryWithoutUtm).length > 0 + ? '?' + querystring.stringify(queryWithoutUtm) + : '' + return res.redirect(path + queryString) + } + } catch (error) { + // log errors and fail silently + OError.tag(error, 'failed to track UTM tags', { + query, + }) + logger.warn({ error }, error.message) + } + + next() + } +} + +module.exports = { + recordUTMTags, +} diff --git a/services/web/app/src/Features/Analytics/RequestHelper.js b/services/web/app/src/Features/Analytics/RequestHelper.js new file mode 100644 index 0000000000..cf07bf55a7 --- /dev/null +++ b/services/web/app/src/Features/Analytics/RequestHelper.js @@ -0,0 +1,55 @@ +const RefererParser = require('referer-parser') +const { URL } = require('url') + +const UTM_KEYS = [ + 'utm_campaign', + 'utm_source', + 'utm_term', + 'utm_medium', + 'utm_count', +] + +function parseUtm(query) { + const utmValues = {} + for (const utmKey of UTM_KEYS) { + if (query[utmKey]) { + utmValues[utmKey] = query[utmKey] + } + } + return Object.keys(utmValues).length > 0 ? utmValues : null +} + +function parseReferrer(referrer, url) { + if (!referrer) { + return { + medium: 'direct', + } + } + + const parsedReferrer = new RefererParser(referrer, url) + + const referrerValues = { + medium: parsedReferrer.medium, + source: parsedReferrer.referer || 'other', + } + + if (referrerValues.medium === 'unknown') { + try { + const referrerHostname = new URL(referrer).hostname + if (referrerHostname) { + referrerValues.medium = 'link' + referrerValues.source = referrerHostname + } + } catch (error) { + // ignore referrer parsing errors + } + } + + return referrerValues +} + +module.exports = { + UTM_KEYS, + parseUtm, + parseReferrer, +} diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 34144a55ed..99b9d29400 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -50,6 +50,7 @@ const InstitutionsController = require('./Features/Institutions/InstitutionsCont const UserMembershipRouter = require('./Features/UserMembership/UserMembershipRouter') const SystemMessageController = require('./Features/SystemMessages/SystemMessageController') const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware') +const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware') const { Joi, validate } = require('./infrastructure/Validation') const { renderUnsupportedBrowserPage, @@ -69,6 +70,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { } webRouter.get('*', AnalyticsRegistrationSourceMiddleware.setInbound()) + webRouter.get('*', AnalyticsUTMTrackingMiddleware.recordUTMTags()) webRouter.get('/login', UserPagesController.loginPage) AuthenticationController.addEndpointToLoginWhitelist('/login') diff --git a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddlewareTests.js b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddlewareTests.js new file mode 100644 index 0000000000..ad49450ffc --- /dev/null +++ b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddlewareTests.js @@ -0,0 +1,195 @@ +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const sinon = require('sinon') +const MockRequest = require('../helpers/MockRequest') +const MockResponse = require('../helpers/MockResponse') +const { assert } = require('chai') + +const MODULE_PATH = path.join( + __dirname, + '../../../../app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware' +) + +describe('AnalyticsUTMTrackingMiddleware', function () { + beforeEach(function () { + this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55' + this.userId = '61795fcb013504bb7b663092' + + this.req = new MockRequest() + this.res = new MockResponse() + this.next = sinon.stub().returns() + this.req.session = { + user: { + _id: this.userId, + analyticsId: this.analyticsId, + }, + } + + this.AnalyticsUTMTrackingMiddleware = SandboxedModule.require(MODULE_PATH, { + requires: { + './AnalyticsManager': (this.AnalyticsManager = { + recordEventForSession: sinon.stub().resolves(), + setUserPropertyForSession: sinon.stub().resolves(), + }), + '@overleaf/settings': { + siteUrl: 'https://www.overleaf.com', + }, + }, + }) + + this.middleware = this.AnalyticsUTMTrackingMiddleware.recordUTMTags() + }) + + describe('without UTM tags in query', function () { + beforeEach(function () { + this.req.url = '/project' + this.middleware(this.req, this.res, this.next) + }) + + it('user is not redirected', function () { + assert.isFalse(this.res.redirected) + }) + + it('next middleware is executed', function () { + sinon.assert.calledOnce(this.next) + }) + + it('no event or user property is recorded', function () { + sinon.assert.notCalled(this.AnalyticsManager.recordEventForSession) + sinon.assert.notCalled(this.AnalyticsManager.setUserPropertyForSession) + }) + }) + + describe('with all UTM tags in query', function () { + beforeEach(function () { + this.req.url = + '/project?utm_source=Organic&utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_term=foo-bar' + this.req.query = { + utm_source: 'Organic', + utm_medium: 'Facebook', + utm_campaign: 'Some Campaign', + utm_term: 'foo-bar', + } + this.middleware(this.req, this.res, this.next) + }) + + it('user is redirected', function () { + assert.isTrue(this.res.redirected) + assert.equal('/project', this.res.redirectedTo) + }) + + it('next middleware is not executed', function () { + sinon.assert.notCalled(this.next) + }) + + it('page-view event is recorded for session', function () { + sinon.assert.calledWith( + this.AnalyticsManager.recordEventForSession, + this.req.session, + 'page-view', + { + path: '/project', + utm_source: 'Organic', + utm_medium: 'Facebook', + utm_campaign: 'Some Campaign', + utm_term: 'foo-bar', + } + ) + }) + + it('utm-tags user property is set for session', function () { + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForSession, + this.req.session, + 'utm-tags', + 'Organic;Facebook;Some Campaign;foo-bar' + ) + }) + }) + + describe('with some UTM tags in query', function () { + beforeEach(function () { + this.req.url = '/project?utm_medium=Facebook&utm_campaign=Some%20Campaign' + this.req.query = { + utm_medium: 'Facebook', + utm_campaign: 'Some Campaign', + } + this.middleware(this.req, this.res, this.next) + }) + + it('user is redirected', function () { + assert.isTrue(this.res.redirected) + assert.equal('/project', this.res.redirectedTo) + }) + + it('next middleware is not executed', function () { + sinon.assert.notCalled(this.next) + }) + + it('page-view event is recorded for session', function () { + sinon.assert.calledWith( + this.AnalyticsManager.recordEventForSession, + this.req.session, + 'page-view', + { + path: '/project', + utm_medium: 'Facebook', + utm_campaign: 'Some Campaign', + } + ) + }) + + it('utm-tags user property is set for session', function () { + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForSession, + this.req.session, + 'utm-tags', + 'N/A;Facebook;Some Campaign;N/A' + ) + }) + }) + + describe('with some UTM tags and additional parameters in query', function () { + beforeEach(function () { + this.req.url = + '/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&other_param=some-value' + this.req.query = { + utm_medium: 'Facebook', + utm_campaign: 'Some Campaign', + other_param: 'some-value', + } + this.middleware(this.req, this.res, this.next) + }) + + it('user is redirected', function () { + assert.isTrue(this.res.redirected) + assert.equal('/project?other_param=some-value', this.res.redirectedTo) + }) + + it('next middleware is not executed', function () { + sinon.assert.notCalled(this.next) + }) + + it('page-view event is recorded for session', function () { + sinon.assert.calledWith( + this.AnalyticsManager.recordEventForSession, + this.req.session, + 'page-view', + { + path: '/project', + utm_medium: 'Facebook', + utm_campaign: 'Some Campaign', + } + ) + }) + + it('utm-tags user property is set for session', function () { + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForSession, + this.req.session, + 'utm-tags', + 'N/A;Facebook;Some Campaign;N/A' + ) + }) + }) +})