feat: Implement Prometheus metrics exposure in SmartMetrics
- Added Prometheus gauges for CPU and memory metrics. - Implemented HTTP server to expose metrics at /metrics endpoint. - Created methods to enable and disable the Prometheus endpoint. - Updated getMetrics() to set gauge values. - Added tests for Prometheus metrics functionality. - Updated documentation plan for Prometheus integration.
This commit is contained in:
@ -119,6 +119,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
npmci node install stable
|
||||||
npmci npm install
|
npmci npm install
|
||||||
pnpm install -g @gitzone/tsdoc
|
pnpm install -g @git.zone/tsdoc
|
||||||
npmci command tsdoc
|
npmci command tsdoc
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
22
package.json
22
package.json
@ -8,17 +8,16 @@
|
|||||||
"author": "Lossless GmbH",
|
"author": "Lossless GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild --web)",
|
"build": "(tsbuild --web)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gitzone/tsbuild": "^2.1.66",
|
"@git.zone/tsbuild": "^2.6.4",
|
||||||
"@gitzone/tsbundle": "^2.0.8",
|
"@git.zone/tsbundle": "^2.0.8",
|
||||||
"@gitzone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^1.2.44",
|
||||||
"@gitzone/tstest": "^1.0.77",
|
"@git.zone/tstest": "^2.3.1",
|
||||||
"@push.rocks/tapbundle": "^5.0.12",
|
"@types/node": "^22.15.30"
|
||||||
"@types/node": "^20.4.8"
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 1 chrome versions"
|
"last 1 chrome versions"
|
||||||
@ -40,8 +39,8 @@
|
|||||||
"@push.rocks/smartlog": "^3.0.2",
|
"@push.rocks/smartlog": "^3.0.2",
|
||||||
"@types/pidusage": "^2.0.2",
|
"@types/pidusage": "^2.0.2",
|
||||||
"pidtree": "^0.6.0",
|
"pidtree": "^0.6.0",
|
||||||
"pidusage": "^3.0.2",
|
"pidusage": "^4.0.1",
|
||||||
"prom-client": "^14.2.0"
|
"prom-client": "^15.1.3"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@ -64,5 +63,6 @@
|
|||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.foss.global/push.rocks/smartmetrics.git"
|
"url": "https://code.foss.global/push.rocks/smartmetrics.git"
|
||||||
}
|
},
|
||||||
}
|
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||||
|
}
|
||||||
|
11361
pnpm-lock.yaml
generated
11361
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
81
readme.plan.md
Normal file
81
readme.plan.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# Prometheus Metrics Implementation Plan
|
||||||
|
|
||||||
|
`cat /home/philkunz/.claude/CLAUDE.md`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Add Prometheus metrics exposure functionality to SmartMetrics while maintaining backward compatibility with existing `getMetrics()` method.
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### 1. Add HTTP Server Dependencies
|
||||||
|
- [x] Check if we need to add any HTTP server dependency to package.json
|
||||||
|
- [x] Import necessary modules in smartmetrics.plugins.ts
|
||||||
|
|
||||||
|
### 2. Create Prometheus Gauges in SmartMetrics Class
|
||||||
|
- [x] Add private properties for custom gauges:
|
||||||
|
- [x] `private cpuPercentageGauge: plugins.promClient.Gauge<string>`
|
||||||
|
- [x] `private memoryPercentageGauge: plugins.promClient.Gauge<string>`
|
||||||
|
- [x] `private memoryUsageBytesGauge: plugins.promClient.Gauge<string>`
|
||||||
|
- [x] Initialize gauges in `setup()` method with appropriate names and help text:
|
||||||
|
- [x] `smartmetrics_cpu_percentage` - "Current CPU usage percentage"
|
||||||
|
- [x] `smartmetrics_memory_percentage` - "Current memory usage percentage"
|
||||||
|
- [x] `smartmetrics_memory_usage_bytes` - "Current memory usage in bytes"
|
||||||
|
|
||||||
|
### 3. Update getMetrics() Method
|
||||||
|
- [x] After calculating metrics, update the Prometheus gauges:
|
||||||
|
- [x] `this.cpuPercentageGauge.set(cpuPercentage)`
|
||||||
|
- [x] `this.memoryPercentageGauge.set(memoryPercentage)`
|
||||||
|
- [x] `this.memoryUsageBytesGauge.set(memoryUsageBytes)`
|
||||||
|
- [x] Ensure gauges are only updated if they exist (defensive programming)
|
||||||
|
|
||||||
|
### 4. Add getPrometheusFormattedMetrics() Method
|
||||||
|
- [x] Create new public async method `getPrometheusFormattedMetrics(): Promise<string>`
|
||||||
|
- [x] Call `this.getMetrics()` to ensure gauges are updated with latest values
|
||||||
|
- [x] Return `await this.registry.metrics()` to get Prometheus text format
|
||||||
|
|
||||||
|
### 5. Add HTTP Server Properties
|
||||||
|
- [x] Add private property for HTTP server: `private prometheusServer?: any`
|
||||||
|
- [x] Add private property for server port: `private prometheusPort?: number`
|
||||||
|
|
||||||
|
### 6. Implement enablePrometheusEndpoint() Method
|
||||||
|
- [x] Create new public method `enablePrometheusEndpoint(port: number = 9090): void`
|
||||||
|
- [x] Check if server is already running, if so, log warning and return
|
||||||
|
- [x] Create minimal HTTP server using Node.js built-in `http` module:
|
||||||
|
- [x] Listen on specified port
|
||||||
|
- [x] Handle GET requests to `/metrics` endpoint
|
||||||
|
- [x] Return Prometheus-formatted metrics with correct Content-Type header
|
||||||
|
- [x] Handle other routes with 404
|
||||||
|
- [x] Store server reference and port for later cleanup
|
||||||
|
- [x] Log info message about endpoint availability
|
||||||
|
|
||||||
|
### 7. Add disablePrometheusEndpoint() Method
|
||||||
|
- [x] Create new public method `disablePrometheusEndpoint(): void`
|
||||||
|
- [x] Check if server exists, if not, return
|
||||||
|
- [x] Close the HTTP server
|
||||||
|
- [x] Clear server reference and port
|
||||||
|
- [x] Log info message about endpoint shutdown
|
||||||
|
|
||||||
|
### 8. Update stop() Method
|
||||||
|
- [x] Call `disablePrometheusEndpoint()` to ensure clean shutdown
|
||||||
|
|
||||||
|
### 9. Add Tests
|
||||||
|
- [x] Add test for `getPrometheusFormattedMetrics()`:
|
||||||
|
- [x] Verify it returns a string
|
||||||
|
- [x] Verify it contains expected metric names
|
||||||
|
- [x] Verify format matches Prometheus text exposition format
|
||||||
|
- [x] Add test for `enablePrometheusEndpoint()`:
|
||||||
|
- [x] Start endpoint on test port (e.g., 19090)
|
||||||
|
- [x] Make HTTP request to `/metrics`
|
||||||
|
- [x] Verify response has correct Content-Type
|
||||||
|
- [x] Verify response contains metrics
|
||||||
|
- [x] Clean up by calling `disablePrometheusEndpoint()`
|
||||||
|
|
||||||
|
### 10. Update Documentation
|
||||||
|
- [ ] Add usage example in readme.md for Prometheus integration
|
||||||
|
- [ ] Document the new methods in code comments
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Using Node.js built-in `http` module to avoid adding unnecessary dependencies
|
||||||
|
- Default port 9090 is commonly used for metrics endpoints
|
||||||
|
- Maintaining backward compatibility - existing functionality unchanged
|
||||||
|
- Prometheus text format example: `metric_name{label="value"} 123.45`
|
53
test/test.ts
53
test/test.ts
@ -1,6 +1,6 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartmetrics from '../ts/index.js';
|
import * as smartmetrics from '../ts/index.js';
|
||||||
|
|
||||||
let testSmartMetrics: smartmetrics.SmartMetrics;
|
let testSmartMetrics: smartmetrics.SmartMetrics;
|
||||||
@ -24,4 +24,55 @@ tap.test('should produce valid metrics', async (tools) => {
|
|||||||
console.log(await testSmartMetrics.getMetrics());
|
console.log(await testSmartMetrics.getMetrics());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should return Prometheus formatted metrics', async () => {
|
||||||
|
const prometheusMetrics = await testSmartMetrics.getPrometheusFormattedMetrics();
|
||||||
|
expect(prometheusMetrics).toBeTypeofString();
|
||||||
|
expect(prometheusMetrics).toContain('smartmetrics_cpu_percentage');
|
||||||
|
expect(prometheusMetrics).toContain('smartmetrics_memory_percentage');
|
||||||
|
expect(prometheusMetrics).toContain('smartmetrics_memory_usage_bytes');
|
||||||
|
expect(prometheusMetrics).toContain('# HELP');
|
||||||
|
expect(prometheusMetrics).toContain('# TYPE');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should enable Prometheus endpoint', async (tools) => {
|
||||||
|
const testPort = 19090;
|
||||||
|
testSmartMetrics.enablePrometheusEndpoint(testPort);
|
||||||
|
|
||||||
|
// Give the server time to start
|
||||||
|
await tools.delayFor(1000);
|
||||||
|
|
||||||
|
// Test the endpoint
|
||||||
|
const response = await fetch(`http://localhost:${testPort}/metrics`);
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers.get('content-type')).toEqual('text/plain; version=0.0.4');
|
||||||
|
|
||||||
|
const metricsText = await response.text();
|
||||||
|
expect(metricsText).toContain('smartmetrics_cpu_percentage');
|
||||||
|
expect(metricsText).toContain('smartmetrics_memory_percentage');
|
||||||
|
expect(metricsText).toContain('smartmetrics_memory_usage_bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle 404 for non-metrics endpoints', async () => {
|
||||||
|
const response = await fetch('http://localhost:19090/notfound');
|
||||||
|
expect(response.status).toEqual(404);
|
||||||
|
const text = await response.text();
|
||||||
|
expect(text).toEqual('Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should disable Prometheus endpoint', async () => {
|
||||||
|
testSmartMetrics.disablePrometheusEndpoint();
|
||||||
|
|
||||||
|
// Give the server time to shut down
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Verify the endpoint is no longer accessible
|
||||||
|
try {
|
||||||
|
await fetch('http://localhost:19090/metrics');
|
||||||
|
throw new Error('Should have failed to connect');
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to fail
|
||||||
|
expect(error.message).toContain('fetch failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tap.start();
|
tap.start();
|
||||||
|
@ -7,11 +7,39 @@ export class SmartMetrics {
|
|||||||
public logger: plugins.smartlog.Smartlog;
|
public logger: plugins.smartlog.Smartlog;
|
||||||
public registry: plugins.promClient.Registry;
|
public registry: plugins.promClient.Registry;
|
||||||
public maxMemoryMB: number;
|
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>;
|
||||||
|
|
||||||
|
// HTTP server for Prometheus endpoint
|
||||||
|
private prometheusServer?: plugins.http.Server;
|
||||||
|
private prometheusPort?: number;
|
||||||
|
|
||||||
public async setup() {
|
public async setup() {
|
||||||
const collectDefaultMetrics = plugins.promClient.collectDefaultMetrics;
|
const collectDefaultMetrics = plugins.promClient.collectDefaultMetrics;
|
||||||
this.registry = new plugins.promClient.Registry();
|
this.registry = new plugins.promClient.Registry();
|
||||||
collectDefaultMetrics({ register: this.registry });
|
collectDefaultMetrics({ register: this.registry });
|
||||||
|
|
||||||
|
// Initialize custom gauges
|
||||||
|
this.cpuPercentageGauge = new plugins.promClient.Gauge({
|
||||||
|
name: 'smartmetrics_cpu_percentage',
|
||||||
|
help: 'Current CPU usage percentage',
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.memoryPercentageGauge = new plugins.promClient.Gauge({
|
||||||
|
name: 'smartmetrics_memory_percentage',
|
||||||
|
help: 'Current memory usage percentage',
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.memoryUsageBytesGauge = new plugins.promClient.Gauge({
|
||||||
|
name: 'smartmetrics_memory_usage_bytes',
|
||||||
|
help: 'Current memory usage in bytes',
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(loggerArg: plugins.smartlog.Smartlog, sourceNameArg: string) {
|
constructor(loggerArg: plugins.smartlog.Smartlog, sourceNameArg: string) {
|
||||||
@ -100,6 +128,17 @@ export class SmartMetrics {
|
|||||||
)} / ${this.formatBytes(this.maxMemoryMB * 1024 * 1024)}`;
|
)} / ${this.formatBytes(this.maxMemoryMB * 1024 * 1024)}`;
|
||||||
|
|
||||||
console.log(`${cpuUsageText} ||| ${memoryUsageText} `);
|
console.log(`${cpuUsageText} ||| ${memoryUsageText} `);
|
||||||
|
|
||||||
|
// Update Prometheus gauges with current values
|
||||||
|
if (this.cpuPercentageGauge) {
|
||||||
|
this.cpuPercentageGauge.set(cpuPercentage);
|
||||||
|
}
|
||||||
|
if (this.memoryPercentageGauge) {
|
||||||
|
this.memoryPercentageGauge.set(memoryPercentage);
|
||||||
|
}
|
||||||
|
if (this.memoryUsageBytesGauge) {
|
||||||
|
this.memoryUsageBytesGauge.set(memoryUsageBytes);
|
||||||
|
}
|
||||||
|
|
||||||
const returnMetrics: interfaces.IMetricsSnapshot = {
|
const returnMetrics: interfaces.IMetricsSnapshot = {
|
||||||
process_cpu_seconds_total: (
|
process_cpu_seconds_total: (
|
||||||
@ -127,7 +166,58 @@ export class SmartMetrics {
|
|||||||
return returnMetrics;
|
return returnMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enablePrometheusEndpoint(port: number = 9090): void {
|
||||||
|
if (this.prometheusServer) {
|
||||||
|
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 {
|
||||||
|
const metrics = await this.getPrometheusFormattedMetrics();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
|
||||||
|
res.end(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Error generating metrics');
|
||||||
|
this.logger.log('error', 'Error generating Prometheus metrics', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.prometheusPort = port;
|
||||||
|
this.prometheusServer.listen(port, () => {
|
||||||
|
this.logger.log('info', `Prometheus metrics endpoint available at http://localhost:${port}/metrics`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public disablePrometheusEndpoint(): void {
|
||||||
|
if (!this.prometheusServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prometheusServer.close(() => {
|
||||||
|
this.logger.log('info', `Prometheus metrics endpoint on port ${this.prometheusPort} has been shut down`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.prometheusServer = undefined;
|
||||||
|
this.prometheusPort = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
public stop() {
|
public stop() {
|
||||||
this.started = false;
|
this.started = false;
|
||||||
|
this.disablePrometheusEndpoint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
// this might be extracted into a package @pushrocks/smartmetrics-interfaces in the future
|
|
||||||
export interface IMetricsSnapshot {
|
export interface IMetricsSnapshot {
|
||||||
process_cpu_seconds_total: number;
|
process_cpu_seconds_total: number;
|
||||||
nodejs_active_handles_total: number;
|
nodejs_active_handles_total: number;
|
||||||
|
@ -2,8 +2,9 @@
|
|||||||
import * as v8 from 'v8';
|
import * as v8 from 'v8';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as http from 'http';
|
||||||
|
|
||||||
export { v8, os, fs };
|
export { v8, os, fs, http };
|
||||||
|
|
||||||
// pushrocks scope
|
// pushrocks scope
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
|
Reference in New Issue
Block a user