@esmj/monitor
Version:
Node.js performance measurement metrics (cpu, memory, event loop, gc)
544 lines (440 loc) • 12.3 kB
JavaScript
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 };