/** * Filesystem lifetime read/write stats (ext4, xfs). * * Enable: default on Linux when unset * Disable: PEARDATA_FS_STATS=0 * * Charts: fs.ext4.{dev}, fs.xfs.{dev} */ import fs from 'fs' import path from 'path' import os from 'os' import { CollectorPlugin } from './plugin.js' import { registerChart } from '../../../shared/metrics.js' import { readFileBuf } from '../../utils/fd-cache.js' import logger from '../../utils/logger.js' const log = logger.child('fs-stats') export function isFsStatsEnabled() { const v = process.env.PEARDATA_FS_STATS if (v === '0' || v === 'off' || v === 'false') return false if (v === '1' || v === 'on' || v === 'true') return true return os.platform() === 'linux' } function readFile(p) { return readFileBuf(p) } function readNum(p) { const raw = readFile(p) if (raw == null) return null const n = Number(String(raw).trim()) return Number.isFinite(n) ? n : null } /** * @param {string} name */ function sanitizeDev(name) { return String(name) .replace(/[^a-zA-Z0-9_.-]/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 64) } /** * @param {'ext4'|'xfs'} fsType * @param {string} dev */ function makeFsChart(fsType, dev) { const safe = sanitizeDev(dev) const id = `fs.${fsType}.${safe}` return { id, name: id, context: `fs.${fsType}`, title: `${fsType.toUpperCase()} ${dev}`, units: 'kB', family: dev, chartType: 'line', priority: fsType === 'ext4' ? 4300 : 4310, plugin: 'fs-stats', dimensions: [ { id: 'write_kb', name: 'write_kb', algorithm: 'absolute' }, { id: 'read_kb', name: 'read_kb', algorithm: 'absolute' }, ], } } /** * @returns {Array<{ fsType: 'ext4'|'xfs', dev: string, write_kb: number|null, read_kb: number|null }>} */ export function collectFsStatsData() { /** @type {Array<{ fsType: 'ext4'|'xfs', dev: string, write_kb: number|null, read_kb: number|null }>} */ const out = [] const ext4Root = '/sys/fs/ext4' if (fs.existsSync(ext4Root)) { for (const dev of fs.readdirSync(ext4Root)) { const base = path.join(ext4Root, dev) let write_kb = readNum(path.join(base, 'lifetime_write_kbytes')) let read_kb = readNum(path.join(base, 'lifetime_read_kbytes')) if (write_kb == null && read_kb == null) { const statsPath = path.join('/proc/fs/ext4', dev, 'stats') const raw = readFile(statsPath) if (raw) { const nums = raw.trim().split(/\s+/).map(Number) if (nums.length >= 2) { write_kb = Number.isFinite(nums[0]) ? nums[0] : null read_kb = Number.isFinite(nums[1]) ? nums[1] : null } } } if (write_kb != null || read_kb != null) { out.push({ fsType: 'ext4', dev, write_kb, read_kb }) } } } const xfsRoot = '/sys/fs/xfs' if (fs.existsSync(xfsRoot)) { for (const dev of fs.readdirSync(xfsRoot)) { const statsDir = path.join(xfsRoot, dev, 'stats') if (!fs.existsSync(statsDir)) continue let write_kb = null let read_kb = null const statsFile = path.join(statsDir, 'stats') const raw = readFile(statsFile) if (raw) { for (const line of raw.split('\n')) { const w = line.match(/write\s+(\d+)/i) const r = line.match(/read\s+(\d+)/i) if (w) write_kb = Number(w[1]) if (r) read_kb = Number(r[1]) } } write_kb = write_kb ?? readNum(path.join(statsDir, 'write')) read_kb = read_kb ?? readNum(path.join(statsDir, 'read')) if (write_kb != null || read_kb != null) { out.push({ fsType: 'xfs', dev, write_kb, read_kb }) } } } return out } export class FsStatsCollector extends CollectorPlugin { constructor(opts = {}) { super({ name: 'fs-stats', intervalMs: opts.intervalMs }) } isEnabled() { return isFsStatsEnabled() } start() { if (!this.isEnabled()) return log.info('Filesystem stats collector started') super.start() } async collect() { const t0 = performance.now() const rows = collectFsStatsData() if (!rows.length) return [] const ts = Date.now() /** @type {Array<{ chart: string, context: string, ts: number, values: Record }>} */ const batch = [] for (const row of rows) { const def = makeFsChart(row.fsType, row.dev) registerChart(def) batch.push({ chart: def.id, context: def.context, ts, values: { write_kb: row.write_kb, read_kb: row.read_kb }, }) } const dur = performance.now() - t0 if (dur > 5) log.debug('tick', { collector: 'fs-stats', durMs: Math.round(dur * 10) / 10 }) return batch } } /** @type {FsStatsCollector|null} */ let singleton = null export function getFsStatsCollector() { if (!singleton) singleton = new FsStatsCollector() return singleton }