mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
215 lines
8.2 KiB
JavaScript
215 lines
8.2 KiB
JavaScript
// overleaf-instance-sensor
|
|
// Live Prometheus gauges for the overleaf-cep instance statistics.
|
|
//
|
|
// Sensor definitions ported from PR yu-i-i/overleaf-cep#178
|
|
// (services/web/modules/instance-stats/.../collectInstanceStats.mjs):
|
|
// * users : total count + active (lastActive >= now-24h)
|
|
// * projects : total count + active (lastUpdated >= now-24h)
|
|
// * files : docs collection count
|
|
// * storage : mongodb dbStats.totalSize, overleaf filestore (du of the
|
|
// data dir), redis RAM (used_memory) + redis disk (aof size)
|
|
// * optional standard/guest split: guest = 'guest-user' in user.adminRoles
|
|
// (the PR#176 segmentation; PR#178 env INSTANCE_STATS_USER_SEGMENTATION_ENABLED)
|
|
//
|
|
// Instead of the PR's hourly snapshot docs in Mongo, we compute on every
|
|
// scrape (cheap counts on this scale; fresh values, no retention state).
|
|
// Each probe is isolated: a failing source emits its error counter but the
|
|
// rest of the endpoint still answers.
|
|
|
|
import http from 'node:http'
|
|
import { execFile } from 'node:child_process'
|
|
import { performance } from 'node:perf_hooks'
|
|
import { promisify } from 'node:util'
|
|
import { MongoClient } from 'mongodb'
|
|
import Redis from 'ioredis'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
const PORT = Number(process.env.PORT || 9464)
|
|
const MONGO_URL = process.env.MONGO_URL || 'mongodb://mongo:27017/sharelatex'
|
|
const REDIS_ADDR = process.env.REDIS_ADDR || 'redis://redis:6379'
|
|
const FILESTORE_DATA_DIR = process.env.FILESTORE_DATA_DIR || '/hostdata/filestore'
|
|
const SEGMENTATION = String(process.env.SEGMENTATION || 'false') === 'true'
|
|
const ACTIVE_WINDOW_MS = Number(process.env.ACTIVE_WINDOW_HOURS || 24) * 3600 * 1000
|
|
|
|
const GUEST_ADMIN_ROLE = 'guest-user' // PR#176/PR#178 guest definition
|
|
|
|
const errors = new Map() // name -> failed probe count
|
|
let lastSuccessSeconds = 0
|
|
|
|
const guestUserFilter = () => ({ adminRoles: { $in: [GUEST_ADMIN_ROLE] } })
|
|
|
|
async function duBytes(path) {
|
|
// byte-exact du of a directory (same semantics as PR#178's duBytes())
|
|
try {
|
|
const { stdout } = await execFileAsync('du', ['-sb', path])
|
|
const m = String(stdout).match(/(\d+)/)
|
|
return m ? Number(m[1]) : 0
|
|
} catch {
|
|
throw new Error(`du failed for ${path}`)
|
|
}
|
|
}
|
|
|
|
function parseRedisInfoInt(info, key) {
|
|
const m = String(info || '').match(new RegExp(`^${key}:(\\d+)`, 'm'))
|
|
return m ? Number(m[1]) : 0
|
|
}
|
|
|
|
async function collect() {
|
|
const nowMs = Date.now()
|
|
const oneAgo = new Date(nowMs - ACTIVE_WINDOW_MS)
|
|
const out = []
|
|
|
|
let mongoOk = true
|
|
try {
|
|
const client = new MongoClient(MONGO_URL, { serverSelectionTimeoutMS: 5000 })
|
|
await client.connect()
|
|
try {
|
|
const db = client.db() // db name comes from MONGO_URL
|
|
const users = db.collection('users')
|
|
const projects = db.collection('projects')
|
|
const docs = db.collection('docs')
|
|
|
|
const totalUsers = await users.countDocuments()
|
|
const activeUsers = await users.countDocuments({ lastActive: { $gte: oneAgo } })
|
|
out.push(
|
|
'# HELP overleaf_instance_users_total Total registered users (segment: all, plus standard/guest when segmentation is enabled).',
|
|
'# TYPE overleaf_instance_users_total gauge',
|
|
`overleaf_instance_users_total{segment="all"} ${totalUsers}`,
|
|
'# HELP overleaf_instance_users_active_24h Users active in the last 24 hours (lastActive >= now-24h).',
|
|
'# TYPE overleaf_instance_users_active_24h gauge',
|
|
`overleaf_instance_users_active_24h{segment="all"} ${activeUsers}`,
|
|
)
|
|
|
|
if (SEGMENTATION) {
|
|
const guestFilter = guestUserFilter()
|
|
const guestUsers = await users.countDocuments(guestFilter)
|
|
const activeGuest = await users.countDocuments({
|
|
...guestFilter,
|
|
lastActive: { $gte: oneAgo },
|
|
})
|
|
out.push(
|
|
`overleaf_instance_users_total{segment="standard"} ${totalUsers - guestUsers}`,
|
|
`overleaf_instance_users_total{segment="guest"} ${guestUsers}`,
|
|
`overleaf_instance_users_active_24h{segment="standard"} ${activeUsers - activeGuest}`,
|
|
`overleaf_instance_users_active_24h{segment="guest"} ${activeGuest}`,
|
|
)
|
|
}
|
|
|
|
const totalProjects = await projects.countDocuments()
|
|
const activeProjects = await projects.countDocuments({ lastUpdated: { $gte: oneAgo } })
|
|
const totalFiles = await docs.countDocuments()
|
|
out.push(
|
|
'# HELP overleaf_instance_projects_total Total projects.',
|
|
'# TYPE overleaf_instance_projects_total gauge',
|
|
`overleaf_instance_projects_total ${totalProjects}`,
|
|
'# HELP overleaf_instance_projects_active_24h Projects updated in the last 24 hours (lastUpdated >= now-24h).',
|
|
'# TYPE overleaf_instance_projects_active_24h gauge',
|
|
`overleaf_instance_projects_active_24h ${activeProjects}`,
|
|
'# HELP overleaf_instance_files_total Total documents (files) in the docs collection.',
|
|
'# TYPE overleaf_instance_files_total gauge',
|
|
`overleaf_instance_files_total ${totalFiles}`,
|
|
)
|
|
|
|
// PR#178: mongodb_storage = dbStats.totalSize (bytes)
|
|
let mongoBytes = 0
|
|
try {
|
|
const stats = await db.stats()
|
|
mongoBytes = Number(stats?.totalSize ?? 0)
|
|
} catch {
|
|
/* keep 0 */
|
|
}
|
|
out.push(
|
|
'# HELP overleaf_instance_storage_bytes Storage used, bytes. component: mongodb | filestore | redis_ram | redis_disk.',
|
|
'# TYPE overleaf_instance_storage_bytes gauge',
|
|
`overleaf_instance_storage_bytes{component="mongodb"} ${mongoBytes}`,
|
|
)
|
|
} finally {
|
|
await client.close()
|
|
}
|
|
} catch {
|
|
mongoOk = false
|
|
errors.set('mongo', (errors.get('mongo') || 0) + 1)
|
|
}
|
|
|
|
// filestore size (SeaweedFS data dir, mounted ro) --------------------------
|
|
let fileOk = true
|
|
try {
|
|
const bytes = await duBytes(FILESTORE_DATA_DIR)
|
|
out.push(`overleaf_instance_storage_bytes{component="filestore"} ${bytes}`)
|
|
} catch {
|
|
fileOk = false
|
|
errors.set('filestore', (errors.get('filestore') || 0) + 1)
|
|
}
|
|
|
|
// redis storage: RAM (used_memory) + disk (aof_current_size) ----------------
|
|
let redisOk = true
|
|
try {
|
|
const r = new Redis(REDIS_ADDR, {
|
|
maxRetriesPerRequest: 1,
|
|
connectTimeout: 5000,
|
|
lazyConnect: true,
|
|
})
|
|
try {
|
|
const usedMemory = parseRedisInfoInt(await r.info('memory'), 'used_memory')
|
|
let aof = 0
|
|
try {
|
|
aof = parseRedisInfoInt(await r.info('persistence'), 'aof_current_size')
|
|
} catch {
|
|
/* aof may be disabled — 0 is the true value */
|
|
}
|
|
out.push(
|
|
`overleaf_instance_storage_bytes{component="redis_ram"} ${usedMemory}`,
|
|
`overleaf_instance_storage_bytes{component="redis_disk"} ${aof}`,
|
|
)
|
|
} finally {
|
|
try {
|
|
r.disconnect()
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
} catch {
|
|
redisOk = false
|
|
errors.set('redis', (errors.get('redis') || 0) + 1)
|
|
}
|
|
|
|
const up = mongoOk && fileOk && redisOk ? 1 : 0
|
|
if (up === 1) lastSuccessSeconds = (Date.now() / 1000) | 0
|
|
|
|
out.push(
|
|
'# HELP overleaf_instance_sensor_up 1 when the last scrape computed all probes successfully.',
|
|
'# TYPE overleaf_instance_sensor_up gauge',
|
|
`overleaf_instance_sensor_up ${up}`,
|
|
'# HELP overleaf_instance_sensor_last_success_timestamp_seconds Unix time of the last fully successful scrape.',
|
|
'# TYPE overleaf_instance_sensor_last_success_timestamp_seconds gauge',
|
|
`overleaf_instance_sensor_last_success_timestamp_seconds ${lastSuccessSeconds}`,
|
|
'# HELP overleaf_instance_sensor_errors_total Failed probe count since start (name=mongo|filestore|redis).',
|
|
'# TYPE overleaf_instance_sensor_errors_total counter',
|
|
)
|
|
for (const [name, n] of errors) {
|
|
out.push(`overleaf_instance_sensor_errors_total{name="${name}"} ${n}`)
|
|
}
|
|
return `${out.join('\n')}\n`
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
if (req.url !== '/metrics') {
|
|
res.writeHead(404, { 'content-type': 'text/plain' })
|
|
res.end('not found\n')
|
|
return
|
|
}
|
|
const t0 = performance.now()
|
|
const body = await collect()
|
|
const dt = (performance.now() - t0) / 1000
|
|
res.writeHead(200, {
|
|
'content-type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
'content-length': Buffer.byteLength(body),
|
|
})
|
|
res.end(body)
|
|
})
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`overleaf-instance-sensor listening on :${PORT}`)
|
|
})
|