Files
peardata/server/services/collectors/processes.js
T
Raven Scott 2e1a3e9b06
CI / test (push) Successful in 1m24s
Release rolling / release (push) Has been cancelled
Updates
2026-07-18 19:44:32 -04:00

376 lines
11 KiB
JavaScript

/**
* Opt-in process top-N collector (Phase 3).
*
* Enable: PEARDATA_PROCESSES=1
* Limit: PEARDATA_PROCESSES_TOP=8 (default)
*
* Emits:
* processes.top_cpu — % of one host CPU for top processes (by name)
* processes.top_rss — RSS MiB for top processes (by RSS)
* processes.top_io — read+write byte rates for top processes (by I/O)
* processes.top_threads — thread count for top processes (by threads)
*
* Linux /proc only; no-op elsewhere.
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import { EventEmitter } from 'events'
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('processes')
export function isProcessCollectorEnabled() {
const v = process.env.PEARDATA_PROCESSES
return v === '1' || v === 'on' || v === 'true'
}
function topN() {
const n = Number(process.env.PEARDATA_PROCESSES_TOP)
return Number.isFinite(n) && n > 0 ? Math.min(32, Math.floor(n)) : 8
}
function hostCpus() {
try {
return os.cpus().length || 1
} catch {
return 1
}
}
function sanitizeDim(name) {
const s = String(name || 'unknown')
.replace(/[^\w.+-]/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48)
return s || 'unknown'
}
/**
* @returns {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number, threads: number, readBytes: number|null, writeBytes: number|null }>|null}
*/
export function listProcStats() {
if (os.platform() !== 'linux') return null
let dirs
try {
dirs = fs.readdirSync('/proc')
} catch {
return null
}
/** @type {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number, threads: number, readBytes: number|null, writeBytes: number|null }>} */
const out = []
for (const ent of dirs) {
if (!/^\d+$/.test(ent)) continue
const pid = Number(ent)
let raw
try {
raw = fs.readFileSync(path.join('/proc', ent, 'stat'), 'utf8')
} catch {
continue
}
const open = raw.indexOf('(')
const close = raw.lastIndexOf(')')
if (open < 0 || close < open) continue
const name = raw.slice(open + 1, close)
const rest = raw.slice(close + 2).split(/\s+/)
// fields after comm: state(0) … utime(11) stime(12) … rss(21)
const utime = Number(rest[11])
const stime = Number(rest[12])
const rssPages = Number(rest[21])
if (!Number.isFinite(utime) || !Number.isFinite(stime)) continue
let threads = 0
try {
const status = fs.readFileSync(path.join('/proc', ent, 'status'), 'utf8')
const m = status.match(/^Threads:\s*(\d+)/m)
if (m) threads = Number(m[1]) || 0
} catch {
// ignore
}
let readBytes = null
let writeBytes = null
try {
const ioRaw = fs.readFileSync(path.join('/proc', ent, 'io'), 'utf8')
const rb = ioRaw.match(/^read_bytes:\s*(\d+)/m)
const wb = ioRaw.match(/^write_bytes:\s*(\d+)/m)
if (rb) readBytes = Number(rb[1]) || 0
if (wb) writeBytes = Number(wb[1]) || 0
} catch {
// unreadable for this pid
}
out.push({
pid,
name: sanitizeDim(name),
utime,
stime,
rssPages: Number.isFinite(rssPages) ? rssPages : 0,
threads,
readBytes,
writeBytes,
})
}
return out
}
function pageSize() {
try {
return os.constants?.os?.PAGE_SIZE || 4096
} catch {
return 4096
}
}
/**
* @param {string[]} names
* @param {'cpu'|'rss'|'io'|'threads'} kind
*/
function registerTopChart(names, kind) {
const dims = [...new Set(names)].slice(0, topN()).map((id) => ({
id,
name: id,
algorithm: 'absolute',
}))
if (!dims.length) {
dims.push({ id: '_idle', name: '_idle', algorithm: 'absolute' })
}
/** @type {import('../../../shared/metrics.js').ChartDef} */
let def
if (kind === 'cpu') {
def = {
id: 'processes.top_cpu',
name: 'processes.top_cpu',
context: 'processes.top_cpu',
title: 'Top processes CPU',
units: 'percentage',
family: 'processes',
chartType: 'stacked',
priority: 6000,
plugin: 'processes',
dimensions: dims,
}
} else if (kind === 'rss') {
def = {
id: 'processes.top_rss',
name: 'processes.top_rss',
context: 'processes.top_rss',
title: 'Top processes RSS',
units: 'MiB',
family: 'processes',
chartType: 'stacked',
priority: 6010,
plugin: 'processes',
dimensions: dims,
}
} else if (kind === 'io') {
def = {
id: 'processes.top_io',
name: 'processes.top_io',
context: 'processes.top_io',
title: 'Top processes I/O',
units: 'KiB/s',
family: 'processes',
chartType: 'stacked',
priority: 6020,
plugin: 'processes',
dimensions: dims,
}
} else {
def = {
id: 'processes.top_threads',
name: 'processes.top_threads',
context: 'processes.top_threads',
title: 'Top processes threads',
units: 'threads',
family: 'processes',
chartType: 'stacked',
priority: 6030,
plugin: 'processes',
dimensions: dims,
}
}
registerChart(def)
return def
}
export class ProcessCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs = opts.intervalMs || Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
this._timer = null
/** @type {Map<number, { ticks: number, wallMs: number, name: string, readBytes: number, writeBytes: number }>|null} */
this._prev = null
this._pageBytes = 4096
}
start() {
if (this._timer) return
this._pageBytes = pageSize()
log.info('Process top-N collector started', { top: topN() })
this._tick()
this._timer = setInterval(() => this._tick(), this.intervalMs)
if (typeof this._timer.unref === 'function') this._timer.unref()
}
stop() {
if (this._timer) {
clearInterval(this._timer)
this._timer = null
}
}
_tick() {
try {
const procs = listProcStats()
if (!procs) return
const ts = Date.now()
const wallMs = ts
const ncpu = hostCpus()
const limit = topN()
/** @type {Map<string, number>} */
const cpuByName = new Map()
/** @type {Map<string, number>} */
const ioByName = new Map()
if (this._prev) {
for (const p of procs) {
const prev = this._prev.get(p.pid)
if (!prev || wallMs <= prev.wallMs) continue
const ticks = p.utime + p.stime
const dTicks = ticks - prev.ticks
const dSec = (wallMs - prev.wallMs) / 1000
if (dTicks < 0 || dSec <= 0) continue
// Linux USER_HZ typically 100
const pct = (dTicks / 100 / dSec) * 100
const key = p.name
cpuByName.set(key, (cpuByName.get(key) || 0) + Math.min(100 * ncpu, pct))
if (
p.readBytes != null &&
p.writeBytes != null &&
prev.readBytes != null &&
prev.writeBytes != null
) {
const dBytes =
Math.max(0, p.readBytes - prev.readBytes) +
Math.max(0, p.writeBytes - prev.writeBytes)
const rateKib = dBytes / dSec / 1024
ioByName.set(key, (ioByName.get(key) || 0) + rateKib)
}
}
}
/** @type {Map<number, { ticks: number, wallMs: number, name: string, readBytes: number, writeBytes: number }>} */
const next = new Map()
for (const p of procs) {
next.set(p.pid, {
ticks: p.utime + p.stime,
wallMs,
name: p.name,
readBytes: p.readBytes ?? 0,
writeBytes: p.writeBytes ?? 0,
})
}
this._prev = next
const cpuRanked = [...cpuByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
const ioRanked = [...ioByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
const rssRanked = [...procs]
.sort((a, b) => b.rssPages - a.rssPages)
.slice(0, limit)
const threadRanked = [...procs].sort((a, b) => b.threads - a.threads).slice(0, limit)
/** @type {Map<string, number>} */
const rssByName = new Map()
for (const p of rssRanked) {
const mib = (p.rssPages * this._pageBytes) / (1024 * 1024)
rssByName.set(p.name, (rssByName.get(p.name) || 0) + mib)
}
const rssTop = [...rssByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
/** @type {Map<string, number>} */
const threadsByName = new Map()
for (const p of threadRanked) {
threadsByName.set(p.name, (threadsByName.get(p.name) || 0) + p.threads)
}
const threadsTop = [...threadsByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
const cpuDef = registerTopChart(
cpuRanked.map(([n]) => n),
'cpu'
)
const rssDef = registerTopChart(
rssTop.map(([n]) => n),
'rss'
)
const ioDef = registerTopChart(
ioRanked.map(([n]) => n),
'io'
)
const threadsDef = registerTopChart(
threadsTop.map(([n]) => n),
'threads'
)
/** @type {Record<string, number|null>} */
const cpuValues = {}
for (const d of cpuDef.dimensions) cpuValues[d.id] = 0
for (const [name, pct] of cpuRanked) cpuValues[name] = pct
/** @type {Record<string, number|null>} */
const rssValues = {}
for (const d of rssDef.dimensions) rssValues[d.id] = 0
for (const [name, mib] of rssTop) rssValues[name] = mib
/** @type {Record<string, number|null>} */
const ioValues = {}
for (const d of ioDef.dimensions) ioValues[d.id] = 0
for (const [name, rate] of ioRanked) ioValues[name] = rate
/** @type {Record<string, number|null>} */
const threadValues = {}
for (const d of threadsDef.dimensions) threadValues[d.id] = 0
for (const [name, count] of threadsTop) threadValues[name] = count
this.emit('samples', [
{
chart: 'processes.top_cpu',
context: 'processes.top_cpu',
ts,
values: cpuValues,
},
{
chart: 'processes.top_rss',
context: 'processes.top_rss',
ts,
values: rssValues,
},
{
chart: 'processes.top_io',
context: 'processes.top_io',
ts,
values: ioValues,
},
{
chart: 'processes.top_threads',
context: 'processes.top_threads',
ts,
values: threadValues,
},
])
} catch (err) {
log.warn('Process tick failed', { error: err.message })
}
}
}
/** @type {ProcessCollector|null} */
let singleton = null
export function getProcessCollector() {
if (!singleton) singleton = new ProcessCollector()
return singleton
}