From bddfa43f365ad6b51f88ce0f64a376aaab8fe645 Mon Sep 17 00:00:00 2001 From: cjihrig Date: Mon, 18 May 2026 21:36:53 -0400 Subject: [PATCH 1/2] fix(opentelemetry-exporter-prometheus): handle additional edge cases in metric name conversion --- experimental/CHANGELOG.md | 1 + .../src/PrometheusSerializer.ts | 21 +++++- .../test/PrometheusSerializer.test.ts | 73 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index d6f2ec294ff..419b9b8a6b1 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -20,6 +20,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 * fix(instrumentation-xhr): resolve relative URLs before matching `ignoreUrls` [#6551](https://github.com/open-telemetry/opentelemetry-js/pull/6551) @Maximiliano-Zeballos * fix(sdk-node): fix setting of ViewOption#name from ConfigurationModel [#6620](https://github.com/open-telemetry/opentelemetry-js/pull/6620) @trentm * fix(web-common): add limit for timeout [#6601](https://github.com/open-telemetry/opentelemetry-js/pull/6601) @maryliag +* fix(opentelemetry-exporter-prometheus): handle additional edge cases in metric name conversion [#xxxx](https://github.com/open-telemetry/opentelemetry-js/pull/xxxx) @cjihrig ### :books: Documentation diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts index ca5fbb097ce..73e75a332ba 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts @@ -228,7 +228,11 @@ export class PrometheusSerializer { private _serializeScopeMetrics(scopeMetrics: ScopeMetrics) { let str = ''; for (const metric of scopeMetrics.metrics) { - str += this._serializeMetricData(metric, scopeMetrics.scope) + '\n'; + const metricStr = this._serializeMetricData(metric, scopeMetrics.scope); + + if (metricStr) { + str += metricStr + '\n'; + } } return str; } @@ -243,6 +247,21 @@ export class PrometheusSerializer { if (this._prefix) { name = `${this._prefix}${name}`; } + + if (name === '') { + diag.error( + `Normalization for metric "${metricData.descriptor.name}" resulted in empty name` + ); + return ''; + } else if (name === '_') { + diag.error( + `Normalization for metric "${metricData.descriptor.name}" resulted in an invalid name` + ); + return ''; + } else if (name[0] >= '0' && name[0] <= '9') { + name = `_${name}`; + } + const dataPointType = metricData.dataPointType; name = enforcePrometheusNamingConvention(name, metricData); diff --git a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts index 5be27055796..1f4f01e9f35 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts @@ -670,6 +670,79 @@ describe('PrometheusSerializer', () => { assert.strictEqual(result, 'test_total 1\n'); }); + + it('replaces special characters with underscores when escaping is enabled', async () => { + const serializer = new PrometheusSerializer(); + const result = await getCounterResult( + 'metric@with#special$chars', + serializer, + { + exportAll: true, + } + ); + + assert.strictEqual( + result, + serializedDefaultResource + + '# HELP metric_with_special_chars_total description missing\n' + + '# TYPE metric_with_special_chars_total counter\n' + + 'metric_with_special_chars_total{otel_scope_name="test"} 1\n' + ); + }); + + it('metric names do not start with a digit when escaping is enabled', async () => { + const serializer = new PrometheusSerializer(); + const result = await getCounterResult('123metric', serializer, { + exportAll: true, + }); + + assert.strictEqual( + result, + serializedDefaultResource + + '# HELP _123metric_total description missing\n' + + '# TYPE _123metric_total counter\n' + + '_123metric_total{otel_scope_name="test"} 1\n' + ); + }); + + it('multiple special characters are collapsed to a single underscore when escaping is enabled', async () => { + const serializer = new PrometheusSerializer(); + const result = await getCounterResult('metric@@##$$name', serializer, { + exportAll: true, + }); + + assert.strictEqual( + result, + serializedDefaultResource + + '# HELP metric_name_total description missing\n' + + '# TYPE metric_name_total counter\n' + + 'metric_name_total{otel_scope_name="test"} 1\n' + ); + }); + + it('metric names of only special characters throw when escaping is enabled', async () => { + const serializer = new PrometheusSerializer(); + const result = await getCounterResult('@#$%', serializer, { + exportAll: true, + }); + + assert.strictEqual( + result, + serializedDefaultResource + '# no registered metrics' + ); + }); + + it('metrics with empty names are not serialized', async () => { + const serializer = new PrometheusSerializer(); + const result = await getCounterResult('', serializer, { + exportAll: true, + }); + + assert.strictEqual( + result, + serializedDefaultResource + '# no registered metrics' + ); + }); }); describe('serialize non-normalized values', () => { From 3e08f5ed9d58b5c915727a71bcd279dc5f90816d Mon Sep 17 00:00:00 2001 From: cjihrig Date: Tue, 19 May 2026 17:31:13 -0400 Subject: [PATCH 2/2] feedback --- experimental/CHANGELOG.md | 2 +- .../src/PrometheusSerializer.ts | 2 +- .../test/PrometheusSerializer.test.ts | 21 ++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 419b9b8a6b1..c6b700490db 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -20,7 +20,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 * fix(instrumentation-xhr): resolve relative URLs before matching `ignoreUrls` [#6551](https://github.com/open-telemetry/opentelemetry-js/pull/6551) @Maximiliano-Zeballos * fix(sdk-node): fix setting of ViewOption#name from ConfigurationModel [#6620](https://github.com/open-telemetry/opentelemetry-js/pull/6620) @trentm * fix(web-common): add limit for timeout [#6601](https://github.com/open-telemetry/opentelemetry-js/pull/6601) @maryliag -* fix(opentelemetry-exporter-prometheus): handle additional edge cases in metric name conversion [#xxxx](https://github.com/open-telemetry/opentelemetry-js/pull/xxxx) @cjihrig +* fix(opentelemetry-exporter-prometheus): handle additional edge cases in metric name conversion [#6727](https://github.com/open-telemetry/opentelemetry-js/pull/6727) @cjihrig ### :books: Documentation diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts index 73e75a332ba..2d90c112243 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts @@ -255,7 +255,7 @@ export class PrometheusSerializer { return ''; } else if (name === '_') { diag.error( - `Normalization for metric "${metricData.descriptor.name}" resulted in an invalid name` + `Normalization for metric "${metricData.descriptor.name}" resulted in an invalid name: "_"` ); return ''; } else if (name[0] >= '0' && name[0] <= '9') { diff --git a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts index 1f4f01e9f35..d491f7f7cf1 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts @@ -4,6 +4,7 @@ */ import * as assert from 'assert'; +import { diag } from '@opentelemetry/api'; import type { Attributes, UpDownCounter } from '@opentelemetry/api'; import type { DataPoint, Histogram } from '@opentelemetry/sdk-metrics'; import { @@ -720,28 +721,46 @@ describe('PrometheusSerializer', () => { ); }); - it('metric names of only special characters throw when escaping is enabled', async () => { + it('metric names of only special characters are not serialized when escaping is enabled', async () => { const serializer = new PrometheusSerializer(); + const diagErr = diag.error; + const spy = sinon.spy(); + diag.error = spy; const result = await getCounterResult('@#$%', serializer, { exportAll: true, }); + diag.error = diagErr; assert.strictEqual( result, serializedDefaultResource + '# no registered metrics' ); + assert.strictEqual(spy.calledOnce, true); + sinon.assert.calledWith( + spy, + `Normalization for metric "@#$%" resulted in an invalid name: "_"` + ); }); it('metrics with empty names are not serialized', async () => { const serializer = new PrometheusSerializer(); + const diagErr = diag.error; + const spy = sinon.spy(); + diag.error = spy; const result = await getCounterResult('', serializer, { exportAll: true, }); + diag.error = diagErr; assert.strictEqual( result, serializedDefaultResource + '# no registered metrics' ); + assert.strictEqual(spy.calledOnce, true); + sinon.assert.calledWith( + spy, + `Normalization for metric "" resulted in empty name` + ); }); });