diff --git a/services/web/app.js b/services/web/app.js index 5aef45a96f..7fcae04db6 100644 --- a/services/web/app.js +++ b/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) { diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index aae184d011..0df10e0715 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/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') } diff --git a/services/web/app/src/Features/User/UserHandler.js b/services/web/app/src/Features/User/UserHandler.js index 7404e358bc..c4c929c452 100644 --- a/services/web/app/src/Features/User/UserHandler.js +++ b/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 - ) - }, - - setupLoginData(user, callback) { - this.populateTeamInvites(user, 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() +} + +module.exports = { + populateTeamInvites, + countActiveUsers: callbackify(countActiveUsers), +} +module.exports.promises = { + populateTeamInvites: promisify(populateTeamInvites), + countActiveUsers, } -module.exports = UserHandler -module.exports.promises = promisifyAll(UserHandler) diff --git a/services/web/app/src/infrastructure/SiteAdminHandler.js b/services/web/app/src/infrastructure/SiteAdminHandler.js index 86e3ed190b..cd6df2a49b 100644 --- a/services/web/app/src/infrastructure/SiteAdminHandler.js +++ b/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) + } + ) + } }, } diff --git a/services/web/migrations/20240923131936_create_user_last_active_index.js b/services/web/migrations/20240923131936_create_user_last_active_index.js new file mode 100644 index 0000000000..62beada2c0 --- /dev/null +++ b/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) +} diff --git a/services/web/test/acceptance/config/settings.test.server-ce.js b/services/web/test/acceptance/config/settings.test.server-ce.js index 225836ab19..87a3294ecc 100644 --- a/services/web/test/acceptance/config/settings.test.server-ce.js +++ b/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) { diff --git a/services/web/test/acceptance/src/ActiveUsersMetricTests.js b/services/web/test/acceptance/src/ActiveUsersMetricTests.js new file mode 100644 index 0000000000..b06a132329 --- /dev/null +++ b/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) + }) +}) diff --git a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js index 681bfa763b..0e4f675b1b 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js +++ b/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 () { diff --git a/services/web/test/unit/src/User/UserHandlerTests.js b/services/web/test/unit/src/User/UserHandlerTests.js index d215c57f07..2dcbc88d6b 100644 --- a/services/web/test/unit/src/User/UserHandlerTests.js +++ b/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) + }) + }) })