UNPKG

@esmj/monitor

Version:

Node.js performance measurement metrics (cpu, memory, event loop, gc)

544 lines (440 loc) 12.3 kB
import { Observer, Observable } from '@esmj/observable'; export { pipe } from '@esmj/observable'; import { cpuUsage, memoryUsage, version, uptime, platform, ppid, pid } from 'node:process'; import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks'; import { loadavg } from 'node:os'; import { getHeapStatistics } from 'node:v8'; function medianNoiseReduction(grouping = 5) { return function _medianNoiseReduction(array) { return array.map((value, index, array) => { const startIndex = index - Math.floor(grouping / 2) < 0 ? 0 : index - Math.floor(grouping / 2); const endIndex = index + Math.ceil(grouping / 2) <= array.length ? index + Math.ceil(grouping / 2) : array.length; const group = array.slice(startIndex, endIndex); return percentile(50)(group); }); }; } function linearRegression() { return function _linearRegression(array) { const { sumY, sumX, sumX2, sumXY } = array.reduce( (result, value, index) => { const x = value?.x ?? index + 1; const y = value?.y ?? value; result.sumX += x; result.sumY += y; result.sumX2 += x * x; result.sumXY += x * y; return result; }, { sumY: 0, sumX: 0, sumX2: 0, sumXY: 0, }, ); const divisor = array.length * sumX2 - sumX * sumX; if (divisor === 0) { return { slope: 0, yIntercept: 0, predict: () => 0 }; } const yIntercept = (sumY * sumX2 - sumX * sumXY) / divisor; const slope = (array.length * sumXY - sumX * sumY) / divisor; return { slope, yIntercept, predict: (x = array.length + 1) => slope * x + yIntercept, }; }; } function percentile(number = 50) { return function _percentile(array) { if (!array.length) { return undefined; } const sortedArray = [...array].sort((a, b) => a - b); const size = sortedArray.length; const min = 100 / (size + 1); const max = (100 * size) / (size + 1); if (number <= min) { return sortedArray[0]; } if (number >= max) { return sortedArray[size - 1]; } const estimatedIndex = (number / 100) * (size + 1) - 1; const index = Math.floor(estimatedIndex); const decimalWeight = Math.abs(estimatedIndex) - index; return ( sortedArray[index] + decimalWeight * (sortedArray[index + 1] - sortedArray[index]) ); }; } function takeLast(size) { return function _takeLast(array) { return array.slice( !size || size > array.length ? 0 : array.length - size, array.length, ); }; } const IS_MEMO = Symbol('MemoSymbol'); function memo(func) { return ((func) => { let cache = {}; const keyGenerator = (...rest) => rest.join('-'); const clear = () => { cache = {}; }; const memoized = (...rest) => { const key = keyGenerator(...rest); if (!cache[key]) { cache[key] = func(...rest); } return cache[key]; }; memoized.clear = clear; memoized[IS_MEMO] = true; return memoized; })(func); } class MetricsHistory extends Observer { #options = { limit: 60 }; #regression = null; #history = []; custom = {}; constructor(options) { super(); this.#options = { ...this.#options, ...options }; this.#regression = linearRegression(); // TODO deprecated, remove in next major version this.percentileMemo = memo((...rest) => this.percentile(...rest)); this.trendMemo = memo((...rest) => this.trend(...rest)); } get size() { return this.#history.length; } get current() { return this.#history[this.#history.length - 1]; } #clearMemo() { this.percentileMemo.clear(); this.trendMemo.clear(); Object.keys(this.custom).forEach((key) => { if (typeof this.custom[key] === 'function' && this.custom[key][IS_MEMO]) { this.custom[key].clear(); } }); } complete() { this.#history = []; this.#clearMemo(); } next(metric) { this.#history.push(metric); if (this.#history.length > this.#options.limit) { this.#history.shift(); } this.#clearMemo(); } error(error) { console.error(error); } add(name, func) { if (this.custom[name]) { throw new Error( `The key "${name}" of custom statistic function is uccupied.`, ); } this.custom[name] = func; } // TODO deprecated, remove in next major version percentile(key, number) { const array = this.getValues(key); return percentile(number)(array); } // TODO deprecated, remove in next major version trend(key, limit) { let array = this.getValues(key); array = array.slice( !limit || limit > array.length ? 0 : array.length - limit, array.length, ); return this.#regression(array); } from(key) { return () => this.getValues(key); } getValues(key) { const keys = key?.split('.') ?? []; return this.#history.map((metric) => { return keys.reduce((result, key) => { return result?.[key]; }, metric); }); } } class Monitor extends Observable { #options = { interval: 1000 }; #intervalId = null; #metrics = []; constructor(options) { super(); this.#options = { ...this.#options, ...options }; } add(metric) { this.#metrics.push(metric); return () => { this.remove(metric); }; } remove(metric) { const index = this.#metrics.indexOf(metric); this.#metrics.splice(index, 1); } start() { this.#runMetricMethod('start', this.#options); this.#measure(); } stop() { clearInterval(this.#intervalId); this.#runMetricMethod('stop', this.#options); this.complete(); } #runMetricMethod(method, args) { return this.#metrics.reduce((result, metric) => { Object.assign(result, metric[method](args)); return result; }, {}); } #measure() { this.#intervalId = setInterval(() => { this.#runMetricMethod('beforeMeasure', this.#options); const metrics = this.#runMetricMethod('measure', this.#options); this.#notify(metrics); this.#runMetricMethod('afterMeasure', this.#options); }, this.#options.interval).unref(); } #notify(...rest) { try { this.next(...rest); } catch (error) { this.error(error); } } } class Metric { start() {} beforeMeasure() {} measure() {} afterMeasure() {} stop() {} } function roundToTwoDecimal(value) { return Math.round(value * 100) / 100; } class CPUUsageMetric extends Metric { #cpuUsage = null; start() { this.#cpuUsage = cpuUsage(); } measure({ interval }) { const cpuUsageData = cpuUsage(this.#cpuUsage); return { cpuUsage: { user: cpuUsageData.user, system: cpuUsageData.system, percent: roundToTwoDecimal( (100 * (cpuUsageData.user + cpuUsageData.system)) / (interval * 1000), ), }, }; } afterMeasure() { this.#cpuUsage = cpuUsage(); } stop() { this.#cpuUsage = null; } } class EventLoopDelayMetric extends Metric { #histogram = null; start() { this.#histogram = monitorEventLoopDelay({ resolution: 20 }); this.#histogram.enable(); } beforeMeasure() { this.#histogram.disable(); } measure({ interval }) { return { eventLoopDelay: { min: roundToTwoDecimal(this.#histogram.min / (interval * 1000)), max: roundToTwoDecimal(this.#histogram.max / (interval * 1000)), mean: roundToTwoDecimal(this.#histogram.mean / (interval * 1000)), stddev: roundToTwoDecimal(this.#histogram.stddev / (interval * 1000)), percentile80: roundToTwoDecimal( this.#histogram.percentile(80) / (interval * 1000), ), }, }; } afterMeasure() { this.#histogram.reset(); this.#histogram.enable(); } stop() { this.#histogram.reset(); this.#histogram.disable(); this.#histogram = null; } } const { eventLoopUtilization } = performance; class EventLoopUtilizationMetric extends Metric { #eventLoopUtilizationDataStart = null; #eventLoopUtilizationDataEnd = null; start() { this.#eventLoopUtilizationDataStart = eventLoopUtilization(); } beforeMeasure() { this.#eventLoopUtilizationDataEnd = eventLoopUtilization(); } measure() { const eventLoopUtilizationData = eventLoopUtilization( this.#eventLoopUtilizationDataEnd, this.#eventLoopUtilizationDataStart, ); return { eventLoopUtilization: { idle: roundToTwoDecimal(eventLoopUtilizationData.idle), active: roundToTwoDecimal(eventLoopUtilizationData.active), utilization: roundToTwoDecimal(eventLoopUtilizationData.utilization), }, }; } afterMeasure() { this.#eventLoopUtilizationDataStart = this.#eventLoopUtilizationDataEnd; } stop() { this.#eventLoopUtilizationDataStart = null; this.#eventLoopUtilizationDataEnd = null; } } class GCMetric extends Metric { #performanceObserver = null; #entry = null; start() { this.#performanceObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries?.[0]) { this.#entry = entries[0]; } /* The entry would be an instance of PerformanceEntry containing metrics of garbage collection. For example: PerformanceEntry { name: 'gc', entryType: 'gc', startTime: 2820.567669, duration: 1.315709, kind: 1 } */ }); this.#performanceObserver.observe({ entryTypes: ['gc'] }); } measure() { return { gc: { entry: this.#entry, }, }; } afterMeasure() { this.#entry = null; } stop() { this.#performanceObserver.disconnect(); } } class LoadAverageMetric extends Metric { measure() { const [minute1, minute5, minute15] = loadavg(); return { loadAverage: { minute1: roundToTwoDecimal(minute1), minute5: roundToTwoDecimal(minute5), minute15: roundToTwoDecimal(minute15), }, }; } } class MemoryUsageMetric extends Metric { #heapStatistics = null; start() { this.#heapStatistics = getHeapStatistics(); } measure() { const memoryUsageData = memoryUsage(); return { memoryUsage: { percent: roundToTwoDecimal( (memoryUsageData.rss / this.#heapStatistics.total_available_size) * 100, ), rss: this.#toMB(memoryUsageData.rss), heapTotal: this.#toMB(memoryUsageData.heapTotal), heapUsed: this.#toMB(memoryUsageData.heapUsed), external: this.#toMB(memoryUsageData.external), arrayBuffers: this.#toMB(memoryUsageData.arrayBuffers), }, }; } stop() { this.#heapStatistics = null; } #toMB(value) { return roundToTwoDecimal(value / 1024 / 1024); } } class ProcessMetric extends Metric { measure() { return { process: { pid, ppid, platform, uptime: uptime(), version, }, }; } } function createMonitoring(options) { const cpuUsageMetric = new CPUUsageMetric(); const eventLoopDelayMetric = new EventLoopDelayMetric(); const eventLoopUtilizationMetric = new EventLoopUtilizationMetric(); const loadAverageMetric = new LoadAverageMetric(); const memoryUsageMetric = new MemoryUsageMetric(); const gcMetric = new GCMetric(); const processMetric = new ProcessMetric(); const monitor = new Monitor(options?.monitor); const metricsHistory = new MetricsHistory(options?.metricsHistory); monitor.subscribe(metricsHistory); monitor.add(cpuUsageMetric); monitor.add(eventLoopDelayMetric); monitor.add(eventLoopUtilizationMetric); monitor.add(loadAverageMetric); monitor.add(memoryUsageMetric); monitor.add(gcMetric); monitor.add(processMetric); return { monitor, metricsHistory }; } export { CPUUsageMetric, EventLoopDelayMetric, EventLoopUtilizationMetric, GCMetric, LoadAverageMetric, MemoryUsageMetric, Metric, MetricsHistory, Monitor, ProcessMetric, createMonitoring, linearRegression, medianNoiseReduction, memo, percentile, takeLast };