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: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
pnpm install -g @gitzone/tsdoc
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
npmci command tsdoc
|
||||
continue-on-error: true
|
||||
|
22
package.json
22
package.json
@ -8,17 +8,16 @@
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --web)",
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild --web)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitzone/tsbuild": "^2.1.66",
|
||||
"@gitzone/tsbundle": "^2.0.8",
|
||||
"@gitzone/tsrun": "^1.2.44",
|
||||
"@gitzone/tstest": "^1.0.77",
|
||||
"@push.rocks/tapbundle": "^5.0.12",
|
||||
"@types/node": "^20.4.8"
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsbundle": "^2.0.8",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^2.3.1",
|
||||
"@types/node": "^22.15.30"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
@ -40,8 +39,8 @@
|
||||
"@push.rocks/smartlog": "^3.0.2",
|
||||
"@types/pidusage": "^2.0.2",
|
||||
"pidtree": "^0.6.0",
|
||||
"pidusage": "^3.0.2",
|
||||
"prom-client": "^14.2.0"
|
||||
"pidusage": "^4.0.1",
|
||||
"prom-client": "^15.1.3"
|
||||
},
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
@ -64,5 +63,6 @@
|
||||
"repository": {
|
||||
"type": "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 { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartmetrics from '../ts/index.js';
|
||||
|
||||
let testSmartMetrics: smartmetrics.SmartMetrics;
|
||||
@ -24,4 +24,55 @@ tap.test('should produce valid metrics', async (tools) => {
|
||||
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();
|
||||
|
@ -7,11 +7,39 @@ export class SmartMetrics {
|
||||
public logger: plugins.smartlog.Smartlog;
|
||||
public registry: plugins.promClient.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>;
|
||||
|
||||
// HTTP server for Prometheus endpoint
|
||||
private prometheusServer?: plugins.http.Server;
|
||||
private prometheusPort?: number;
|
||||
|
||||
public async setup() {
|
||||
const collectDefaultMetrics = plugins.promClient.collectDefaultMetrics;
|
||||
this.registry = new plugins.promClient.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) {
|
||||
@ -100,6 +128,17 @@ export class SmartMetrics {
|
||||
)} / ${this.formatBytes(this.maxMemoryMB * 1024 * 1024)}`;
|
||||
|
||||
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 = {
|
||||
process_cpu_seconds_total: (
|
||||
@ -127,7 +166,58 @@ export class SmartMetrics {
|
||||
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() {
|
||||
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 {
|
||||
process_cpu_seconds_total: number;
|
||||
nodejs_active_handles_total: number;
|
||||
|
@ -2,8 +2,9 @@
|
||||
import * as v8 from 'v8';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
|
||||
export { v8, os, fs };
|
||||
export { v8, os, fs, http };
|
||||
|
||||
// pushrocks scope
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
|
Reference in New Issue
Block a user