diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 9b6d0b4625..595add19a3 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to experimental packages in this project will be documented ### :rocket: (Enhancement) * feat(sdk-node): configure trace exporter with environment variables [#3143](https://github.com/open-telemetry/opentelemetry-js/pull/3143) @svetlanabrennan +* feat(prometheus): serialize resource as target_info gauge [#3300](https://github.com/open-telemetry/opentelemetry-js/pull/3300) @pichlermarc ### :bug: (Bug Fix) diff --git a/experimental/examples/prometheus/package.json b/experimental/examples/prometheus/package.json index 9816279e53..837fcf9b61 100644 --- a/experimental/examples/prometheus/package.json +++ b/experimental/examples/prometheus/package.json @@ -1,6 +1,6 @@ { "name": "prometheus-example", - "version": "0.32.0", + "version": "0.33.0", "description": "Example of using @opentelemetry/sdk-metrics and @opentelemetry/exporter-prometheus", "main": "index.js", "scripts": { @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.0.2", - "@opentelemetry/exporter-prometheus": "0.32.0", - "@opentelemetry/sdk-metrics": "0.32.0" + "@opentelemetry/exporter-prometheus": "0.33.0", + "@opentelemetry/sdk-metrics": "0.33.0" } } diff --git a/experimental/packages/opentelemetry-exporter-prometheus/package.json b/experimental/packages/opentelemetry-exporter-prometheus/package.json index e45cdd33c6..c8305d3c88 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/package.json +++ b/experimental/packages/opentelemetry-exporter-prometheus/package.json @@ -61,7 +61,8 @@ "dependencies": { "@opentelemetry/api-metrics": "0.33.0", "@opentelemetry/core": "1.7.0", - "@opentelemetry/sdk-metrics": "0.33.0" + "@opentelemetry/sdk-metrics": "0.33.0", + "@opentelemetry/resources": "1.7.0" }, "homepage": "https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-exporter-prometheus" } diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts index 42b051e301..4f333eec55 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts @@ -18,15 +18,22 @@ import { diag } from '@opentelemetry/api'; import { globalErrorHandler, } from '@opentelemetry/core'; -import { Aggregation, AggregationTemporality, MetricReader } from '@opentelemetry/sdk-metrics'; -import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; +import { + Aggregation, + AggregationTemporality, + MetricReader +} from '@opentelemetry/sdk-metrics'; +import { + createServer, + IncomingMessage, + Server, + ServerResponse +} from 'http'; import { ExporterConfig } from './export/types'; import { PrometheusSerializer } from './PrometheusSerializer'; /** Node.js v8.x compat */ import { URL } from 'url'; -const NO_REGISTERED_METRICS = '# no registered metrics'; - export class PrometheusExporter extends MetricReader { static readonly DEFAULT_OPTIONS = { host: undefined, @@ -154,7 +161,7 @@ export class PrometheusExporter extends MetricReader { /** * Request handler that responds with the current state of metrics - * @param request Incoming HTTP request of server instance + * @param _request Incoming HTTP request of server instance * @param response HTTP response objet used to response to request */ public getMetricsRequestHandler( @@ -195,11 +202,7 @@ export class PrometheusExporter extends MetricReader { if (errors.length) { diag.error('PrometheusExporter: metrics collection errors', ...errors); } - let result = this._serializer.serialize(resourceMetrics); - if (result === '') { - result = NO_REGISTERED_METRICS; - } - response.end(result); + response.end(this._serializer.serialize(resourceMetrics)); }, err => { response.end(`# failed to export metrics: ${err}`); diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts index 94144732ac..410cbfbf34 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts @@ -29,6 +29,7 @@ import type { MetricAttributeValue } from '@opentelemetry/api-metrics'; import { hrTimeToMilliseconds } from '@opentelemetry/core'; +import { Resource } from '@opentelemetry/resources'; type PrometheusDataTypeLiteral = | 'counter' @@ -167,6 +168,8 @@ function stringify( }\n`; } +const NO_REGISTERED_METRICS = '# no registered metrics'; + export class PrometheusSerializer { private _prefix: string | undefined; private _appendTimestamp: boolean; @@ -180,10 +183,16 @@ export class PrometheusSerializer { serialize(resourceMetrics: ResourceMetrics): string { let str = ''; + for (const scopeMetrics of resourceMetrics.scopeMetrics) { str += this._serializeScopeMetrics(scopeMetrics); } - return str; + + if (str === '') { + str += NO_REGISTERED_METRICS; + } + + return this._serializeResource(resourceMetrics.resource) + str; } private _serializeScopeMetrics(scopeMetrics: ScopeMetrics) { @@ -311,4 +320,13 @@ export class PrometheusSerializer { return results; } + + protected _serializeResource(resource: Resource): string { + const name = 'target_info'; + const help = `# HELP ${name} Target metadata`; + const type = `# TYPE ${name} gauge`; + + const results = stringify(name, resource.attributes, 1).trim(); + return `${help}\n${type}\n${results}\n`; + } } diff --git a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts index 080ac599e3..6b60952670 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts @@ -14,16 +14,28 @@ * limitations under the License. */ -import { Meter, ObservableResult } from '@opentelemetry/api-metrics'; +import { + Meter, + ObservableResult +} from '@opentelemetry/api-metrics'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; import * as http from 'http'; import { PrometheusExporter } from '../src'; -import { mockedHrTimeMs, mockHrTime } from './util'; +import { + mockedHrTimeMs, + mockHrTime +} from './util'; import { SinonStubbedInstance } from 'sinon'; import { Counter } from '@opentelemetry/api-metrics'; +const serializedEmptyResourceLines = [ + '# HELP target_info Target metadata', + '# TYPE target_info gauge', + 'target_info 1' +]; + describe('PrometheusExporter', () => { beforeEach(() => { mockHrTime(); @@ -249,11 +261,12 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.strictEqual( - lines[0], + lines[serializedEmptyResourceLines.length], '# HELP counter_total a test description' ); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_total a test description', '# TYPE counter_total counter', `counter_total{key1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -283,6 +296,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP metric_observable_gauge a test description', '# TYPE metric_observable_gauge gauge', `metric_observable_gauge{pid="123",core="1"} 0.999 ${mockedHrTimeMs}`, @@ -302,6 +316,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_total a test description', '# TYPE counter_total counter', `counter_total{counterKey1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -331,11 +346,14 @@ describe('PrometheusExporter', () => { }); }); - it('should export a comment if no metrics are registered', async () => { + it('should export resource even if no metrics are registered', async () => { const body = await request('http://localhost:9464/metrics'); const lines = body.split('\n'); - assert.deepStrictEqual(lines, ['# no registered metrics']); + assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, + '# no registered metrics' + ]); }); it('should add a description if missing', async () => { @@ -347,6 +365,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_total description missing', '# TYPE counter_total counter', `counter_total{key1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -363,6 +382,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_bad_name_total description missing', '# TYPE counter_bad_name_total counter', `counter_bad_name_total{key1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -380,6 +400,7 @@ describe('PrometheusExporter', () => { const body = await request('http://localhost:9464/metrics'); const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter a test description', '# TYPE counter gauge', `counter{key1="attributeValue1"} 20 ${mockedHrTimeMs}`, @@ -387,7 +408,7 @@ describe('PrometheusExporter', () => { ]); }); - it('should export an ObservableCounter as a counter', async() => { + it('should export an ObservableCounter as a counter', async () => { function getValue() { return 20; } @@ -408,6 +429,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP metric_observable_counter a test description', '# TYPE metric_observable_counter counter', `metric_observable_counter{key1="attributeValue1"} 20 ${mockedHrTimeMs}`, @@ -436,6 +458,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP metric_observable_up_down_counter a test description', '# TYPE metric_observable_up_down_counter gauge', `metric_observable_up_down_counter{key1="attributeValue1"} 20 ${mockedHrTimeMs}`, @@ -443,7 +466,7 @@ describe('PrometheusExporter', () => { ]); }); - it('should export a Histogram as a summary', async() => { + it('should export a Histogram as a summary', async () => { const histogram = meter.createHistogram('test_histogram', { description: 'a test description', }); @@ -454,6 +477,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP test_histogram a test description', '# TYPE test_histogram histogram', `test_histogram_count{key1="attributeValue1"} 1 ${mockedHrTimeMs}`, @@ -507,6 +531,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP test_prefix_counter_total description missing', '# TYPE test_prefix_counter_total counter', `test_prefix_counter_total{key1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -535,6 +560,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_total description missing', '# TYPE counter_total counter', `counter_total{key1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -563,6 +589,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_total description missing', '# TYPE counter_total counter', `counter_total{key1="attributeValue1"} 10 ${mockedHrTimeMs}`, @@ -591,6 +618,7 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ + ...serializedEmptyResourceLines, '# HELP counter_total description missing', '# TYPE counter_total counter', 'counter_total{key1="attributeValue1"} 10', diff --git a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts index 1ae4ab8f53..852946f355 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts @@ -15,7 +15,10 @@ */ import * as assert from 'assert'; -import { MetricAttributes, UpDownCounter } from '@opentelemetry/api-metrics'; +import { + MetricAttributes, + UpDownCounter +} from '@opentelemetry/api-metrics'; import { Aggregation, AggregationTemporality, @@ -31,13 +34,22 @@ import { } from '@opentelemetry/sdk-metrics'; import * as sinon from 'sinon'; import { PrometheusSerializer } from '../src'; -import { mockedHrTimeMs, mockHrTime } from './util'; +import { + mockedHrTimeMs, + mockHrTime +} from './util'; +import { Resource } from '@opentelemetry/resources'; const attributes = { foo1: 'bar1', foo2: 'bar2', }; +const serializedEmptyResource = + '# HELP target_info Target metadata\n' + + '# TYPE target_info gauge\n' + + 'target_info 1\n'; + class TestMetricReader extends MetricReader { constructor() { super({ @@ -278,7 +290,7 @@ describe('PrometheusSerializer', () => { const reader = new TestMetricReader(); const meterProvider = new MeterProvider({ views: [ - new View({aggregation: new LastValueAggregation(), instrumentName: '*' }) + new View({ aggregation: new LastValueAggregation(), instrumentName: '*' }) ] }); meterProvider.addMetricReader(reader); @@ -361,7 +373,7 @@ describe('PrometheusSerializer', () => { assert.strictEqual( result, '# HELP test foobar\n' + - '# TYPE test histogram\n' + + '# TYPE test histogram\n' + `test_count{val="1"} 3 ${mockedHrTimeMs}\n` + `test_sum{val="1"} 175 ${mockedHrTimeMs}\n` + `test_bucket{val="1",le="1"} 0 ${mockedHrTimeMs}\n` + @@ -465,6 +477,7 @@ describe('PrometheusSerializer', () => { const result = await getCounterResult('test', serializer, { unit: unitOfMetric, exportAll: true }); assert.strictEqual( result, + serializedEmptyResource + '# HELP test_total description missing\n' + `# UNIT test_total ${unitOfMetric}\n` + '# TYPE test_total counter\n' + @@ -478,6 +491,7 @@ describe('PrometheusSerializer', () => { const result = await getCounterResult('test', serializer, { exportAll: true }); assert.strictEqual( result, + serializedEmptyResource + '# HELP test_total description missing\n' + '# TYPE test_total counter\n' + `test_total 1 ${mockedHrTimeMs}\n` @@ -607,7 +621,7 @@ describe('PrometheusSerializer', () => { const serializer = new PrometheusSerializer(); const result = await testSerializer(serializer, 'test_total', counter => { - // if you try to use a attribute name like account-id prometheus will complain + // if you try to use an attribute name like account-id prometheus will complain // with an error like: // error while linting: text format parsing error in line 282: expected '=' after label name, found '-' counter.add(1, ({ @@ -621,4 +635,24 @@ describe('PrometheusSerializer', () => { ); }); }); + + describe('_serializeResource', () => { + it('should serialize resource', () => { + const serializer = new PrometheusSerializer(undefined, true); + const result = serializer['_serializeResource'](new Resource({ + env: 'prod', + hostname: 'myhost', + datacenter: 'sdc', + region: 'europe', + owner: 'frontend' + })); + + assert.strictEqual( + result, + '# HELP target_info Target metadata\n' + + '# TYPE target_info gauge\n' + + 'target_info{env="prod",hostname="myhost",datacenter="sdc",region="europe",owner="frontend"} 1\n' + ); + }); + }); }); diff --git a/experimental/packages/opentelemetry-exporter-prometheus/tsconfig.json b/experimental/packages/opentelemetry-exporter-prometheus/tsconfig.json index 138ffc3aad..1283f8e6d4 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/tsconfig.json +++ b/experimental/packages/opentelemetry-exporter-prometheus/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../packages/opentelemetry-core" }, + { + "path": "../../../packages/opentelemetry-resources" + }, { "path": "../opentelemetry-api-metrics" }, diff --git a/lerna.json b/lerna.json index 5c0f76b9c2..d97ab02a62 100644 --- a/lerna.json +++ b/lerna.json @@ -5,6 +5,7 @@ "api", "packages/*", "experimental/packages/*", + "experimental/examples/*", "experimental/backwards-compatability/*", "integration-tests/*", "selenium-tests",