mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[real-time] record metrics on number of connected clients per project (#24727)
Note: "connected" here means across all real-time pods. - editing_session_mode, counter - mode=connect - a client connected - mode=disconnect - a client disconnect - mode=update - continuous editing - status=empty - all clients disconnected - status=single - a single client is connected - status=multi - multiple clients are connected - project_not_empty_since histogram with buckets [0,1h,2h,1d,2d,1w,30d] - status=empty/single/multi as described above GitOrigin-RevId: 1cc42e72bbb5aae754399bdbc3f8771642f35c22
This commit is contained in:
@@ -3,6 +3,7 @@ const Settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
const rclient = redis.createClient(Settings.redis.realtime)
|
||||
const Keys = Settings.redis.realtime.key_schema
|
||||
|
||||
@@ -13,6 +14,20 @@ const FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4
|
||||
const USER_TIMEOUT_IN_S = ONE_HOUR_IN_S / 4
|
||||
const REFRESH_TIMEOUT_IN_S = 10 // only show clients which have responded to a refresh request in the last 10 seconds
|
||||
|
||||
function recordProjectNotEmptySinceMetric(res, status) {
|
||||
const diff = Date.now() / 1000 - parseInt(res, 10)
|
||||
const BUCKETS = [
|
||||
0,
|
||||
ONE_HOUR_IN_S,
|
||||
2 * ONE_HOUR_IN_S,
|
||||
ONE_DAY_IN_S,
|
||||
2 * ONE_DAY_IN_S,
|
||||
7 * ONE_DAY_IN_S,
|
||||
30 * ONE_DAY_IN_S,
|
||||
]
|
||||
Metrics.histogram('project_not_empty_since', diff, BUCKETS, { status })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Use the same method for when a user connects, and when a user sends a cursor
|
||||
// update. This way we don't care if the connected_user key has expired when
|
||||
@@ -23,6 +38,7 @@ module.exports = {
|
||||
const multi = rclient.multi()
|
||||
|
||||
multi.sadd(Keys.clientsInProject({ project_id: projectId }), clientId)
|
||||
multi.scard(Keys.clientsInProject({ project_id: projectId }))
|
||||
multi.expire(
|
||||
Keys.clientsInProject({ project_id: projectId }),
|
||||
FOUR_DAYS_IN_S
|
||||
@@ -66,10 +82,15 @@ module.exports = {
|
||||
USER_TIMEOUT_IN_S
|
||||
)
|
||||
|
||||
multi.exec(function (err) {
|
||||
multi.exec(function (err, res) {
|
||||
if (err) {
|
||||
err = new OError('problem marking user as connected').withCause(err)
|
||||
}
|
||||
const [, nConnectedClients] = res
|
||||
Metrics.inc('editing_session_mode', 1, {
|
||||
mode: cursorData ? 'update' : 'connect',
|
||||
status: nConnectedClients === 1 ? 'single' : 'multi',
|
||||
})
|
||||
callback(err)
|
||||
})
|
||||
},
|
||||
@@ -100,6 +121,7 @@ module.exports = {
|
||||
logger.debug({ projectId, clientId }, 'marking user as disconnected')
|
||||
const multi = rclient.multi()
|
||||
multi.srem(Keys.clientsInProject({ project_id: projectId }), clientId)
|
||||
multi.scard(Keys.clientsInProject({ project_id: projectId }))
|
||||
multi.expire(
|
||||
Keys.clientsInProject({ project_id: projectId }),
|
||||
FOUR_DAYS_IN_S
|
||||
@@ -107,10 +129,54 @@ module.exports = {
|
||||
multi.del(
|
||||
Keys.connectedUser({ project_id: projectId, client_id: clientId })
|
||||
)
|
||||
multi.exec(function (err) {
|
||||
multi.exec(function (err, res) {
|
||||
if (err) {
|
||||
err = new OError('problem marking user as disconnected').withCause(err)
|
||||
}
|
||||
const [, nConnectedClients] = res
|
||||
const status =
|
||||
nConnectedClients === 0
|
||||
? 'empty'
|
||||
: nConnectedClients === 1
|
||||
? 'single'
|
||||
: 'multi'
|
||||
Metrics.inc('editing_session_mode', 1, {
|
||||
mode: 'disconnect',
|
||||
status,
|
||||
})
|
||||
if (status === 'empty') {
|
||||
rclient.getdel(Keys.projectNotEmptySince({ projectId }), (err, res) => {
|
||||
if (err) {
|
||||
logger.warn(
|
||||
{ err, projectId },
|
||||
'could not collect projectNotEmptySince'
|
||||
)
|
||||
} else if (res) {
|
||||
recordProjectNotEmptySinceMetric(res, status)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Only populate projectNotEmptySince when more clients remain connected.
|
||||
const nowInSeconds = Math.ceil(Date.now() / 1000).toString()
|
||||
rclient.set(
|
||||
Keys.projectNotEmptySince({ projectId }),
|
||||
nowInSeconds,
|
||||
'NX',
|
||||
'GET',
|
||||
'EX',
|
||||
31 * ONE_DAY_IN_S,
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
logger.warn(
|
||||
{ err, projectId },
|
||||
'could not set/collect projectNotEmptySince'
|
||||
)
|
||||
} else if (res) {
|
||||
recordProjectNotEmptySinceMetric(res, status)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
callback(err)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -38,6 +38,9 @@ const settings = {
|
||||
connectedUser({ project_id, client_id }) {
|
||||
return `connected_user:{${project_id}}:${client_id}`
|
||||
},
|
||||
projectNotEmptySince({ projectId }) {
|
||||
return `projectNotEmptySince:{${projectId}}`
|
||||
},
|
||||
},
|
||||
maxRetriesPerRequest: parseInt(
|
||||
process.env.REAL_TIME_REDIS_MAX_RETRIES_PER_REQUEST ||
|
||||
|
||||
@@ -30,12 +30,18 @@ describe('ConnectedUsersManager', function () {
|
||||
connectedUser({ project_id: projectId, client_id: clientId }) {
|
||||
return `connected_user:${projectId}:${clientId}`
|
||||
},
|
||||
projectNotEmptySince({ projectId }) {
|
||||
return `projectNotEmptySince:{${projectId}}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
this.rClient = {
|
||||
auth() {},
|
||||
getdel: sinon.stub(),
|
||||
scard: sinon.stub(),
|
||||
set: sinon.stub(),
|
||||
setex: sinon.stub(),
|
||||
sadd: sinon.stub(),
|
||||
get: sinon.stub(),
|
||||
@@ -51,10 +57,15 @@ describe('ConnectedUsersManager', function () {
|
||||
},
|
||||
}
|
||||
tk.freeze(new Date())
|
||||
this.Metrics = {
|
||||
inc: sinon.stub(),
|
||||
histogram: sinon.stub(),
|
||||
}
|
||||
|
||||
this.ConnectedUsersManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': this.settings,
|
||||
'@overleaf/metrics': this.Metrics,
|
||||
'@overleaf/redis-wrapper': {
|
||||
createClient: () => {
|
||||
return this.rClient
|
||||
@@ -83,7 +94,7 @@ describe('ConnectedUsersManager', function () {
|
||||
|
||||
describe('updateUserPosition', function () {
|
||||
beforeEach(function () {
|
||||
return this.rClient.exec.callsArgWith(0)
|
||||
this.rClient.exec.yields(null, [1, 1])
|
||||
})
|
||||
|
||||
it('should set a key with the date and give it a ttl', function (done) {
|
||||
@@ -240,7 +251,7 @@ describe('ConnectedUsersManager', function () {
|
||||
)
|
||||
})
|
||||
|
||||
return it('should set the cursor position when provided', function (done) {
|
||||
it('should set the cursor position when provided', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
@@ -259,11 +270,72 @@ describe('ConnectedUsersManager', function () {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('editing_session_mode', function () {
|
||||
const cases = {
|
||||
'should bump the metric when connecting to empty room': {
|
||||
nConnectedClients: 1,
|
||||
cursorData: null,
|
||||
labels: {
|
||||
mode: 'connect',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when connecting to non-empty room': {
|
||||
nConnectedClients: 2,
|
||||
cursorData: null,
|
||||
labels: {
|
||||
mode: 'connect',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
'should bump the metric when updating in empty room': {
|
||||
nConnectedClients: 1,
|
||||
cursorData: { row: 42 },
|
||||
labels: {
|
||||
mode: 'update',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when updating in non-empty room': {
|
||||
nConnectedClients: 2,
|
||||
cursorData: { row: 42 },
|
||||
labels: {
|
||||
mode: 'update',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for (const [
|
||||
name,
|
||||
{ nConnectedClients, cursorData, labels },
|
||||
] of Object.entries(cases)) {
|
||||
it(name, function (done) {
|
||||
this.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
cursorData,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.Metrics.inc).to.have.been.calledWith(
|
||||
'editing_session_mode',
|
||||
1,
|
||||
labels
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('markUserAsDisconnected', function () {
|
||||
beforeEach(function () {
|
||||
return this.rClient.exec.callsArgWith(0)
|
||||
this.rClient.exec.yields(null, [1, 0])
|
||||
})
|
||||
|
||||
it('should remove the user from the set', function (done) {
|
||||
@@ -294,7 +366,7 @@ describe('ConnectedUsersManager', function () {
|
||||
)
|
||||
})
|
||||
|
||||
return it('should add a ttl to the connected user set so it stays clean', function (done) {
|
||||
it('should add a ttl to the connected user set so it stays clean', function (done) {
|
||||
return this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
@@ -310,6 +382,163 @@ describe('ConnectedUsersManager', function () {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('editing_session_mode', function () {
|
||||
const cases = {
|
||||
'should bump the metric when disconnecting from now empty room': {
|
||||
nConnectedClients: 0,
|
||||
labels: {
|
||||
mode: 'disconnect',
|
||||
status: 'empty',
|
||||
},
|
||||
},
|
||||
'should bump the metric when disconnecting from now single room': {
|
||||
nConnectedClients: 1,
|
||||
labels: {
|
||||
mode: 'disconnect',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when disconnecting from now multi room': {
|
||||
nConnectedClients: 2,
|
||||
labels: {
|
||||
mode: 'disconnect',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for (const [name, { nConnectedClients, labels }] of Object.entries(
|
||||
cases
|
||||
)) {
|
||||
it(name, function (done) {
|
||||
this.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.Metrics.inc).to.have.been.calledWith(
|
||||
'editing_session_mode',
|
||||
1,
|
||||
labels
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('projectNotEmptySince', function () {
|
||||
it('should clear the projectNotEmptySince key when empty and skip metric if not set', function (done) {
|
||||
this.rClient.exec.yields(null, [1, 0])
|
||||
this.rClient.getdel.yields(null, '')
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.getdel).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`
|
||||
)
|
||||
expect(this.Metrics.histogram).to.not.have.been.called
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
it('should clear the projectNotEmptySince key when empty and record metric if set', function (done) {
|
||||
this.rClient.exec.yields(null, [1, 0])
|
||||
tk.freeze(1_234_000)
|
||||
this.rClient.getdel.yields(null, '1230')
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.getdel).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`
|
||||
)
|
||||
expect(this.Metrics.histogram).to.have.been.calledWith(
|
||||
'project_not_empty_since',
|
||||
4,
|
||||
sinon.match.any,
|
||||
{ status: 'empty' }
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
it('should set projectNotEmptySince key when single and skip metric if not set before', function (done) {
|
||||
this.rClient.exec.yields(null, [1, 1])
|
||||
tk.freeze(1_233_001) // should ceil up
|
||||
this.rClient.set.yields(null, '')
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.set).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`,
|
||||
'1234',
|
||||
'NX',
|
||||
'GET',
|
||||
'EX',
|
||||
31 * 24 * 60 * 60
|
||||
)
|
||||
expect(this.Metrics.histogram).to.not.have.been.called
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
const cases = {
|
||||
'should set projectNotEmptySince key when single and record metric if set before':
|
||||
{
|
||||
nConnectedClients: 1,
|
||||
labels: {
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should set projectNotEmptySince key when multi and record metric if set before':
|
||||
{
|
||||
nConnectedClients: 2,
|
||||
labels: {
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
for (const [name, { nConnectedClients, labels }] of Object.entries(
|
||||
cases
|
||||
)) {
|
||||
it(name, function (done) {
|
||||
this.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
tk.freeze(1_235_000)
|
||||
this.rClient.set.yields(null, '1230')
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.set).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`,
|
||||
'1235',
|
||||
'NX',
|
||||
'GET',
|
||||
'EX',
|
||||
31 * 24 * 60 * 60
|
||||
)
|
||||
expect(this.Metrics.histogram).to.have.been.calledWith(
|
||||
'project_not_empty_since',
|
||||
5,
|
||||
sinon.match.any,
|
||||
labels
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getConnectedUser', function () {
|
||||
|
||||
Reference in New Issue
Block a user