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