This commit is contained in:
2026-02-19 09:51:34 +00:00
parent 7c3197455f
commit 32d75804c0
7 changed files with 878 additions and 99 deletions

View File

@@ -17,8 +17,7 @@
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.8",
"@types/node": "^25.3.0",
"@types/pidusage": "^2.0.2"
"@types/node": "^25.3.0"
},
"browserslist": [
"last 1 chrome versions"
@@ -37,10 +36,7 @@
],
"dependencies": {
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartlog": "^3.1.11",
"pidtree": "^0.6.0",
"pidusage": "^4.0.1",
"prom-client": "^15.1.3"
"@push.rocks/smartlog": "^3.1.11"
},
"type": "module",
"keywords": [

59
pnpm-lock.yaml generated
View File

@@ -14,15 +14,6 @@ importers:
'@push.rocks/smartlog':
specifier: ^3.1.11
version: 3.1.11
pidtree:
specifier: ^0.6.0
version: 0.6.0
pidusage:
specifier: ^4.0.1
version: 4.0.1
prom-client:
specifier: ^15.1.3
version: 15.1.3
devDependencies:
'@git.zone/tsbuild':
specifier: ^4.1.2
@@ -39,9 +30,6 @@ importers:
'@types/node':
specifier: ^25.3.0
version: 25.3.0
'@types/pidusage':
specifier: ^2.0.2
version: 2.0.5
packages:
@@ -693,10 +681,6 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@oxc-project/types@0.99.0':
resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==}
@@ -1871,9 +1855,6 @@ packages:
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@types/pidusage@2.0.5':
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
'@types/ping@0.4.4':
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
@@ -2077,9 +2058,6 @@ packages:
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
engines: {node: '>=10.0.0'}
bintrees@1.0.2:
resolution: {integrity: sha1-SfiW1uhYpKSZ34XDj7OZua/4QPg=}
body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -3519,15 +3497,6 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pidtree@0.6.0:
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
engines: {node: '>=0.10'}
hasBin: true
pidusage@4.0.1:
resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==}
engines: {node: '>=18'}
ping@0.4.4:
resolution: {integrity: sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ==}
engines: {node: '>=4.0.0'}
@@ -3552,10 +3521,6 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -3917,9 +3882,6 @@ packages:
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
@@ -5743,8 +5705,6 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@opentelemetry/api@1.9.0': {}
'@oxc-project/types@0.99.0': {}
'@pdf-lib/standard-fonts@1.0.0':
@@ -7762,8 +7722,6 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/pidusage@2.0.5': {}
'@types/ping@0.4.4': {}
'@types/qs@6.14.0': {}
@@ -7958,8 +7916,6 @@ snapshots:
basic-ftp@5.0.5: {}
bintrees@1.0.2: {}
body-parser@1.20.3:
dependencies:
bytes: 3.1.2
@@ -9663,12 +9619,6 @@ snapshots:
picomatch@4.0.3: {}
pidtree@0.6.0: {}
pidusage@4.0.1:
dependencies:
safe-buffer: 5.2.1
ping@0.4.4: {}
pkg-dir@4.2.0:
@@ -9689,11 +9639,6 @@ snapshots:
progress@2.0.3: {}
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.0
tdigest: 0.1.2
property-information@7.1.0: {}
proto-list@1.2.4: {}
@@ -10200,10 +10145,6 @@ snapshots:
fast-fifo: 1.3.2
streamx: 2.22.1
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
text-decoder@1.2.3:
dependencies:
b4a: 1.6.7

View File

@@ -5,37 +5,36 @@ export class SmartMetrics {
public started = false;
public sourceNameArg: string;
public logger: plugins.smartlog.Smartlog;
public registry: plugins.promClient.Registry;
public registry: plugins.prom.Registry;
public maxMemoryMB: number;
// Prometheus gauges for custom metrics
private cpuPercentageGauge: plugins.promClient.Gauge<string>;
private memoryPercentageGauge: plugins.promClient.Gauge<string>;
private memoryUsageBytesGauge: plugins.promClient.Gauge<string>;
private cpuPercentageGauge: plugins.prom.Gauge;
private memoryPercentageGauge: plugins.prom.Gauge;
private memoryUsageBytesGauge: plugins.prom.Gauge;
// HTTP server for Prometheus endpoint
private prometheusServer?: plugins.http.Server;
private prometheusPort?: number;
public setup() {
const collectDefaultMetrics = plugins.promClient.collectDefaultMetrics;
this.registry = new plugins.promClient.Registry();
collectDefaultMetrics({ register: this.registry });
this.registry = new plugins.prom.Registry();
plugins.prom.collectDefaultMetrics(this.registry);
// Initialize custom gauges
this.cpuPercentageGauge = new plugins.promClient.Gauge({
this.cpuPercentageGauge = new plugins.prom.Gauge({
name: 'smartmetrics_cpu_percentage',
help: 'Current CPU usage percentage',
registers: [this.registry]
});
this.memoryPercentageGauge = new plugins.promClient.Gauge({
this.memoryPercentageGauge = new plugins.prom.Gauge({
name: 'smartmetrics_memory_percentage',
help: 'Current memory usage percentage',
registers: [this.registry]
});
this.memoryUsageBytesGauge = new plugins.promClient.Gauge({
this.memoryUsageBytesGauge = new plugins.prom.Gauge({
name: 'smartmetrics_memory_usage_bytes',
help: 'Current memory usage in bytes',
registers: [this.registry]
@@ -110,23 +109,23 @@ export class SmartMetrics {
public async getMetrics() {
let pids: number[] = [];
try {
pids = await plugins.pidtree(process.pid);
pids = await plugins.pidtree.getChildPids(process.pid);
} catch {
// pidtree can fail if process tree cannot be read
}
const stats = await plugins.pidusage([process.pid, ...pids]);
const stats = await plugins.pidusage.getPidUsage([process.pid, ...pids]);
let cpuPercentage = 0;
for (const stat of Object.keys(stats)) {
if (!stats[stat]) continue;
cpuPercentage += stats[stat].cpu;
if (!stats[stat as any]) continue;
cpuPercentage += stats[stat as any].cpu;
}
let cpuUsageText = `${Math.round(cpuPercentage * 100) / 100} %`;
let memoryUsageBytes = 0;
for (const stat of Object.keys(stats)) {
if (!stats[stat]) continue;
memoryUsageBytes += stats[stat].memory;
if (!stats[stat as any]) continue;
memoryUsageBytes += stats[stat as any].memory;
}
let memoryPercentage =
@@ -149,15 +148,15 @@ export class SmartMetrics {
// Calculate Node.js metrics directly
const cpuUsage = process.cpuUsage();
const process_cpu_seconds_total = (cpuUsage.user + cpuUsage.system) / 1000000; // Convert from microseconds to seconds
const heapStats = plugins.v8.getHeapStatistics();
const nodejs_heap_size_total_bytes = heapStats.total_heap_size;
// Note: Active handles and requests are internal Node.js metrics that require deprecated APIs
// We return 0 here, but the Prometheus default collectors will track the real values
const nodejs_active_handles_total = 0;
const nodejs_active_requests_total = 0;
const returnMetrics: interfaces.IMetricsSnapshot = {
process_cpu_seconds_total,
nodejs_active_handles_total,
@@ -175,7 +174,7 @@ export class SmartMetrics {
public async getPrometheusFormattedMetrics(): Promise<string> {
// Update metrics to ensure gauges have latest values
await this.getMetrics();
// Return Prometheus text exposition format
return await this.registry.metrics();
}
@@ -185,7 +184,7 @@ export class SmartMetrics {
this.logger.log('warn', 'Prometheus endpoint is already running');
return;
}
this.prometheusServer = plugins.http.createServer(async (req, res) => {
if (req.url === '/metrics' && req.method === 'GET') {
try {
@@ -202,7 +201,7 @@ export class SmartMetrics {
res.end('Not Found');
}
});
this.prometheusPort = port;
this.prometheusServer.listen(port, () => {
this.logger.log('info', `Prometheus metrics endpoint available at http://localhost:${port}/metrics`);
@@ -213,12 +212,12 @@ export class SmartMetrics {
if (!this.prometheusServer) {
return;
}
const port = this.prometheusPort;
this.prometheusServer.close(() => {
this.logger.log('info', `Prometheus metrics endpoint on port ${port} has been shut down`);
});
this.prometheusServer = undefined;
this.prometheusPort = undefined;
}

View File

@@ -0,0 +1,55 @@
import * as fs from 'fs';
// Get all descendant PIDs of the given root PID by reading /proc/<pid>/stat.
// Returns an array of descendant PIDs (excludes the root itself).
export async function getChildPids(rootPid: number): Promise<number[]> {
const parentMap = new Map<number, number[]>(); // parent → children
let entries: string[];
try {
entries = fs.readdirSync('/proc');
} catch {
return [];
}
for (const entry of entries) {
const pid = parseInt(entry, 10);
if (isNaN(pid)) continue;
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
// Format: pid (comm) state ppid ...
// comm can contain spaces and parentheses, so find the last ')' first
const closeParenIdx = stat.lastIndexOf(')');
if (closeParenIdx === -1) continue;
const afterComm = stat.slice(closeParenIdx + 2); // skip ') '
const fields = afterComm.split(' ');
const ppid = parseInt(fields[1], 10); // field index 1 after state is ppid
if (!parentMap.has(ppid)) {
parentMap.set(ppid, []);
}
parentMap.get(ppid)!.push(pid);
} catch {
// Process may have exited between readdir and readFile
continue;
}
}
// BFS from rootPid to collect all descendants
const result: number[] = [];
const queue: number[] = [rootPid];
while (queue.length > 0) {
const current = queue.shift()!;
const children = parentMap.get(current);
if (children) {
for (const child of children) {
result.push(child);
queue.push(child);
}
}
}
return result;
}

117
ts/smartmetrics.pidusage.ts Normal file
View File

@@ -0,0 +1,117 @@
import * as fs from 'fs';
import { execSync } from 'child_process';
// Cached system constants
let clkTck: number | null = null;
let pageSize: number | null = null;
function getClkTck(): number {
if (clkTck === null) {
try {
clkTck = parseInt(execSync('getconf CLK_TCK', { encoding: 'utf8' }).trim(), 10);
} catch {
clkTck = 100; // standard Linux default
}
}
return clkTck;
}
function getPageSize(): number {
if (pageSize === null) {
try {
pageSize = parseInt(execSync('getconf PAGESIZE', { encoding: 'utf8' }).trim(), 10);
} catch {
pageSize = 4096; // standard Linux default
}
}
return pageSize;
}
// History for CPU delta tracking
interface ISnapshot {
utime: number;
stime: number;
timestamp: number; // hrtime in seconds
}
const history = new Map<number, ISnapshot>();
function readProcStat(pid: number): { utime: number; stime: number; rss: number } | null {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
// Format: pid (comm) state ppid ... fields
// utime is field 14, stime is field 15, rss is field 24 (1-indexed)
const closeParenIdx = stat.lastIndexOf(')');
if (closeParenIdx === -1) return null;
const afterComm = stat.slice(closeParenIdx + 2);
const fields = afterComm.split(' ');
// fields[0] = state (field 3), so utime = fields[11] (field 14), stime = fields[12] (field 15), rss = fields[21] (field 24)
const utime = parseInt(fields[11], 10);
const stime = parseInt(fields[12], 10);
const rss = parseInt(fields[21], 10);
return { utime, stime, rss };
} catch {
return null;
}
}
function hrtimeSeconds(): number {
const [sec, nsec] = process.hrtime();
return sec + nsec / 1e9;
}
export interface IPidUsageResult {
cpu: number;
memory: number;
}
/**
* Get CPU percentage and memory usage for the given PIDs.
* CPU% is calculated as a delta between successive calls.
*/
export async function getPidUsage(
pids: number[]
): Promise<Record<number, IPidUsageResult>> {
const tck = getClkTck();
const ps = getPageSize();
const result: Record<number, IPidUsageResult> = {};
for (const pid of pids) {
const stat = readProcStat(pid);
if (!stat) {
continue;
}
const now = hrtimeSeconds();
const totalTicks = stat.utime + stat.stime;
const memoryBytes = stat.rss * ps;
const prev = history.get(pid);
if (prev) {
const elapsedSeconds = now - prev.timestamp;
const ticksDelta = totalTicks - (prev.utime + prev.stime);
const cpuSeconds = ticksDelta / tck;
const cpuPercent = elapsedSeconds > 0 ? (cpuSeconds / elapsedSeconds) * 100 : 0;
result[pid] = {
cpu: cpuPercent,
memory: memoryBytes,
};
} else {
// First call for this PID — no delta available, report 0% cpu
result[pid] = {
cpu: 0,
memory: memoryBytes,
};
}
// Update history
history.set(pid, {
utime: stat.utime,
stime: stat.stime,
timestamp: now,
});
}
return result;
}

View File

@@ -12,9 +12,9 @@ import * as smartlog from '@push.rocks/smartlog';
export { smartdelay, smartlog };
// third party scope
import pidusage from 'pidusage';
import pidtree from 'pidtree';
import * as promClient from 'prom-client';
// own implementations (replacing pidtree, pidusage, prom-client)
import * as pidtree from './smartmetrics.pidtree.js';
import * as pidusage from './smartmetrics.pidusage.js';
import * as prom from './smartmetrics.prom.js';
export { pidusage, pidtree, promClient };
export { pidtree, pidusage, prom };

671
ts/smartmetrics.prom.ts Normal file
View File

@@ -0,0 +1,671 @@
import * as v8 from 'v8';
import * as fs from 'fs';
import { PerformanceObserver, monitorEventLoopDelay } from 'perf_hooks';
// ── Metric types ────────────────────────────────────────────────────────────
export interface IGaugeConfig {
name: string;
help: string;
registers?: Registry[];
labelNames?: string[];
collect?: () => void | Promise<void>;
}
export interface ICounterConfig {
name: string;
help: string;
registers?: Registry[];
labelNames?: string[];
collect?: () => void | Promise<void>;
}
export interface IHistogramConfig {
name: string;
help: string;
registers?: Registry[];
labelNames?: string[];
buckets?: number[];
collect?: () => void | Promise<void>;
}
interface IMetric {
name: string;
help: string;
type: string;
collect?: () => void | Promise<void>;
getLines(): Promise<string[]>;
}
// ── Registry ────────────────────────────────────────────────────────────────
export class Registry {
private metricsList: IMetric[] = [];
registerMetric(metric: IMetric): void {
this.metricsList.push(metric);
}
async metrics(): Promise<string> {
const lines: string[] = [];
for (const m of this.metricsList) {
if (m.collect) {
await m.collect();
}
lines.push(`# HELP ${m.name} ${m.help}`);
lines.push(`# TYPE ${m.name} ${m.type}`);
lines.push(...(await m.getLines()));
}
return lines.join('\n') + '\n';
}
}
// ── Gauge ───────────────────────────────────────────────────────────────────
export class Gauge implements IMetric {
public name: string;
public help: string;
public type = 'gauge';
public collect?: () => void | Promise<void>;
private value = 0;
private labelledValues = new Map<string, number>();
constructor(config: IGaugeConfig) {
this.name = config.name;
this.help = config.help;
this.collect = config.collect;
if (config.registers) {
for (const r of config.registers) {
r.registerMetric(this);
}
}
}
set(labelsOrValue: Record<string, string> | number, value?: number): void {
if (typeof labelsOrValue === 'number') {
this.value = labelsOrValue;
} else {
const key = this.labelsToKey(labelsOrValue);
this.labelledValues.set(key, value!);
}
}
inc(labelsOrAmount?: Record<string, string> | number, amount?: number): void {
if (labelsOrAmount === undefined) {
this.value += 1;
} else if (typeof labelsOrAmount === 'number') {
this.value += labelsOrAmount;
} else {
const key = this.labelsToKey(labelsOrAmount);
const cur = this.labelledValues.get(key) || 0;
this.labelledValues.set(key, cur + (amount ?? 1));
}
}
async getLines(): Promise<string[]> {
const lines: string[] = [];
if (this.labelledValues.size > 0) {
for (const [key, val] of this.labelledValues) {
lines.push(`${this.name}{${key}} ${formatValue(val)}`);
}
} else {
lines.push(`${this.name} ${formatValue(this.value)}`);
}
return lines;
}
/** Reset all values */
reset(): void {
this.value = 0;
this.labelledValues.clear();
}
private labelsToKey(labels: Record<string, string>): string {
return Object.entries(labels)
.map(([k, v]) => `${k}="${v}"`)
.join(',');
}
}
// ── Counter ─────────────────────────────────────────────────────────────────
export class Counter implements IMetric {
public name: string;
public help: string;
public type = 'counter';
public collect?: () => void | Promise<void>;
private value = 0;
private labelledValues = new Map<string, number>();
constructor(config: ICounterConfig) {
this.name = config.name;
this.help = config.help;
this.collect = config.collect;
if (config.registers) {
for (const r of config.registers) {
r.registerMetric(this);
}
}
}
inc(labelsOrAmount?: Record<string, string> | number, amount?: number): void {
if (labelsOrAmount === undefined) {
this.value += 1;
} else if (typeof labelsOrAmount === 'number') {
this.value += labelsOrAmount;
} else {
const key = this.labelsToKey(labelsOrAmount);
const cur = this.labelledValues.get(key) || 0;
this.labelledValues.set(key, cur + (amount ?? 1));
}
}
async getLines(): Promise<string[]> {
const lines: string[] = [];
if (this.labelledValues.size > 0) {
for (const [key, val] of this.labelledValues) {
lines.push(`${this.name}{${key}} ${formatValue(val)}`);
}
} else {
lines.push(`${this.name} ${formatValue(this.value)}`);
}
return lines;
}
reset(): void {
this.value = 0;
this.labelledValues.clear();
}
private labelsToKey(labels: Record<string, string>): string {
return Object.entries(labels)
.map(([k, v]) => `${k}="${v}"`)
.join(',');
}
}
// ── Histogram ───────────────────────────────────────────────────────────────
export class Histogram implements IMetric {
public name: string;
public help: string;
public type = 'histogram';
public collect?: () => void | Promise<void>;
private bucketBounds: number[];
private bucketCounts: number[];
private sum = 0;
private count = 0;
private labelledData = new Map<
string,
{ bucketCounts: number[]; sum: number; count: number }
>();
constructor(config: IHistogramConfig) {
this.name = config.name;
this.help = config.help;
this.bucketBounds = config.buckets || [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
this.bucketCounts = new Array(this.bucketBounds.length).fill(0);
this.collect = config.collect;
if (config.registers) {
for (const r of config.registers) {
r.registerMetric(this);
}
}
}
observe(labelsOrValue: Record<string, string> | number, value?: number): void {
if (typeof labelsOrValue === 'number') {
this.observeUnlabelled(labelsOrValue);
} else {
const key = this.labelsToKey(labelsOrValue);
let data = this.labelledData.get(key);
if (!data) {
data = {
bucketCounts: new Array(this.bucketBounds.length).fill(0),
sum: 0,
count: 0,
};
this.labelledData.set(key, data);
}
data.sum += value!;
data.count += 1;
for (let i = 0; i < this.bucketBounds.length; i++) {
if (value! <= this.bucketBounds[i]) {
data.bucketCounts[i]++;
}
}
}
}
private observeUnlabelled(val: number): void {
this.sum += val;
this.count += 1;
for (let i = 0; i < this.bucketBounds.length; i++) {
if (val <= this.bucketBounds[i]) {
this.bucketCounts[i]++;
}
}
}
async getLines(): Promise<string[]> {
const lines: string[] = [];
if (this.labelledData.size > 0) {
for (const [key, data] of this.labelledData) {
for (let i = 0; i < this.bucketBounds.length; i++) {
lines.push(
`${this.name}_bucket{${key},le="${this.bucketBounds[i]}"} ${data.bucketCounts[i]}`
);
}
lines.push(`${this.name}_bucket{${key},le="+Inf"} ${data.count}`);
lines.push(`${this.name}_sum{${key}} ${formatValue(data.sum)}`);
lines.push(`${this.name}_count{${key}} ${data.count}`);
}
} else {
for (let i = 0; i < this.bucketBounds.length; i++) {
lines.push(
`${this.name}_bucket{le="${this.bucketBounds[i]}"} ${this.bucketCounts[i]}`
);
}
lines.push(`${this.name}_bucket{le="+Inf"} ${this.count}`);
lines.push(`${this.name}_sum ${formatValue(this.sum)}`);
lines.push(`${this.name}_count ${this.count}`);
}
return lines;
}
reset(): void {
this.sum = 0;
this.count = 0;
this.bucketCounts.fill(0);
this.labelledData.clear();
}
private labelsToKey(labels: Record<string, string>): string {
return Object.entries(labels)
.map(([k, v]) => `${k}="${v}"`)
.join(',');
}
}
// ── Default Metrics Collectors ──────────────────────────────────────────────
export function collectDefaultMetrics(registry: Registry): void {
registerProcessCpuTotal(registry);
registerProcessStartTime(registry);
registerProcessMemory(registry);
registerProcessOpenFds(registry);
registerProcessMaxFds(registry);
registerEventLoopLag(registry);
registerProcessHandles(registry);
registerProcessRequests(registry);
registerProcessResources(registry);
registerHeapSizeAndUsed(registry);
registerHeapSpaces(registry);
registerVersion(registry);
registerGc(registry);
}
function registerProcessCpuTotal(registry: Registry): void {
const userGauge = new Gauge({
name: 'process_cpu_user_seconds_total',
help: 'Total user CPU time spent in seconds.',
registers: [registry],
collect() {
const now = process.cpuUsage();
userGauge.set(now.user / 1e6);
},
});
const systemGauge = new Gauge({
name: 'process_cpu_system_seconds_total',
help: 'Total system CPU time spent in seconds.',
registers: [registry],
collect() {
const now = process.cpuUsage();
systemGauge.set(now.system / 1e6);
},
});
new Gauge({
name: 'process_cpu_seconds_total',
help: 'Total user and system CPU time spent in seconds.',
registers: [registry],
collect() {
const now = process.cpuUsage();
this.set((now.user + now.system) / 1e6);
},
});
}
function registerProcessStartTime(registry: Registry): void {
const startTimeSeconds = Math.floor(Date.now() / 1000 - process.uptime());
new Gauge({
name: 'process_start_time_seconds',
help: 'Start time of the process since unix epoch in seconds.',
registers: [registry],
collect() {
this.set(startTimeSeconds);
},
});
}
function registerProcessMemory(registry: Registry): void {
new Gauge({
name: 'process_resident_memory_bytes',
help: 'Resident memory size in bytes.',
registers: [registry],
collect() {
this.set(process.memoryUsage.rss());
},
});
new Gauge({
name: 'process_virtual_memory_bytes',
help: 'Virtual memory size in bytes.',
registers: [registry],
collect() {
try {
const status = fs.readFileSync('/proc/self/status', 'utf8');
const match = status.match(/VmSize:\s+(\d+)\s+kB/);
if (match) {
this.set(parseInt(match[1], 10) * 1024);
}
} catch {
// not on Linux — skip
}
},
});
new Gauge({
name: 'process_heap_bytes',
help: 'Process heap size in bytes.',
registers: [registry],
collect() {
this.set(process.memoryUsage().heapUsed);
},
});
}
function registerProcessOpenFds(registry: Registry): void {
new Gauge({
name: 'process_open_fds',
help: 'Number of open file descriptors.',
registers: [registry],
collect() {
try {
const fds = fs.readdirSync('/proc/self/fd');
this.set(fds.length);
} catch {
this.set(0);
}
},
});
}
function registerProcessMaxFds(registry: Registry): void {
new Gauge({
name: 'process_max_fds',
help: 'Maximum number of open file descriptors.',
registers: [registry],
collect() {
try {
const limits = fs.readFileSync('/proc/self/limits', 'utf8');
const match = limits.match(/Max open files\s+(\d+)/);
if (match) {
this.set(parseInt(match[1], 10));
}
} catch {
this.set(0);
}
},
});
}
function registerEventLoopLag(registry: Registry): void {
let histogram: ReturnType<typeof monitorEventLoopDelay> | null = null;
try {
histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
} catch {
// Not available in this runtime
}
new Gauge({
name: 'nodejs_eventloop_lag_seconds',
help: 'Lag of event loop in seconds.',
registers: [registry],
collect() {
if (histogram) {
this.set(histogram.mean / 1e9);
}
},
});
new Gauge({
name: 'nodejs_eventloop_lag_min_seconds',
help: 'The minimum recorded event loop delay.',
registers: [registry],
collect() {
if (histogram) {
this.set(histogram.min / 1e9);
}
},
});
new Gauge({
name: 'nodejs_eventloop_lag_max_seconds',
help: 'The maximum recorded event loop delay.',
registers: [registry],
collect() {
if (histogram) {
this.set(histogram.max / 1e9);
}
},
});
new Gauge({
name: 'nodejs_eventloop_lag_mean_seconds',
help: 'The mean of the recorded event loop delays.',
registers: [registry],
collect() {
if (histogram) {
this.set(histogram.mean / 1e9);
}
},
});
new Gauge({
name: 'nodejs_eventloop_lag_stddev_seconds',
help: 'The standard deviation of the recorded event loop delays.',
registers: [registry],
collect() {
if (histogram) {
this.set(histogram.stddev / 1e9);
}
},
});
for (const p of [50, 90, 99]) {
new Gauge({
name: `nodejs_eventloop_lag_p${p}_seconds`,
help: `The ${p}th percentile of the recorded event loop delays.`,
registers: [registry],
collect() {
if (histogram) {
this.set(histogram.percentile(p) / 1e9);
}
},
});
}
}
function registerProcessHandles(registry: Registry): void {
new Gauge({
name: 'nodejs_active_handles_total',
help: 'Number of active libuv handles grouped by handle type.',
registers: [registry],
collect() {
const handles = (process as any)._getActiveHandles?.();
this.set(handles ? handles.length : 0);
},
});
}
function registerProcessRequests(registry: Registry): void {
new Gauge({
name: 'nodejs_active_requests_total',
help: 'Number of active libuv requests grouped by request type.',
registers: [registry],
collect() {
const requests = (process as any)._getActiveRequests?.();
this.set(requests ? requests.length : 0);
},
});
}
function registerProcessResources(registry: Registry): void {
new Gauge({
name: 'nodejs_active_resources_total',
help: 'Number of active resources that are currently keeping the event loop alive.',
registers: [registry],
collect() {
try {
const resources = (process as any).getActiveResourcesInfo?.();
this.set(resources ? resources.length : 0);
} catch {
this.set(0);
}
},
});
}
function registerHeapSizeAndUsed(registry: Registry): void {
new Gauge({
name: 'nodejs_heap_size_total_bytes',
help: 'Process heap size from Node.js in bytes.',
registers: [registry],
collect() {
this.set(process.memoryUsage().heapTotal);
},
});
new Gauge({
name: 'nodejs_heap_size_used_bytes',
help: 'Process heap size used from Node.js in bytes.',
registers: [registry],
collect() {
this.set(process.memoryUsage().heapUsed);
},
});
new Gauge({
name: 'nodejs_external_memory_bytes',
help: 'Node.js external memory size in bytes.',
registers: [registry],
collect() {
this.set(process.memoryUsage().external);
},
});
}
function registerHeapSpaces(registry: Registry): void {
const spaceGauge = new Gauge({
name: 'nodejs_heap_space_size_total_bytes',
help: 'Process heap space size total from Node.js in bytes.',
labelNames: ['space'],
registers: [registry],
collect() {
spaceGauge.reset();
const spaces = v8.getHeapSpaceStatistics();
for (const space of spaces) {
spaceGauge.set({ space: space.space_name }, space.space_size);
}
},
});
const usedGauge = new Gauge({
name: 'nodejs_heap_space_size_used_bytes',
help: 'Process heap space size used from Node.js in bytes.',
labelNames: ['space'],
registers: [registry],
collect() {
usedGauge.reset();
const spaces = v8.getHeapSpaceStatistics();
for (const space of spaces) {
usedGauge.set({ space: space.space_name }, space.space_used_size);
}
},
});
const availableGauge = new Gauge({
name: 'nodejs_heap_space_size_available_bytes',
help: 'Process heap space size available from Node.js in bytes.',
labelNames: ['space'],
registers: [registry],
collect() {
availableGauge.reset();
const spaces = v8.getHeapSpaceStatistics();
for (const space of spaces) {
availableGauge.set({ space: space.space_name }, space.space_available_size);
}
},
});
}
function registerVersion(registry: Registry): void {
const versionParts = process.version.slice(1).split('.').map(Number);
const gauge = new Gauge({
name: 'nodejs_version_info',
help: 'Node.js version info.',
labelNames: ['version', 'major', 'minor', 'patch'],
registers: [registry],
collect() {
gauge.set(
{
version: process.version,
major: String(versionParts[0]),
minor: String(versionParts[1]),
patch: String(versionParts[2]),
},
1
);
},
});
}
function registerGc(registry: Registry): void {
const gcHistogram = new Histogram({
name: 'nodejs_gc_duration_seconds',
help: 'Garbage collection duration by kind, in seconds.',
labelNames: ['kind'],
buckets: [0.001, 0.01, 0.1, 1, 2, 5],
registers: [registry],
});
const kindLabels: Record<number, string> = {
1: 'Scavenge',
2: 'Mark/Sweep/Compact',
4: 'IncrementalMarking',
8: 'ProcessWeakCallbacks',
15: 'All',
};
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const gcEntry = entry as any;
const kind = kindLabels[gcEntry.detail?.kind ?? gcEntry.kind] || 'Unknown';
gcHistogram.observe({ kind }, entry.duration / 1000);
}
});
obs.observe({ entryTypes: ['gc'] });
} catch {
// GC observation not available
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function formatValue(v: number): string {
if (Number.isInteger(v)) return String(v);
return v.toString();
}