[web] avoid UserAnalyticsIdCache (#33121)

* [web] always provide an analyticsId when registering new users

* [web] add recordEventForMongoUser and use it during login

* [web] use recordEventForMongoUser/recordEventForSession where possible

* [web] use session cache for analyticsId when refreshing features

* [web] add setUserPropertyForMongoUser and use it where possible

* [web] use setUserPropertyForSessionInBackground where possible

* [web] use mongo user for analyticsId when setting utm user properties

* [web] admin-panel: user updated user when sending events

* [web] enforce labsProgram being populated on user object

Co-authored-by: Andrew Rumble <andrew.rumble@overleaf.com>

* [web] revert FeaturesUpdater.refreshFeatures change

* [web] labs: move session maintenance into handler

* [web] analyticsMiddleware: ensure that all users have labsProgram field

* [web] migrate a few more events to recordEventForSession

* [migrations] mark back-fill of db.users.analyticsId as required

* [web] setUserPropertyForSessionInBackground: throw early

---------

Co-authored-by: Andrew Rumble <andrew.rumble@overleaf.com>
GitOrigin-RevId: 19c26bc36abd8c11f0a8f458d25abfb7cbda0bb7
This commit is contained in:
Jakob Ackermann
2026-06-17 08:08:32 +00:00
committed by Copybot
co-authored by Andrew Rumble
parent 7035a2e025
commit 6323fddbd8
37 changed files with 320 additions and 184 deletions
@@ -82,7 +82,7 @@ async function recordEventForUser(userId, event, segmentation) {
userId, userId,
event, event,
segmentation, segmentation,
isLabsUser: Boolean(labsProgram), isLabsUser: labsProgram,
isLoggedIn: true, isLoggedIn: true,
}) })
} }
@@ -97,6 +97,31 @@ function recordEventForUserInBackground(userId, event, segmentation) {
}) })
} }
async function recordEventForMongoUser(user, event, segmentation) {
const { userId, analyticsId, labsProgram } = _getIdsFromMongoUser(user)
if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
return
}
_recordEvent({
analyticsId,
userId,
event,
segmentation,
isLabsUser: labsProgram,
isLoggedIn: true,
})
}
function recordEventForMongoUserInBackground(user, event, segmentation) {
const { userId } = _getIdsFromMongoUser(user) // throw before going into the background.
recordEventForMongoUser(user, event, segmentation).catch(err => {
logger.warn(
{ err, userId, event, segmentation },
'failed to record event for user'
)
})
}
function recordEventForSession(session, event, segmentation) { function recordEventForSession(session, event, segmentation) {
const { analyticsId, userId } = getIdsFromSession(session) const { analyticsId, userId } = getIdsFromSession(session)
if (!analyticsId) { if (!analyticsId) {
@@ -142,7 +167,7 @@ async function setUserPropertyForUser(userId, propertyName, propertyValue) {
if (analyticsId) { if (analyticsId) {
await _setUserProperty({ await _setUserProperty({
analyticsId, analyticsId,
isLabsUser: Boolean(labsProgram), isLabsUser: labsProgram,
propertyName, propertyName,
propertyValue, propertyValue,
}) })
@@ -158,6 +183,41 @@ function setUserPropertyForUserInBackground(userId, property, value) {
}) })
} }
/**
* @param {{_id: ObjectId, analyticsId: string, labsProgram: boolean}} user
* @param {string} propertyName
* @param {any} propertyValue
* @return {Promise<void>}
*/
async function setUserPropertyForMongoUser(user, propertyName, propertyValue) {
const { userId, analyticsId, labsProgram } = _getIdsFromMongoUser(user)
if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
return
}
_checkPropertyValue(propertyValue)
await _setUserProperty({
analyticsId,
isLabsUser: labsProgram,
propertyName,
propertyValue,
})
}
/**
* @param {{_id: ObjectId, analyticsId: string, labsProgram: boolean}} user
* @param {string} property
* @param {any} value
*/
function setUserPropertyForMongoUserInBackground(user, property, value) {
const { userId } = _getIdsFromMongoUser(user) // throw before going into the background.
setUserPropertyForMongoUser(user, property, value).catch(err => {
logger.warn(
{ err, userId, property, value },
'failed to set user property for user'
)
})
}
async function setUserPropertyForAnalyticsId( async function setUserPropertyForAnalyticsId(
analyticsId, analyticsId,
propertyName, propertyName,
@@ -192,8 +252,8 @@ async function setUserPropertyForSession(session, propertyName, propertyValue) {
} }
function setUserPropertyForSessionInBackground(session, property, value) { function setUserPropertyForSessionInBackground(session, property, value) {
const { analyticsId, userId } = getIdsFromSession(session) // throw before going into the background.
setUserPropertyForSession(session, property, value).catch(err => { setUserPropertyForSession(session, property, value).catch(err => {
const { analyticsId, userId } = getIdsFromSession(session)
logger.warn( logger.warn(
{ err, analyticsId, userId, property, value }, { err, analyticsId, userId, property, value },
'failed to set user property for session' 'failed to set user property for session'
@@ -452,6 +512,26 @@ function _isAnalyticsDisabled() {
return !(Settings.analytics && Settings.analytics.enabled) return !(Settings.analytics && Settings.analytics.enabled)
} }
/**
* @param {{_id: ObjectId, analyticsId: string, labsProgram: boolean}} user
* @return {{userId: string, analyticsId: string, labsProgram: boolean}}
*/
function _getIdsFromMongoUser(user) {
const userId = user?._id?.toString()
if (!userId) {
throw new Error('bug: include db.users._id in projection')
}
const analyticsId = user?.analyticsId
if (!analyticsId) {
throw new Error('bug: include db.users.analyticsId in projection')
}
const labsProgram = user?.labsProgram
if (typeof labsProgram !== 'boolean') {
throw new Error('bug: include db.users.labsProgram in projection')
}
return { userId, analyticsId, labsProgram }
}
function _checkPropertyValue(propertyValue) { function _checkPropertyValue(propertyValue) {
if (propertyValue === undefined) { if (propertyValue === undefined) {
throw new Error( throw new Error(
@@ -498,13 +578,15 @@ async function analyticsIdMiddleware(req, res, next) {
if (sessionUser) { if (sessionUser) {
// For old sessions, session.analyticsId is the anon id immediately after login. Do not use it! // For old sessions, session.analyticsId is the anon id immediately after login. Do not use it!
session.analyticsId = sessionUser.analyticsId session.analyticsId = sessionUser.analyticsId
if (!session.analyticsId) { if (!session.analyticsId || typeof sessionUser.labsProgram !== 'boolean') {
session.analyticsId = sessionUser.analyticsId = const { analyticsId, labsProgram } =
await UserAnalyticsDataCache.getAnalyticsId( await UserAnalyticsDataCache.getAnalyticsData(
sessionUser._id, sessionUser._id,
// Do not drill down further, this middleware is on all endpoints. // Do not drill down further, this middleware is on all endpoints.
'analyticsIdMiddleware' 'analyticsIdMiddleware'
) )
session.analyticsId = sessionUser.analyticsId = analyticsId
sessionUser.labsProgram = labsProgram
} }
} else if (!session.analyticsId) { } else if (!session.analyticsId) {
// generate an `analyticsId` if needed // generate an `analyticsId` if needed
@@ -521,9 +603,13 @@ export default {
recordEventForSession, recordEventForSession,
recordEventForUser, recordEventForUser,
recordEventForUserInBackground, recordEventForUserInBackground,
recordEventForMongoUser,
recordEventForMongoUserInBackground,
emitPackageUsage, emitPackageUsage,
setUserPropertyForUser, setUserPropertyForUser,
setUserPropertyForUserInBackground, setUserPropertyForUserInBackground,
setUserPropertyForMongoUser,
setUserPropertyForMongoUserInBackground,
setUserPropertyForSession, setUserPropertyForSession,
setUserPropertyForSessionInBackground, setUserPropertyForSessionInBackground,
setUserPropertyForAnalyticsId, setUserPropertyForAnalyticsId,
@@ -25,32 +25,32 @@ function clearInbound(session) {
} }
} }
function addUserProperties(userId, session) { function addUserProperties(user, session) {
if (!session) { if (!session) {
return return
} }
if (session.required_login_from_product_medium) { if (session.required_login_from_product_medium) {
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-product-medium`, `registered-from-product-medium`,
session.required_login_from_product_medium session.required_login_from_product_medium
) )
if (session.required_login_from_product_source) { if (session.required_login_from_product_source) {
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-product-source`, `registered-from-product-source`,
session.required_login_from_product_source session.required_login_from_product_source
) )
} }
} else if (session.referal_id) { } else if (session.referal_id) {
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-bonus-scheme`, `registered-from-bonus-scheme`,
true true
) )
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-product-medium`, `registered-from-product-medium`,
'bonus-scheme' 'bonus-scheme'
) )
@@ -58,16 +58,16 @@ function addUserProperties(userId, session) {
if (session.inbound) { if (session.inbound) {
if (session.inbound.referrer && session.inbound.referrer.medium) { if (session.inbound.referrer && session.inbound.referrer.medium) {
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-referrer-medium`, `registered-from-referrer-medium`,
`${session.inbound.referrer.medium `${session.inbound.referrer.medium
.charAt(0) .charAt(0)
.toUpperCase()}${session.inbound.referrer.medium.slice(1)}` .toUpperCase()}${session.inbound.referrer.medium.slice(1)}`
) )
if (session.inbound.referrer.source) { if (session.inbound.referrer.source) {
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-referrer-source`, `registered-from-referrer-source`,
session.inbound.referrer.source session.inbound.referrer.source
) )
@@ -77,8 +77,8 @@ function addUserProperties(userId, session) {
if (session.inbound.utm) { if (session.inbound.utm) {
for (const utmKey of RequestHelper.REGISTRATION_UTM_KEYS) { for (const utmKey of RequestHelper.REGISTRATION_UTM_KEYS) {
if (session.inbound.utm[utmKey]) { if (session.inbound.utm[utmKey]) {
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
`registered-from-${utmKey.replace('_', '-')}`, `registered-from-${utmKey.replace('_', '-')}`,
session.inbound.utm[utmKey] session.inbound.utm[utmKey]
) )
@@ -684,7 +684,7 @@ function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) {
LoginRateLimiter.recordSuccessfulLogin(user.email, () => {}) LoginRateLimiter.recordSuccessfulLogin(user.email, () => {})
AuthenticationController._recordSuccessfulLogin(user._id, () => {}) AuthenticationController._recordSuccessfulLogin(user._id, () => {})
AuthenticationController.ipMatchCheck(req, user) AuthenticationController.ipMatchCheck(req, user)
Analytics.recordEventForUserInBackground(user._id, 'user-logged-in', { Analytics.recordEventForMongoUserInBackground(user, 'user-logged-in', {
source: req.session.saml source: req.session.saml
? 'saml' ? 'saml'
: req.user_info?.auth_provider || 'email-password', : req.user_info?.auth_provider || 'email-password',
@@ -8,7 +8,7 @@ import { expressify } from '@overleaf/promise-utils'
async function optIn(req, res) { async function optIn(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
await BetaProgramHandler.promises.optIn(userId) await BetaProgramHandler.promises.optIn(req.session, userId)
try { try {
await SplitTestSessionHandler.promises.sessionMaintenance(req, null) await SplitTestSessionHandler.promises.sessionMaintenance(req, null)
} catch (error) { } catch (error) {
@@ -22,7 +22,7 @@ async function optIn(req, res) {
async function optOut(req, res) { async function optOut(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
await BetaProgramHandler.promises.optOut(userId) await BetaProgramHandler.promises.optOut(req.session, userId)
try { try {
await SplitTestSessionHandler.promises.sessionMaintenance(req, null) await SplitTestSessionHandler.promises.sessionMaintenance(req, null)
} catch (error) { } catch (error) {
@@ -3,23 +3,23 @@ import metrics from '@overleaf/metrics'
import UserUpdater from '../User/UserUpdater.mjs' import UserUpdater from '../User/UserUpdater.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
async function optIn(userId) { async function optIn(session, userId) {
await UserUpdater.promises.updateUser(userId, { $set: { betaProgram: true } }) await UserUpdater.promises.updateUser(userId, { $set: { betaProgram: true } })
metrics.inc('beta-program.opt-in') metrics.inc('beta-program.opt-in')
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForSessionInBackground(
userId, session,
'beta-program', 'beta-program',
true true
) )
} }
async function optOut(userId) { async function optOut(session, userId) {
await UserUpdater.promises.updateUser(userId, { await UserUpdater.promises.updateUser(userId, {
$set: { betaProgram: false }, $set: { betaProgram: false },
}) })
metrics.inc('beta-program.opt-out') metrics.inc('beta-program.opt-out')
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForSessionInBackground(
userId, session,
'beta-program', 'beta-program',
false false
) )
@@ -493,17 +493,13 @@ async function acceptInvite(req, res) {
} else if (invite.privileges === PrivilegeLevels.READ_ONLY) { } else if (invite.privileges === PrivilegeLevels.READ_ONLY) {
editMode = 'view' editMode = 'view'
} }
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(req.session, 'project-joined', {
currentUser._id, projectId,
'project-joined', ownerId: invite.sendingUserId, // only owner can invite others
{ mode: editMode,
projectId, role: invite.privileges,
ownerId: invite.sendingUserId, // only owner can invite others source: urlToken ? 'email-invite' : 'sharing-link',
mode: editMode, })
role: invite.privileges,
source: urlToken ? 'email-invite' : 'sharing-link',
}
)
if (req.xhr) { if (req.xhr) {
res.sendStatus(204) // Done async via project page notification res.sendStatus(204) // Done async via project page notification
@@ -98,14 +98,14 @@ async function exportProjectConversion(req, res) {
type, type,
{ compileFromHistory, rootResourcePath } { compileFromHistory, rootResourcePath }
) )
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', { AnalyticsManager.recordEventForSession(req.session, 'convert-format', {
sourceFormat: 'latex', sourceFormat: 'latex',
targetFormat: type, targetFormat: type,
status: 'success', status: 'success',
operation: 'export', operation: 'export',
}) })
} catch (error) { } catch (error) {
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', { AnalyticsManager.recordEventForSession(req.session, 'convert-format', {
sourceFormat: 'latex', sourceFormat: 'latex',
targetFormat: type, targetFormat: type,
status: 'failure', status: 'failure',
@@ -82,13 +82,9 @@ async function createLinkedFile(req, res, next) {
userId userId
) )
if (name.endsWith('.bib')) { if (name.endsWith('.bib')) {
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(req.session, 'linked-bib-file', {
userId, integration: provider,
'linked-bib-file', })
{
integration: provider,
}
)
} }
return res.json({ new_file_id: newFileId }) return res.json({ new_file_id: newFileId })
} catch (err) { } catch (err) {
@@ -756,8 +756,8 @@ const _ProjectController = {
planLimit, planLimit,
exceedAtLimit, exceedAtLimit,
} }
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(
userId, req.session,
'project-opened', 'project-opened',
projectOpenedSegmentation projectOpenedSegmentation
) )
@@ -843,8 +843,8 @@ const _ProjectController = {
userIsMemberOfGroupSubscription userIsMemberOfGroupSubscription
) )
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForSessionInBackground(
userId, req.session,
'customer-io-integration', 'customer-io-integration',
true true
) )
@@ -273,8 +273,8 @@ async function projectListPage(req, res, next) {
customerIoEnabled = true customerIoEnabled = true
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForSessionInBackground(
userId, req.session,
'customer-io-integration', 'customer-io-integration',
true true
) )
@@ -39,10 +39,14 @@ function featuresEpochIsCurrent(user) {
/** /**
* Refresh features for the given user * Refresh features for the given user
* @param {string} userId
* @param {string} reason
*/ */
async function refreshFeatures(userId, reason) { async function refreshFeatures(userId, reason) {
const user = await UserGetter.promises.getUser(userId, { const user = await UserGetter.promises.getUser(userId, {
_id: 1, _id: 1,
analyticsId: 1,
labsProgram: 1,
features: 1, features: 1,
email: 1, email: 1,
}) })
@@ -51,8 +55,8 @@ async function refreshFeatures(userId, reason) {
logger.debug({ userId, features, reason }, 'updating user features') logger.debug({ userId, features, reason }, 'updating user features')
const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(features) const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(features)
AnalyticsManager.setUserPropertyForUserInBackground( AnalyticsManager.setUserPropertyForMongoUserInBackground(
userId, user,
'feature-set', 'feature-set',
matchedFeatureSet matchedFeatureSet
) )
@@ -427,8 +427,8 @@ async function pauseSubscription(req, res, next) {
const { subscription } = const { subscription } =
await LimitationsManager.promises.userHasSubscription(user) await LimitationsManager.promises.userHasSubscription(user)
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(
user._id, req.session,
'subscription-pause-scheduled', 'subscription-pause-scheduled',
{ {
pause_length: pauseCycles, pause_length: pauseCycles,
@@ -342,7 +342,7 @@ async function grantTokenAccessReadAndWrite(req, res, next) {
...(pendingEditor && { pendingEditor: true }), ...(pendingEditor && { pendingEditor: true }),
} }
) )
AnalyticsManager.recordEventForUserInBackground(userId, 'project-joined', { AnalyticsManager.recordEventForSession(req.session, 'project-joined', {
role: pendingEditor role: pendingEditor
? PrivilegeLevels.READ_ONLY ? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE, : PrivilegeLevels.READ_AND_WRITE,
@@ -452,7 +452,8 @@ async function grantTokenAccessReadOnly(req, res, next) {
await TokenAccessHandler.promises.addReadOnlyUserToProject( await TokenAccessHandler.promises.addReadOnlyUserToProject(
userId, userId,
project._id, project._id,
project.owner_ref project.owner_ref,
req.session
) )
return res.json({ return res.json({
@@ -154,13 +154,13 @@ const TokenAccessHandler = {
throw new Error('invalid token type') throw new Error('invalid token type')
}, },
async addReadOnlyUserToProject(userId, projectId, ownerId) { async addReadOnlyUserToProject(userId, projectId, ownerId, session) {
if (!Features.hasFeature('link-sharing')) { if (!Features.hasFeature('link-sharing')) {
throw new Error('link sharing is disabled') throw new Error('link sharing is disabled')
} }
userId = new ObjectId(userId.toString()) userId = new ObjectId(userId.toString())
projectId = new ObjectId(projectId.toString()) projectId = new ObjectId(projectId.toString())
Analytics.recordEventForUserInBackground(userId, 'project-joined', { Analytics.recordEventForSession(session, 'project-joined', {
role: PrivilegeLevels.READ_ONLY, role: PrivilegeLevels.READ_ONLY,
projectId: projectId.toString(), projectId: projectId.toString(),
source: 'link-sharing', source: 'link-sharing',
@@ -210,16 +210,12 @@ async function importDocument(req, res, next) {
archivePath archivePath
) )
await ProjectOptionsHandler.promises.setCompiler(project._id, 'lualatex') await ProjectOptionsHandler.promises.setCompiler(project._id, 'lualatex')
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(req.session, 'convert-format', {
userId, sourceFormat: conversionType,
'convert-format', targetFormat: 'latex',
{ status: 'success',
sourceFormat: conversionType, operation: 'import',
targetFormat: 'latex', })
status: 'success',
operation: 'import',
}
)
res.json({ success: true, project_id: project._id }) res.json({ success: true, project_id: project._id })
} finally { } finally {
await fsPromises.unlink(archivePath).catch(unlinkErr => { await fsPromises.unlink(archivePath).catch(unlinkErr => {
@@ -230,7 +226,7 @@ async function importDocument(req, res, next) {
}) })
} }
} catch (error) { } catch (error) {
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', { AnalyticsManager.recordEventForSession(req.session, 'convert-format', {
sourceFormat: conversionType, sourceFormat: conversionType,
targetFormat: 'latex', targetFormat: 'latex',
status: 'failure', status: 'failure',
@@ -43,8 +43,8 @@ async function recordRegistrationEvent(user) {
if (user.thirdPartyIdentifiers && user.thirdPartyIdentifiers.length > 0) { if (user.thirdPartyIdentifiers && user.thirdPartyIdentifiers.length > 0) {
segmentation.provider = user.thirdPartyIdentifiers[0].providerId segmentation.provider = user.thirdPartyIdentifiers[0].providerId
} }
Analytics.recordEventForUserInBackground( Analytics.recordEventForMongoUserInBackground(
user._id, user,
'user-registered', 'user-registered',
segmentation segmentation
) )
@@ -110,11 +110,11 @@ async function createNewUser(attributes, options = {}) {
} }
await recordRegistrationEvent(user) await recordRegistrationEvent(user)
await Analytics.setUserPropertyForUser(user._id, 'created-at', new Date()) await Analytics.setUserPropertyForMongoUser(user, 'created-at', new Date())
await Analytics.setUserPropertyForUser(user._id, 'user-id', user._id) await Analytics.setUserPropertyForMongoUser(user, 'user-id', user._id)
if (attributes.analyticsId) { if (attributes.analyticsId) {
await Analytics.setUserPropertyForUser( await Analytics.setUserPropertyForMongoUser(
user._id, user,
'analytics-id', 'analytics-id',
attributes.analyticsId attributes.analyticsId
) )
@@ -247,15 +247,11 @@ const _checkConfirmationCode =
delete req.session[sessionKey] delete req.session[sessionKey]
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(req.session, 'email-verified', {
user._id, provider: 'email',
'email-verified', verification_type: 'token',
{ isPrimary: user.email === emailToCheck,
provider: 'email', })
verification_type: 'token',
isPrimary: user.email === emailToCheck,
}
)
const redirectUrl = const redirectUrl =
AuthenticationController.getRedirectFromSession(req) || '/project' AuthenticationController.getRedirectFromSession(req) || '/project'
@@ -391,16 +387,14 @@ const resendExistingSecondaryEmailConfirmationCode = _resendConfirmationCode(
) )
async function confirmSecondaryEmailPage(req, res) { async function confirmSecondaryEmailPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!req.session.pendingSecondaryEmail) { if (!req.session.pendingSecondaryEmail) {
const redirectURL = const redirectURL =
AuthenticationController.getRedirectFromSession(req) || '/project' AuthenticationController.getRedirectFromSession(req) || '/project'
return res.redirect(redirectURL) return res.redirect(redirectURL)
} }
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(
userId, req.session,
'confirm-secondary-email-page-displayed' 'confirm-secondary-email-page-displayed'
) )
@@ -421,8 +415,8 @@ async function addSecondaryEmailPage(req, res) {
return res.redirect(redirectURL) return res.redirect(redirectURL)
} }
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(
userId, req.session,
'add-secondary-email-page-displayed' 'add-secondary-email-page-displayed'
) )
@@ -442,8 +436,8 @@ async function primaryEmailCheckPage(req, res) {
return res.redirect('/project') return res.redirect('/project')
} }
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(
userId, req.session,
'primary-email-check-page-displayed' 'primary-email-check-page-displayed'
) )
@@ -456,8 +450,8 @@ async function primaryEmailCheck(req, res) {
$set: { lastPrimaryEmailCheck: new Date() }, $set: { lastPrimaryEmailCheck: new Date() },
}) })
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForSession(
userId, req.session,
'primary-email-check-done' 'primary-email-check-done'
) )
@@ -687,7 +681,7 @@ const UserEmailsController = {
} }
UserGetter.getUser( UserGetter.getUser(
userData.userId, userData.userId,
{ email: 1 }, { _id: 1, email: 1, analyticsId: 1, labsProgram: 1 },
function (error, user) { function (error, user) {
if (error) { if (error) {
logger.error( logger.error(
@@ -696,8 +690,8 @@ const UserEmailsController = {
) )
} }
const isPrimary = user?.email === userData.email const isPrimary = user?.email === userData.email
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForMongoUserInBackground(
userData.userId, user,
'email-verified', 'email-verified',
{ {
provider: 'email', provider: 'email',
@@ -14,24 +14,28 @@ async function schedulePostRegistrationAnalytics(user) {
} }
async function postRegistrationAnalytics(userId) { async function postRegistrationAnalytics(userId) {
const user = await UserGetter.promises.getUser({ _id: userId }, { email: 1 }) const user = await UserGetter.promises.getUser(
{ _id: userId },
{ email: 1, _id: 1, analyticsId: 1, labsProgram: 1 }
)
if (!user) { if (!user) {
return return
} }
await checkAffiliations(userId) await checkAffiliations(user)
} }
async function checkAffiliations(userId) { async function checkAffiliations(user) {
const affiliationsData = const affiliationsData = await InstitutionsAPI.promises.getUserAffiliations(
await InstitutionsAPI.promises.getUserAffiliations(userId) user._id.toString()
)
const hasCommonsAccountAffiliation = affiliationsData.some( const hasCommonsAccountAffiliation = affiliationsData.some(
affiliationData => affiliationData =>
affiliationData.institution && affiliationData.institution.commonsAccount affiliationData.institution && affiliationData.institution.commonsAccount
) )
if (hasCommonsAccountAffiliation) { if (hasCommonsAccountAffiliation) {
await AnalyticsManager.setUserPropertyForUser( await AnalyticsManager.setUserPropertyForMongoUser(
userId, user,
'registered-from-commons-account', 'registered-from-commons-account',
true true
) )
@@ -166,10 +166,11 @@ export default class TokenUsageRateLimiter {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {{ auditLogTool?: string }} [options] - if `auditLogTool` is set, an `ai-quota-breach` audit log entry is written with `{ tool }` in the info payload when the request is blocked * @param {{ auditLogTool?: string }} [options] - if `auditLogTool` is set, an `ai-quota-breach` audit log entry is written with `{ tool }` in the info payload when the request is blocked
*/ */
async checkUsage(userId, res, options = {}) { async checkUsage(userId, req, res, options = {}) {
const allowance = await this._getAllowance(userId) const allowance = await this._getAllowance(userId)
const currentUsage = await this.getCurrentUsage(userId) const currentUsage = await this.getCurrentUsage(userId)
const periodStart = currentUsage.periodStart ?? new Date() const periodStart = currentUsage.periodStart ?? new Date()
@@ -190,8 +191,8 @@ export default class TokenUsageRateLimiter {
) )
} }
await AnalyticsManager.recordEventForUser( AnalyticsManager.recordEventForSession(
userId, req.session,
'ai-token-usage-limit-exceeded' 'ai-token-usage-limit-exceeded'
) )
@@ -58,6 +58,8 @@ async function getIEEEUsers() {
_id: 1, _id: 1,
teamName: 1, teamName: 1,
'member_details._id': 1, 'member_details._id': 1,
'member_details.analyticsId': 1,
'member_details.labsProgram': 1,
'member_details.email': 1, 'member_details.email': 1,
'member_details.emails.email': 1, 'member_details.emails.email': 1,
}, },
@@ -122,8 +124,8 @@ async function main() {
index = index + 1 index = index + 1
if (COMMIT) { if (COMMIT) {
await AnalyticsManager.setUserPropertyForUser( await AnalyticsManager.setUserPropertyForMongoUser(
userDetails._id.toString(), userDetails,
'ieee-retirement', 'ieee-retirement',
true true
) )
@@ -438,6 +438,7 @@ describe('AnalyticsManager', function () {
ctx.req.session.user = { ctx.req.session.user = {
_id: ctx.userId, _id: ctx.userId,
analyticsId: ctx.analyticsId, analyticsId: ctx.analyticsId,
labsProgram: false,
} }
await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
assert.equal(ctx.analyticsId, ctx.req.session.analyticsId) assert.equal(ctx.analyticsId, ctx.req.session.analyticsId)
@@ -445,10 +446,14 @@ describe('AnalyticsManager', function () {
}) })
it('sets session.analyticsId with a legacy user session without an analyticsId', async function (ctx) { it('sets session.analyticsId with a legacy user session without an analyticsId', async function (ctx) {
ctx.UserAnalyticsDataCache.getAnalyticsId.resolves(ctx.userId) ctx.UserAnalyticsDataCache.getAnalyticsData.resolves({
analyticsId: ctx.userId,
labsProgram: false,
})
ctx.req.session.user = { ctx.req.session.user = {
_id: ctx.userId, _id: ctx.userId,
analyticsId: undefined, analyticsId: undefined,
labsProgram: false,
} }
await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
assert.equal(ctx.userId, ctx.req.session.analyticsId) assert.equal(ctx.userId, ctx.req.session.analyticsId)
@@ -456,10 +461,14 @@ describe('AnalyticsManager', function () {
}) })
it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function (ctx) { it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function (ctx) {
ctx.UserAnalyticsDataCache.getAnalyticsId.resolves(ctx.userId) ctx.UserAnalyticsDataCache.getAnalyticsData.resolves({
analyticsId: ctx.userId,
labsProgram: false,
})
ctx.req.session.user = { ctx.req.session.user = {
_id: ctx.userId, _id: ctx.userId,
analyticsId: undefined, analyticsId: undefined,
labsProgram: false,
} }
ctx.req.analyticsId = 'foo' ctx.req.analyticsId = 'foo'
ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
@@ -467,11 +476,30 @@ describe('AnalyticsManager', function () {
}) })
}) })
it('updates session.user.labsProgram when not defined', async function (ctx) {
ctx.UserAnalyticsDataCache.getAnalyticsData.resolves({
analyticsId: ctx.userId,
labsProgram: false,
})
ctx.req.session.user = {
_id: ctx.userId,
analyticsId: ctx.userId,
}
ctx.req.session.analyticsId = ctx.userId
ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
assert.equal(ctx.req.session.user.labsProgram, false)
})
})
it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function (ctx) { it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function (ctx) {
ctx.UserAnalyticsDataCache.getAnalyticsId.resolves(ctx.userId) ctx.UserAnalyticsDataCache.getAnalyticsData.resolves({
analyticsId: ctx.userId,
labsProgram: false,
})
ctx.req.session.user = { ctx.req.session.user = {
_id: ctx.userId, _id: ctx.userId,
analyticsId: undefined, analyticsId: undefined,
labsProgram: false,
} }
ctx.req.analyticsId = ctx.userId ctx.req.analyticsId = ctx.userId
await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
@@ -144,7 +144,7 @@ describe('AuthenticationController', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager', '../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ () => ({
default: (ctx.AnalyticsManager = { default: (ctx.AnalyticsManager = {
recordEventForUserInBackground: sinon.stub(), recordEventForMongoUserInBackground: sinon.stub(),
identifyUser: sinon.stub(), identifyUser: sinon.stub(),
getIdsFromSession: sinon.stub().returns({ userId: ctx.user._id }), getIdsFromSession: sinon.stub().returns({ userId: ctx.user._id }),
}), }),
@@ -1646,8 +1646,8 @@ describe('AuthenticationController', function () {
it('should track the login event', function (ctx) { it('should track the login event', function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.recordEventForUserInBackground, ctx.AnalyticsManager.recordEventForMongoUserInBackground,
ctx.user._id, ctx.user,
'user-logged-in' 'user-logged-in'
) )
}) })
@@ -34,11 +34,12 @@ describe('BetaProgramHandler', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager', '../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ () => ({
default: (ctx.AnalyticsManager = { default: (ctx.AnalyticsManager = {
setUserPropertyForUserInBackground: sinon.stub(), setUserPropertyForSessionInBackground: sinon.stub(),
}), }),
}) })
) )
ctx.session = {}
ctx.handler = (await import(modulePath)).default ctx.handler = (await import(modulePath)).default
}) })
@@ -46,7 +47,7 @@ describe('BetaProgramHandler', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.user.betaProgram = false ctx.user.betaProgram = false
ctx.call = callback => { ctx.call = callback => {
ctx.handler.optIn(ctx.user_id, callback) ctx.handler.optIn(ctx.session, ctx.user_id, callback)
} }
}) })
@@ -65,8 +66,8 @@ describe('BetaProgramHandler', function () {
ctx.call(err => { ctx.call(err => {
expect(err).to.not.exist expect(err).to.not.exist
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.setUserPropertyForUserInBackground, ctx.AnalyticsManager.setUserPropertyForSessionInBackground,
ctx.user_id, ctx.session,
'beta-program', 'beta-program',
true true
) )
@@ -105,7 +106,7 @@ describe('BetaProgramHandler', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.user.betaProgram = true ctx.user.betaProgram = true
ctx.call = callback => { ctx.call = callback => {
ctx.handler.optOut(ctx.user_id, callback) ctx.handler.optOut(ctx.session, ctx.user_id, callback)
} }
}) })
@@ -124,8 +125,8 @@ describe('BetaProgramHandler', function () {
ctx.call(err => { ctx.call(err => {
expect(err).to.not.exist expect(err).to.not.exist
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.setUserPropertyForUserInBackground, ctx.AnalyticsManager.setUserPropertyForSessionInBackground,
ctx.user_id, ctx.session,
'beta-program', 'beta-program',
false false
) )
@@ -51,7 +51,7 @@ describe('CollaboratorsInviteController', function () {
getSessionUser: sinon.stub().returns(ctx.currentUser), getSessionUser: sinon.stub().returns(ctx.currentUser),
} }
ctx.AnalyticsManger = { recordEventForUserInBackground: sinon.stub() } ctx.AnalyticsManger = { recordEventForSession: sinon.stub() }
ctx.rateLimiter = { ctx.rateLimiter = {
consume: sinon.stub().resolves(), consume: sinon.stub().resolves(),
@@ -1756,8 +1756,8 @@ describe('CollaboratorsInviteController', function () {
) )
}) })
ctx.AnalyticsManger.recordEventForUserInBackground.should.have.been.calledWith( ctx.AnalyticsManger.recordEventForSession.should.have.been.calledWith(
ctx.currentUser._id, ctx.req.session,
'project-joined', 'project-joined',
sinon.match({ source: 'sharing-link' }) sinon.match({ source: 'sharing-link' })
) )
@@ -104,6 +104,7 @@ describe('ProjectDownloadsController', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager.mjs', '../../../../app/src/Features/Analytics/AnalyticsManager.mjs',
() => ({ () => ({
default: (ctx.AnalyticsManager = { default: (ctx.AnalyticsManager = {
recordEventForSession: sinon.stub(),
recordEventForUserInBackground: sinon.stub(), recordEventForUserInBackground: sinon.stub(),
}), }),
}) })
@@ -369,8 +370,8 @@ describe('ProjectDownloadsController', function () {
it('should record a successful convert-format analytics event', function (ctx) { it('should record a successful convert-format analytics event', function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.recordEventForUserInBackground, ctx.AnalyticsManager.recordEventForSession,
ctx.userId, ctx.req.session,
'convert-format', 'convert-format',
{ {
sourceFormat: 'latex', sourceFormat: 'latex',
@@ -439,8 +439,10 @@ describe('ProjectController', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager', '../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ () => ({
default: { default: {
recordEventForSession: () => {},
recordEventForUserInBackground: () => {}, recordEventForUserInBackground: () => {},
setUserPropertyForUserInBackground: () => {}, setUserPropertyForUserInBackground: () => {},
setUserPropertyForSessionInBackground: () => {},
}, },
}) })
) )
@@ -13,6 +13,7 @@ vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.mjs', () => {
return { return {
default: { default: {
setUserPropertyForUserInBackground: () => {}, setUserPropertyForUserInBackground: () => {},
setUserPropertyForSessionInBackground: () => {},
}, },
} }
}) })
@@ -130,7 +130,7 @@ describe('FeaturesUpdater', function () {
.resolves(ctx.user) .resolves(ctx.user)
ctx.AnalyticsManager = { ctx.AnalyticsManager = {
setUserPropertyForUserInBackground: sinon.stub(), setUserPropertyForMongoUserInBackground: sinon.stub(),
} }
ctx.Modules = { ctx.Modules = {
promises: { hooks: { fire: sinon.stub().resolves([]) } }, promises: { hooks: { fire: sinon.stub().resolves([]) } },
@@ -443,8 +443,8 @@ describe('FeaturesUpdater', function () {
it('should send the corresponding feature set user property', function (ctx) { it('should send the corresponding feature set user property', function (ctx) {
expect( expect(
ctx.AnalyticsManager.setUserPropertyForUserInBackground ctx.AnalyticsManager.setUserPropertyForMongoUserInBackground
).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all') ).to.have.been.calledWith(ctx.user, 'feature-set', 'all')
}) })
it('should sync subscription properties to customer.io', function (ctx) { it('should sync subscription properties to customer.io', function (ctx) {
@@ -919,8 +919,8 @@ describe('FeaturesUpdater', function () {
it('should send mixed feature set user property', function (ctx) { it('should send mixed feature set user property', function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.setUserPropertyForUserInBackground, ctx.AnalyticsManager.setUserPropertyForMongoUserInBackground,
ctx.user._id, ctx.user,
'feature-set', 'feature-set',
'mixed' 'mixed'
) )
@@ -329,8 +329,8 @@ describe('TokenAccessController', function () {
it('records a project-joined event for the user', function (ctx) { it('records a project-joined event for the user', function (ctx) {
expect( expect(
ctx.AnalyticsManager.recordEventForUserInBackground ctx.AnalyticsManager.recordEventForSession
).to.have.been.calledWith(ctx.user._id, 'project-joined', { ).to.have.been.calledWith(ctx.req.session, 'project-joined', {
mode: 'edit', mode: 'edit',
projectId: ctx.project._id.toString(), projectId: ctx.project._id.toString(),
ownerId: ctx.project.owner_ref.toString(), ownerId: ctx.project.owner_ref.toString(),
@@ -406,8 +406,8 @@ describe('TokenAccessController', function () {
it('records a project-joined event for the user', function (ctx) { it('records a project-joined event for the user', function (ctx) {
expect( expect(
ctx.AnalyticsManager.recordEventForUserInBackground ctx.AnalyticsManager.recordEventForSession
).to.have.been.calledWith(ctx.user._id, 'project-joined', { ).to.have.been.calledWith(ctx.req.session, 'project-joined', {
mode: 'view', mode: 'view',
projectId: ctx.project._id.toString(), projectId: ctx.project._id.toString(),
pendingEditor: true, pendingEditor: true,
@@ -863,7 +863,8 @@ describe('TokenAccessController', function () {
).to.have.been.calledWith( ).to.have.been.calledWith(
ctx.user._id, ctx.user._id,
ctx.project._id, ctx.project._id,
ctx.project.owner_ref ctx.project.owner_ref,
ctx.req.session
) )
}) })
@@ -59,7 +59,7 @@ describe('TokenAccessHandler', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager', '../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ () => ({
default: (ctx.Analytics = { default: (ctx.Analytics = {
recordEventForUserInBackground: sinon.stub(), recordEventForSession: sinon.stub(),
}), }),
}) })
) )
@@ -153,7 +153,8 @@ describe('TokenAccessHandler', function () {
await ctx.TokenAccessHandler.promises.addReadOnlyUserToProject( await ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
ctx.userId, ctx.userId,
ctx.projectId, ctx.projectId,
ctx.project.owner_ref ctx.project.owner_ref,
ctx.req.session
) )
expect(ctx.Project.updateOne.callCount).to.equal(1) expect(ctx.Project.updateOne.callCount).to.equal(1)
expect( expect(
@@ -165,8 +166,8 @@ describe('TokenAccessHandler', function () {
'tokenAccessReadOnly_refs' 'tokenAccessReadOnly_refs'
) )
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.Analytics.recordEventForUserInBackground, ctx.Analytics.recordEventForSession,
ctx.userId, ctx.req.session,
'project-joined', 'project-joined',
{ {
mode: 'view', mode: 'view',
@@ -516,7 +517,8 @@ describe('TokenAccessHandler', function () {
ctx.TokenAccessHandler.promises.addReadOnlyUserToProject( ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
ctx.userId, ctx.userId,
ctx.projectId, ctx.projectId,
ctx.project.owner_ref ctx.project.owner_ref,
ctx.req.session
) )
).to.be.rejectedWith('link sharing is disabled') ).to.be.rejectedWith('link sharing is disabled')
expect(ctx.Project.updateOne.callCount).to.equal(0) expect(ctx.Project.updateOne.callCount).to.equal(0)
@@ -120,6 +120,7 @@ describe('ProjectUploadController', function () {
) )
ctx.AnalyticsManager = { ctx.AnalyticsManager = {
recordEventForSession: sinon.stub(),
recordEventForUserInBackground: sinon.stub(), recordEventForUserInBackground: sinon.stub(),
} }
vi.doMock( vi.doMock(
@@ -545,8 +546,8 @@ describe('ProjectUploadController', function () {
it('should record a successful convert-format analytics event', function (ctx) { it('should record a successful convert-format analytics event', function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.recordEventForUserInBackground, ctx.AnalyticsManager.recordEventForSession,
ctx.user_id, ctx.req.session,
'convert-format', 'convert-format',
{ {
sourceFormat: 'docx', sourceFormat: 'docx',
@@ -615,8 +616,8 @@ describe('ProjectUploadController', function () {
it('should record a successful convert-format analytics event', function (ctx) { it('should record a successful convert-format analytics event', function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.recordEventForUserInBackground, ctx.AnalyticsManager.recordEventForSession,
ctx.user_id, ctx.req.session,
'convert-format', 'convert-format',
{ {
sourceFormat: 'markdown', sourceFormat: 'markdown',
@@ -687,8 +688,8 @@ describe('ProjectUploadController', function () {
it('should record a failed convert-format analytics event', function (ctx) { it('should record a failed convert-format analytics event', function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.AnalyticsManager.recordEventForUserInBackground, ctx.AnalyticsManager.recordEventForSession,
ctx.user_id, ctx.req.session,
'convert-format', 'convert-format',
{ {
sourceFormat: 'docx', sourceFormat: 'docx',
@@ -6,7 +6,7 @@ const modulePath = '../../../../app/src/Features/User/UserCreator.mjs'
describe('UserCreator', function () { describe('UserCreator', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
const self = ctx const self = ctx
ctx.user = { _id: '12390i', ace: {} } ctx.user = { _id: '12390i', ace: {}, analyticsId: 'uuid' }
ctx.user.save = sinon.stub().resolves(self.user) ctx.user.save = sinon.stub().resolves(self.user)
ctx.UserModel = class Project { ctx.UserModel = class Project {
constructor() { constructor() {
@@ -64,8 +64,9 @@ describe('UserCreator', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager', '../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ () => ({
default: (ctx.Analytics = { default: (ctx.Analytics = {
recordEventForMongoUserInBackground: sinon.stub(),
recordEventForUserInBackground: sinon.stub(), recordEventForUserInBackground: sinon.stub(),
setUserPropertyForUser: sinon.stub(), setUserPropertyForMongoUser: sinon.stub(),
}), }),
}) })
) )
@@ -320,13 +321,13 @@ describe('UserCreator', function () {
) )
assert.equal(user.email, ctx.email) assert.equal(user.email, ctx.email)
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.Analytics.recordEventForUserInBackground, ctx.Analytics.recordEventForMongoUserInBackground,
user._id, user,
'user-registered' 'user-registered'
) )
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.Analytics.setUserPropertyForUser, ctx.Analytics.setUserPropertyForMongoUser,
user._id, user,
'created-at' 'created-at'
) )
}) })
@@ -65,7 +65,8 @@ describe('UserEmailsController', function () {
} }
ctx.HttpErrorHandler = { conflict: vi.fn() } ctx.HttpErrorHandler = { conflict: vi.fn() }
ctx.AnalyticsManager = { ctx.AnalyticsManager = {
recordEventForUserInBackground: vi.fn(), recordEventForSession: vi.fn(),
recordEventForMongoUserInBackground: vi.fn(),
} }
ctx.UserAuditLogHandler = { ctx.UserAuditLogHandler = {
addEntry: vi.fn((userId, op, initiatorId, ip, info, callback) => addEntry: vi.fn((userId, op, initiatorId, ip, info, callback) =>
@@ -810,13 +811,15 @@ describe('UserEmailsController', function () {
ctx.req, ctx.req,
{ json: vi.fn() } { json: vi.fn() }
) )
expect( expect(ctx.AnalyticsManager.recordEventForSession).toHaveBeenCalledWith(
ctx.AnalyticsManager.recordEventForUserInBackground ctx.req.session,
).toHaveBeenCalledWith(ctx.user._id, 'email-verified', { 'email-verified',
provider: 'email', {
verification_type: 'token', provider: 'email',
isPrimary: ctx.user.email === ctx.email, verification_type: 'token',
}) isPrimary: ctx.user.email === ctx.email,
}
)
}) })
it('removes pendingExistingEmail from session', async function (ctx) { it('removes pendingExistingEmail from session', async function (ctx) {
@@ -15,16 +15,17 @@ describe('UserPostRegistrationAnalyticsManager', function () {
getUser: sinon.stub().resolves(), getUser: sinon.stub().resolves(),
}, },
} }
ctx.fakeUser = { _id: ctx.fakeUserId, analyticsId: 'uuid' }
ctx.UserGetter.promises.getUser ctx.UserGetter.promises.getUser
.withArgs({ _id: ctx.fakeUserId }) .withArgs({ _id: ctx.fakeUserId })
.resolves({ _id: ctx.fakeUserId }) .resolves(ctx.fakeUser)
ctx.InstitutionsAPI = { ctx.InstitutionsAPI = {
promises: { promises: {
getUserAffiliations: sinon.stub().resolves([]), getUserAffiliations: sinon.stub().resolves([]),
}, },
} }
ctx.AnalyticsManager = { ctx.AnalyticsManager = {
setUserPropertyForUser: sinon.stub().resolves(), setUserPropertyForMongoUser: sinon.stub().resolves(),
} }
vi.doMock('../../../../app/src/infrastructure/Queues', () => ({ vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
@@ -77,7 +78,7 @@ describe('UserPostRegistrationAnalyticsManager', function () {
) )
expect(ctx.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been expect(ctx.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
.called .called
expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been expect(ctx.AnalyticsManager.setUserPropertyForMongoUser).not.to.have.been
.called .called
}) })
@@ -99,9 +100,9 @@ describe('UserPostRegistrationAnalyticsManager', function () {
ctx.fakeUserId ctx.fakeUserId
) )
expect( expect(
ctx.AnalyticsManager.setUserPropertyForUser ctx.AnalyticsManager.setUserPropertyForMongoUser
).to.have.been.calledWith( ).to.have.been.calledWith(
ctx.fakeUserId, ctx.fakeUser,
'registered-from-commons-account', 'registered-from-commons-account',
true true
) )
@@ -118,7 +119,7 @@ describe('UserPostRegistrationAnalyticsManager', function () {
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics( await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
ctx.fakeUserId ctx.fakeUserId
) )
expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been expect(ctx.AnalyticsManager.setUserPropertyForMongoUser).not.to.have.been
.called .called
}) })
}) })
@@ -90,7 +90,7 @@ describe('WorkbenchRateLimiter', function () {
'../../../../app/src/Features/Analytics/AnalyticsManager', '../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ () => ({
default: { default: {
recordEventForUser: sinon.stub(), recordEventForSession: sinon.stub(),
}, },
}) })
) )
@@ -164,6 +164,7 @@ describe('WorkbenchRateLimiter', function () {
describe('with no data', function () { describe('with no data', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
await UserFeatureUsage.deleteMany({}).exec() await UserFeatureUsage.deleteMany({}).exec()
ctx.req = { session: {} }
ctx.res = { ctx.res = {
set: sinon.stub(), set: sinon.stub(),
headersSent: false, headersSent: false,
@@ -172,12 +173,16 @@ describe('WorkbenchRateLimiter', function () {
it('should not throw', async function (ctx) { it('should not throw', async function (ctx) {
await expect( await expect(
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
).to.eventually.be.fulfilled ).to.eventually.be.fulfilled
}) })
it('sets rate limit headers', async function (ctx) { it('sets rate limit headers', async function (ctx) {
await ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) await ctx.WorkbenchRateLimiter.checkUsage(
ctx.alphaUserId,
ctx.req,
ctx.res
)
expect(ctx.res.set).to.have.been.calledWith( expect(ctx.res.set).to.have.been.calledWith(
'Token-RateLimit-Limit', 'Token-RateLimit-Limit',
'8000000' '8000000'
@@ -197,6 +202,7 @@ describe('WorkbenchRateLimiter', function () {
describe('with existing usage', function () { describe('with existing usage', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
await UserFeatureUsage.deleteMany({}).exec() await UserFeatureUsage.deleteMany({}).exec()
ctx.req = { session: {} }
ctx.res = { ctx.res = {
set: sinon.stub(), set: sinon.stub(),
headersSent: false, headersSent: false,
@@ -215,12 +221,16 @@ describe('WorkbenchRateLimiter', function () {
it('should not throw if under limit', async function (ctx) { it('should not throw if under limit', async function (ctx) {
await expect( await expect(
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
).to.eventually.be.fulfilled ).to.eventually.be.fulfilled
}) })
it('sets rate limit headers', async function (ctx) { it('sets rate limit headers', async function (ctx) {
await ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) await ctx.WorkbenchRateLimiter.checkUsage(
ctx.alphaUserId,
ctx.req,
ctx.res
)
expect(ctx.res.set).to.have.been.calledWith( expect(ctx.res.set).to.have.been.calledWith(
'Token-RateLimit-Limit', 'Token-RateLimit-Limit',
'8000000' '8000000'
@@ -243,7 +253,7 @@ describe('WorkbenchRateLimiter', function () {
await usageRecord.save() await usageRecord.save()
await expect( await expect(
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
).to.eventually.be.rejectedWith(/rate limit exceeded/i) ).to.eventually.be.rejectedWith(/rate limit exceeded/i)
}) })
}) })
@@ -269,12 +279,16 @@ describe('WorkbenchRateLimiter', function () {
it('should not throw', async function (ctx) { it('should not throw', async function (ctx) {
await expect( await expect(
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.req, ctx.res)
).to.eventually.be.fulfilled ).to.eventually.be.fulfilled
}) })
it('sets rate limit headers', async function (ctx) { it('sets rate limit headers', async function (ctx) {
await ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res) await ctx.WorkbenchRateLimiter.checkUsage(
ctx.alphaUserId,
ctx.req,
ctx.res
)
expect(ctx.res.set).to.have.been.calledWith( expect(ctx.res.set).to.have.been.calledWith(
'Token-RateLimit-Limit', 'Token-RateLimit-Limit',
'8000000' '8000000'
@@ -1,6 +1,6 @@
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
const tags = ['server-ce', 'server-pro', 'saas', 'nonblocking'] const tags = ['server-ce', 'server-pro', 'saas']
const migrate = async client => { const migrate = async client => {
const { db } = client const { db } = client
@@ -1,7 +1,7 @@
import { db } from './lib/mongodb.mjs' import { db } from './lib/mongodb.mjs'
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
const tags = ['saas', 'server-ce', 'server-pro', 'nonblocking'] const tags = ['saas', 'server-ce', 'server-pro']
const migrate = async () => { const migrate = async () => {
await batchedUpdate(db.users, { analyticsId: { $exists: false } }, [ await batchedUpdate(db.users, { analyticsId: { $exists: false } }, [