Parcourir la source

[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
Jakob Ackermann il y a 1 mois
Parent
commit
6323fddbd8
37 fichiers modifiés avec 320 ajouts et 184 suppressions
  1. 92 6
      services/web/app/src/Features/Analytics/AnalyticsManager.mjs
  2. 15 15
      services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.mjs
  3. 1 1
      services/web/app/src/Features/Authentication/AuthenticationController.mjs
  4. 2 2
      services/web/app/src/Features/BetaProgram/BetaProgramController.mjs
  5. 6 6
      services/web/app/src/Features/BetaProgram/BetaProgramHandler.mjs
  6. 7 11
      services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
  7. 2 2
      services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs
  8. 3 7
      services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
  9. 4 4
      services/web/app/src/Features/Project/ProjectController.mjs
  10. 2 2
      services/web/app/src/Features/Project/ProjectListController.mjs
  11. 6 2
      services/web/app/src/Features/Subscription/FeaturesUpdater.mjs
  12. 2 2
      services/web/app/src/Features/Subscription/SubscriptionController.mjs
  13. 3 2
      services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
  14. 2 2
      services/web/app/src/Features/TokenAccess/TokenAccessHandler.mjs
  15. 7 11
      services/web/app/src/Features/Uploads/ProjectUploadController.mjs
  16. 6 6
      services/web/app/src/Features/User/UserCreator.mjs
  17. 16 22
      services/web/app/src/Features/User/UserEmailsController.mjs
  18. 11 7
      services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.mjs
  19. 4 3
      services/web/app/src/infrastructure/rate-limiters/TokenUsageRateLimiter.mjs
  20. 4 2
      services/web/scripts/remove_unwanted_ieee_collabratec_users.mjs
  21. 31 3
      services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs
  22. 3 3
      services/web/test/unit/src/Authentication/AuthenticationController.test.mjs
  23. 8 7
      services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs
  24. 3 3
      services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
  25. 3 2
      services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs
  26. 2 0
      services/web/test/unit/src/Project/ProjectController.test.mjs
  27. 1 0
      services/web/test/unit/src/Project/ProjectListController.test.mjs
  28. 5 5
      services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs
  29. 6 5
      services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs
  30. 7 5
      services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs
  31. 7 6
      services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs
  32. 7 6
      services/web/test/unit/src/User/UserCreator.test.mjs
  33. 11 8
      services/web/test/unit/src/User/UserEmailsController.test.mjs
  34. 7 6
      services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs
  35. 22 8
      services/web/test/unit/src/infrastructure/WorkbenchRateLimiter.sequential.test.mjs
  36. 1 1
      tools/migrations/20260615090000_back_fill_labsProgram.mjs
  37. 1 1
      tools/migrations/20260616070000_back_fill_users_analyticsId.mjs

+ 92 - 6
services/web/app/src/Features/Analytics/AnalyticsManager.mjs

@@ -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,

+ 15 - 15
services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.mjs

@@ -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]
           )

+ 1 - 1
services/web/app/src/Features/Authentication/AuthenticationController.mjs

@@ -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',

+ 2 - 2
services/web/app/src/Features/BetaProgram/BetaProgramController.mjs

@@ -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) {

+ 6 - 6
services/web/app/src/Features/BetaProgram/BetaProgramHandler.mjs

@@ -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
   )

+ 7 - 11
services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs

@@ -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

+ 2 - 2
services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs

@@ -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',

+ 3 - 7
services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs

@@ -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) {

+ 4 - 4
services/web/app/src/Features/Project/ProjectController.mjs

@@ -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
       )

+ 2 - 2
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -273,8 +273,8 @@ async function projectListPage(req, res, next) {
 
     customerIoEnabled = true
 
-    AnalyticsManager.setUserPropertyForUserInBackground(
-      userId,
+    AnalyticsManager.setUserPropertyForSessionInBackground(
+      req.session,
       'customer-io-integration',
       true
     )

+ 6 - 2
services/web/app/src/Features/Subscription/FeaturesUpdater.mjs

@@ -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
   )

+ 2 - 2
services/web/app/src/Features/Subscription/SubscriptionController.mjs

@@ -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,

+ 3 - 2
services/web/app/src/Features/TokenAccess/TokenAccessController.mjs

@@ -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({

+ 2 - 2
services/web/app/src/Features/TokenAccess/TokenAccessHandler.mjs

@@ -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',

+ 7 - 11
services/web/app/src/Features/Uploads/ProjectUploadController.mjs

@@ -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',

+ 6 - 6
services/web/app/src/Features/User/UserCreator.mjs

@@ -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
     )

+ 16 - 22
services/web/app/src/Features/User/UserEmailsController.mjs

@@ -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',

+ 11 - 7
services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.mjs

@@ -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
     )

+ 4 - 3
services/web/app/src/infrastructure/rate-limiters/TokenUsageRateLimiter.mjs

@@ -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'
       )
 

+ 4 - 2
services/web/scripts/remove_unwanted_ieee_collabratec_users.mjs

@@ -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
       )

+ 31 - 3
services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs

@@ -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, () => {

+ 3 - 3
services/web/test/unit/src/Authentication/AuthenticationController.test.mjs

@@ -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'
         )
       })

+ 8 - 7
services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs

@@ -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
           )

+ 3 - 3
services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs

@@ -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' })
         )

+ 3 - 2
services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs

@@ -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',

+ 2 - 0
services/web/test/unit/src/Project/ProjectController.test.mjs

@@ -439,8 +439,10 @@ describe('ProjectController', function () {
       '../../../../app/src/Features/Analytics/AnalyticsManager',
       () => ({
         default: {
+          recordEventForSession: () => {},
           recordEventForUserInBackground: () => {},
           setUserPropertyForUserInBackground: () => {},
+          setUserPropertyForSessionInBackground: () => {},
         },
       })
     )

+ 1 - 0
services/web/test/unit/src/Project/ProjectListController.test.mjs

@@ -13,6 +13,7 @@ vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.mjs', () => {
   return {
     default: {
       setUserPropertyForUserInBackground: () => {},
+      setUserPropertyForSessionInBackground: () => {},
     },
   }
 })

+ 5 - 5
services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs

@@ -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'
         )

+ 6 - 5
services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs

@@ -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
         )
       })
 

+ 7 - 5
services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs

@@ -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)

+ 7 - 6
services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs

@@ -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',

+ 7 - 6
services/web/test/unit/src/User/UserCreator.test.mjs

@@ -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'
         )
       })

+ 11 - 8
services/web/test/unit/src/User/UserEmailsController.test.mjs

@@ -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) {

+ 7 - 6
services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs

@@ -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
     })
   })

+ 22 - 8
services/web/test/unit/src/infrastructure/WorkbenchRateLimiter.sequential.test.mjs

@@ -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 - 1
tools/migrations/20260615090000_back_fill_labsProgram.mjs

@@ -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 - 1
tools/migrations/20260616070000_back_fill_users_analyticsId.mjs

@@ -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 } }, [