From 78fc27d6572d407af3c43aef593f40f77ce88dbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:58:56 +0000 Subject: [PATCH 1/4] Initial plan From 51304b86bb339c4b7354c6bd13ade715d993ec79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:41:42 +0000 Subject: [PATCH 2/4] fix: restore reserved suffix stripping in sanitizeMetricName In client_java 1.6.0, `PrometheusNaming.sanitizeMetricName()` was changed to return the input string unchanged (since all non-empty valid UTF-8 strings are now valid metric names). This was a regression for downstream tools like the JMX Exporter and the simpleclient bridge, which relied on `sanitizeMetricName` to strip reserved Prometheus metric name suffixes (`_total`, `_created`, `_info`, `_bucket` and their dot variants) before passing names to snapshot builders. Without the stripping: - A JMX attribute "Total" could produce a metric named `kafka_consumer_request_total` instead of `kafka_consumer_request` - With `inferCounterTypeFromName: true`, this triggers unintended counter-type inference because the name ends with `_total` - With `inferCounterTypeFromName: false`, the metric is still discoverable under the wrong name, breaking downstream registry lookups The fix restores the stripping behaviour. Note that users who create metrics directly via `Counter.builder().name("events_total")` are not affected: the builder API bypasses `sanitizeMetricName` entirely. Agent-Logs-Url: https://github.com/prometheus/client_java/sessions/7c842dd0-c793-4e16-815f-d3b5e03c3ba8 Co-authored-by: jaydeluca <7630696+jaydeluca@users.noreply.github.com> --- .../model/snapshots/PrometheusNaming.java | 46 +++++++++++++++++-- .../model/snapshots/MetricMetadataTest.java | 12 +++-- .../model/snapshots/PrometheusNamingTest.java | 44 ++++++++++++++---- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java index ea2653931..6b071db5e 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java @@ -17,6 +17,17 @@ */ public class PrometheusNaming { + /** + * Reserved metric name suffixes. These suffixes are automatically appended by Prometheus + * exposition format writers for specific metric types: {@code _total} and {@code _created} for + * counters, {@code _info} for info metrics, and {@code _bucket} for histograms. Including these + * in a base metric name via {@link #sanitizeMetricName(String)} would cause confusion or + * double-suffixing, so they are stripped during sanitization. + */ + static final String[] RESERVED_METRIC_NAME_SUFFIXES = { + "_total", "_created", "_bucket", "_info", ".total", ".created", ".bucket", ".info" + }; + /** * Test if a metric name is valid. Any non-empty valid UTF-8 string is accepted. * @@ -153,16 +164,43 @@ public static String prometheusName(String name) { } /** - * Convert an arbitrary string to a valid metric name. Since any non-empty valid UTF-8 string is a - * valid metric name, this simply returns the input unchanged. + * Convert an arbitrary string to a valid metric name. + * + *

Reserved metric name suffixes ({@code _total}, {@code _created}, {@code _bucket}, {@code + * _info} and their dot variants) are stripped. These suffixes are appended automatically by + * Prometheus exposition format writers, so including them in a base metric name would result in + * double-suffixing or unintended type inference. For example, a JMX attribute named {@code + * RequestTotal} would be sanitized from {@code kafka_consumer_request_total} to {@code + * kafka_consumer_request}, and the counter writer would add {@code _total} back at scrape time. + * + *

