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(); }