Files
peardata/server/services/collector.js
T
Raven Scott af6609c2d3
CI / test (push) Successful in 1m11s
Release rolling / release (push) Successful in 7m35s
CPU reductions (same charts/features)
Cache cpuidle names (was re-reading name sysfs every tick × cores)
Cache CPU sysfs dir list + register cpufreq/throttle once
Map for per-core prev lookups (was O(n²) .find)
Dedupe meminfo/vmstat once per tick
Cache iface speed/duplex/mtu/qlen (30s)
Cache inotify limits (30s)
Gate registerChart for IRQs, cpuidle, peardock mapped charts
Remove leftover profile() stderr timers
Cache os.cpus().length in docker collector
Grow fd-cache buffer past 64KB for /proc/interrupts etc.
2026-07-22 13:40:39 -04:00

1340 lines
41 KiB
JavaScript

/**
* System metrics collector (~1s).
*
* Collects host-wide system / memory / disk / net / IP stats plus
* per-CPU, per-disk, per-iface, and per-mount instance charts.
* Linux uses /proc + /sys; other platforms fall back to Node `os`.
*/
import os from 'os'
import fs from 'fs'
import { EventEmitter } from 'events'
import {
SAMPLE_INTERVAL_MS,
getAllChartDefs,
registerChart,
makeCpuCoreChart,
makeDiskIoChart,
makeDiskOpsChart,
makeDiskUtilChart,
makeDiskAwaitChart,
makeDiskAvgszChart,
makeDiskQopsChart,
makeDiskBusyChart,
makeDiskIotimeChart,
makeDiskDiscardChart,
makeDiskSvctmChart,
makeDiskMergedChart,
makeDiskFlushChart,
makeNetChart,
makeNetPacketsChart,
makeNetErrorsChart,
makeNetDropsChart,
makeNetSpeedChart,
makeNetDuplexChart,
makeNetMtuChart,
makeNetQueueChart,
makeDiskSpaceChart,
makeDiskInodesChart,
} from '../../shared/metrics.js'
import {
collectCpuSysfs,
collectMemDeep,
collectNetDeep,
parsePressureFull,
ifaceSpeedKbps,
ifaceLinkMeta,
} from './collectors/host-deep.js'
import { collectHostMore } from './collectors/host-more.js'
import { readFileCached, closeFdCache } from '../utils/fd-cache.js'
import logger from '../utils/logger.js'
const log = logger.child('collector')
const IS_LINUX = os.platform() === 'linux'
function safeProcessUptime() {
try {
if (typeof process !== 'undefined' && typeof process.uptime === 'function') {
return process.uptime()
}
} catch {
// Bare / restricted hosts may deny process.uptime
}
return 0
}
function bytesToMiB(n) {
return n / (1024 * 1024)
}
function bytesToGiB(n) {
return n / (1024 * 1024 * 1024)
}
function readFile(path) {
return IS_LINUX ? readFileCached(path) : null
}
function rate(prev, cur, dtSec) {
if (dtSec <= 0 || cur < prev) return 0
return (cur - prev) / dtSec
}
function pct(part, total) {
return total > 0 ? (part / total) * 100 : 0
}
function parseMeminfo() {
const raw = readFile('/proc/meminfo')
if (!raw) return null
/** @type {Record<string, number>} */
const out = {}
for (const line of raw.split('\n')) {
// Include keys like Active(anon)
const m = line.match(/^([\w()]+):\s+(\d+)/)
if (m) out[m[1]] = Number(m[2]) * 1024
}
return out
}
function parseLoadavg() {
const raw = readFile('/proc/loadavg')
if (!raw) {
const l = os.loadavg()
return { load1: l[0], load5: l[1], load15: l[2], running: 0, total: 0 }
}
const parts = raw.trim().split(/\s+/)
const [running, total] = (parts[3] || '0/0').split('/').map(Number)
return {
load1: Number(parts[0]),
load5: Number(parts[1]),
load15: Number(parts[2]),
running: running || 0,
total: total || 0,
}
}
/** @returns {{ aggregate: object, cores: object[], intr: number, ctxt: number, processes: number, procsRunning: number, procsBlocked: number }|null} */
function parseProcStat() {
const raw = readFile('/proc/stat')
if (!raw) return null
const cores = []
let aggregate = null
let intr = 0
let ctxt = 0
let processes = 0
let procsRunning = 0
let procsBlocked = 0
for (const line of raw.split('\n')) {
const p = line.trim().split(/\s+/)
if (!p[0]) continue
if (p[0] === 'cpu') {
// user nice system idle iowait irq softirq steal guest guest_nice
aggregate = {
user: Number(p[1]) || 0,
nice: Number(p[2]) || 0,
system: Number(p[3]) || 0,
idle: Number(p[4]) || 0,
iowait: Number(p[5]) || 0,
irq: Number(p[6]) || 0,
softirq: Number(p[7]) || 0,
steal: Number(p[8]) || 0,
guest: Number(p[9]) || 0,
guest_nice: Number(p[10]) || 0,
}
} else if (/^cpu\d+$/.test(p[0])) {
cores.push({
id: p[0].slice(3),
user: Number(p[1]) || 0,
nice: Number(p[2]) || 0,
system: Number(p[3]) || 0,
idle: Number(p[4]) || 0,
iowait: Number(p[5]) || 0,
irq: Number(p[6]) || 0,
softirq: Number(p[7]) || 0,
steal: Number(p[8]) || 0,
guest: Number(p[9]) || 0,
guest_nice: Number(p[10]) || 0,
})
} else if (p[0] === 'intr') intr = Number(p[1]) || 0
else if (p[0] === 'ctxt') ctxt = Number(p[1]) || 0
else if (p[0] === 'processes') processes = Number(p[1]) || 0
else if (p[0] === 'procs_running') procsRunning = Number(p[1]) || 0
else if (p[0] === 'procs_blocked') procsBlocked = Number(p[1]) || 0
}
return { aggregate, cores, intr, ctxt, processes, procsRunning, procsBlocked }
}
/**
* Convert /proc/stat jiffy counters to percentages.
* Linux accounts guest in user and guest_nice in nice — subtract before totaling
* (same approach as htop/netdata) so idle isn't understated on hypervisor hosts.
*/
function cpuDeltaPct(prev, cur) {
if (!prev || !cur) {
return {
guest_nice: 0,
guest: 0,
steal: 0,
softirq: 0,
irq: 0,
user: 0,
system: 0,
nice: 0,
iowait: 0,
idle: 100,
}
}
const delta = (k) => Math.max(0, (cur[k] || 0) - (prev[k] || 0))
const guest = delta('guest')
const guestNice = delta('guest_nice')
// guest ⊆ user, guest_nice ⊆ nice in kernel counters
const user = Math.max(0, delta('user') - guest)
const nice = Math.max(0, delta('nice') - guestNice)
const d = {
guest_nice: guestNice,
guest,
steal: delta('steal'),
softirq: delta('softirq'),
irq: delta('irq'),
user,
system: delta('system'),
nice,
iowait: delta('iowait'),
idle: delta('idle'),
}
let total = 0
for (const v of Object.values(d)) total += v
if (total <= 0) total = 1
return {
guest_nice: pct(d.guest_nice, total),
guest: pct(d.guest, total),
steal: pct(d.steal, total),
softirq: pct(d.softirq, total),
irq: pct(d.irq, total),
user: pct(d.user, total),
system: pct(d.system, total),
nice: pct(d.nice, total),
iowait: pct(d.iowait, total),
idle: pct(d.idle, total),
}
}
function parseNetDev() {
const raw = readFile('/proc/net/dev')
/** @type {Record<string, { rxBytes: number, txBytes: number, rxPackets: number, txPackets: number, rxErrs: number, txErrs: number, rxDrop: number, txDrop: number, rxMulti: number }>} */
const ifaces = {}
if (!raw) return ifaces
for (const line of raw.split('\n').slice(2)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 17) continue
const name = parts[0].replace(':', '')
if (!name || name === 'lo') continue
ifaces[name] = {
rxBytes: Number(parts[1]) || 0,
rxPackets: Number(parts[2]) || 0,
rxErrs: Number(parts[3]) || 0,
rxDrop: Number(parts[4]) || 0,
rxMulti: Number(parts[8]) || 0,
txBytes: Number(parts[9]) || 0,
txPackets: Number(parts[10]) || 0,
txErrs: Number(parts[11]) || 0,
txDrop: Number(parts[12]) || 0,
}
}
return ifaces
}
function parseDiskstats() {
const raw = readFile('/proc/diskstats')
/** @type {Record<string, {
* reads: number, readSectors: number, readMs: number,
* writes: number, writeSectors: number, writeMs: number,
* ioMs: number, ioInFlight: number,
* discards: number, discardSectors: number
* }>} */
const disks = {}
if (!raw) return disks
for (const line of raw.split('\n')) {
const p = line.trim().split(/\s+/)
if (p.length < 14) continue
const name = p[2]
if (/^(loop|ram|fd|sr)/.test(name)) continue
const includeParts =
process.env.PEARDATA_DISK_PARTITIONS === '1' ||
process.env.PEARDATA_DISK_PARTITIONS === 'on'
if (!includeParts) {
if (/^nvme\d+n\d+p\d+$/.test(name)) continue
if (/^(sd|vd|xvd|hd)[a-z]+\d+$/.test(name)) continue
if (/^mmcblk\d+p\d+$/.test(name)) continue
}
disks[name] = {
reads: Number(p[3]) || 0,
readMerged: Number(p[4]) || 0,
readSectors: Number(p[5]) || 0,
readMs: Number(p[6]) || 0,
writes: Number(p[7]) || 0,
writeMerged: Number(p[8]) || 0,
writeSectors: Number(p[9]) || 0,
writeMs: Number(p[10]) || 0,
ioInFlight: Number(p[11]) || 0,
ioMs: Number(p[12]) || 0,
discards: p.length > 14 ? Number(p[14]) || 0 : 0,
discardSectors: p.length > 16 ? Number(p[16]) || 0 : 0,
flushes: p.length > 18 ? Number(p[18]) || 0 : 0,
}
}
return disks
}
function parseVmstat() {
const raw = readFile('/proc/vmstat')
if (!raw) return null
/** @type {Record<string, number>} */
const out = {}
for (const line of raw.split('\n')) {
const [k, v] = line.trim().split(/\s+/)
if (k && v != null) out[k] = Number(v) || 0
}
return out
}
function parseSnmp() {
const raw = readFile('/proc/net/snmp')
if (!raw) return { ip: {}, tcp: {}, udp: {}, icmp: {} }
/** @type {Record<string, Record<string, number>>} */
const tables = {}
const lines = raw.split('\n')
for (let i = 0; i + 1 < lines.length; i += 2) {
const header = lines[i].trim().split(/\s+/)
const values = lines[i + 1].trim().split(/\s+/)
const name = header[0].replace(':', '')
/** @type {Record<string, number>} */
const row = {}
for (let j = 1; j < header.length; j++) {
row[header[j]] = Number(values[j]) || 0
}
tables[name.toLowerCase()] = row
}
return {
ip: tables.ip || {},
tcp: tables.tcp || {},
udp: tables.udp || {},
icmp: tables.icmp || {},
}
}
function parseSockstat() {
const raw = readFile('/proc/net/sockstat')
if (!raw) return { tcpInuse: 0 }
const m = raw.match(/TCP:\s+inuse\s+(\d+)/)
return { tcpInuse: m ? Number(m[1]) : 0 }
}
function parsePressure(kind) {
return parsePressureFull(kind)
}
function parseMounts() {
const raw = readFile('/proc/mounts')
if (!raw) return []
const skipFs = new Set([
'proc',
'sysfs',
'devtmpfs',
'devpts',
'tmpfs',
'cgroup',
'cgroup2',
'pstore',
'bpf',
'debugfs',
'tracefs',
'securityfs',
'fusectl',
'configfs',
'mqueue',
'hugetlbfs',
'rpc_pipefs',
'binfmt_misc',
'autofs',
'overlay',
'nsfs',
])
/** @type {Array<{ path: string, fs: string }>} */
const mounts = []
for (const line of raw.split('\n')) {
const p = line.split(/\s+/)
if (p.length < 3) continue
const path = p[1]
const fsType = p[2]
if (skipFs.has(fsType)) continue
if (!path.startsWith('/')) continue
// limit to root + first-level mounts to keep overhead low
if (path !== '/' && path.split('/').filter(Boolean).length > 2) continue
mounts.push({ path, fs: fsType })
}
return mounts
}
function mountId(path) {
if (path === '/') return 'root'
return path.replace(/^\//, '').replace(/[^\w.-]+/g, '_')
}
function sampleMount(path) {
try {
const st = fs.statfsSync(path)
const bsize = Number(st.bsize) || 4096
const blocks = Number(st.blocks) || 0
const bfree = Number(st.bfree) || 0
const bavail = Number(st.bavail) || 0
const files = Number(st.files) || 0
const ffree = Number(st.ffree) || 0
const total = blocks * bsize
const free = bfree * bsize
const avail = bavail * bsize
const used = Math.max(0, total - free)
const reserved = Math.max(0, free - avail)
const inodesUsed = Math.max(0, files - ffree)
return {
space: {
avail: bytesToGiB(avail),
used: bytesToGiB(used),
reserved_for_root: bytesToGiB(reserved),
},
inodes: {
avail: ffree,
used: inodesUsed,
reserved_for_root: 0,
},
}
} catch {
return null
}
}
export class MetricsCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs =
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
this.timer = null
this.running = false
this.sampleCount = 0
this.lastTs = 0
this.lastStat = null
this.lastNet = null
this.lastDisk = null
this.lastVm = null
this.lastSnmp = null
/** Deep collector state (freq/throttle/softnet/TcpExt) */
this.deepState = {}
/** @type {Map<string, Record<string, number|null>>} */
this.latest = new Map()
this._chartsRegistered = false
this._lastCpuUsage = process.cpuUsage()
this._lastCpuTs = Date.now()
}
start() {
if (this.running) return
this.running = true
this._tick()
this.timer = setInterval(() => this._tick(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
log.info('Collector started', { intervalMs: this.intervalMs })
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
if (IS_LINUX) closeFdCache()
}
_tick() {
const t0 = performance.now()
try {
const ts = Date.now()
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
this.lastTs = ts
const prevVmSnapshot = this.lastVm
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
if (IS_LINUX) {
const memSnap = parseMeminfo()
const vmSnap = parseVmstat()
this._safeCollect('cpu', () => this._collectCpuAndProcs(batch, ts, dtSec))
this._safeCollect('cpu_sysfs', () => collectCpuSysfs(batch, ts, dtSec, this.deepState))
this._safeCollect('memory', () => this._collectMemory(batch, ts, dtSec, memSnap, vmSnap))
this._safeCollect('disk', () => this._collectDisk(batch, ts, dtSec))
this._safeCollect('net', () => this._collectNet(batch, ts, dtSec))
this._safeCollect('ip', () => this._collectIp(batch, ts, dtSec))
this._safeCollect('pressure', () => this._collectPressure(batch, ts))
this._safeCollect('diskspace', () => this._collectDiskSpace(batch, ts))
this._safeCollect('host_more', () => {
collectHostMore(batch, ts, dtSec, this.deepState, {
mem: memSnap,
vm: vmSnap,
prevVm: prevVmSnapshot,
})
})
} else {
this._safeCollect('cpu', () => this._collectCpuFallback(batch, ts, dtSec))
this._safeCollect('memory', () => this._collectMemoryFallback(batch, ts))
}
let uptime = 0
try {
uptime = os.uptime()
} catch {
if (IS_LINUX) {
const raw = readFile('/proc/uptime')
if (raw) uptime = Number(raw.trim().split(/\s+/)[0]) || 0
}
}
batch.push({
chart: 'system.uptime',
context: 'system.uptime',
ts,
values: { uptime },
})
if (IS_LINUX) {
const entropy = readFile('/proc/sys/kernel/random/entropy_avail')
if (entropy != null) {
batch.push({
chart: 'system.entropy',
context: 'system.entropy',
ts,
values: { entropy: Number(entropy.trim()) || 0 },
})
}
}
for (const s of batch) this.latest.set(s.chart, s.values)
this.sampleCount++
this._chartsRegistered = true
this.emit('samples', batch)
} catch (err) {
log.error('Collector tick failed', { error: err.message })
}
const dur = performance.now() - t0
if (dur > 15) {
const now = Date.now()
const cpuDelta = process.cpuUsage(this._lastCpuUsage)
const wallMs = now - this._lastCpuTs
this._lastCpuUsage = process.cpuUsage()
this._lastCpuTs = now
const totalMs = (cpuDelta.user + cpuDelta.system) / 1000
const pct = wallMs > 0 ? Math.round((totalMs / wallMs) * 100) : 0
if (pct > 30) {
log.debug('tick', { collector: 'main', durMs: Math.round(dur * 10) / 10, cpuPct: pct, cpuUserMs: Math.round(cpuDelta.user / 1000), cpuSysMs: Math.round(cpuDelta.system / 1000), wallMs })
} else {
log.debug('tick', { collector: 'main', durMs: Math.round(dur * 10) / 10 })
}
}
}
_safeCollect(name, fn) {
const t0 = performance.now()
try {
fn()
} catch (err) {
log.warn(`Collector ${name} failed`, { error: err.message })
}
const dur = performance.now() - t0
if (dur > 5) log.debug(`collect:${name}`, { durMs: Math.round(dur * 10) / 10 })
}
_collectCpuFallback(batch, ts, dtSec) {
const cpus = os.cpus()
const load = os.loadavg()
const agg = { user: 0, nice: 0, system: 0, idle: 0, irq: 0, iowait: 0, softirq: 0, steal: 0, guest: 0, guest_nice: 0 }
const prevById = this._lastStatCoresById || new Map()
/** @type {Map<string, object>} */
const nextById = new Map()
for (let i = 0; i < cpus.length; i++) {
const c = cpus[i]
const cur = { id: String(i), user: c.times.user, nice: c.times.nice, system: c.times.sys, idle: c.times.idle, irq: c.times.irq, iowait: 0, softirq: 0, steal: 0, guest: 0, guest_nice: 0 }
if (!this._chartsRegistered) registerChart(makeCpuCoreChart(i))
const prev = prevById.get(String(i))
batch.push({ chart: `cpu.cpu${i}`, context: 'cpu.cpu', ts, values: cpuDeltaPct(prev, cur) })
for (const k of Object.keys(agg)) agg[k] += cur[k] || 0
nextById.set(String(i), cur)
}
batch.push({ chart: 'system.cpu', context: 'system.cpu', ts, values: cpuDeltaPct(this.lastStat?.aggregate, agg) })
this.lastStat = { aggregate: agg, cores: [...nextById.values()], intr: 0, ctxt: 0, processes: 0, procsRunning: 0, procsBlocked: 0 }
this._lastStatCoresById = nextById
batch.push({ chart: 'system.processes', context: 'system.processes', ts, values: { running: load[0] < 0 ? 0 : Math.round(load[0]), blocked: 0 } })
batch.push({ chart: 'system.active_processes', context: 'system.active_processes', ts, values: { active: 0 } })
batch.push({ chart: 'system.load', context: 'system.load', ts, values: { load1: load[0], load5: load[1], load15: load[2] } })
}
_collectMemoryFallback(batch, ts) {
const total = os.totalmem()
const free = os.freemem()
batch.push({ chart: 'system.ram', context: 'system.ram', ts, values: { free: bytesToMiB(free), used: bytesToMiB(total - free), cached: 0, buffers: 0 } })
batch.push({ chart: 'mem.available', context: 'mem.available', ts, values: { avail: bytesToMiB(free) } })
}
_collectCpuAndProcs(batch, ts, dtSec) {
const stat = parseProcStat()
const load = parseLoadavg()
if (stat?.aggregate) {
const cpuVals = cpuDeltaPct(this.lastStat?.aggregate, stat.aggregate)
batch.push({ chart: 'system.cpu', context: 'system.cpu', ts, values: cpuVals })
const prevById = this._lastStatCoresById || new Map()
/** @type {Map<string, object>} */
const nextById = new Map()
for (const core of stat.cores) {
if (!this._chartsRegistered) registerChart(makeCpuCoreChart(core.id))
const prev = prevById.get(core.id)
batch.push({
chart: `cpu.cpu${core.id}`,
context: 'cpu.cpu',
ts,
values: cpuDeltaPct(prev, core),
})
nextById.set(core.id, core)
}
const prev = this.lastStat
batch.push({
chart: 'system.intr',
context: 'system.intr',
ts,
values: { interrupts: prev ? rate(prev.intr, stat.intr, dtSec) : 0 },
})
batch.push({
chart: 'system.ctxt',
context: 'system.ctxt',
ts,
values: { switches: prev ? rate(prev.ctxt, stat.ctxt, dtSec) : 0 },
})
batch.push({
chart: 'system.forks',
context: 'system.forks',
ts,
values: { started: prev ? rate(prev.processes, stat.processes, dtSec) : 0 },
})
batch.push({
chart: 'system.processes',
context: 'system.processes',
ts,
values: { running: stat.procsRunning, blocked: stat.procsBlocked },
})
batch.push({
chart: 'system.active_processes',
context: 'system.active_processes',
ts,
values: { active: load.total || stat.procsRunning + stat.procsBlocked },
})
this.lastStat = stat
this._lastStatCoresById = nextById
} else {
// Node os fallback
const cpus = os.cpus()
const agg = { user: 0, nice: 0, system: 0, idle: 0, irq: 0, iowait: 0, softirq: 0, steal: 0, guest: 0, guest_nice: 0 }
const prevById = this._lastStatCoresById || new Map()
/** @type {Map<string, object>} */
const nextById = new Map()
cpus.forEach((c, i) => {
const cur = {
id: String(i),
user: c.times.user,
nice: c.times.nice,
system: c.times.sys,
idle: c.times.idle,
irq: c.times.irq,
iowait: 0,
softirq: 0,
steal: 0,
guest: 0,
guest_nice: 0,
}
if (!this._chartsRegistered) registerChart(makeCpuCoreChart(i))
const prevCore = prevById.get(String(i))
batch.push({
chart: `cpu.cpu${i}`,
context: 'cpu.cpu',
ts,
values: cpuDeltaPct(prevCore, cur),
})
for (const k of Object.keys(agg)) agg[k] += cur[k] || 0
nextById.set(String(i), cur)
})
batch.push({
chart: 'system.cpu',
context: 'system.cpu',
ts,
values: cpuDeltaPct(this.lastStat?.aggregate, agg),
})
this.lastStat = { aggregate: agg, cores: [...nextById.values()], intr: 0, ctxt: 0, processes: 0, procsRunning: 0, procsBlocked: 0 }
this._lastStatCoresById = nextById
batch.push({
chart: 'system.processes',
context: 'system.processes',
ts,
values: { running: load.running, blocked: 0 },
})
batch.push({
chart: 'system.active_processes',
context: 'system.active_processes',
ts,
values: { active: load.total || 0 },
})
}
batch.push({
chart: 'system.load',
context: 'system.load',
ts,
values: { load1: load.load1, load5: load.load5, load15: load.load15 },
})
}
_collectMemory(batch, ts, dtSec, memArg, vmArg) {
const mem = memArg || parseMeminfo()
const vm = vmArg || parseVmstat()
const total = os.totalmem()
const freeOs = os.freemem()
if (mem) {
const freeB = mem.MemFree || freeOs
const buffers = mem.Buffers || 0
// Shmem (tmpfs etc.) is already counted in Cached — subtract so used isn't understated
const cached = Math.max(
0,
(mem.Cached || 0) + (mem.SReclaimable || 0) - (mem.Shmem || 0)
)
const used = Math.max(0, (mem.MemTotal || total) - freeB - buffers - cached)
batch.push({
chart: 'system.ram',
context: 'system.ram',
ts,
values: {
free: bytesToMiB(freeB),
used: bytesToMiB(used),
cached: bytesToMiB(cached),
buffers: bytesToMiB(buffers),
},
})
batch.push({
chart: 'mem.available',
context: 'mem.available',
ts,
values: { avail: bytesToMiB(mem.MemAvailable || freeB) },
})
const swapTotal = mem.SwapTotal || 0
const swapFree = mem.SwapFree || 0
batch.push({
chart: 'mem.swap',
context: 'mem.swap',
ts,
values: {
free: bytesToMiB(swapFree),
used: bytesToMiB(Math.max(0, swapTotal - swapFree)),
},
})
batch.push({
chart: 'mem.swap_cached',
context: 'mem.swap_cached',
ts,
values: { cached: bytesToMiB(mem.SwapCached || 0) },
})
batch.push({
chart: 'mem.kernel',
context: 'mem.kernel',
ts,
values: {
slab: bytesToMiB(mem.Slab || 0),
kernel_stack: bytesToMiB(mem.KernelStack || 0),
page_tables: bytesToMiB(mem.PageTables || 0),
vmalloc_used: bytesToMiB(mem.VmallocUsed || 0),
percpu: bytesToMiB(mem.Percpu || 0),
},
})
const reclaimable = mem.SReclaimable || 0
const unreclaim = mem.SUnreclaim || Math.max(0, (mem.Slab || 0) - reclaimable)
batch.push({
chart: 'mem.slab',
context: 'mem.slab',
ts,
values: {
reclaimable: bytesToMiB(reclaimable),
unreclaimable: bytesToMiB(unreclaim),
},
})
batch.push({
chart: 'mem.writeback',
context: 'mem.writeback',
ts,
values: {
dirty: bytesToMiB(mem.Dirty || 0),
writeback: bytesToMiB(mem.Writeback || 0),
FuseWriteback: bytesToMiB(mem.WritebackTmp || 0),
NfsWriteback: bytesToMiB(mem.NFS_Unstable || 0),
Bounce: bytesToMiB(mem.Bounce || 0),
},
})
batch.push({
chart: 'mem.committed',
context: 'mem.committed',
ts,
values: { Committed_AS: bytesToMiB(mem.Committed_AS || 0) },
})
} else {
batch.push({
chart: 'system.ram',
context: 'system.ram',
ts,
values: {
free: bytesToMiB(freeOs),
used: bytesToMiB(total - freeOs),
cached: 0,
buffers: 0,
},
})
batch.push({
chart: 'mem.available',
context: 'mem.available',
ts,
values: { avail: bytesToMiB(freeOs) },
})
}
const prevVm = this.lastVm
if (vm) {
const page = 4
batch.push({
chart: 'mem.swapio',
context: 'mem.swapio',
ts,
values: {
in: prevVm ? rate(prevVm.pswpin || 0, vm.pswpin || 0, dtSec) * page : 0,
out: prevVm ? rate(prevVm.pswpout || 0, vm.pswpout || 0, dtSec) * page : 0,
},
})
batch.push({
chart: 'system.pgpgio',
context: 'system.pgpgio',
ts,
values: {
in: prevVm ? rate(prevVm.pgpgin || 0, vm.pgpgin || 0, dtSec) : 0,
out: prevVm ? rate(prevVm.pgpgout || 0, vm.pgpgout || 0, dtSec) : 0,
},
})
const minor = (vm.pgfault || 0) - (vm.pgmajfault || 0)
const prevMinor = prevVm ? (prevVm.pgfault || 0) - (prevVm.pgmajfault || 0) : 0
batch.push({
chart: 'system.pgfaults',
context: 'system.pgfaults',
ts,
values: {
minor: prevVm ? rate(prevMinor, minor, dtSec) : 0,
major: prevVm ? rate(prevVm.pgmajfault || 0, vm.pgmajfault || 0, dtSec) : 0,
},
})
}
if (mem) collectMemDeep(batch, ts, dtSec, mem, vm, prevVm)
if (vm) this.lastVm = vm
}
_collectDisk(batch, ts, dtSec) {
const disks = parseDiskstats()
let aggRead = 0
let aggWrite = 0
const prevAll = this.lastDisk || {}
for (const [name, d] of Object.entries(disks)) {
if (name === '__agg') continue
const registeredDisks = this._registeredDisks || (this._registeredDisks = new Set())
if (!registeredDisks.has(name)) {
registerChart(makeDiskIoChart(name))
registerChart(makeDiskOpsChart(name))
registerChart(makeDiskUtilChart(name))
registerChart(makeDiskAwaitChart(name))
registerChart(makeDiskAvgszChart(name))
registerChart(makeDiskQopsChart(name))
registerChart(makeDiskBusyChart(name))
registerChart(makeDiskIotimeChart(name))
if (d.discards != null) registerChart(makeDiskDiscardChart(name))
registerChart(makeDiskSvctmChart(name))
registerChart(makeDiskMergedChart(name))
registerChart(makeDiskFlushChart(name))
registeredDisks.add(name)
}
const prev = prevAll[name]
const readBytes = d.readSectors * 512
const writeBytes = d.writeSectors * 512
aggRead += readBytes
aggWrite += writeBytes
const readKiB = prev ? rate(prev.readBytes, readBytes, dtSec) / 1024 : 0
const writeKiB = prev ? rate(prev.writeBytes, writeBytes, dtSec) / 1024 : 0
const dReads = prev ? Math.max(0, d.reads - prev.reads) : 0
const dWrites = prev ? Math.max(0, d.writes - prev.writes) : 0
const dReadMs = prev ? Math.max(0, d.readMs - prev.readMs) : 0
const dWriteMs = prev ? Math.max(0, d.writeMs - prev.writeMs) : 0
const dReadSec = prev ? Math.max(0, d.readSectors - prev.readSectors) : 0
const dWriteSec = prev ? Math.max(0, d.writeSectors - prev.writeSectors) : 0
batch.push({
chart: `disk_io.${name}`,
context: 'disk.io',
ts,
values: { reads: readKiB, writes: writeKiB },
})
batch.push({
chart: `disk_ops.${name}`,
context: 'disk.ops',
ts,
values: {
reads: prev ? rate(prev.reads, d.reads, dtSec) : 0,
writes: prev ? rate(prev.writes, d.writes, dtSec) : 0,
},
})
const util = prev ? Math.min(100, rate(prev.ioMs, d.ioMs, dtSec) / 10) : 0
batch.push({
chart: `disk_util.${name}`,
context: 'disk.util',
ts,
values: { utilization: util },
})
batch.push({
chart: `disk_await.${name}`,
context: 'disk.await',
ts,
values: {
reads: dReads > 0 ? dReadMs / dReads : 0,
writes: dWrites > 0 ? dWriteMs / dWrites : 0,
},
})
batch.push({
chart: `disk_avgsz.${name}`,
context: 'disk.avgsz',
ts,
values: {
reads: dReads > 0 ? (dReadSec * 512) / dReads / 1024 : 0,
writes: dWrites > 0 ? (dWriteSec * 512) / dWrites / 1024 : 0,
},
})
batch.push({
chart: `disk_qops.${name}`,
context: 'disk.qops',
ts,
values: { operations: d.ioInFlight || 0 },
})
batch.push({
chart: `disk_busy.${name}`,
context: 'disk.busy',
ts,
values: { busy: prev ? rate(prev.ioMs, d.ioMs, dtSec) : 0 },
})
batch.push({
chart: `disk_iotime.${name}`,
context: 'disk.iotime',
ts,
values: {
reads: prev ? rate(prev.readMs, d.readMs, dtSec) : 0,
writes: prev ? rate(prev.writeMs, d.writeMs, dtSec) : 0,
},
})
if (d.discards != null) {
batch.push({
chart: `disk_discard.${name}`,
context: 'disk.discard',
ts,
values: {
operations: prev ? rate(prev.discards || 0, d.discards, dtSec) : 0,
sectors: prev ? rate(prev.discardSectors || 0, d.discardSectors, dtSec) : 0,
},
})
}
const ops = dReads + dWrites
const busyDelta = prev ? Math.max(0, d.ioMs - prev.ioMs) : 0
batch.push({
chart: `disk_svctm.${name}`,
context: 'disk.svctm',
ts,
values: { svctm: ops > 0 ? busyDelta / ops : 0 },
})
batch.push({
chart: `disk_merged.${name}`,
context: 'disk.merged',
ts,
values: {
reads: prev ? rate(prev.readMerged || 0, d.readMerged || 0, dtSec) : 0,
writes: prev ? rate(prev.writeMerged || 0, d.writeMerged || 0, dtSec) : 0,
},
})
batch.push({
chart: `disk_flush.${name}`,
context: 'disk.flush',
ts,
values: {
operations: prev ? rate(prev.flushes || 0, d.flushes || 0, dtSec) : 0,
},
})
disks[name] = { ...d, readBytes, writeBytes }
}
const prevAgg = this.lastDisk?.__agg
batch.push({
chart: 'system.io',
context: 'system.io',
ts,
values: {
in: prevAgg ? rate(prevAgg.read, aggRead, dtSec) / 1024 : 0,
out: prevAgg ? rate(prevAgg.write, aggWrite, dtSec) / 1024 : 0,
},
})
disks.__agg = { read: aggRead, write: aggWrite }
this.lastDisk = disks
}
_collectNet(batch, ts, dtSec) {
const ifaces = parseNetDev()
let rx = 0
let tx = 0
const prevAll = this.lastNet || {}
for (const [name, n] of Object.entries(ifaces)) {
if (name === '__agg') continue
const registeredIfaces = this._registeredIfaces || (this._registeredIfaces = new Set())
if (!registeredIfaces.has(name)) {
registerChart(makeNetChart(name))
registerChart(makeNetPacketsChart(name))
registerChart(makeNetErrorsChart(name))
registerChart(makeNetDropsChart(name))
registerChart(makeNetSpeedChart(name))
registerChart(makeNetDuplexChart(name))
registerChart(makeNetMtuChart(name))
registerChart(makeNetQueueChart(name))
registeredIfaces.add(name)
}
const prev = prevAll[name]
rx += n.rxBytes
tx += n.txBytes
batch.push({
chart: `net.${name}`,
context: 'net.net',
ts,
values: {
received: prev ? (rate(prev.rxBytes, n.rxBytes, dtSec) * 8) / 1000 : 0,
sent: prev ? (rate(prev.txBytes, n.txBytes, dtSec) * 8) / 1000 : 0,
},
})
batch.push({
chart: `net_packets.${name}`,
context: 'net.packets',
ts,
values: {
received: prev ? rate(prev.rxPackets, n.rxPackets, dtSec) : 0,
sent: prev ? rate(prev.txPackets, n.txPackets, dtSec) : 0,
multicast: prev ? rate(prev.rxMulti, n.rxMulti, dtSec) : 0,
},
})
batch.push({
chart: `net_errors.${name}`,
context: 'net.errors',
ts,
values: {
inbound: prev ? rate(prev.rxErrs, n.rxErrs, dtSec) : 0,
outbound: prev ? rate(prev.txErrs, n.txErrs, dtSec) : 0,
},
})
batch.push({
chart: `net_drops.${name}`,
context: 'net.drops',
ts,
values: {
inbound: prev ? rate(prev.rxDrop, n.rxDrop, dtSec) : 0,
outbound: prev ? rate(prev.txDrop, n.txDrop, dtSec) : 0,
},
})
const speed = ifaceSpeedKbps(name, this.deepState)
if (speed != null) {
batch.push({
chart: `net_speed.${name}`,
context: 'net.speed',
ts,
values: { speed },
})
}
const link = ifaceLinkMeta(name, this.deepState)
if (link.duplex) {
batch.push({
chart: `net_duplex.${name}`,
context: 'net.duplex',
ts,
values: { full: link.duplex === 'full' ? 1 : 0, half: link.duplex === 'half' ? 1 : 0 },
})
}
if (link.mtu > 0) {
batch.push({
chart: `net_mtu.${name}`,
context: 'net.mtu',
ts,
values: { mtu: link.mtu },
})
}
batch.push({
chart: `net_queue.${name}`,
context: 'net.queue_length',
ts,
values: { tx_queue_len: link.qlen },
})
}
const prevAgg = this.lastNet?.__agg
batch.push({
chart: 'system.net',
context: 'system.net',
ts,
values: {
received: prevAgg ? (rate(prevAgg.rx, rx, dtSec) * 8) / 1000 : 0,
sent: prevAgg ? (rate(prevAgg.tx, tx, dtSec) * 8) / 1000 : 0,
},
})
ifaces.__agg = { rx, tx }
this.lastNet = ifaces
}
_collectIp(batch, ts, dtSec) {
const snmp = parseSnmp()
const sock = parseSockstat()
const prev = this.lastSnmp
const ip = snmp.ip
const tcp = snmp.tcp
const udp = snmp.udp
const ipRx = ip.InOctets || 0
const ipTx = ip.OutOctets || 0
batch.push({
chart: 'system.ip',
context: 'system.ip',
ts,
values: {
received: prev ? (rate(prev.ipRx || 0, ipRx, dtSec) * 8) / 1000 : 0,
sent: prev ? (rate(prev.ipTx || 0, ipTx, dtSec) * 8) / 1000 : 0,
},
})
// IPv6 octets not always in snmp; leave zeros unless /proc/net/snmp6 present
const snmp6 = readFile('/proc/net/snmp6')
let ip6Rx = 0
let ip6Tx = 0
if (snmp6) {
for (const line of snmp6.split('\n')) {
const [k, v] = line.trim().split(/\s+/)
if (k === 'Ip6InOctets') ip6Rx = Number(v) || 0
if (k === 'Ip6OutOctets') ip6Tx = Number(v) || 0
}
}
batch.push({
chart: 'system.ipv6',
context: 'system.ipv6',
ts,
values: {
received: prev ? (rate(prev.ip6Rx || 0, ip6Rx, dtSec) * 8) / 1000 : 0,
sent: prev ? (rate(prev.ip6Tx || 0, ip6Tx, dtSec) * 8) / 1000 : 0,
},
})
batch.push({
chart: 'ip.tcppackets',
context: 'ip.tcppackets',
ts,
values: {
received: prev ? rate(prev.tcpInSegs || 0, tcp.InSegs || 0, dtSec) : 0,
sent: prev ? rate(prev.tcpOutSegs || 0, tcp.OutSegs || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ip.tcperrors',
context: 'ip.tcperrors',
ts,
values: {
InErrs: prev ? rate(prev.tcpInErrs || 0, tcp.InErrs || 0, dtSec) : 0,
InCsumErrors: prev ? rate(prev.tcpInCsumErrors || 0, tcp.InCsumErrors || 0, dtSec) : 0,
RetransSegs: prev ? rate(prev.tcpRetrans || 0, tcp.RetransSegs || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ip.tcpopens',
context: 'ip.tcpopens',
ts,
values: {
active: prev ? rate(prev.tcpActive || 0, tcp.ActiveOpens || 0, dtSec) : 0,
passive: prev ? rate(prev.tcpPassive || 0, tcp.PassiveOpens || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ip.tcpsock',
context: 'ip.tcpsock',
ts,
values: { connections: sock.tcpInuse || tcp.CurrEstab || 0 },
})
batch.push({
chart: 'ipv4.packets',
context: 'ipv4.packets',
ts,
values: {
received: prev ? rate(prev.ipInReceives || 0, ip.InReceives || 0, dtSec) : 0,
sent: prev ? rate(prev.ipOutRequests || 0, ip.OutRequests || 0, dtSec) : 0,
forwarded: prev ? rate(prev.ipForwDatagrams || 0, ip.ForwDatagrams || 0, dtSec) : 0,
delivered: prev ? rate(prev.ipInDelivers || 0, ip.InDelivers || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ipv4.errors',
context: 'ipv4.errors',
ts,
values: {
InDiscards: prev ? rate(prev.ipInDiscards || 0, ip.InDiscards || 0, dtSec) : 0,
OutDiscards: prev ? rate(prev.ipOutDiscards || 0, ip.OutDiscards || 0, dtSec) : 0,
InHdrErrors: prev ? rate(prev.ipInHdrErrors || 0, ip.InHdrErrors || 0, dtSec) : 0,
OutNoRoutes: prev ? rate(prev.ipOutNoRoutes || 0, ip.OutNoRoutes || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ipv4.udppackets',
context: 'ipv4.udppackets',
ts,
values: {
received: prev ? rate(prev.udpInDatagrams || 0, udp.InDatagrams || 0, dtSec) : 0,
sent: prev ? rate(prev.udpOutDatagrams || 0, udp.OutDatagrams || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ipv4.udperrors',
context: 'ipv4.udperrors',
ts,
values: {
RcvbufErrors: prev ? rate(prev.udpRcvbufErrors || 0, udp.RcvbufErrors || 0, dtSec) : 0,
SndbufErrors: prev ? rate(prev.udpSndbufErrors || 0, udp.SndbufErrors || 0, dtSec) : 0,
InErrors: prev ? rate(prev.udpInErrors || 0, udp.InErrors || 0, dtSec) : 0,
NoPorts: prev ? rate(prev.udpNoPorts || 0, udp.NoPorts || 0, dtSec) : 0,
},
})
this.lastSnmp = {
ipRx,
ipTx,
ip6Rx,
ip6Tx,
tcpInSegs: tcp.InSegs || 0,
tcpOutSegs: tcp.OutSegs || 0,
tcpInErrs: tcp.InErrs || 0,
tcpInCsumErrors: tcp.InCsumErrors || 0,
tcpRetrans: tcp.RetransSegs || 0,
tcpActive: tcp.ActiveOpens || 0,
tcpPassive: tcp.PassiveOpens || 0,
ipInReceives: ip.InReceives || 0,
ipOutRequests: ip.OutRequests || 0,
ipForwDatagrams: ip.ForwDatagrams || 0,
ipInDelivers: ip.InDelivers || 0,
ipInDiscards: ip.InDiscards || 0,
ipOutDiscards: ip.OutDiscards || 0,
ipInHdrErrors: ip.InHdrErrors || 0,
ipOutNoRoutes: ip.OutNoRoutes || 0,
udpInDatagrams: udp.InDatagrams || 0,
udpOutDatagrams: udp.OutDatagrams || 0,
udpRcvbufErrors: udp.RcvbufErrors || 0,
udpSndbufErrors: udp.SndbufErrors || 0,
udpInErrors: udp.InErrors || 0,
udpNoPorts: udp.NoPorts || 0,
}
collectNetDeep(batch, ts, dtSec, this.deepState, snmp)
}
_collectPressure(batch, ts) {
for (const [kind, someChart, fullChart] of [
['cpu', 'system.cpu_some_pressure', 'system.cpu_full_pressure'],
['memory', 'system.memory_some_pressure', 'system.memory_full_pressure'],
['io', 'system.io_some_pressure', 'system.io_full_pressure'],
]) {
const p = parsePressure(kind)
if (!p) continue
batch.push({
chart: someChart,
context: someChart,
ts,
values: { some10: p.some10, some60: p.some60, some300: p.some300 },
})
if (p.full10 != null) {
batch.push({
chart: fullChart,
context: fullChart,
ts,
values: { full10: p.full10, full60: p.full60, full300: p.full300 },
})
}
}
}
_collectDiskSpace(batch, ts) {
if (typeof fs.statfsSync !== 'function') return
for (const m of parseMounts()) {
const id = mountId(m.path)
const sample = sampleMount(m.path)
if (!sample) continue
if (!this._chartsRegistered) {
registerChart(makeDiskSpaceChart(id, m.path))
registerChart(makeDiskInodesChart(id, m.path))
}
batch.push({
chart: `disk_space.${id}`,
context: 'disk.space',
ts,
values: sample.space,
})
batch.push({
chart: `disk_inodes.${id}`,
context: 'disk.inodes',
ts,
values: sample.inodes,
})
}
}
getLatest() {
/** @type {Record<string, Record<string, number|null>>} */
const out = {}
for (const [k, v] of this.latest) out[k] = v
return out
}
getNodeInfo(publicKeyHex, agentVersion) {
return {
nodeId: publicKeyHex?.slice(0, 16) || os.hostname(),
hostname: os.hostname(),
publicKeyHex: publicKeyHex || null,
platform: os.platform(),
arch: os.arch(),
release: os.release(),
cpus: os.cpus().length,
totalMemMiB: bytesToMiB(os.totalmem()),
agentVersion,
startedAt: Date.now() - Math.floor(safeProcessUptime() * 1000),
charts: getAllChartDefs().map((c) => c.id),
contexts: [...new Set(getAllChartDefs().map((c) => c.context))],
sampleIntervalMs: this.intervalMs,
sampleCount: this.sampleCount,
}
}
}
/** @type {MetricsCollector|null} */
let singleton = null
export function getCollector() {
if (!singleton) singleton = new MetricsCollector()
return singleton
}
/** @internal exported for unit tests */
export { cpuDeltaPct }