Files
peardata/server/services/collectors/peardock.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

276 lines
8.6 KiB
JavaScript

/**
* PearDock bridge collector (Phase 3 spike).
*
* Dials dock / agent peers and remaps their docker/container charts into
* peardock.* — dials remote peers over public RPC (no PearDock source vendored).
*
* Enable: PEARDATA_PEARDOCK=1
* Peers: PEARDATA_PEARDOCK_PEERS=hex,hex (or PEARDATA_PEARDOCK_PEERS_FILE)
* Optional: PEARDATA_PEARDOCK_SEED, PEARDATA_PEARDOCK_POLL_MS
*
* Charts: peardock.containers, peardock.cpu.<id>, peardock.mem.<id>
* (sourced from remote docker.* charts when present)
*/
import fs from 'fs'
import { EventEmitter } from 'events'
import { PearDataConnection } from '../../../client/connection.js'
import { Methods } from '../../../shared/protocol.js'
import { registerChart } from '../../../shared/metrics.js'
import { isHexId, resolveContainerLabel } from '../../../shared/container-names.js'
import logger from '../../utils/logger.js'
const log = logger.child('peardock')
/** @type {Set<string>} */
const _mappedCharts = new Set()
export function isPearDockEnabled() {
const v = process.env.PEARDATA_PEARDOCK
return v === '1' || v === 'on' || v === 'true'
}
/**
* @returns {string[]}
*/
export function parsePearDockPeers() {
const raw = process.env.PEARDATA_PEARDOCK_PEERS || ''
const fromEnv = raw
.split(/[,\s]+/)
.map((s) => s.trim().toLowerCase())
.filter((s) => /^[0-9a-f]{64}$/.test(s))
const file = process.env.PEARDATA_PEARDOCK_PEERS_FILE
if (!file) return [...new Set(fromEnv)]
try {
const text = fs.readFileSync(file, 'utf8')
const fromFile = text
.split(/\r?\n/)
.map((l) => l.replace(/#.*$/, '').trim().toLowerCase())
.filter((s) => /^[0-9a-f]{64}$/.test(s))
return [...new Set([...fromEnv, ...fromFile])]
} catch {
return [...new Set(fromEnv)]
}
}
/**
* Map remote docker/container chart id → peardock chart id.
* @param {string} chartId
* @returns {string|null}
*/
export function remapDockChart(chartId) {
const id = String(chartId || '')
if (id.startsWith('docker.')) return `peardock.${id.slice('docker.'.length)}`
if (id.startsWith('container.')) return `peardock.${id.slice('container.'.length)}`
if (id.startsWith('cgroup.docker.')) return `peardock.${id.slice('cgroup.docker.'.length)}`
return null
}
/**
* @param {any} metrics getAllMetrics-style payload
* @returns {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string }>}
*/
export function extractDockCharts(metrics) {
const root = metrics?.body || metrics
const charts = root?.charts || {}
/** @type {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string, title?: string, family?: string }>} */
const out = []
for (const [chartId, point] of Object.entries(charts)) {
const mapped = remapDockChart(chartId)
if (!mapped) continue
const values = point?.dimensions || point?.values || {}
/** @type {Record<string, number|null>} */
const nums = {}
for (const [k, v] of Object.entries(values)) {
const n = typeof v === 'object' && v != null ? Number(v.value) : Number(v)
nums[k] = Number.isFinite(n) ? n : null
}
const parts = mapped.split('.')
const context = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : mapped
out.push({
chart: mapped,
context,
values: nums,
sourceChart: chartId,
title: point?.title || point?.name || undefined,
family: point?.family || undefined,
})
}
return out
}
/**
* @param {string} mappedId
* @param {string} context
* @param {Record<string, number|null>} values
* @param {{ title?: string, family?: string }} [meta]
*/
function registerMappedChart(mappedId, context, values, meta = {}) {
if (_mappedCharts.has(mappedId)) return
const dims = Object.keys(values).map((id) => ({
id,
name: id,
algorithm: 'absolute',
}))
if (!dims.length) dims.push({ id: 'value', name: 'value', algorithm: 'absolute' })
const short = mappedId.split('.').pop() || mappedId
const fromMeta = meta.family || meta.title
let display
if (fromMeta) {
// Titles look like "nginx · CPU" — keep the name side
const base = String(fromMeta).includes('·')
? String(fromMeta).split('·')[0].trim()
: String(fromMeta).trim()
display = resolveContainerLabel(base || fromMeta, null)
} else {
display = isHexId(short, 12) ? `container ${short.slice(0, 12)}` : short
}
const kind = context.includes('mem') ? 'memory' : context.includes('cpu') ? 'CPU' : 'metrics'
const units = context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count'
registerChart({
id: mappedId,
name: mappedId,
context,
title: `${display} · ${kind}`,
units,
family: display,
chartType: 'line',
priority: 8500,
plugin: 'peardock',
dimensions: dims,
})
_mappedCharts.add(mappedId)
}
export class PearDockCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.pollMs = opts.pollMs || Number(process.env.PEARDATA_PEARDOCK_POLL_MS) || 5000
this.adminSeed = opts.adminSeed || process.env.PEARDATA_PEARDOCK_SEED || null
this.peerKeys = opts.peers || parsePearDockPeers()
/** @type {Map<string, PearDataConnection|null>} */
this.conns = new Map()
this._timer = null
this._busy = false
}
start() {
if (this._timer) return
registerChart({
id: 'peardock.containers',
name: 'peardock.containers',
context: 'peardock.containers',
title: 'PearDock bridged containers',
units: 'containers',
family: 'peardock',
chartType: 'line',
priority: 8490,
plugin: 'peardock',
dimensions: [
{ id: 'charts', name: 'charts', algorithm: 'absolute' },
{ id: 'peers', name: 'peers', algorithm: 'absolute' },
],
})
log.info('PearDock bridge started', { peers: this.peerKeys.length, pollMs: this.pollMs })
this._tick()
this._timer = setInterval(() => this._tick(), this.pollMs)
if (typeof this._timer.unref === 'function') this._timer.unref()
}
stop() {
if (this._timer) {
clearInterval(this._timer)
this._timer = null
}
for (const [pk, conn] of this.conns) {
if (conn) conn.destroy().catch(() => {})
this.conns.set(pk, null)
}
}
async _ensure(pk) {
let conn = this.conns.get(pk)
if (conn?.connected) return conn
try {
if (conn) await conn.destroy().catch(() => {})
conn = new PearDataConnection(pk, {
adminSeed: this.adminSeed,
timeoutMs: 20_000,
})
await conn.connect()
this.conns.set(pk, conn)
conn.on('disconnected', () => this.conns.set(pk, null))
return conn
} catch (err) {
this.conns.set(pk, null)
log.warn('PearDock dial failed', { peer: pk.slice(0, 12), error: err.message })
return null
}
}
async _tick() {
if (this._busy) return
this._busy = true
try {
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
let peersUp = 0
let chartCount = 0
for (const pk of this.peerKeys) {
const conn = await this._ensure(pk)
if (!conn) continue
try {
const [metrics, catalog] = await Promise.all([
conn.request(Methods.getAllMetrics, { format: 'json' }),
conn.request(Methods.listCharts, {}).catch(() => ({ charts: {} })),
])
const chartMeta = catalog?.charts || {}
const mapped = extractDockCharts(metrics)
peersUp++
for (const row of mapped) {
const remote = chartMeta[row.sourceChart] || {}
registerMappedChart(row.chart, row.context, row.values, {
title: remote.title || row.title,
family: remote.family || row.family,
})
batch.push({
chart: row.chart,
context: row.context,
ts,
values: row.values,
})
chartCount++
}
} catch (err) {
log.warn('PearDock poll failed', { peer: pk.slice(0, 12), error: err.message })
try {
await conn.destroy()
} catch {
// ignore
}
this.conns.set(pk, null)
}
}
batch.unshift({
chart: 'peardock.containers',
context: 'peardock.containers',
ts,
values: { charts: chartCount, peers: peersUp },
})
this.emit('samples', batch)
} finally {
this._busy = false
}
}
}
/** @type {PearDockCollector|null} */
let singleton = null
export function getPearDockCollector() {
if (!singleton) singleton = new PearDockCollector()
return singleton
}