mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 23:14:04 -05:00
Merge pull request #5595 from overleaf/ab-utm-tracking
UTM Tracking GitOrigin-RevId: 9fff6dad166875c94dbfad80770e9ad32f883418
This commit is contained in:
parent
bd8c6608c0
commit
69c751ce39
6 changed files with 311 additions and 53 deletions
|
@ -1,6 +1,5 @@
|
||||||
const RefererParser = require('referer-parser')
|
|
||||||
const { URL } = require('url')
|
|
||||||
const AnalyticsManager = require('./AnalyticsManager')
|
const AnalyticsManager = require('./AnalyticsManager')
|
||||||
|
const RequestHelper = require('./RequestHelper')
|
||||||
|
|
||||||
function clearSource(session) {
|
function clearSource(session) {
|
||||||
if (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) {
|
function setInbound(session, url, query, referrer) {
|
||||||
const inboundSession = {
|
const inboundSession = {
|
||||||
referrer: parseReferrer(referrer, url),
|
referrer: RequestHelper.parseReferrer(referrer, url),
|
||||||
utm: parseUtm(query),
|
utm: RequestHelper.parseUtm(query),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inboundSession.referrer || inboundSession.utm) {
|
if (inboundSession.referrer || inboundSession.utm) {
|
||||||
|
@ -123,7 +75,7 @@ function addUserProperties(userId, session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.inbound.utm) {
|
if (session.inbound.utm) {
|
||||||
for (const utmKey of UTM_KEYS) {
|
for (const utmKey of RequestHelper.UTM_KEYS) {
|
||||||
if (session.inbound.utm[utmKey]) {
|
if (session.inbound.utm[utmKey]) {
|
||||||
AnalyticsManager.setUserPropertyForUser(
|
AnalyticsManager.setUserPropertyForUser(
|
||||||
userId,
|
userId,
|
||||||
|
|
|
@ -29,7 +29,7 @@ function setInbound() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SessionManager.isUserLoggedIn(req.session)) {
|
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')
|
const referrer = req.header('referrer')
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
const _ = require('lodash')
|
||||||
|
const { URL } = require('url')
|
||||||
|
const RequestHelper = require('./RequestHelper')
|
||||||
|
const AnalyticsManager = require('./AnalyticsManager')
|
||||||
|
const querystring = require('querystring')
|
||||||
|
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 pathname = new URL(req.url).pathname
|
||||||
|
|
||||||
|
AnalyticsManager.recordEventForSession(req.session, 'page-view', {
|
||||||
|
path: pathname,
|
||||||
|
...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)
|
||||||
|
: ''
|
||||||
|
res.redirect(pathname + 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,
|
||||||
|
}
|
55
services/web/app/src/Features/Analytics/RequestHelper.js
Normal file
55
services/web/app/src/Features/Analytics/RequestHelper.js
Normal file
|
@ -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,
|
||||||
|
}
|
|
@ -50,6 +50,7 @@ const InstitutionsController = require('./Features/Institutions/InstitutionsCont
|
||||||
const UserMembershipRouter = require('./Features/UserMembership/UserMembershipRouter')
|
const UserMembershipRouter = require('./Features/UserMembership/UserMembershipRouter')
|
||||||
const SystemMessageController = require('./Features/SystemMessages/SystemMessageController')
|
const SystemMessageController = require('./Features/SystemMessages/SystemMessageController')
|
||||||
const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware')
|
const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware')
|
||||||
|
const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware')
|
||||||
const { Joi, validate } = require('./infrastructure/Validation')
|
const { Joi, validate } = require('./infrastructure/Validation')
|
||||||
const {
|
const {
|
||||||
renderUnsupportedBrowserPage,
|
renderUnsupportedBrowserPage,
|
||||||
|
@ -69,6 +70,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||||
}
|
}
|
||||||
|
|
||||||
webRouter.get('*', AnalyticsRegistrationSourceMiddleware.setInbound())
|
webRouter.get('*', AnalyticsRegistrationSourceMiddleware.setInbound())
|
||||||
|
webRouter.get('*', AnalyticsUTMTrackingMiddleware.recordUTMTags())
|
||||||
|
|
||||||
webRouter.get('/login', UserPagesController.loginPage)
|
webRouter.get('/login', UserPagesController.loginPage)
|
||||||
AuthenticationController.addEndpointToLoginWhitelist('/login')
|
AuthenticationController.addEndpointToLoginWhitelist('/login')
|
||||||
|
|
|
@ -0,0 +1,189 @@
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.middleware = this.AnalyticsUTMTrackingMiddleware.recordUTMTags()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('without UTM tags in query', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
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 = 'https://www.overleaf.com/project'
|
||||||
|
this.req.query = {
|
||||||
|
utm_source: 'Organic',
|
||||||
|
utm_medium: 'Horizon',
|
||||||
|
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 executed', function () {
|
||||||
|
sinon.assert.calledOnce(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: 'Horizon',
|
||||||
|
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;Horizon;Some Campaign;foo-bar'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with some UTM tags in query', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.req.url = 'https://www.overleaf.com/project'
|
||||||
|
this.req.query = {
|
||||||
|
utm_medium: 'Horizon',
|
||||||
|
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 executed', function () {
|
||||||
|
sinon.assert.calledOnce(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: 'Horizon',
|
||||||
|
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;Horizon;Some Campaign;N/A'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with some UTM tags and additional parameters in query', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.req.url = 'https://www.overleaf.com/project'
|
||||||
|
this.req.query = {
|
||||||
|
utm_medium: 'Horizon',
|
||||||
|
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 executed', function () {
|
||||||
|
sinon.assert.calledOnce(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: 'Horizon',
|
||||||
|
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;Horizon;Some Campaign;N/A'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue