From 32d75804c02a166806ee73ac5e906446e864e224 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 19 Feb 2026 09:51:34 +0000 Subject: [PATCH] update --- package.json | 8 +- pnpm-lock.yaml | 59 --- ts/smartmetrics.classes.smartmetrics.ts | 57 +- ts/smartmetrics.pidtree.ts | 55 ++ ts/smartmetrics.pidusage.ts | 117 +++++ ts/smartmetrics.plugins.ts | 10 +- ts/smartmetrics.prom.ts | 671 ++++++++++++++++++++++++ 7 files changed, 878 insertions(+), 99 deletions(-) create mode 100644 ts/smartmetrics.pidtree.ts create mode 100644 ts/smartmetrics.pidusage.ts create mode 100644 ts/smartmetrics.prom.ts diff --git a/package.json b/package.json index 0887ed7..15c0da1 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 159a373..117908a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ts/smartmetrics.classes.smartmetrics.ts b/ts/smartmetrics.classes.smartmetrics.ts index 4f2bff3..cbb4ec0 100644 --- a/ts/smartmetrics.classes.smartmetrics.ts +++ b/ts/smartmetrics.classes.smartmetrics.ts @@ -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; - private memoryPercentageGauge: plugins.promClient.Gauge; - private memoryUsageBytesGauge: plugins.promClient.Gauge; - + 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 { // 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; } diff --git a/ts/smartmetrics.pidtree.ts b/ts/smartmetrics.pidtree.ts new file mode 100644 index 0000000..292bfce --- /dev/null +++ b/ts/smartmetrics.pidtree.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs'; + +// Get all descendant PIDs of the given root PID by reading /proc//stat. +// Returns an array of descendant PIDs (excludes the root itself). +export async function getChildPids(rootPid: number): Promise { + const parentMap = new Map(); // 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; +} diff --git a/ts/smartmetrics.pidusage.ts b/ts/smartmetrics.pidusage.ts new file mode 100644 index 0000000..6e6fe4e --- /dev/null +++ b/ts/smartmetrics.pidusage.ts @@ -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(); + +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> { + const tck = getClkTck(); + const ps = getPageSize(); + const result: Record = {}; + + 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; +} diff --git a/ts/smartmetrics.plugins.ts b/ts/smartmetrics.plugins.ts index 0e75619..ec57795 100644 --- a/ts/smartmetrics.plugins.ts +++ b/ts/smartmetrics.plugins.ts @@ -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 }; diff --git a/ts/smartmetrics.prom.ts b/ts/smartmetrics.prom.ts new file mode 100644 index 0000000..09d5fd4 --- /dev/null +++ b/ts/smartmetrics.prom.ts @@ -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; +} + +export interface ICounterConfig { + name: string; + help: string; + registers?: Registry[]; + labelNames?: string[]; + collect?: () => void | Promise; +} + +export interface IHistogramConfig { + name: string; + help: string; + registers?: Registry[]; + labelNames?: string[]; + buckets?: number[]; + collect?: () => void | Promise; +} + +interface IMetric { + name: string; + help: string; + type: string; + collect?: () => void | Promise; + getLines(): Promise; +} + +// ── Registry ──────────────────────────────────────────────────────────────── + +export class Registry { + private metricsList: IMetric[] = []; + + registerMetric(metric: IMetric): void { + this.metricsList.push(metric); + } + + async metrics(): Promise { + 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; + private value = 0; + private labelledValues = new Map(); + 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 | 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 | 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 { + 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 { + 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; + private value = 0; + private labelledValues = new Map(); + + 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 | 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 { + 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 { + 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; + 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 | 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 { + 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 { + 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 | 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 = { + 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(); +}