diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index b627766308e..09101ecd0ae 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to experimental packages in this project will be documented ### :rocket: (Enhancement) +* feat(exporter-prometheus): add additional attributes option [#5317](/~https://github.com/open-telemetry/opentelemetry-js/pull/5317) @marius-a-mueller + * Add `withResourceConstantLabels` option to `ExporterConfig`. It can be used to define a regex pattern to choose which resource attributes will be used as static labels on the metrics. The default is to not set any static labels. + ### :bug: (Bug Fix) * fix(exporter-metrics-otlp-http): browser OTLPMetricExporter was not passing config to OTLPMetricExporterBase super class [#5331](/~https://github.com/open-telemetry/opentelemetry-js/pull/5331) @trentm diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts index e9f95051f82..88871a96523 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts @@ -34,6 +34,7 @@ export class PrometheusExporter extends MetricReader { endpoint: '/metrics', prefix: '', appendTimestamp: false, + withResourceConstantLabels: undefined, }; private readonly _host?: string; @@ -43,6 +44,7 @@ export class PrometheusExporter extends MetricReader { private readonly _server: Server; private readonly _prefix?: string; private readonly _appendTimestamp: boolean; + private readonly _withResourceConstantLabels?: string | RegExp; private _serializer: PrometheusSerializer; private _startServerPromise: Promise | undefined; @@ -82,11 +84,15 @@ export class PrometheusExporter extends MetricReader { typeof config.appendTimestamp === 'boolean' ? config.appendTimestamp : PrometheusExporter.DEFAULT_OPTIONS.appendTimestamp; + this._withResourceConstantLabels = + config.withResourceConstantLabels || + PrometheusExporter.DEFAULT_OPTIONS.withResourceConstantLabels; // unref to prevent prometheus exporter from holding the process open on exit this._server = createServer(this._requestHandler).unref(); this._serializer = new PrometheusSerializer( this._prefix, - this._appendTimestamp + this._appendTimestamp, + this._withResourceConstantLabels ); this._baseUrl = `http://${this._host}:${this._port}/`; diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts index bf9abc0b632..4672950692e 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts @@ -174,17 +174,29 @@ const NO_REGISTERED_METRICS = '# no registered metrics'; export class PrometheusSerializer { private _prefix: string | undefined; private _appendTimestamp: boolean; + private _additionalAttributes: Attributes | undefined; + private _withResourceConstantLabels: string | RegExp | undefined; - constructor(prefix?: string, appendTimestamp = false) { + constructor( + prefix?: string, + appendTimestamp = false, + withResourceConstantLabels?: string | RegExp + ) { if (prefix) { this._prefix = prefix + '_'; } this._appendTimestamp = appendTimestamp; + this._withResourceConstantLabels = withResourceConstantLabels; } serialize(resourceMetrics: ResourceMetrics): string { let str = ''; + this._additionalAttributes = this._filterResourceConstantLabels( + resourceMetrics.resource.attributes, + this._withResourceConstantLabels + ); + for (const scopeMetrics of resourceMetrics.scopeMetrics) { str += this._serializeScopeMetrics(scopeMetrics); } @@ -196,6 +208,23 @@ export class PrometheusSerializer { return this._serializeResource(resourceMetrics.resource) + str; } + private _filterResourceConstantLabels( + attributes: Attributes, + pattern: string | RegExp | undefined + ) { + if (pattern) { + const filteredAttributes: Attributes = {}; + for (const [key, value] of Object.entries(attributes)) { + const sanitizedAttributeName = sanitizePrometheusMetricName(key); + if (sanitizedAttributeName.match(pattern)) { + filteredAttributes[sanitizedAttributeName] = value; + } + } + return filteredAttributes; + } + return; + } + private _serializeScopeMetrics(scopeMetrics: ScopeMetrics) { let str = ''; for (const metric of scopeMetrics.metrics) { @@ -263,7 +292,7 @@ export class PrometheusSerializer { attributes, value, this._appendTimestamp ? timestamp : undefined, - undefined + this._additionalAttributes ); return results; } @@ -288,7 +317,7 @@ export class PrometheusSerializer { attributes, value, this._appendTimestamp ? timestamp : undefined, - undefined + this._additionalAttributes ); } @@ -315,12 +344,12 @@ export class PrometheusSerializer { attributes, cumulativeSum, this._appendTimestamp ? timestamp : undefined, - { + Object.assign({}, this._additionalAttributes ?? {}, { le: upperBound === undefined || upperBound === Infinity ? '+Inf' : String(upperBound), - } + }) ); } diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/export/types.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/export/types.ts index 78721a90c04..7931385433d 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/export/types.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/export/types.ts @@ -66,4 +66,12 @@ export interface ExporterConfig { * @experimental */ metricProducers?: MetricProducer[]; + + /** + * Regex pattern for defining which resource attributes will be applied + * as constant labels to the metrics. + * e.g. 'telemetry_.+' for all attributes starting with 'telemetry'. + * @default undefined (no resource attributes are applied) + */ + withResourceConstantLabels?: string | RegExp; } diff --git a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts index 6a31edbd827..6def3bea1aa 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts @@ -553,6 +553,40 @@ describe('PrometheusExporter', () => { '', ]); }); + + it('should export a metric with all resource attributes', async () => { + exporter = new PrometheusExporter({ + withResourceConstantLabels: '.*', + }); + setup(exporter); + const body = await request('http://localhost:9464/metrics'); + const lines = body.split('\n'); + + assert.deepStrictEqual(lines, [ + ...serializedDefaultResourceLines, + '# HELP counter_total description missing', + '# TYPE counter_total counter', + `counter_total{key1="attributeValue1",service_name="${serviceName}",telemetry_sdk_language="${sdkLanguage}",telemetry_sdk_name="${sdkName}",telemetry_sdk_version="${sdkVersion}"} 10`, + '', + ]); + }); + + it('should export a metric with two resource attributes', async () => { + exporter = new PrometheusExporter({ + withResourceConstantLabels: 'telemetry_sdk_language|telemetry_sdk_name', + }); + setup(exporter); + const body = await request('http://localhost:9464/metrics'); + const lines = body.split('\n'); + + assert.deepStrictEqual(lines, [ + ...serializedDefaultResourceLines, + '# HELP counter_total description missing', + '# TYPE counter_total counter', + `counter_total{key1="attributeValue1",telemetry_sdk_language="${sdkLanguage}",telemetry_sdk_name="${sdkName}"} 10`, + '', + ]); + }); }); }); diff --git a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts index 50dba5ee903..0b93bd113d0 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts @@ -42,10 +42,12 @@ const attributes = { foo2: 'bar2', }; +const resourceAttributes = `service_name="${serviceName}",telemetry_sdk_language="${sdkLanguage}",telemetry_sdk_name="${sdkName}",telemetry_sdk_version="${sdkVersion}"`; + const serializedDefaultResource = '# HELP target_info Target metadata\n' + '# TYPE target_info gauge\n' + - `target_info{service_name="${serviceName}",telemetry_sdk_language="${sdkLanguage}",telemetry_sdk_name="${sdkName}",telemetry_sdk_version="${sdkVersion}"} 1\n`; + `target_info{${resourceAttributes}} 1\n`; class TestMetricReader extends MetricReader { constructor() { @@ -104,6 +106,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(metric.dataPointType, DataPointType.SUM); const pointData = metric.dataPoints as DataPoint[]; assert.strictEqual(pointData.length, 1); + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); const result = serializer['_serializeSingularDataPoint']( metric.descriptor.name, @@ -159,6 +165,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(metric.dataPointType, DataPointType.HISTOGRAM); const pointData = metric.dataPoints as DataPoint[]; assert.strictEqual(pointData.length, 1); + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); const result = serializer['_serializeHistogramDataPoint']( metric.descriptor.name, @@ -195,6 +205,20 @@ describe('PrometheusSerializer', () => { `test_bucket{foo1="bar1",foo2="bar2",le="+Inf"} 1 ${mockedHrTimeMs}\n` ); }); + + it('serialize metric record with sum aggregator with timestamp and all resource attributes', async () => { + const serializer = new PrometheusSerializer(undefined, true, '.*'); + const result = await testSerializer(serializer); + assert.strictEqual( + result, + `test_count{foo1="bar1",foo2="bar2",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + + `test_sum{foo1="bar1",foo2="bar2",${resourceAttributes}} 5 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",${resourceAttributes},le="1"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",${resourceAttributes},le="10"} 1 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",${resourceAttributes},le="100"} 1 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",${resourceAttributes},le="+Inf"} 1 ${mockedHrTimeMs}\n` + ); + }); }); }); @@ -224,6 +248,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); assert.strictEqual(resourceMetrics.scopeMetrics[0].metrics.length, 1); const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); const result = serializer['_serializeScopeMetrics'](scopeMetrics); return result; @@ -252,6 +280,18 @@ describe('PrometheusSerializer', () => { `test_total{val="2"} 1 ${mockedHrTimeMs}\n` ); }); + + it('should serialize metric record with timestamp and all resource attributes', async () => { + const serializer = new PrometheusSerializer(undefined, true, '.*'); + const result = await testSerializer(serializer); + assert.strictEqual( + result, + '# HELP test_total foobar\n' + + '# TYPE test_total counter\n' + + `test_total{val="1",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + + `test_total{val="2",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + ); + }); }); describe('non-monotonic Sum', () => { @@ -279,6 +319,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); assert.strictEqual(resourceMetrics.scopeMetrics[0].metrics.length, 1); const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); return serializer['_serializeScopeMetrics'](scopeMetrics); } @@ -306,6 +350,19 @@ describe('PrometheusSerializer', () => { `test_total{val="2"} 1 ${mockedHrTimeMs}\n` ); }); + + it('serialize metric record with timestamp and all resource attributes', async () => { + const serializer = new PrometheusSerializer(undefined, true, '.*'); + + const result = await testSerializer(serializer); + assert.strictEqual( + result, + '# HELP test_total foobar\n' + + '# TYPE test_total gauge\n' + + `test_total{val="1",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + + `test_total{val="2",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + ); + }); }); describe('Gauge', () => { @@ -335,6 +392,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); assert.strictEqual(resourceMetrics.scopeMetrics[0].metrics.length, 1); const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); return serializer['_serializeScopeMetrics'](scopeMetrics); } @@ -362,6 +423,18 @@ describe('PrometheusSerializer', () => { `test_total{val="2"} 1 ${mockedHrTimeMs}\n` ); }); + + it('serialize metric record with timestamp and all resource attributes', async () => { + const serializer = new PrometheusSerializer(undefined, true, '.*'); + const result = await testSerializer(serializer); + assert.strictEqual( + result, + '# HELP test_total foobar\n' + + '# TYPE test_total gauge\n' + + `test_total{val="1",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + + `test_total{val="2",${resourceAttributes}} 1 ${mockedHrTimeMs}\n` + ); + }); }); describe('with ExplicitBucketHistogramAggregation', () => { @@ -395,6 +468,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); assert.strictEqual(resourceMetrics.scopeMetrics[0].metrics.length, 1); const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); const result = serializer['_serializeScopeMetrics'](scopeMetrics); return result; @@ -422,6 +499,28 @@ describe('PrometheusSerializer', () => { ); }); + it('serialize cumulative metric record with all resource attributes', async () => { + const serializer = new PrometheusSerializer('', false, '.*'); + const result = await testSerializer(serializer); + assert.strictEqual( + result, + '# HELP test foobar\n' + + '# TYPE test histogram\n' + + `test_count{val="1",${resourceAttributes}} 3\n` + + `test_sum{val="1",${resourceAttributes}} 175\n` + + `test_bucket{val="1",${resourceAttributes},le="1"} 0\n` + + `test_bucket{val="1",${resourceAttributes},le="10"} 1\n` + + `test_bucket{val="1",${resourceAttributes},le="100"} 2\n` + + `test_bucket{val="1",${resourceAttributes},le="+Inf"} 3\n` + + `test_count{val="2",${resourceAttributes}} 1\n` + + `test_sum{val="2",${resourceAttributes}} 5\n` + + `test_bucket{val="2",${resourceAttributes},le="1"} 0\n` + + `test_bucket{val="2",${resourceAttributes},le="10"} 1\n` + + `test_bucket{val="2",${resourceAttributes},le="100"} 1\n` + + `test_bucket{val="2",${resourceAttributes},le="+Inf"} 1\n` + ); + }); + it('serialize cumulative metric record on instrument that allows negative values', async () => { const serializer = new PrometheusSerializer(); const reader = new TestMetricReader(); @@ -453,6 +552,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); assert.strictEqual(resourceMetrics.scopeMetrics[0].metrics.length, 1); const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); const result = serializer['_serializeScopeMetrics'](scopeMetrics); assert.strictEqual( @@ -504,6 +607,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(metric.dataPointType, DataPointType.SUM); const pointData = metric.dataPoints as DataPoint[]; assert.strictEqual(pointData.length, 1); + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); if (exportAll) { const result = serializer.serialize(resourceMetrics); @@ -595,6 +702,10 @@ describe('PrometheusSerializer', () => { assert.strictEqual(metric.dataPointType, DataPointType.SUM); const pointData = metric.dataPoints as DataPoint[]; assert.strictEqual(pointData.length, 1); + const resourceAttributes = resourceMetrics.resource.attributes; + serializer['_additionalAttributes'] = serializer[ + '_filterResourceConstantLabels' + ](resourceAttributes, serializer['_withResourceConstantLabels']); const result = serializer['_serializeSingularDataPoint']( metric.descriptor.name,