This behaviour was present in client_java 1.5.x and is restored here to fix a regression + * introduced in 1.6.0 that affected downstream tools (e.g. the JMX Exporter and the simpleclient + * bridge) which relied on {@code sanitizeMetricName} to strip these suffixes before passing names + * to the snapshot builders. * - * @throws IllegalArgumentException if the input is empty + * @throws IllegalArgumentException if the input is empty or becomes empty after stripping */ public static String sanitizeMetricName(String metricName) { if (metricName.isEmpty()) { throw new IllegalArgumentException("Cannot convert an empty string to a valid metric name."); } - return metricName; + String sanitizedName = metricName; + boolean modified = true; + while (modified) { + modified = false; + for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { + if (sanitizedName.equals(reservedSuffix)) { + // Corner case: sanitizeMetricName("_total") returns "total". + return reservedSuffix.substring(1); + } + if (sanitizedName.endsWith(reservedSuffix)) { + sanitizedName = + sanitizedName.substring(0, sanitizedName.length() - reservedSuffix.length()); + modified = true; + } + } + } + return sanitizedName; } /** diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricMetadataTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricMetadataTest.java index 8a4731ac8..5781eb146 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricMetadataTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricMetadataTest.java @@ -35,26 +35,30 @@ void testSanitizationIllegalCharacters() { @Test void testNameWithTotalSuffix() { + // sanitizeMetricName strips the reserved _total suffix. MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("my_events_total")); - assertThat(metadata.getName()).isEqualTo("my_events_total"); + assertThat(metadata.getName()).isEqualTo("my_events"); } @Test void testNameWithInfoSuffix() { + // sanitizeMetricName strips the reserved _info suffix. MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("target_info")); - assertThat(metadata.getName()).isEqualTo("target_info"); + assertThat(metadata.getName()).isEqualTo("target"); } @Test void testNameWithCreatedSuffix() { + // sanitizeMetricName strips the reserved _created suffix. MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("my_events_created")); - assertThat(metadata.getName()).isEqualTo("my_events_created"); + assertThat(metadata.getName()).isEqualTo("my_events"); } @Test void testNameWithBucketSuffix() { + // sanitizeMetricName strips the reserved _bucket suffix. MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("my_histogram_bucket")); - assertThat(metadata.getName()).isEqualTo("my_histogram_bucket"); + assertThat(metadata.getName()).isEqualTo("my_histogram"); } @Test diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java index dcebd14d8..e141f2575 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java @@ -22,24 +22,50 @@ class PrometheusNamingTest { @Test void testSanitizeMetricName() { - assertThat(sanitizeMetricName("my_counter_total")).isEqualTo("my_counter_total"); - assertThat(sanitizeMetricName("jvm.info")).isEqualTo("jvm.info"); - assertThat(sanitizeMetricName("jvm_info")).isEqualTo("jvm_info"); + // Reserved suffixes are stripped to avoid confusion with Prometheus type conventions. + assertThat(sanitizeMetricName("my_counter_total")).isEqualTo("my_counter"); + assertThat(sanitizeMetricName("jvm.info")).isEqualTo("jvm"); + assertThat(sanitizeMetricName("jvm_info")).isEqualTo("jvm"); assertThat(sanitizeMetricName("a.b")).isEqualTo("a.b"); - assertThat(sanitizeMetricName("_total")).isEqualTo("_total"); + assertThat(sanitizeMetricName("_total")).isEqualTo("total"); assertThat(sanitizeMetricName("total")).isEqualTo("total"); - assertThat(sanitizeMetricName("my_events_created")).isEqualTo("my_events_created"); - assertThat(sanitizeMetricName("my_histogram_bucket")).isEqualTo("my_histogram_bucket"); + assertThat(sanitizeMetricName("my_events_created")).isEqualTo("my_events"); + assertThat(sanitizeMetricName("my_histogram_bucket")).isEqualTo("my_histogram"); + } + + /** + * Regression test: reserved suffixes must be stripped even when the raw name comes from an + * external system (e.g. JMX Exporter converting a JMX attribute named {@code "Total"} into a + * Prometheus name {@code kafka_consumer_request_total}). + * + *

Without stripping, an UNKNOWN metric would be stored under {@code + * kafka_consumer_request_total} instead of {@code kafka_consumer_request}, breaking registry + * lookups by the expected base name and potentially triggering unintended counter-type inference + * in tools that check for the {@code _total} suffix. + */ + @Test + void testSanitizeMetricNameStripsReservedSuffixForDownstreamTools() { + // A JMX attribute "Total" produces "kafka_consumer_request_total" as the raw name. + // sanitizeMetricName must strip "_total" so that the metric is stored and looked up under + // "kafka_consumer_request", not "kafka_consumer_request_total". + assertThat(sanitizeMetricName("kafka_consumer_request_total")) + .isEqualTo("kafka_consumer_request"); + // Dot variant is stripped too. + assertThat(sanitizeMetricName("kafka_consumer_request.total")) + .isEqualTo("kafka_consumer_request"); + // Multiple chained reserved suffixes are stripped iteratively. + assertThat(sanitizeMetricName("events_total_created")).isEqualTo("events"); } @Test void testSanitizeMetricNameWithUnit() { assertThat(prometheusName(sanitizeMetricName("def", Unit.RATIO))) .isEqualTo("def_" + Unit.RATIO); + // _total is stripped first, then the unit is appended. assertThat(prometheusName(sanitizeMetricName("my_counter_total", Unit.RATIO))) - .isEqualTo("my_counter_total_" + Unit.RATIO); - assertThat(sanitizeMetricName("jvm.info", Unit.RATIO)).isEqualTo("jvm.info_" + Unit.RATIO); - assertThat(sanitizeMetricName("_total", Unit.RATIO)).isEqualTo("_total_" + Unit.RATIO); + .isEqualTo("my_counter_" + Unit.RATIO); + assertThat(sanitizeMetricName("jvm.info", Unit.RATIO)).isEqualTo("jvm_" + Unit.RATIO); + assertThat(sanitizeMetricName("_total", Unit.RATIO)).isEqualTo("total_" + Unit.RATIO); assertThat(sanitizeMetricName("total", Unit.RATIO)).isEqualTo("total_" + Unit.RATIO); } From 06ddff1f7e1bdecc4db2985e484dbac3a3fba2d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:45:07 +0000 Subject: [PATCH 3/4] refactor: address code review feedback in sanitizeMetricName - Separate the equals-only check (where name IS the suffix) into its own loop before the endsWith stripping loop, to avoid re-checking it on every outer iteration - Add break after stripping a suffix so the inner loop restarts immediately rather than completing all remaining suffix checks - Add test for dot-variant corner case: sanitizeMetricName(".total") returns "total" Agent-Logs-Url: https://github.com/prometheus/client_java/sessions/7c842dd0-c793-4e16-815f-d3b5e03c3ba8 Co-authored-by: jaydeluca <7630696+jaydeluca@users.noreply.github.com> --- .../model/snapshots/PrometheusNaming.java | 16 ++++++++++------ .../model/snapshots/PrometheusNamingTest.java | 3 +++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java index 6b071db5e..3380b3fad 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java @@ -178,25 +178,29 @@ public static String prometheusName(String name) { * bridge) which relied on {@code sanitizeMetricName} to strip these suffixes before passing names * to the snapshot builders. * - * @throws IllegalArgumentException if the input is empty or becomes empty after stripping + * @throws IllegalArgumentException if the input is empty */ public static String sanitizeMetricName(String metricName) { if (metricName.isEmpty()) { throw new IllegalArgumentException("Cannot convert an empty string to a valid metric name."); } String sanitizedName = metricName; - boolean modified = true; - while (modified) { - modified = false; + boolean stripped = true; + while (stripped) { + stripped = false; + // When the name equals the suffix exactly, drop the leading separator character to avoid + // returning an empty string (e.g. "_total" → "total", ".info" → "info"). for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { if (sanitizedName.equals(reservedSuffix)) { - // Corner case: sanitizeMetricName("_total") returns "total". return reservedSuffix.substring(1); } + } + for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { if (sanitizedName.endsWith(reservedSuffix)) { sanitizedName = sanitizedName.substring(0, sanitizedName.length() - reservedSuffix.length()); - modified = true; + stripped = true; + break; // restart the outer loop to re-check all suffixes on the shortened name } } } diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java index e141f2575..5efe49fc6 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java @@ -27,7 +27,10 @@ void testSanitizeMetricName() { assertThat(sanitizeMetricName("jvm.info")).isEqualTo("jvm"); assertThat(sanitizeMetricName("jvm_info")).isEqualTo("jvm"); assertThat(sanitizeMetricName("a.b")).isEqualTo("a.b"); + // "_total" / ".total" corner cases: the suffix is the entire name, so the separator + // character is dropped to avoid returning an empty string. assertThat(sanitizeMetricName("_total")).isEqualTo("total"); + assertThat(sanitizeMetricName(".total")).isEqualTo("total"); assertThat(sanitizeMetricName("total")).isEqualTo("total"); assertThat(sanitizeMetricName("my_events_created")).isEqualTo("my_events"); assertThat(sanitizeMetricName("my_histogram_bucket")).isEqualTo("my_histogram"); From 3816ccc12aa845080ba54e1981221d5dfc9c8e9f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 5 May 2026 15:25:34 +0200 Subject: [PATCH 4/4] feat: add normalizeMetricName helper (#2098) --- .../model/snapshots/PrometheusNaming.java | 38 ++++++++++++++++++- .../model/snapshots/PrometheusNamingTest.java | 24 ++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java index 3380b3fad..63cbb7879 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java @@ -46,7 +46,9 @@ public class PrometheusNaming { * format, this will be represented as two values: {@code processing_time_seconds_total} for the * counter value, and the optional {@code processing_time_seconds_created} timestamp. * - *

Use {@link #sanitizeMetricName(String)} to convert arbitrary Strings to valid metric names. + *

Use {@link #sanitizeMetricName(String)} for compatibility-preserving sanitization that + * strips reserved suffixes, or {@link #normalizeMetricName(String)} for permissive normalization + * that keeps the original suffixes intact. */ public static boolean isValidMetricName(String name) { return validateMetricName(name) == null; @@ -178,6 +180,9 @@ public static String prometheusName(String name) { * bridge) which relied on {@code sanitizeMetricName} to strip these suffixes before passing names * to the snapshot builders. * + *

If you want permissive normalization that keeps reserved suffixes intact, use {@link + * #normalizeMetricName(String)} instead. + * * @throws IllegalArgumentException if the input is empty */ public static String sanitizeMetricName(String metricName) { @@ -221,6 +226,37 @@ public static String sanitizeMetricName(String metricName, Unit unit) { return result; } + /** + * Convert an arbitrary string to a valid metric name without stripping reserved suffixes. + * + *

Any non-empty valid UTF-8 string is accepted and returned unchanged. This is the permissive + * normalization behavior introduced in 1.6.0. Use this method for new integrations that want to + * preserve the original metric name and rely on registration-time collision detection instead of + * suffix stripping. + * + * @throws IllegalArgumentException if the input is empty + */ + public static String normalizeMetricName(String metricName) { + if (metricName.isEmpty()) { + throw new IllegalArgumentException("Cannot convert an empty string to a valid metric name."); + } + return metricName; + } + + /** + * Like {@link #normalizeMetricName(String)}, but also makes sure that the unit is appended as a + * suffix if the unit is not {@code null}. + */ + public static String normalizeMetricName(String metricName, Unit unit) { + String result = normalizeMetricName(metricName); + if (unit != null) { + if (!result.endsWith("_" + unit) && !result.endsWith("." + unit)) { + result += "_" + unit; + } + } + return result; + } + /** * Convert an arbitrary string to a name where {@link #isValidLabelName(String) * isValidLabelName(name)} is true. diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java index 5efe49fc6..ee6d2d027 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java @@ -2,6 +2,7 @@ import static io.prometheus.metrics.model.snapshots.PrometheusNaming.escapeName; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.isValidLabelName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.normalizeMetricName; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeLabelName; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeMetricName; @@ -72,6 +73,29 @@ void testSanitizeMetricNameWithUnit() { assertThat(sanitizeMetricName("total", Unit.RATIO)).isEqualTo("total_" + Unit.RATIO); } + @Test + void testNormalizeMetricName() { + assertThat(normalizeMetricName("my_counter_total")).isEqualTo("my_counter_total"); + assertThat(normalizeMetricName("jvm.info")).isEqualTo("jvm.info"); + assertThat(normalizeMetricName("jvm_info")).isEqualTo("jvm_info"); + assertThat(normalizeMetricName("a.b")).isEqualTo("a.b"); + assertThat(normalizeMetricName("_total")).isEqualTo("_total"); + assertThat(normalizeMetricName(".total")).isEqualTo(".total"); + assertThat(normalizeMetricName("my_events_created")).isEqualTo("my_events_created"); + assertThat(normalizeMetricName("my_histogram_bucket")).isEqualTo("my_histogram_bucket"); + } + + @Test + void testNormalizeMetricNameWithUnit() { + assertThat(prometheusName(normalizeMetricName("def", Unit.RATIO))) + .isEqualTo("def_" + Unit.RATIO); + assertThat(prometheusName(normalizeMetricName("my_counter_total", Unit.RATIO))) + .isEqualTo("my_counter_total_" + Unit.RATIO); + assertThat(normalizeMetricName("jvm.info", Unit.RATIO)).isEqualTo("jvm.info_" + Unit.RATIO); + assertThat(normalizeMetricName("_total", Unit.RATIO)).isEqualTo("_total_" + Unit.RATIO); + assertThat(normalizeMetricName("total", Unit.RATIO)).isEqualTo("total_" + Unit.RATIO); + } + @Test void testSanitizeLabelName() { assertThat(prometheusName(sanitizeLabelName("0abc.def"))).isEqualTo("_abc_def");