ソースを参照

[web] Expose metric for active users in SP (#20130)

* [web] Expose metric for active users in SP

* Removed redundant UserHandler.setupLoginData()

In the past this method was also calling
a now deleted notifyDomainLicence(), but now
this is just an alias for populateTeamInvites()

* Added migration for `lastActive`

* Added secondary read precedence to count active users

GitOrigin-RevId: 86d6db31e1ae74ae40c6599e6acb731d8c4a04bd
Miguel Serrano 1 年間 前
コミット
3ff142d478

+ 7 - 2
services/web/app.js

@@ -80,8 +80,13 @@ if (!module.parent) {
     })
 }
 
-// monitor site maintenance file
-SiteAdminHandler.initialise()
+// initialise site admin tasks
+Promise.all([mongodb.waitForDb(), mongoose.connectionPromise])
+  .then(() => SiteAdminHandler.initialise())
+  .catch(err => {
+    logger.fatal({ err }, 'Cannot connect to mongo. Exiting.')
+    process.exit(1)
+  })
 
 // handle SIGTERM for graceful shutdown in kubernetes
 process.on('SIGTERM', function (signal) {

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

@@ -638,7 +638,7 @@ function _afterLoginSessionSetup(req, user, callback) {
 const _afterLoginSessionSetupAsync = promisify(_afterLoginSessionSetup)
 
 function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) {
-  UserHandler.setupLoginData(user, err => {
+  UserHandler.populateTeamInvites(user, err => {
     if (err != null) {
       logger.warn({ err }, 'error setting up login data')
     }

+ 28 - 12
services/web/app/src/Features/User/UserHandler.js

@@ -1,17 +1,33 @@
-const { promisifyAll } = require('@overleaf/promise-utils')
+const { callbackify, promisify } = require('@overleaf/promise-utils')
 const TeamInvitesHandler = require('../Subscription/TeamInvitesHandler')
+const {
+  db,
+  READ_PREFERENCE_SECONDARY,
+} = require('../../infrastructure/mongodb')
 
-const UserHandler = {
-  populateTeamInvites(user, callback) {
-    TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail(
-      user.email,
-      callback
+function populateTeamInvites(user, callback) {
+  TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail(
+    user.email,
+    callback
+  )
+}
+
+async function countActiveUsers() {
+  const oneYearAgo = new Date()
+  oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1)
+  return await db.users
+    .find(
+      { lastActive: { $gte: oneYearAgo } },
+      { readPreference: READ_PREFERENCE_SECONDARY }
     )
-  },
+    .count()
+}
 
-  setupLoginData(user, callback) {
-    this.populateTeamInvites(user, callback)
-  },
+module.exports = {
+  populateTeamInvites,
+  countActiveUsers: callbackify(countActiveUsers),
+}
+module.exports.promises = {
+  populateTeamInvites: promisify(populateTeamInvites),
+  countActiveUsers,
 }
-module.exports = UserHandler
-module.exports.promises = promisifyAll(UserHandler)

+ 27 - 0
services/web/app/src/infrastructure/SiteAdminHandler.js

@@ -3,7 +3,11 @@ const settings = require('@overleaf/settings')
 const fs = require('fs')
 const {
   addOptionalCleanupHandlerAfterDrainingConnections,
+  addRequiredCleanupHandlerBeforeDrainingConnections,
 } = require('./GracefulShutdown')
+const Features = require('./Features')
+const UserHandler = require('../Features/User/UserHandler')
+const metrics = require('@overleaf/metrics')
 
 // Monitor a site maintenance file (e.g. /etc/site_status) periodically and
 // close the site if the file contents contain the string "closed".
@@ -42,6 +46,16 @@ function checkSiteMaintenanceFileSync() {
   updateSiteMaintenanceStatus(content)
 }
 
+const SERVER_PRO_ACTIVE_USERS_METRIC_INTERVAL =
+  settings.activeUserMetricInterval || 1000 * 60 * 60
+
+function publishActiveUsersMetric() {
+  UserHandler.promises
+    .countActiveUsers()
+    .then(activeUserCount => metrics.gauge('num_active_users', activeUserCount))
+    .catch(error => logger.error({ error }, 'error counting active users'))
+}
+
 module.exports = {
   initialise() {
     if (settings.enabledServices.includes('web') && statusFile) {
@@ -61,5 +75,18 @@ module.exports = {
         }
       )
     }
+    if (!Features.hasFeature('saas')) {
+      publishActiveUsersMetric()
+      const intervalHandle = setInterval(
+        publishActiveUsersMetric,
+        SERVER_PRO_ACTIVE_USERS_METRIC_INTERVAL
+      )
+      addRequiredCleanupHandlerBeforeDrainingConnections(
+        'publish server pro usage metrics',
+        () => {
+          clearInterval(intervalHandle)
+        }
+      )
+    }
   },
 }

+ 20 - 0
services/web/migrations/20240923131936_create_user_last_active_index.js

@@ -0,0 +1,20 @@
+const Helpers = require('./lib/helpers')
+
+exports.tags = ['server-ce', 'server-pro', 'saas']
+
+const indexes = [
+  {
+    key: { lastActive: 1 },
+    name: 'lastActive_1',
+  },
+]
+
+exports.migrate = async client => {
+  const { db } = client
+  await Helpers.addIndexesToCollection(db.users, indexes)
+}
+
+exports.rollback = async client => {
+  const { db } = client
+  await Helpers.dropIndexesFromCollection(db.users, indexes)
+}

+ 1 - 0
services/web/test/acceptance/config/settings.test.server-ce.js

@@ -4,6 +4,7 @@ const base = require('./settings.test.defaults')
 
 module.exports = base.mergeWith({
   defaultFeatures: ServerCEDefaults.defaultFeatures,
+  activeUserMetricInterval: 100,
 })
 
 module.exports.mergeWith = function (overrides) {

+ 34 - 0
services/web/test/acceptance/src/ActiveUsersMetricTests.js

@@ -0,0 +1,34 @@
+const { promisify } = require('util')
+const { expect } = require('chai')
+const Features = require('../../../app/src/infrastructure/Features')
+const {
+  promises: { getMetric },
+} = require('./helpers/metrics')
+const User = require('./helpers/User').promises
+const sleep = promisify(setTimeout)
+
+async function getActiveUsersMetric() {
+  return getMetric(line => line.startsWith('num_active_users'))
+}
+
+describe('ActiveUsersMetricTests', function () {
+  before(async function () {
+    if (Features.hasFeature('saas')) {
+      this.skip()
+    }
+  })
+
+  it('updates "num_active_users" metric after a new user opens a project', async function () {
+    expect(await getActiveUsersMetric()).to.equal(0)
+
+    this.user = new User()
+    await this.user.login()
+    const projectId = await this.user.createProject('test project')
+    await this.user.openProject(projectId)
+
+    // settings.activeUserMetricInterval is configured to 100ms
+    await sleep(110)
+
+    expect(await getActiveUsersMetric()).to.equal(1)
+  })
+})

+ 6 - 4
services/web/test/unit/src/Authentication/AuthenticationControllerTests.js

@@ -94,7 +94,7 @@ describe('AuthenticationController', function () {
           },
         }),
         '../User/UserHandler': (this.UserHandler = {
-          setupLoginData: sinon.stub(),
+          populateTeamInvites: sinon.stub(),
         }),
         '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
           recordEventForUserInBackground: sinon.stub(),
@@ -556,7 +556,7 @@ describe('AuthenticationController', function () {
       })
 
       it('should not setup the user data in the background', function () {
-        this.UserHandler.setupLoginData.called.should.equal(false)
+        this.UserHandler.populateTeamInvites.called.should.equal(false)
       })
 
       it('should record a failed login', function () {
@@ -1134,7 +1134,7 @@ describe('AuthenticationController', function () {
       this.AuthenticationController._clearRedirectFromSession = sinon.stub()
       this.AuthenticationController._redirectToReconfirmPage = sinon.stub()
       this.UserSessionsManager.trackSession = sinon.stub()
-      this.UserHandler.setupLoginData = sinon.stub()
+      this.UserHandler.populateTeamInvites = sinon.stub()
       this.LoginRateLimiter.recordSuccessfulLogin = sinon.stub()
       this.AuthenticationController._recordSuccessfulLogin = sinon.stub()
       this.AnalyticsManager.recordEvent = sinon.stub()
@@ -1461,7 +1461,9 @@ describe('AuthenticationController', function () {
       })
 
       it('should setup the user data in the background', function () {
-        this.UserHandler.setupLoginData.calledWith(this.user).should.equal(true)
+        this.UserHandler.populateTeamInvites
+          .calledWith(this.user)
+          .should.equal(true)
       })
 
       it('should set res.session.justLoggedIn', function () {

+ 16 - 0
services/web/test/unit/src/User/UserHandlerTests.js

@@ -1,4 +1,5 @@
 const sinon = require('sinon')
+const { expect } = require('chai')
 const modulePath = '../../../../app/src/Features/User/UserHandler.js'
 const SandboxedModule = require('sandboxed-module')
 
@@ -14,9 +15,18 @@ describe('UserHandler', function () {
       createTeamInvitesForLegacyInvitedEmail: sinon.stub().yields(),
     }
 
+    this.db = {
+      users: {
+        find: sinon.stub().returns({
+          count: sinon.stub().resolves(2),
+        }),
+      },
+    }
+
     this.UserHandler = SandboxedModule.require(modulePath, {
       requires: {
         '../Subscription/TeamInvitesHandler': this.TeamInvitesHandler,
+        '../../infrastructure/mongodb': { db: this.db },
       },
     })
   })
@@ -32,4 +42,10 @@ describe('UserHandler', function () {
         .should.eq(true)
     })
   })
+
+  describe('countActiveUsers', function () {
+    it('return user count from DB lookup', async function () {
+      expect(await this.UserHandler.promises.countActiveUsers()).to.equal(2)
+    })
+  })
 })