diff --git a/src/index.js b/src/index.js index 2c74e80..6dd6628 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,18 @@ const { CircuitBreaker } = require('./resilience/circuit-breaker'); const { EventBus } = require('./events/event-bus'); const { TaskScheduler } = require('./orchestration/scheduler'); +const { + TelemetryExporter, + PrometheusExporter, + OpenTelemetryExporter, + TelemetryManager +} = require('./telemetry'); + module.exports = { + TelemetryExporter, + PrometheusExporter, + OpenTelemetryExporter, + TelemetryManager, Engine, CircuitBreaker, EventBus, diff --git a/src/telemetry/index.js b/src/telemetry/index.js new file mode 100644 index 0000000..81b8383 --- /dev/null +++ b/src/telemetry/index.js @@ -0,0 +1,141 @@ +class TelemetryExporter { + constructor(options = {}) { + this.name = options.name || 'base-exporter'; + this.enabled = options.enabled !== false; + } + + async export(metrics) { + throw new Error('export() must be implemented by subclasses'); + } +} + +Documentation = 'Prometheus Exporter'; + +class PrometheusExporter extends TelemetryExporter { + constructor(options = {}) { + super({ name: 'prometheus', ...options }); + this.prefix = options.prefix || 'brocode'; + } + + format(metrics) { + const prefix = this.prefix; + const lines = []; + + lines.push('# HELP ' + prefix + '_engine_status Current operational status of the runtime engine'); + lines.push('# TYPE' + ' ' + prefix + '_engine_status gauge'); + const statusVal = metrics.status === 'RUNNING' ? 1 : 0; + lines.push(prefix + '_engine_status{engine="' + metrics.engine + '",version="' + metrics.version + '"} ' + statusVal); + + lines.push('# HELP ' + prefix + '_uptime_seconds Engine uptime duration in seconds'); + lines.push('# TYPE' + ' ' + prefix + '_uptime_seconds counter'); + lines.push(prefix + '_uptime_seconds{engine="' + metrics.engine + '"} ' + (metrics.uptimeSeconds || 0)); + + if (metrics.memory) { + lines.push('# HELP ' + prefix + '_memory_heap_used_mb Heap memory used in MB'); + lines.push('# TYPE' + ' ' + prefix + '_memory_heap_used_mb gauge'); + lines.push(prefix + '_memory_heap_used_mb{engine="' + metrics.engine + '"} ' + (metrics.memory.heapUsedMB || 0)); + + lines.push('# HELP ' + prefix + '_memory_rss_mb Resident Set Size memory in MB'); + lines.push('# TYPE' + ' ' + prefix + '_memory_rss_mb gauge'); + lines.push(prefix + '_memory_rss_mb{engine="' + metrics.engine + '"} ' + (metrics.memory.rssMB || 0)); + } + + return lines.join('\n') + '\n'; + } + + async export(metrics) { + return this.format(metrics); + } +} + +class OpenTelemetryExporter extends TelemetryExporter { + constructor(options = {}) { + super({ name: 'opentelemetry', ...options }); + this.serviceName = options.serviceName || 'brocode-service'; + } + + format(metrics) { + const timestampNano = (Date.now() * 1000000).toString(); + return { + resourceMetrics: [ + { + resource: { + attributes: [ + { key: 'service.name', value: { stringValue: this.serviceName } }, + { key: 'engine.name', value: { stringValue: metrics.engine } }, + { key: 'engine.version', value: { stringValue: metrics.version } } + ] + }, + scopeMetrics: [ + { + scope: { name: 'brocode.runtime.metrics', vesion: '0.1.0' }, + metrics: [ + { + name: 'runtime.uptime', + description: 'Uptime duration in seconds', + unit: 's', + gauge: { + dataPoints: [{ timeUnixNano: timestampNano, asInt: metrics.uptimeSeconds || 0 }] + } + }, + { + name: 'runtime.memory.heap_used', + description: 'Memory heap used in MB', + unit: 'MB', + gauge: { + dataPoints: [{ timeUnixNano: timestampNano, asDouble: metrics.memory ? metrics.memory.heapUsedMB : 0 }] + } + } + ] + } + ] + } + ] + }; + } + + async export(metrics) { + return this.format(metrics); + } +} + +class TelemetryManager { + constructor() { + this.exporters = new Map(); + } + + register(exporter) { + if (!exporter || !exporter.name) { + throw new Error('Exporter must have a name'); + } + this.exporters.set(exporter.name, exporter); + return this; + } + + + unregister(name) { + return this.exporters.delete(name); + } + + get(name) { + return this.exporters.get(name); + } + + + async collect(metrics) { + const results = {}; + for (const [name, exporter] of this.exporters.entries()) { + if (exporter.enabled) { + results[name] = await exporter.export(metrics); + } + } + return results; + } +} + +module.exports = { + TelemetryExporter, + PrometheusExporter, + OpenTelemetryExporter, + TelemetryManager +}; \ No newline at end of file diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js new file mode 100644 index 0000000..36d3462 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,74 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { + TelemetryExporter, + PrometheusExporter, + OpenTelemetryExporter, + TelemetryManager +} = require('../src/telemetry'); + +test('PrometheusExporter formats metrics into Prometheus exposition format', async () => { + const exporter = new PrometheusExporter({ prefix: 'test_brocode' }); + const metrics = { + status: 'RUNNING', + engine: 'engine-1', + version: '0.1.0', + uptimeSeconds: 120, + memory: { + heapUsedMB: 45.5, + heapTotalMB: 60.0, + rssMB: 85.2 + } + }; + + const output = await exporter.export(metrics); + assert.ok(output.includes('# HELP test_brocode_engine_status')); + assert.ok(output.includes('test_brocode_engine_status{engine="engine-1",version="0.1.0"} 1')); + assert.ok(output.includes('test_brocode_uptime_seconds{engine="engine-1"} 120')); + assert.ok(output.includes('test_brocode_memory_heap_used_mb{engine="engine-1"} 45.5')); +}); + +test('OpenTelemetryExporter produces compliant OTel ResourceMetrics payload', async () => { + const exporter = new OpenTelemetryExporter({ serviceName: 'custom-service' }); + const metrics = { + status: 'RUNNING', + engine: 'engine-1', + version: '0.1.0', + uptimeSeconds: 60, + memory: { heapUsedMB: 30 } + }; + + const output = await exporter.export(metrics); + assert.ok(output.resourceMetrics); + const resource = output.resourceMetrics[0].resource; + const serviceAttr = resource.attributes.find(a => a.key === 'service.name'); + assert.strictEqual(serviceAttr.value.stringValue, 'custom-service'); + + const metricList = output.resourceMetrics[0].scopeMetrics[0].metrics; + const uptimeMetric = metricList.find(m => m.name === 'runtime.uptime'); + assert.strictEqual(uptimeMetric.gauge.dataPoints[0].asInt, 60); +}); + +test('TelemetryManager registers, retrieves, and collects across multiple exporters', async () => { + const manager = new TelemetryManager(); + const prom = new PrometheusExporter(); + const otel = new OpenTelemetryExporter(); + + manager.register(prom); + manager.register(otel); + + assert.strictEqual(manager.get('prometheus'), prom); + assert.strictEqual(manager.get('opentelemetry'), otel); + + const metrics = { + status: 'STOPPED', + engine: 'kernel', + version: '0.1.0', + uptimeSeconds: 0 + }; + + const collected = await manager.collect(metrics); + assert.ok(collected.prometheus); + assert.ok(collected.opentelemetry); + assert.ok(collected.prometheus.includes('brocode_engine_status{engine="kernel",version="0.1.0"} 0')); +});