From 05178862569de9a5cd24b4dd1066ae6cf891cccd Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 17 Mar 2026 07:47:23 +0000 Subject: [PATCH 1/3] feat: Add OpenMetrics2 enabled flag for explicit OM2 writer activation The OM2 writer selection previously activated when any feature flag was set, but there was no way to just enable OM2 without opting into a specific feature. This adds an explicit `enabled` gate (io.prometheus.openmetrics2.enabled) as the single control for OM2 writer selection. Feature flags alone no longer activate OM2. The programmatic `enableOpenMetrics2()` configurator sets enabled=true implicitly, matching its name. Signed-off-by: Gregor Zeitlinger --- .../config/OpenMetrics2Properties.java | 25 ++++++++++- .../metrics/config/PrometheusProperties.java | 3 +- .../config/OpenMetrics2PropertiesTest.java | 12 +++++ .../expositionformats/ExpositionFormats.java | 6 +-- .../ExpositionFormatsTest.java | 44 ++++--------------- 5 files changed, 47 insertions(+), 43 deletions(-) diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/OpenMetrics2Properties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/OpenMetrics2Properties.java index a92536036..be1d13279 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/OpenMetrics2Properties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/OpenMetrics2Properties.java @@ -9,27 +9,39 @@ public class OpenMetrics2Properties { private static final String PREFIX = "io.prometheus.openmetrics2"; + private static final String ENABLED = "enabled"; private static final String CONTENT_NEGOTIATION = "content_negotiation"; private static final String COMPOSITE_VALUES = "composite_values"; private static final String EXEMPLAR_COMPLIANCE = "exemplar_compliance"; private static final String NATIVE_HISTOGRAMS = "native_histograms"; + @Nullable private final Boolean enabled; @Nullable private final Boolean contentNegotiation; @Nullable private final Boolean compositeValues; @Nullable private final Boolean exemplarCompliance; @Nullable private final Boolean nativeHistograms; private OpenMetrics2Properties( + @Nullable Boolean enabled, @Nullable Boolean contentNegotiation, @Nullable Boolean compositeValues, @Nullable Boolean exemplarCompliance, @Nullable Boolean nativeHistograms) { + this.enabled = enabled; this.contentNegotiation = contentNegotiation; this.compositeValues = compositeValues; this.exemplarCompliance = exemplarCompliance; this.nativeHistograms = nativeHistograms; } + /** + * Enable the OpenMetrics 2.0 text format writer. When {@code true}, the OM2 writer is used + * instead of OM1 for OpenMetrics responses. Default is {@code false}. + */ + public boolean getEnabled() { + return enabled != null && enabled; + } + /** Gate OM2 features behind content negotiation. Default is {@code false}. */ public boolean getContentNegotiation() { return contentNegotiation != null && contentNegotiation; @@ -56,12 +68,13 @@ public boolean getNativeHistograms() { */ static OpenMetrics2Properties load(PropertySource propertySource) throws PrometheusPropertiesException { + Boolean enabled = Util.loadBoolean(PREFIX, ENABLED, propertySource); Boolean contentNegotiation = Util.loadBoolean(PREFIX, CONTENT_NEGOTIATION, propertySource); Boolean compositeValues = Util.loadBoolean(PREFIX, COMPOSITE_VALUES, propertySource); Boolean exemplarCompliance = Util.loadBoolean(PREFIX, EXEMPLAR_COMPLIANCE, propertySource); Boolean nativeHistograms = Util.loadBoolean(PREFIX, NATIVE_HISTOGRAMS, propertySource); return new OpenMetrics2Properties( - contentNegotiation, compositeValues, exemplarCompliance, nativeHistograms); + enabled, contentNegotiation, compositeValues, exemplarCompliance, nativeHistograms); } public static Builder builder() { @@ -70,6 +83,7 @@ public static Builder builder() { public static class Builder { + @Nullable private Boolean enabled; @Nullable private Boolean contentNegotiation; @Nullable private Boolean compositeValues; @Nullable private Boolean exemplarCompliance; @@ -77,6 +91,12 @@ public static class Builder { private Builder() {} + /** See {@link #getEnabled()} */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + /** See {@link #getContentNegotiation()} */ public Builder contentNegotiation(boolean contentNegotiation) { this.contentNegotiation = contentNegotiation; @@ -103,6 +123,7 @@ public Builder nativeHistograms(boolean nativeHistograms) { /** Enable all OpenMetrics 2.0 features */ public Builder enableAll() { + this.enabled = true; this.contentNegotiation = true; this.compositeValues = true; this.exemplarCompliance = true; @@ -112,7 +133,7 @@ public Builder enableAll() { public OpenMetrics2Properties build() { return new OpenMetrics2Properties( - contentNegotiation, compositeValues, exemplarCompliance, nativeHistograms); + enabled, contentNegotiation, compositeValues, exemplarCompliance, nativeHistograms); } } } diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java index a9045d711..55e7d8dab 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java @@ -242,7 +242,8 @@ public Builder exporterOpenTelemetryProperties( } public Builder enableOpenMetrics2(Consumer configurator) { - OpenMetrics2Properties.Builder openMetrics2Builder = OpenMetrics2Properties.builder(); + OpenMetrics2Properties.Builder openMetrics2Builder = + OpenMetrics2Properties.builder().enabled(true); configurator.accept(openMetrics2Builder); this.openMetrics2Properties = openMetrics2Builder.build(); return this; diff --git a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/OpenMetrics2PropertiesTest.java b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/OpenMetrics2PropertiesTest.java index c3a0b9fca..e7a273464 100644 --- a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/OpenMetrics2PropertiesTest.java +++ b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/OpenMetrics2PropertiesTest.java @@ -15,6 +15,8 @@ void load() { load( new HashMap<>( Map.of( + "io.prometheus.openmetrics2.enabled", + "true", "io.prometheus.openmetrics2.content_negotiation", "true", "io.prometheus.openmetrics2.composite_values", @@ -23,6 +25,7 @@ void load() { "true", "io.prometheus.openmetrics2.native_histograms", "true"))); + assertThat(properties.getEnabled()).isTrue(); assertThat(properties.getContentNegotiation()).isTrue(); assertThat(properties.getCompositeValues()).isTrue(); assertThat(properties.getExemplarCompliance()).isTrue(); @@ -31,6 +34,11 @@ void load() { @Test void loadInvalidValue() { + assertThatExceptionOfType(PrometheusPropertiesException.class) + .isThrownBy( + () -> load(new HashMap<>(Map.of("io.prometheus.openmetrics2.enabled", "invalid")))) + .withMessage( + "io.prometheus.openmetrics2.enabled: Expecting 'true' or 'false'. Found: invalid"); assertThatExceptionOfType(PrometheusPropertiesException.class) .isThrownBy( () -> @@ -79,11 +87,13 @@ private static OpenMetrics2Properties load(Map map) { void builder() { OpenMetrics2Properties properties = OpenMetrics2Properties.builder() + .enabled(true) .contentNegotiation(true) .compositeValues(false) .exemplarCompliance(true) .nativeHistograms(false) .build(); + assertThat(properties.getEnabled()).isTrue(); assertThat(properties.getContentNegotiation()).isTrue(); assertThat(properties.getCompositeValues()).isFalse(); assertThat(properties.getExemplarCompliance()).isTrue(); @@ -93,6 +103,7 @@ void builder() { @Test void builderEnableAll() { OpenMetrics2Properties properties = OpenMetrics2Properties.builder().enableAll().build(); + assertThat(properties.getEnabled()).isTrue(); assertThat(properties.getContentNegotiation()).isTrue(); assertThat(properties.getCompositeValues()).isTrue(); assertThat(properties.getExemplarCompliance()).isTrue(); @@ -102,6 +113,7 @@ void builderEnableAll() { @Test void defaultValues() { OpenMetrics2Properties properties = OpenMetrics2Properties.builder().build(); + assertThat(properties.getEnabled()).isFalse(); assertThat(properties.getContentNegotiation()).isFalse(); assertThat(properties.getCompositeValues()).isFalse(); assertThat(properties.getExemplarCompliance()).isFalse(); diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java index ea2b294a2..a4a7088b8 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java @@ -76,11 +76,7 @@ public ExpositionFormatWriter findWriter(@Nullable String acceptHeader) { } private boolean isOpenMetrics2Enabled() { - OpenMetrics2Properties props = openMetrics2TextFormatWriter.getOpenMetrics2Properties(); - return props.getContentNegotiation() - || props.getCompositeValues() - || props.getExemplarCompliance() - || props.getNativeHistograms(); + return openMetrics2TextFormatWriter.getOpenMetrics2Properties().getEnabled(); } public PrometheusProtobufWriter getPrometheusProtobufWriter() { diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java index 35619042c..339c5dfa0 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java @@ -119,60 +119,36 @@ void testOM2DisabledByDefault() { } @Test - void testOM2EnabledWithContentNegotiation() { + void testOM2EnabledOnly() { PrometheusProperties props = PrometheusProperties.builder() - .openMetrics2Properties( - OpenMetrics2Properties.builder().contentNegotiation(true).build()) + .openMetrics2Properties(OpenMetrics2Properties.builder().enabled(true).build()) .build(); ExpositionFormats formats = ExpositionFormats.init(props); ExpositionFormatWriter writer = formats.findWriter("application/openmetrics-text"); - // When contentNegotiation is enabled, should return OM2 writer assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); } @Test - void testOM2EnabledWithCompositeValues() { - PrometheusProperties props = - PrometheusProperties.builder() - .openMetrics2Properties(OpenMetrics2Properties.builder().compositeValues(true).build()) - .build(); - ExpositionFormats formats = ExpositionFormats.init(props); - ExpositionFormatWriter writer = formats.findWriter("application/openmetrics-text"); - assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); - } - - @Test - void testOM2EnabledWithExemplarCompliance() { + void testOM2NotEnabledByFeatureFlagAlone() { + // Feature flags without enabled=true should not activate the OM2 writer PrometheusProperties props = PrometheusProperties.builder() .openMetrics2Properties( - OpenMetrics2Properties.builder().exemplarCompliance(true).build()) - .build(); - ExpositionFormats formats = ExpositionFormats.init(props); - ExpositionFormatWriter writer = formats.findWriter("application/openmetrics-text"); - // When exemplarCompliance is enabled, should return OM2 writer - assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); - } - - @Test - void testOM2EnabledWithNativeHistograms() { - PrometheusProperties props = - PrometheusProperties.builder() - .openMetrics2Properties(OpenMetrics2Properties.builder().nativeHistograms(true).build()) + OpenMetrics2Properties.builder().contentNegotiation(true).build()) .build(); ExpositionFormats formats = ExpositionFormats.init(props); ExpositionFormatWriter writer = formats.findWriter("application/openmetrics-text"); - // When nativeHistograms is enabled, should return OM2 writer - assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); + assertThat(writer).isInstanceOf(OpenMetricsTextFormatWriter.class); } @Test - void testOM2EnabledWithMultipleFlags() { + void testOM2EnabledWithFeatureFlags() { PrometheusProperties props = PrometheusProperties.builder() .openMetrics2Properties( OpenMetrics2Properties.builder() + .enabled(true) .contentNegotiation(true) .compositeValues(true) .nativeHistograms(true) @@ -180,7 +156,6 @@ void testOM2EnabledWithMultipleFlags() { .build(); ExpositionFormats formats = ExpositionFormats.init(props); ExpositionFormatWriter writer = formats.findWriter("application/openmetrics-text"); - // When multiple OM2 flags are enabled, should return OM2 writer assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); } @@ -188,8 +163,7 @@ void testOM2EnabledWithMultipleFlags() { void testProtobufWriterTakesPrecedence() { PrometheusProperties props = PrometheusProperties.builder() - .openMetrics2Properties( - OpenMetrics2Properties.builder().contentNegotiation(true).build()) + .openMetrics2Properties(OpenMetrics2Properties.builder().enabled(true).build()) .build(); ExpositionFormats formats = ExpositionFormats.init(props); ExpositionFormatWriter writer = From e95d408a821976b2632412d1adf99e53b554a8d5 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 14 Mar 2026 14:38:34 +0000 Subject: [PATCH 2/3] feat: move suffix handling to scrape time with OTel preserve_names Move metric name suffix handling (_total, _info, unit suffixes) from creation time to scrape time. Each format writer now owns its suffix conventions: - OM1: smart-appends suffixes (skip if already present) - OTel: legacy path strips _total + unit; preserve_names=true passes names through exactly as the user wrote them - Registry detects cross-format name collisions at registration time Key changes: - Remove all reserved metric name suffixes from PrometheusNaming - Store original user-provided name separately from exposition base name in MetricMetadata (originalName vs expositionBaseName) - Add preserve_names config to ExporterOpenTelemetryProperties - Smart-append logic in OM1/protobuf writers for _total and _info - Two-layer collision detection in PrometheusRegistry Closes #1941 Signed-off-by: Gregor Zeitlinger --- .../ExporterOpenTelemetryProperties.java | 33 ++- .../metrics/core/metrics/Counter.java | 2 +- .../prometheus/metrics/core/metrics/Info.java | 2 +- .../core/metrics/MetricWithFixedMetadata.java | 40 +++- .../opentelemetry/OpenTelemetryExporter.java | 10 + .../opentelemetry/OtelAutoConfig.java | 13 +- .../PrometheusMetricProducer.java | 8 +- .../otelmodel/MetricDataFactory.java | 29 ++- .../otelmodel/PrometheusMetricData.java | 6 +- .../exporter/opentelemetry/ExportTest.java | 66 +++++- .../opentelemetry/OtelAutoConfigTest.java | 27 +++ .../PrometheusProtobufWriterImpl.java | 7 +- .../OpenMetricsTextFormatWriter.java | 58 ++++-- .../PrometheusTextFormatWriter.java | 60 +++++- .../metrics/model/registry/Collector.java | 6 +- .../model/registry/PrometheusRegistry.java | 93 ++++++++- .../model/snapshots/MetricMetadata.java | 86 +++++++- .../model/snapshots/PrometheusNaming.java | 103 ++-------- .../model/snapshots/SnapshotEscaper.java | 9 + .../registry/PrometheusRegistryTest.java | 189 ++++++++++++++++++ .../model/snapshots/CounterSnapshotTest.java | 4 +- .../model/snapshots/GaugeSnapshotTest.java | 8 +- .../model/snapshots/InfoSnapshotTest.java | 8 +- .../model/snapshots/MetricMetadataTest.java | 45 ++++- .../model/snapshots/PrometheusNamingTest.java | 46 ++--- 25 files changed, 778 insertions(+), 180 deletions(-) diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterOpenTelemetryProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterOpenTelemetryProperties.java index bd1dcdaf2..6e93f5de7 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterOpenTelemetryProperties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterOpenTelemetryProperties.java @@ -46,6 +46,7 @@ public class ExporterOpenTelemetryProperties { private static final String SERVICE_VERSION = "service_version"; private static final String RESOURCE_ATTRIBUTES = "resource_attributes"; // otel.resource.attributes + private static final String PRESERVE_NAMES = "preserve_names"; private static final String PREFIX = "io.prometheus.exporter.opentelemetry"; @Nullable private final String endpoint; @@ -58,6 +59,7 @@ public class ExporterOpenTelemetryProperties { @Nullable private final String serviceInstanceId; @Nullable private final String serviceVersion; private final Map resourceAttributes; + @Nullable private final Boolean preserveNames; private ExporterOpenTelemetryProperties( @Nullable String protocol, @@ -69,7 +71,8 @@ private ExporterOpenTelemetryProperties( @Nullable String serviceNamespace, @Nullable String serviceInstanceId, @Nullable String serviceVersion, - Map resourceAttributes) { + Map resourceAttributes, + @Nullable Boolean preserveNames) { this.protocol = protocol; this.endpoint = endpoint; this.headers = headers; @@ -80,6 +83,7 @@ private ExporterOpenTelemetryProperties( this.serviceInstanceId = serviceInstanceId; this.serviceVersion = serviceVersion; this.resourceAttributes = resourceAttributes; + this.preserveNames = preserveNames; } @Nullable @@ -130,6 +134,16 @@ public Map getResourceAttributes() { return resourceAttributes; } + /** + * When {@code true}, metric names are preserved as-is (including suffixes like {@code _total}). + * When {@code false} (default), standard OTel name normalization is applied (stripping unit + * suffix). + */ + @Nullable + public Boolean getPreserveNames() { + return preserveNames; + } + /** * Note that this will remove entries from {@code propertySource}. This is because we want to know * if there are unused properties remaining after all properties have been loaded. @@ -147,6 +161,7 @@ static ExporterOpenTelemetryProperties load(PropertySource propertySource) String serviceVersion = Util.loadString(PREFIX, SERVICE_VERSION, propertySource); Map resourceAttributes = Util.loadMap(PREFIX, RESOURCE_ATTRIBUTES, propertySource); + Boolean preserveNames = Util.loadBoolean(PREFIX, PRESERVE_NAMES, propertySource); return new ExporterOpenTelemetryProperties( protocol, endpoint, @@ -157,7 +172,8 @@ static ExporterOpenTelemetryProperties load(PropertySource propertySource) serviceNamespace, serviceInstanceId, serviceVersion, - resourceAttributes); + resourceAttributes, + preserveNames); } public static Builder builder() { @@ -176,6 +192,7 @@ public static class Builder { @Nullable private String serviceInstanceId; @Nullable private String serviceVersion; private final Map resourceAttributes = new HashMap<>(); + @Nullable private Boolean preserveNames; private Builder() {} @@ -318,6 +335,15 @@ public Builder resourceAttribute(String name, String value) { return this; } + /** + * When {@code true}, metric names are preserved as-is (including suffixes like {@code _total}). + * When {@code false} (default), standard OTel name normalization is applied. + */ + public Builder preserveNames(boolean preserveNames) { + this.preserveNames = preserveNames; + return this; + } + public ExporterOpenTelemetryProperties build() { return new ExporterOpenTelemetryProperties( protocol, @@ -329,7 +355,8 @@ public ExporterOpenTelemetryProperties build() { serviceNamespace, serviceInstanceId, serviceVersion, - resourceAttributes); + resourceAttributes, + preserveNames); } } } diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java index c5f2f1cff..8530c988f 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java @@ -241,7 +241,7 @@ private Builder(PrometheusProperties properties) { */ @Override public Builder name(String name) { - return super.name(stripTotalSuffix(name)); + return super.nameWithOriginal(stripTotalSuffix(name), name); } @Override diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java index 011f0bb73..e782c9156 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java @@ -146,7 +146,7 @@ private Builder(PrometheusProperties config) { */ @Override public Builder name(String name) { - return super.name(stripInfoSuffix(name)); + return super.nameWithOriginal(stripInfoSuffix(name), name); } /** Throws an {@link UnsupportedOperationException} because Info metrics cannot have a unit. */ diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/MetricWithFixedMetadata.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/MetricWithFixedMetadata.java index 12c48c51d..1b63004d8 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/MetricWithFixedMetadata.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/MetricWithFixedMetadata.java @@ -25,8 +25,14 @@ public abstract class MetricWithFixedMetadata extends Metric { protected MetricWithFixedMetadata(Builder builder) { super(builder); + String name = makeName(builder.name, builder.unit); + if (builder.originalName == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + String originalName = builder.originalName; + String expositionBaseName = makeExpositionBaseName(originalName, builder.unit); this.metadata = - new MetricMetadata(makeName(builder.name, builder.unit), builder.help, builder.unit); + new MetricMetadata(name, expositionBaseName, originalName, builder.help, builder.unit); this.labelNames = Arrays.copyOf(builder.labelNames, builder.labelNames.length); } @@ -47,6 +53,18 @@ private String makeName(@Nullable String name, @Nullable Unit unit) { return name; } + private String makeExpositionBaseName(@Nullable String expositionBaseName, @Nullable Unit unit) { + if (expositionBaseName == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + if (unit != null) { + if (!expositionBaseName.endsWith("_" + unit) && !expositionBaseName.endsWith("." + unit)) { + expositionBaseName += "_" + unit; + } + } + return expositionBaseName; + } + @Override public String getPrometheusName() { return metadata.getPrometheusName(); @@ -68,6 +86,7 @@ public abstract static class Builder, M extends MetricWi extends Metric.Builder { @Nullable private String name; + @Nullable private String originalName; @Nullable private Unit unit; @Nullable private String help; private String[] labelNames = new String[0]; @@ -82,6 +101,25 @@ public B name(String name) { throw new IllegalArgumentException("'" + name + "': Illegal metric name: " + error); } this.name = name; + this.originalName = name; + return self(); + } + + /** + * Set the metric name and original name separately. Used by Counter and Info builders which + * strip type suffixes from the name but preserve the original for exposition. + */ + protected B nameWithOriginal(String name, String originalName) { + String error = PrometheusNaming.validateMetricName(name); + if (error != null) { + throw new IllegalArgumentException("'" + name + "': Illegal metric name: " + error); + } + error = PrometheusNaming.validateMetricName(originalName); + if (error != null) { + throw new IllegalArgumentException("'" + originalName + "': Illegal metric name: " + error); + } + this.name = name; + this.originalName = originalName; return self(); } diff --git a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OpenTelemetryExporter.java b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OpenTelemetryExporter.java index 8f122d3ee..727647e2e 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OpenTelemetryExporter.java +++ b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OpenTelemetryExporter.java @@ -41,6 +41,7 @@ public static class Builder { @Nullable String serviceInstanceId; @Nullable String serviceVersion; final Map resourceAttributes = new HashMap<>(); + @Nullable Boolean preserveNames; private Builder(PrometheusProperties config) { this.config = config; @@ -194,6 +195,15 @@ public Builder resourceAttribute(String name, String value) { return this; } + /** + * When {@code true}, metric names are preserved as-is (including suffixes like {@code _total}). + * When {@code false} (default), standard OTel name normalization is applied. + */ + public Builder preserveNames(boolean preserveNames) { + this.preserveNames = preserveNames; + return this; + } + public OpenTelemetryExporter buildAndStart() { if (registry == null) { registry = PrometheusRegistry.defaultRegistry; diff --git a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfig.java b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfig.java index e0c6a0fa9..2ea96e3c3 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfig.java +++ b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfig.java @@ -38,8 +38,10 @@ static MetricReader createReader( instrumentationScopeInfo); MetricReader reader = requireNonNull(readerRef.get()); + boolean preserveNames = resolvePreserveNames(builder, config); reader.register( - new PrometheusMetricProducer(registry, instrumentationScopeInfo, getResourceField(sdk))); + new PrometheusMetricProducer( + registry, instrumentationScopeInfo, getResourceField(sdk), preserveNames)); return reader; } @@ -107,6 +109,15 @@ private static Attributes otelResourceAttributes( return builder.build(); } + static boolean resolvePreserveNames( + OpenTelemetryExporter.Builder builder, PrometheusProperties config) { + if (builder.preserveNames != null) { + return builder.preserveNames; + } + Boolean fromConfig = config.getExporterOpenTelemetryProperties().getPreserveNames(); + return fromConfig != null && fromConfig; + } + static Resource getResourceField(AutoConfiguredOpenTelemetrySdk sdk) { try { Method method = AutoConfiguredOpenTelemetrySdk.class.getDeclaredMethod("getResource"); diff --git a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/PrometheusMetricProducer.java b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/PrometheusMetricProducer.java index 9344fc4db..886cdd85c 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/PrometheusMetricProducer.java +++ b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/PrometheusMetricProducer.java @@ -29,14 +29,17 @@ class PrometheusMetricProducer implements CollectionRegistration { private final PrometheusRegistry registry; private final Resource resource; private final InstrumentationScopeInfo instrumentationScopeInfo; + private final boolean preserveNames; public PrometheusMetricProducer( PrometheusRegistry registry, InstrumentationScopeInfo instrumentationScopeInfo, - Resource resource) { + Resource resource, + boolean preserveNames) { this.registry = registry; this.instrumentationScopeInfo = instrumentationScopeInfo; this.resource = resource; + this.preserveNames = preserveNames; } @Override @@ -57,7 +60,8 @@ public Collection collectAllMetrics() { new MetricDataFactory( resourceWithTargetInfo, scopeFromInfo != null ? scopeFromInfo : instrumentationScopeInfo, - System.currentTimeMillis()); + System.currentTimeMillis(), + preserveNames); for (MetricSnapshot snapshot : snapshots) { if (snapshot instanceof CounterSnapshot) { addUnlessNull(result, factory.create((CounterSnapshot) snapshot)); diff --git a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/MetricDataFactory.java b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/MetricDataFactory.java index 78ecb0ebe..576ba05c7 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/MetricDataFactory.java +++ b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/MetricDataFactory.java @@ -17,14 +17,17 @@ public class MetricDataFactory { private final Resource resource; private final InstrumentationScopeInfo instrumentationScopeInfo; private final long currentTimeMillis; + private final boolean preserveNames; public MetricDataFactory( Resource resource, InstrumentationScopeInfo instrumentationScopeInfo, - long currentTimeMillis) { + long currentTimeMillis, + boolean preserveNames) { this.resource = resource; this.instrumentationScopeInfo = instrumentationScopeInfo; this.currentTimeMillis = currentTimeMillis; + this.preserveNames = preserveNames; } @Nullable @@ -36,7 +39,8 @@ public MetricData create(CounterSnapshot snapshot) { snapshot.getMetadata(), new PrometheusCounter(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } @Nullable @@ -48,7 +52,8 @@ public MetricData create(GaugeSnapshot snapshot) { snapshot.getMetadata(), new PrometheusGauge(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } @Nullable @@ -60,13 +65,15 @@ public MetricData create(HistogramSnapshot snapshot) { snapshot.getMetadata(), new PrometheusNativeHistogram(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } else if (firstDataPoint.hasClassicHistogramData()) { return new PrometheusMetricData<>( snapshot.getMetadata(), new PrometheusClassicHistogram(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } } return null; @@ -81,7 +88,8 @@ public MetricData create(SummarySnapshot snapshot) { snapshot.getMetadata(), new PrometheusSummary(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } @Nullable @@ -93,7 +101,8 @@ public MetricData create(InfoSnapshot snapshot) { snapshot.getMetadata(), new PrometheusInfo(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } @Nullable @@ -105,7 +114,8 @@ public MetricData create(StateSetSnapshot snapshot) { snapshot.getMetadata(), new PrometheusStateSet(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } @Nullable @@ -117,6 +127,7 @@ public MetricData create(UnknownSnapshot snapshot) { snapshot.getMetadata(), new PrometheusUnknown(snapshot, currentTimeMillis), instrumentationScopeInfo, - resource); + resource, + preserveNames); } } diff --git a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusMetricData.java b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusMetricData.java index 20603123c..004bbfe45 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusMetricData.java +++ b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusMetricData.java @@ -23,10 +23,12 @@ class PrometheusMetricData> implements MetricData { MetricMetadata metricMetadata, T data, InstrumentationScopeInfo instrumentationScopeInfo, - Resource resource) { + Resource resource, + boolean preserveNames) { this.instrumentationScopeInfo = instrumentationScopeInfo; this.resource = resource; - this.name = getNameWithoutUnit(metricMetadata); + this.name = + preserveNames ? metricMetadata.getOriginalName() : getNameWithoutUnit(metricMetadata); this.description = metricMetadata.getHelp(); this.unit = convertUnit(metricMetadata.getUnit()); this.data = data; diff --git a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java index e134b2373..de08f4317 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java +++ b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java @@ -47,7 +47,8 @@ void setUp() throws IllegalAccessException, NoSuchFieldException { new PrometheusMetricProducer( registry, InstrumentationScopeInfo.create("test"), - Resource.create(Attributes.builder().put("staticRes", "value").build())); + Resource.create(Attributes.builder().put("staticRes", "value").build()), + false); reader.register(prometheusMetricProducer); } @@ -324,6 +325,69 @@ void metricsWithoutDataPointsAreNotExported() { assertThat(metrics).isEmpty(); } + @Test + void preserveNamesWithUnit() throws NoSuchFieldException, IllegalAccessException { + PrometheusRegistry preserveRegistry = new PrometheusRegistry(); + Field field = testing.getClass().getDeclaredField("metricReader"); + field.setAccessible(true); + MetricReader reader = (MetricReader) field.get(testing); + PrometheusMetricProducer preserveProducer = + new PrometheusMetricProducer( + preserveRegistry, + InstrumentationScopeInfo.create("test"), + Resource.create(Attributes.builder().put("staticRes", "value").build()), + true); + reader.register(preserveProducer); + + Counter.builder().name("req").unit(Unit.BYTES).register(preserveRegistry).inc(); + + List metrics = testing.getMetrics(); + assertThat(metrics).hasSize(1); + OpenTelemetryAssertions.assertThat(metrics.get(0)).hasName("req").hasUnit("By"); + } + + @Test + void preserveNamesWithUnitAlreadyInName() throws NoSuchFieldException, IllegalAccessException { + PrometheusRegistry preserveRegistry = new PrometheusRegistry(); + Field field = testing.getClass().getDeclaredField("metricReader"); + field.setAccessible(true); + MetricReader reader = (MetricReader) field.get(testing); + PrometheusMetricProducer preserveProducer = + new PrometheusMetricProducer( + preserveRegistry, + InstrumentationScopeInfo.create("test"), + Resource.create(Attributes.builder().put("staticRes", "value").build()), + true); + reader.register(preserveProducer); + + Counter.builder().name("req_bytes").unit(Unit.BYTES).register(preserveRegistry).inc(); + + List metrics = testing.getMetrics(); + assertThat(metrics).hasSize(1); + OpenTelemetryAssertions.assertThat(metrics.get(0)).hasName("req_bytes").hasUnit("By"); + } + + @Test + void preserveNamesWithoutUnit() throws NoSuchFieldException, IllegalAccessException { + PrometheusRegistry preserveRegistry = new PrometheusRegistry(); + Field field = testing.getClass().getDeclaredField("metricReader"); + field.setAccessible(true); + MetricReader reader = (MetricReader) field.get(testing); + PrometheusMetricProducer preserveProducer = + new PrometheusMetricProducer( + preserveRegistry, + InstrumentationScopeInfo.create("test"), + Resource.create(Attributes.builder().put("staticRes", "value").build()), + true); + reader.register(preserveProducer); + + Counter.builder().name("events_total").register(preserveRegistry).inc(); + + List metrics = testing.getMetrics(); + assertThat(metrics).hasSize(1); + OpenTelemetryAssertions.assertThat(metrics.get(0)).hasName("events_total"); + } + private MetricAssert metricAssert() { List metrics = testing.getMetrics(); assertThat(metrics).hasSize(1); diff --git a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfigTest.java b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfigTest.java index 5a9103565..a81aec440 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfigTest.java +++ b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfigTest.java @@ -8,6 +8,7 @@ import io.opentelemetry.sdk.autoconfigure.internal.AutoConfigureUtil; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; import io.prometheus.metrics.config.ExporterOpenTelemetryProperties; +import io.prometheus.metrics.config.PrometheusProperties; import io.prometheus.metrics.config.PrometheusPropertiesLoader; import java.util.Collections; import java.util.HashMap; @@ -17,6 +18,7 @@ import java.util.function.Consumer; import java.util.stream.Stream; import org.assertj.core.api.AbstractStringAssert; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -289,6 +291,31 @@ void properties(String name, TestCase testCase) { } } + @Test + void resolvePreserveNamesFromBuilder() { + OpenTelemetryExporter.Builder builder = OpenTelemetryExporter.builder(); + builder.preserveNames(true); + PrometheusProperties config = PrometheusProperties.get(); + assertThat(OtelAutoConfig.resolvePreserveNames(builder, config)).isTrue(); + } + + @Test + void resolvePreserveNamesDefault() { + OpenTelemetryExporter.Builder builder = OpenTelemetryExporter.builder(); + PrometheusProperties config = PrometheusProperties.get(); + assertThat(OtelAutoConfig.resolvePreserveNames(builder, config)).isFalse(); + } + + @Test + void resolvePreserveNamesFromConfig() { + OpenTelemetryExporter.Builder builder = OpenTelemetryExporter.builder(); + ExporterOpenTelemetryProperties otelProps = + ExporterOpenTelemetryProperties.builder().preserveNames(true).build(); + PrometheusProperties config = + PrometheusProperties.builder().exporterOpenTelemetryProperties(otelProps).build(); + assertThat(OtelAutoConfig.resolvePreserveNames(builder, config)).isTrue(); + } + private static ExporterOpenTelemetryProperties getExporterOpenTelemetryProperties( TestCase testCase) { if (testCase.propertiesBuilder == null) { diff --git a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java index d714fb5cd..5e58275ca 100644 --- a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java +++ b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java @@ -296,7 +296,12 @@ private void setMetadataUnlessEmpty( if (nameSuffix == null) { builder.setName(SnapshotEscaper.getMetadataName(metadata, scheme)); } else { - builder.setName(SnapshotEscaper.getMetadataName(metadata, scheme) + nameSuffix); + String expositionBaseName = SnapshotEscaper.getExpositionBaseMetadataName(metadata, scheme); + if (expositionBaseName.endsWith(nameSuffix)) { + builder.setName(expositionBaseName); + } else { + builder.setName(SnapshotEscaper.getMetadataName(metadata, scheme) + nameSuffix); + } } if (metadata.getHelp() != null) { builder.setHelp(metadata.getHelp()); diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java index 293fbfb8c..bffb60c14 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java @@ -6,6 +6,7 @@ import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeOpenMetricsTimestamp; +import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getExpositionBaseMetadataName; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getMetadataName; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getSnapshotLabelName; @@ -141,13 +142,14 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingSch private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "counter", metadata, scheme); + String counterName = resolveExpositionName(metadata, "_total", scheme); + String baseName = resolveBaseName(counterName, "_total"); + writeMetadataWithName(writer, baseName, "counter", metadata); for (CounterSnapshot.CounterDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_total", data.getLabels(), scheme); + writeNameAndLabels(writer, counterName, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme); - writeCreated(writer, metadata, data, scheme); + writeCreated(writer, baseName, data, scheme); } } @@ -274,10 +276,11 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "info", metadata, scheme); + String infoName = resolveExpositionName(metadata, "_info", scheme); + String baseName = resolveBaseName(infoName, "_info"); + writeMetadataWithName(writer, baseName, "info", metadata); for (InfoSnapshot.InfoDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_info", data.getLabels(), scheme); + writeNameAndLabels(writer, infoName, null, data.getLabels(), scheme); writer.write("1"); writeScrapeTimestampAndExemplar(writer, data, null, scheme); } @@ -363,9 +366,14 @@ private void writeCountAndSum( private void writeCreated( Writer writer, MetricMetadata metadata, DataPointSnapshot data, EscapingScheme scheme) throws IOException { + writeCreated(writer, getMetadataName(metadata, scheme), data, scheme); + } + + private void writeCreated( + Writer writer, String baseName, DataPointSnapshot data, EscapingScheme scheme) + throws IOException { if (createdTimestampsEnabled && data.hasCreatedTimestamp()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_created", data.getLabels(), scheme); + writeNameAndLabels(writer, baseName, "_created", data.getLabels(), scheme); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); if (data.hasScrapeTimestamp()) { writer.write(' '); @@ -436,27 +444,53 @@ private void writeScrapeTimestampAndExemplar( writer.write('\n'); } + /** + * Returns the full exposition name for a metric. If the original name already ends with the given + * suffix (e.g. "_total" for counters), uses the original name directly. Otherwise, appends the + * suffix to the base name. + */ + private static String resolveExpositionName( + MetricMetadata metadata, String suffix, EscapingScheme scheme) { + String expositionBaseName = getExpositionBaseMetadataName(metadata, scheme); + if (expositionBaseName.endsWith(suffix)) { + return expositionBaseName; + } + return getMetadataName(metadata, scheme) + suffix; + } + private void writeMetadata( Writer writer, String typeName, MetricMetadata metadata, EscapingScheme scheme) throws IOException { + writeMetadataWithName(writer, getMetadataName(metadata, scheme), typeName, metadata); + } + + private void writeMetadataWithName( + Writer writer, String name, String typeName, MetricMetadata metadata) throws IOException { writer.write("# TYPE "); - writeName(writer, getMetadataName(metadata, scheme), NameType.Metric); + writeName(writer, name, NameType.Metric); writer.write(' '); writer.write(typeName); writer.write('\n'); if (metadata.getUnit() != null) { writer.write("# UNIT "); - writeName(writer, getMetadataName(metadata, scheme), NameType.Metric); + writeName(writer, name, NameType.Metric); writer.write(' '); writeEscapedString(writer, metadata.getUnit().toString()); writer.write('\n'); } if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) { writer.write("# HELP "); - writeName(writer, getMetadataName(metadata, scheme), NameType.Metric); + writeName(writer, name, NameType.Metric); writer.write(' '); writeEscapedString(writer, metadata.getHelp()); writer.write('\n'); } } + + private static String resolveBaseName(String fullName, String suffix) { + if (fullName.endsWith(suffix)) { + return fullName.substring(0, fullName.length() - suffix.length()); + } + return fullName; + } } diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java index cc9f067ba..b40dcfdf2 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java @@ -7,6 +7,7 @@ import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writePrometheusTimestamp; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.escapeMetricSnapshot; +import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getExpositionBaseMetadataName; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getMetadataName; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getSnapshotLabelName; @@ -157,14 +158,17 @@ public void writeCreated(Writer writer, MetricSnapshot snapshot, EscapingScheme throws IOException { boolean metadataWritten = false; MetricMetadata metadata = snapshot.getMetadata(); + String baseName = getMetadataName(metadata, scheme); + if (snapshot instanceof CounterSnapshot) { + baseName = resolveBaseName(resolveExpositionName(metadata, "_total", scheme), "_total"); + } for (DataPointSnapshot data : snapshot.getDataPoints()) { if (data.hasCreatedTimestamp()) { if (!metadataWritten) { - writeMetadata(writer, "_created", "gauge", metadata, scheme); + writeMetadataWithFullName(writer, baseName + "_created", "gauge", metadata); metadataWritten = true; } - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_created", data.getLabels(), scheme); + writeNameAndLabels(writer, baseName, "_created", data.getLabels(), scheme); writePrometheusTimestamp(writer, data.getCreatedTimestampMillis(), timestampsInMs); writeScrapeTimestampAndNewline(writer, data); } @@ -175,10 +179,10 @@ private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingSchem throws IOException { if (!snapshot.getDataPoints().isEmpty()) { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "_total", "counter", metadata, scheme); + String counterName = resolveExpositionName(metadata, "_total", scheme); + writeMetadataWithFullName(writer, counterName, "counter", metadata); for (CounterSnapshot.CounterDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_total", data.getLabels(), scheme); + writeNameAndLabels(writer, counterName, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); writeScrapeTimestampAndNewline(writer, data); } @@ -321,10 +325,10 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "_info", "gauge", metadata, scheme); + String infoName = resolveExpositionName(metadata, "_info", scheme); + writeMetadataWithFullName(writer, infoName, "gauge", metadata); for (InfoSnapshot.InfoDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_info", data.getLabels(), scheme); + writeNameAndLabels(writer, infoName, null, data.getLabels(), scheme); writer.write("1"); writeScrapeTimestampAndNewline(writer, data); } @@ -433,6 +437,44 @@ private void writeMetadata( writer.write('\n'); } + private void writeMetadataWithFullName( + Writer writer, String fullName, String typeString, MetricMetadata metadata) + throws IOException { + if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) { + writer.write("# HELP "); + writeName(writer, fullName, NameType.Metric); + writer.write(' '); + writeEscapedHelp(writer, metadata.getHelp()); + writer.write('\n'); + } + writer.write("# TYPE "); + writeName(writer, fullName, NameType.Metric); + writer.write(' '); + writer.write(typeString); + writer.write('\n'); + } + + /** + * Returns the full exposition name for a metric. If the original name already ends with the given + * suffix (e.g. "_total" for counters), uses the original name directly. Otherwise, appends the + * suffix to the base name. + */ + private static String resolveExpositionName( + MetricMetadata metadata, String suffix, EscapingScheme scheme) { + String expositionBaseName = getExpositionBaseMetadataName(metadata, scheme); + if (expositionBaseName.endsWith(suffix)) { + return expositionBaseName; + } + return getMetadataName(metadata, scheme) + suffix; + } + + private static String resolveBaseName(String fullName, String suffix) { + if (fullName.endsWith(suffix)) { + return fullName.substring(0, fullName.length() - suffix.length()); + } + return fullName; + } + private void writeEscapedHelp(Writer writer, String s) throws IOException { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java index beca6001e..1d1e7d232 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java @@ -34,7 +34,8 @@ default MetricSnapshot collect(PrometheusScrapeRequest scrapeRequest) { @Nullable default MetricSnapshot collect(Predicate includedNames) { MetricSnapshot result = collect(); - if (includedNames.test(result.getMetadata().getPrometheusName())) { + if (includedNames.test(result.getMetadata().getPrometheusName()) + || includedNames.test(result.getMetadata().getExpositionBasePrometheusName())) { return result; } else { return null; @@ -51,7 +52,8 @@ default MetricSnapshot collect(Predicate includedNames) { default MetricSnapshot collect( Predicate includedNames, PrometheusScrapeRequest scrapeRequest) { MetricSnapshot result = collect(scrapeRequest); - if (includedNames.test(result.getMetadata().getPrometheusName())) { + if (includedNames.test(result.getMetadata().getPrometheusName()) + || includedNames.test(result.getMetadata().getExpositionBasePrometheusName())) { return result; } else { return null; diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java index f66824972..499dbf9d1 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java @@ -28,13 +28,24 @@ public class PrometheusRegistry { private final ConcurrentHashMap> multiCollectorMetadata = new ConcurrentHashMap<>(); + /** + * Maps exposition-level names (e.g. "events_total", "events_created") to the owning + * prometheusName (e.g. "events"). Used to detect cross-type collisions at registration time. + */ + private final ConcurrentHashMap expositionNameOwners = new ConcurrentHashMap<>(); + /** Stores the registration details for a Collector at registration time. */ private static class CollectorRegistration { final String prometheusName; + @Nullable final String expositionBasePrometheusName; final Set labelNames; - CollectorRegistration(String prometheusName, @Nullable Set labelNames) { + CollectorRegistration( + String prometheusName, + @Nullable String expositionBasePrometheusName, + @Nullable Set labelNames) { this.prometheusName = prometheusName; + this.expositionBasePrometheusName = expositionBasePrometheusName; this.labelNames = immutableLabelNames(labelNames); } } @@ -147,9 +158,44 @@ private static Set immutableLabelNames(@Nullable Set labelNames) return Collections.unmodifiableSet(new HashSet<>(labelNames)); } + /** + * Computes the set of exposition-level time series names that a metric with the given name and + * type will produce. + */ + static Set computeExpositionNames(String prometheusName, MetricType type) { + Set names = new HashSet<>(); + switch (type) { + case COUNTER: + names.add(prometheusName + "_total"); + names.add(prometheusName + "_created"); + break; + case INFO: + names.add(prometheusName + "_info"); + break; + case HISTOGRAM: + names.add(prometheusName + "_bucket"); + names.add(prometheusName + "_count"); + names.add(prometheusName + "_sum"); + names.add(prometheusName + "_created"); + break; + case SUMMARY: + names.add(prometheusName + "_count"); + names.add(prometheusName + "_sum"); + names.add(prometheusName + "_created"); + break; + case GAUGE: + case STATESET: + case UNKNOWN: + default: + names.add(prometheusName); + break; + } + return names; + } + /** * Validates the registration of a metric with the given parameters. Ensures type consistency, - * label schema uniqueness, and help/unit consistency. + * label schema uniqueness, help/unit consistency, and exposition name collision detection. */ private void validateRegistration( String prometheusName, @@ -161,6 +207,23 @@ private void validateRegistration( final Set names = normalizedLabels; final String helpForValidation = help; final Unit unitForValidation = unit; + + // Check exposition name collisions before modifying any state. + Set expositionNames = computeExpositionNames(prometheusName, type); + for (String expositionName : expositionNames) { + String owner = expositionNameOwners.get(expositionName); + if (owner != null && !owner.equals(prometheusName)) { + throw new IllegalArgumentException( + "'" + + prometheusName + + "' and '" + + owner + + "' have conflicting exposition name: '" + + expositionName + + "'"); + } + } + registered.compute( prometheusName, (n, existingInfo) -> { @@ -190,6 +253,11 @@ private void validateRegistration( return existingInfo; } }); + + // Registration succeeded — claim exposition names. + for (String expositionName : expositionNames) { + expositionNameOwners.put(expositionName, prometheusName); + } } public void register(Collector collector) { @@ -208,8 +276,12 @@ public void register(Collector collector) { // Collectors that don't implement getPrometheusName()/getMetricType() will skip validation. if (prometheusName != null && metricType != null) { validateRegistration(prometheusName, metricType, normalizedLabels, help, unit); + String expositionBasePrometheusName = + metadata != null ? metadata.getExpositionBasePrometheusName() : null; collectorMetadata.put( - collector, new CollectorRegistration(prometheusName, normalizedLabels)); + collector, + new CollectorRegistration( + prometheusName, expositionBasePrometheusName, normalizedLabels)); } // Catch RuntimeException broadly because collector methods (getPrometheusName, getMetricType, // etc.) are user-implemented and could throw any RuntimeException. Ensures cleanup on @@ -280,7 +352,7 @@ public void unregister(MultiCollector collector) { /** * Removes the label schema for the given metric name. If no label schemas remain for that name, - * removes the metric name entirely from the registry. + * removes the metric name entirely from the registry, including its exposition name reservations. */ private void unregisterLabelSchema(String prometheusName, Set labelNames) { registered.computeIfPresent( @@ -288,6 +360,11 @@ private void unregisterLabelSchema(String prometheusName, Set labelNames (name, info) -> { info.removeLabelSet(labelNames); if (info.isEmpty()) { + // Remove exposition name reservations for this metric. + Set expositionNames = computeExpositionNames(prometheusName, info.getType()); + for (String expositionName : expositionNames) { + expositionNameOwners.remove(expositionName, prometheusName); + } return null; } return info; @@ -300,6 +377,7 @@ public void clear() { registered.clear(); collectorMetadata.clear(); multiCollectorMetadata.clear(); + expositionNameOwners.clear(); } public MetricSnapshots scrape() { @@ -347,7 +425,12 @@ public MetricSnapshots scrape( String prometheusName = collector.getPrometheusName(); // prometheusName == null means the name is unknown, and we have to scrape to learn the name. // prometheusName != null means we can skip the scrape if the name is excluded. - if (prometheusName == null || includedNames.test(prometheusName)) { + // Also test the original name (e.g. "events_total" for a counter named "events"). + CollectorRegistration reg = collectorMetadata.get(collector); + String expositionName = reg != null ? reg.expositionBasePrometheusName : null; + if (prometheusName == null + || includedNames.test(prometheusName) + || (expositionName != null && includedNames.test(expositionName))) { MetricSnapshot snapshot = scrapeRequest == null ? collector.collect(includedNames) diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java index 9c54f96d5..74f6f55d6 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java @@ -29,6 +29,22 @@ public final class MetricMetadata { */ private final String prometheusName; + /** + * The base name for exposition, with unit suffix ensured and type suffix preserved. For example, + * for {@code Counter.builder().name("events_total").unit(BYTES)}, this is "events_total_bytes". + * Used by format writers for smart-append logic (e.g. deciding whether to append _total). + */ + private final String expositionBaseName; + + private final String expositionBasePrometheusName; + + /** + * The original name as provided by the user, before any modification (no suffix stripping, no + * unit appending). For example, for {@code Counter.builder().name("req").unit(BYTES)}, this is + * "req". Used by the OTel exporter with {@code preserve_names=true}. + */ + private final String originalName; + @Nullable private final String help; @Nullable private final Unit unit; @@ -52,11 +68,48 @@ public MetricMetadata(String name, String help) { * @param unit optional. May be {@code null}. */ public MetricMetadata(String name, @Nullable String help, @Nullable Unit unit) { + this(name, name, help, unit); + } + + /** + * Constructor with exposition base name. + * + * @param name the base name (with type suffixes stripped, e.g. "events" for a counter named + * "events_total") + * @param expositionBaseName the name with unit suffix ensured and type suffix preserved, used by + * format writers for smart-append logic + * @param help optional. May be {@code null}. + * @param unit optional. May be {@code null}. + */ + public MetricMetadata( + String name, String expositionBaseName, @Nullable String help, @Nullable Unit unit) { + this(name, expositionBaseName, expositionBaseName, help, unit); + } + + /** + * Constructor with exposition base name and original name. + * + * @param name the base name (with type suffixes stripped, e.g. "events" for a counter named + * "events_total") + * @param expositionBaseName the name with unit suffix ensured and type suffix preserved + * @param originalName the raw name as provided by the user, before any modification + * @param help optional. May be {@code null}. + * @param unit optional. May be {@code null}. + */ + public MetricMetadata( + String name, + String expositionBaseName, + String originalName, + @Nullable String help, + @Nullable Unit unit) { this.name = name; + this.expositionBaseName = expositionBaseName; + this.originalName = originalName; this.help = help; this.unit = unit; validate(); this.prometheusName = PrometheusNaming.prometheusName(name); + this.expositionBasePrometheusName = PrometheusNaming.prometheusName(expositionBaseName); } /** @@ -79,6 +132,32 @@ public String getPrometheusName() { return prometheusName; } + /** + * The original name as provided by the user, before any modification. For example, if the user + * called {@code Counter.builder().name("req").unit(BYTES)}, this returns "req" while {@link + * #getName()} returns "req_bytes" and {@link #getExpositionBaseName()} returns "req_bytes". + */ + public String getOriginalName() { + return originalName; + } + + /** + * The base name for exposition, with unit suffix ensured and type suffix preserved. For example, + * if the user called {@code Counter.builder().name("events_total")}, this returns "events_total" + * while {@link #getName()} returns "events". + */ + public String getExpositionBaseName() { + return expositionBaseName; + } + + /** + * Same as {@link #getExpositionBaseName()} but with all invalid characters and dots replaced by + * underscores. + */ + public String getExpositionBasePrometheusName() { + return expositionBasePrometheusName; + } + @Nullable public String getHelp() { return help; @@ -125,6 +204,11 @@ private void validate() { } MetricMetadata escape(EscapingScheme escapingScheme) { - return new MetricMetadata(PrometheusNaming.escapeName(name, escapingScheme), help, unit); + return new MetricMetadata( + PrometheusNaming.escapeName(name, escapingScheme), + PrometheusNaming.escapeName(expositionBaseName, escapingScheme), + PrometheusNaming.escapeName(originalName, escapingScheme), + help, + unit); } } 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 4f766fdad..ea2653931 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 @@ -18,34 +18,12 @@ public class PrometheusNaming { /** - * According to OpenMetrics {@code _count} and {@code _sum} (and {@code _gcount}, {@code _gsum}) - * should also be reserved metric name suffixes. However, popular instrumentation libraries have - * Gauges with names ending in {@code _count}. Examples: + * Test if a metric name is valid. Any non-empty valid UTF-8 string is accepted. * - *
    - *
  • Micrometer: {@code jvm_buffer_count} - *
  • OpenTelemetry: {@code process_runtime_jvm_buffer_count} - *
- * - *

We do not treat {@code _count} and {@code _sum} as reserved suffixes here for compatibility - * with these libraries. However, there is a risk of name conflict if someone creates a gauge - * named {@code my_data_count} and a histogram or summary named {@code my_data}, because the - * histogram or summary will implicitly have a sample named {@code my_data_count}. - */ - private static final String[] RESERVED_METRIC_NAME_SUFFIXES = { - "_total", "_created", "_bucket", "_info", - ".total", ".created", ".bucket", ".info" - }; - - /** - * Test if a metric name is valid. Rules: - * - *

    - *
  • The name must match Metric - * names. - *
  • The name MUST NOT end with one of the {@link #RESERVED_METRIC_NAME_SUFFIXES}. - *
+ *

Collision detection for suffixes like {@code _total}, {@code _info}, {@code _bucket}, etc. + * is handled at registration time by the {@link + * io.prometheus.metrics.model.registry.PrometheusRegistry PrometheusRegistry}, not by name + * validation. * *

If a metric has a {@link Unit}, the metric name SHOULD end with the unit as a suffix. Note * that OpenMetrics requires metric names to have their unit @@ -70,11 +48,6 @@ public static boolean isValidMetricName(String name) { */ @Nullable public static String validateMetricName(String name) { - for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { - if (name.endsWith(reservedSuffix)) { - return "The metric name must not include the '" + reservedSuffix + "' suffix."; - } - } if (isValidUtf8(name)) { return null; } @@ -141,10 +114,7 @@ public static boolean isValidLegacyLabelName(String name) { return true; } - /** - * Units may not have illegal characters, and they may not end with a reserved suffix like - * 'total'. - */ + /** Units may not have illegal characters. */ public static boolean isValidUnitName(String name) { return validateUnitName(name) == null; } @@ -155,12 +125,6 @@ public static String validateUnitName(String name) { if (name.isEmpty()) { return "The unit name must not be empty."; } - for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { - String suffixName = reservedSuffix.substring(1); - if (name.endsWith(suffixName)) { - return suffixName + " is a reserved suffix in Prometheus"; - } - } // Check if all characters are [a-zA-Z0-9_.:]+ for (int i = 0; i < name.length(); i++) { char c = name.charAt(i); @@ -189,31 +153,16 @@ public static String prometheusName(String name) { } /** - * Convert an arbitrary string to a name where {@link #isValidMetricName(String) - * isValidMetricName(name)} is true. + * 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. + * + * @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; - for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { - if (sanitizedName.equals(reservedSuffix)) { - // This is for the corner case when you call sanitizeMetricName("_total"). - // In that case the result will be "total". - return reservedSuffix.substring(1); - } - if (sanitizedName.endsWith(reservedSuffix)) { - sanitizedName = - sanitizedName.substring(0, sanitizedName.length() - reservedSuffix.length()); - modified = true; - } - } - } - return sanitizedName; + return metricName; } /** @@ -249,11 +198,10 @@ public static String sanitizeLabelName(String labelName) { } /** - * Convert an arbitrary string to a name where {@link #validateUnitName(String)} is {@code null} - * (i.e. the name is valid). + * Convert an arbitrary string to a valid unit name by replacing illegal characters. * - * @throws IllegalArgumentException if the {@code unitName} cannot be converted, for example if - * you call {@code sanitizeUnitName("total")} or {@code sanitizeUnitName("")}. + * @throws IllegalArgumentException if the {@code unitName} cannot be converted, e.g. if you call + * {@code sanitizeUnitName("")}. * @throws NullPointerException if {@code unitName} is null. */ public static String sanitizeUnitName(String unitName) { @@ -261,24 +209,11 @@ public static String sanitizeUnitName(String unitName) { throw new IllegalArgumentException("Cannot convert an empty string to a valid unit name."); } String sanitizedName = replaceIllegalCharsInUnitName(unitName); - boolean modified = true; - while (modified) { - modified = false; - while (sanitizedName.startsWith("_") || sanitizedName.startsWith(".")) { - sanitizedName = sanitizedName.substring(1); - modified = true; - } - while (sanitizedName.endsWith(".") || sanitizedName.endsWith("_")) { - sanitizedName = sanitizedName.substring(0, sanitizedName.length() - 1); - modified = true; - } - for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { - String suffixName = reservedSuffix.substring(1); - if (sanitizedName.endsWith(suffixName)) { - sanitizedName = sanitizedName.substring(0, sanitizedName.length() - suffixName.length()); - modified = true; - } - } + while (sanitizedName.startsWith("_") || sanitizedName.startsWith(".")) { + sanitizedName = sanitizedName.substring(1); + } + while (sanitizedName.endsWith(".") || sanitizedName.endsWith("_")) { + sanitizedName = sanitizedName.substring(0, sanitizedName.length() - 1); } if (sanitizedName.isEmpty()) { throw new IllegalArgumentException( diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java index 422b36ee0..b4f69e9bb 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java @@ -96,6 +96,15 @@ public static String getMetadataName(MetricMetadata metadata, EscapingScheme sch } } + public static String getExpositionBaseMetadataName( + MetricMetadata metadata, EscapingScheme scheme) { + if (scheme == EscapingScheme.UNDERSCORE_ESCAPING) { + return metadata.getExpositionBasePrometheusName(); + } else { + return metadata.getExpositionBaseName(); + } + } + public static Labels escapeLabels(Labels labels, EscapingScheme scheme) { Labels.Builder outLabelsBuilder = Labels.builder(); diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java index 90a04934e..6dabad653 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java @@ -1087,4 +1087,193 @@ public String getPrometheusName() { assertThatCode(() -> registry.unregister(legacy)).doesNotThrowAnyException(); assertThat(registry.scrape().size()).isEqualTo(0); } + + @Test + void register_gaugeWithTotalSuffix_andCounter_collisionDetected() { + PrometheusRegistry registry = new PrometheusRegistry(); + + // Register a counter "events" — this claims exposition name "events_total" + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("events").build(); + } + + @Override + public String getPrometheusName() { + return "events"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + // Register a gauge "events_total" — this claims exposition name "events_total" + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("events_total").build(); + } + + @Override + public String getPrometheusName() { + return "events_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + registry.register(counter); + assertThatThrownBy(() -> registry.register(gauge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("conflicting exposition name"); + } + + @Test + void register_gaugeWithCountSuffix_andHistogram_collisionDetected() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector histogram = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("foo").build(); + } + + @Override + public String getPrometheusName() { + return "foo"; + } + + @Override + public MetricType getMetricType() { + return MetricType.HISTOGRAM; + } + }; + + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("foo_count").build(); + } + + @Override + public String getPrometheusName() { + return "foo_count"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + registry.register(histogram); + assertThatThrownBy(() -> registry.register(gauge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("conflicting exposition name"); + } + + @Test + void register_gaugeWithTotalSuffix_andHistogram_noCollision() { + PrometheusRegistry registry = new PrometheusRegistry(); + + // Histogram "foo" claims: foo_bucket, foo_count, foo_sum, foo_created + // Gauge "foo_total" claims: foo_total + // No overlap — should succeed. + Collector histogram = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("foo").build(); + } + + @Override + public String getPrometheusName() { + return "foo"; + } + + @Override + public MetricType getMetricType() { + return MetricType.HISTOGRAM; + } + }; + + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("foo_total").build(); + } + + @Override + public String getPrometheusName() { + return "foo_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + registry.register(histogram); + assertThatCode(() -> registry.register(gauge)).doesNotThrowAnyException(); + } + + @Test + void register_expositionCollision_unregisterAndReregister() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("events").build(); + } + + @Override + public String getPrometheusName() { + return "events"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("events_total").build(); + } + + @Override + public String getPrometheusName() { + return "events_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + registry.register(counter); + assertThatThrownBy(() -> registry.register(gauge)).isInstanceOf(IllegalArgumentException.class); + + // After unregistering the counter, the gauge should succeed + registry.unregister(counter); + assertThatCode(() -> registry.register(gauge)).doesNotThrowAnyException(); + } } diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java index ca4346cdb..16a324323 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java @@ -92,8 +92,8 @@ void testEmptyCounter() { @Test void testTotalSuffixPresent() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> CounterSnapshot.builder().name("test_total").build()); + CounterSnapshot snapshot = CounterSnapshot.builder().name("test_total").build(); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test_total"); } @Test diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java index 5154e1eb1..7bd965913 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java @@ -87,14 +87,14 @@ void testEmptyGauge() { @Test void testTotalSuffixPresent() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> CounterSnapshot.builder().name("test_total").build()); + CounterSnapshot snapshot = CounterSnapshot.builder().name("test_total").build(); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test_total"); } @Test void testTotalSuffixPresentDot() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> CounterSnapshot.builder().name("test.total").build()); + CounterSnapshot snapshot = CounterSnapshot.builder().name("test.total").build(); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test_total"); } @Test diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java index 3cf7d69af..608876bdf 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java @@ -61,13 +61,13 @@ void testDataImmutable() { @Test void testNameMustNotIncludeSuffix() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> InfoSnapshot.builder().name("jvm_info").build()); + InfoSnapshot snapshot = InfoSnapshot.builder().name("jvm_info").build(); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("jvm_info"); } @Test void testNameMustNotIncludeSuffixDot() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> InfoSnapshot.builder().name("jvm.info").build()); + InfoSnapshot snapshot = InfoSnapshot.builder().name("jvm.info").build(); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("jvm_info"); } } 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 41efe043b..8a4731ac8 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 @@ -34,21 +34,27 @@ void testSanitizationIllegalCharacters() { } @Test - void testSanitizationCounter() { + void testNameWithTotalSuffix() { MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("my_events_total")); - assertThat(metadata.getName()).isEqualTo("my_events"); + assertThat(metadata.getName()).isEqualTo("my_events_total"); } @Test - void testSanitizationInfo() { + void testNameWithInfoSuffix() { MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("target_info")); - assertThat(metadata.getName()).isEqualTo("target"); + assertThat(metadata.getName()).isEqualTo("target_info"); } @Test - void testSanitizationWeirdCornerCase() { - MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("_total_created")); - assertThat(metadata.getName()).isEqualTo("total"); + void testNameWithCreatedSuffix() { + MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("my_events_created")); + assertThat(metadata.getName()).isEqualTo("my_events_created"); + } + + @Test + void testNameWithBucketSuffix() { + MetricMetadata metadata = new MetricMetadata(sanitizeMetricName("my_histogram_bucket")); + assertThat(metadata.getName()).isEqualTo("my_histogram_bucket"); } @Test @@ -72,4 +78,29 @@ void testUnitSuffixAdded() { void testUnitNotDuplicated() { assertThat(sanitizeMetricName("my_counter_bytes", Unit.BYTES)).isEqualTo("my_counter_bytes"); } + + @Test + void testFiveArgConstructor() { + MetricMetadata metadata = + new MetricMetadata("req_bytes", "req_bytes", "req", "help", Unit.BYTES); + assertThat(metadata.getName()).isEqualTo("req_bytes"); + assertThat(metadata.getExpositionBaseName()).isEqualTo("req_bytes"); + assertThat(metadata.getOriginalName()).isEqualTo("req"); + assertThat(metadata.getHelp()).isEqualTo("help"); + assertThat(metadata.getUnit()).isEqualTo(Unit.BYTES); + } + + @Test + void testFourArgConstructorDefaultsOriginalName() { + MetricMetadata metadata = new MetricMetadata("req_bytes", "req_bytes", "help", Unit.BYTES); + assertThat(metadata.getOriginalName()).isEqualTo("req_bytes"); + assertThat(metadata.getExpositionBaseName()).isEqualTo("req_bytes"); + } + + @Test + void testThreeArgConstructorDefaultsOriginalName() { + MetricMetadata metadata = new MetricMetadata("req_bytes", "help", Unit.BYTES); + assertThat(metadata.getOriginalName()).isEqualTo("req_bytes"); + assertThat(metadata.getExpositionBaseName()).isEqualTo("req_bytes"); + } } 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 847bb0f38..dcebd14d8 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,13 +22,14 @@ class PrometheusNamingTest { @Test void testSanitizeMetricName() { - assertThat(sanitizeMetricName("my_counter_total")).isEqualTo("my_counter"); - assertThat(sanitizeMetricName("jvm.info")).isEqualTo("jvm"); - assertThat(sanitizeMetricName("jvm_info")).isEqualTo("jvm"); - assertThat(sanitizeMetricName("jvm.info")).isEqualTo("jvm"); + assertThat(sanitizeMetricName("my_counter_total")).isEqualTo("my_counter_total"); + assertThat(sanitizeMetricName("jvm.info")).isEqualTo("jvm.info"); + assertThat(sanitizeMetricName("jvm_info")).isEqualTo("jvm_info"); 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"); } @Test @@ -36,9 +37,9 @@ void testSanitizeMetricNameWithUnit() { assertThat(prometheusName(sanitizeMetricName("def", Unit.RATIO))) .isEqualTo("def_" + Unit.RATIO); assertThat(prometheusName(sanitizeMetricName("my_counter_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); + .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); assertThat(sanitizeMetricName("total", Unit.RATIO)).isEqualTo("total_" + Unit.RATIO); } @@ -55,40 +56,29 @@ void testSanitizeLabelName() { @Test void testValidateUnitName() { - assertThat(validateUnitName("secondstotal")).isNotNull(); - assertThat(validateUnitName("total")).isNotNull(); - assertThat(validateUnitName("seconds_total")).isNotNull(); - assertThat(validateUnitName("_total")).isNotNull(); assertThat(validateUnitName("")).isNotNull(); assertThat(validateUnitName("seconds")).isNull(); assertThat(validateUnitName("2")).isNull(); + assertThat(validateUnitName("total")).isNull(); + assertThat(validateUnitName("info")).isNull(); + assertThat(validateUnitName("created")).isNull(); + assertThat(validateUnitName("bucket")).isNull(); } @Test void testSanitizeUnitName() { assertThat(sanitizeUnitName("seconds")).isEqualTo("seconds"); - assertThat(sanitizeUnitName("seconds_total")).isEqualTo("seconds"); - assertThat(sanitizeUnitName("seconds_total_total")).isEqualTo("seconds"); assertThat(sanitizeUnitName("m/s")).isEqualTo("m_s"); - assertThat(sanitizeUnitName("secondstotal")).isEqualTo("seconds"); assertThat(sanitizeUnitName("2")).isEqualTo("2"); + assertThat(sanitizeUnitName("total")).isEqualTo("total"); + assertThat(sanitizeUnitName("info")).isEqualTo("info"); + assertThat(sanitizeUnitName("created")).isEqualTo("created"); + assertThat(sanitizeUnitName("bucket")).isEqualTo("bucket"); } @Test - void testInvalidUnitName1() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> sanitizeUnitName("total")); - } - - @Test - void testInvalidUnitName2() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> sanitizeUnitName("_total")); - } - - @Test - void testInvalidUnitName3() { + void testInvalidUnitName_percent() { assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy(() -> sanitizeUnitName("%")); } From 9ebf25a1aba256d60e4fa0e01219f236981bda49 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 17 Mar 2026 08:48:20 +0000 Subject: [PATCH 3/3] feat: OM2 writer outputs names as provided, no suffix appending The OM2 writer now uses expositionBaseName instead of appending _total (counters) or unit suffixes. The _info suffix is enforced per the OM2 spec (MUST). Tests updated to verify OM2-specific output rather than asserting identity with OM1. Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 118 +++++++++--------- .../OpenMetrics2TextFormatWriterTest.java | 60 +++++++-- 2 files changed, 110 insertions(+), 68 deletions(-) diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java index 0e03a112a..53df3dc49 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java @@ -6,7 +6,7 @@ import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeOpenMetricsTimestamp; -import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getMetadataName; +import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getExpositionBaseMetadataName; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getSnapshotLabelName; import io.prometheus.metrics.config.EscapingScheme; @@ -40,9 +40,9 @@ import javax.annotation.Nullable; /** - * Write the OpenMetrics 2.0 text format. This is currently a skeleton implementation that produces - * identical output to OpenMetrics 1.0, with infrastructure for future OM2 features. This is - * experimental and subject to change as the OpenMetrics * 2.0 specification evolves. */ @@ -171,22 +171,24 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingSch private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "counter", metadata, scheme); + // OM2: use the name as provided by the user, no _total appending + String counterName = getExpositionBaseMetadataName(metadata, scheme); + writeMetadataWithName(writer, counterName, "counter", metadata); for (CounterSnapshot.CounterDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_total", data.getLabels(), scheme); + writeNameAndLabels(writer, counterName, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme); - writeCreated(writer, metadata, data, scheme); + writeCreated(writer, counterName, data, scheme); } } private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "gauge", metadata, scheme); + String name = getExpositionBaseMetadataName(metadata, scheme); + writeMetadataWithName(writer, name, "gauge", metadata); for (GaugeSnapshot.GaugeDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels(writer, getMetadataName(metadata, scheme), null, data.getLabels(), scheme); + writeNameAndLabels(writer, name, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); if (exemplarsOnAllMetricTypesEnabled) { writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme); @@ -199,20 +201,21 @@ private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme sc private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); + String name = getExpositionBaseMetadataName(metadata, scheme); if (snapshot.isGaugeHistogram()) { - writeMetadata(writer, "gaugehistogram", metadata, scheme); + writeMetadataWithName(writer, name, "gaugehistogram", metadata); writeClassicHistogramBuckets( - writer, metadata, "_gcount", "_gsum", snapshot.getDataPoints(), scheme); + writer, name, "_gcount", "_gsum", snapshot.getDataPoints(), scheme); } else { - writeMetadata(writer, "histogram", metadata, scheme); + writeMetadataWithName(writer, name, "histogram", metadata); writeClassicHistogramBuckets( - writer, metadata, "_count", "_sum", snapshot.getDataPoints(), scheme); + writer, name, "_count", "_sum", snapshot.getDataPoints(), scheme); } } private void writeClassicHistogramBuckets( Writer writer, - MetricMetadata metadata, + String name, String countSuffix, String sumSuffix, List dataList, @@ -225,13 +228,7 @@ private void writeClassicHistogramBuckets( for (int i = 0; i < buckets.size(); i++) { cumulativeCount += buckets.getCount(i); writeNameAndLabels( - writer, - getMetadataName(metadata, scheme), - "_bucket", - data.getLabels(), - scheme, - "le", - buckets.getUpperBound(i)); + writer, name, "_bucket", data.getLabels(), scheme, "le", buckets.getUpperBound(i)); writeLong(writer, cumulativeCount); Exemplar exemplar; if (i == 0) { @@ -243,9 +240,9 @@ private void writeClassicHistogramBuckets( } // In OpenMetrics format, histogram _count and _sum are either both present or both absent. if (data.hasCount() && data.hasSum()) { - writeCountAndSum(writer, metadata, data, countSuffix, sumSuffix, exemplars, scheme); + writeCountAndSum(writer, name, data, countSuffix, sumSuffix, exemplars, scheme); } - writeCreated(writer, metadata, data, scheme); + writeCreated(writer, name, data, scheme); } } @@ -263,12 +260,13 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem throws IOException { boolean metadataWritten = false; MetricMetadata metadata = snapshot.getMetadata(); + String name = getExpositionBaseMetadataName(metadata, scheme); for (SummarySnapshot.SummaryDataPointSnapshot data : snapshot.getDataPoints()) { if (data.getQuantiles().size() == 0 && !data.hasCount() && !data.hasSum()) { continue; } if (!metadataWritten) { - writeMetadata(writer, "summary", metadata, scheme); + writeMetadataWithName(writer, name, "summary", metadata); metadataWritten = true; } Exemplars exemplars = data.getExemplars(); @@ -280,13 +278,7 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem int exemplarIndex = 1; for (Quantile quantile : data.getQuantiles()) { writeNameAndLabels( - writer, - getMetadataName(metadata, scheme), - null, - data.getLabels(), - scheme, - "quantile", - quantile.getQuantile()); + writer, name, null, data.getLabels(), scheme, "quantile", quantile.getQuantile()); writeDouble(writer, quantile.getValue()); if (exemplars.size() > 0 && exemplarsOnAllMetricTypesEnabled) { exemplarIndex = (exemplarIndex + 1) % exemplars.size(); @@ -296,18 +288,20 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem } } // Unlike histograms, summaries can have only a count or only a sum according to OpenMetrics. - writeCountAndSum(writer, metadata, data, "_count", "_sum", exemplars, scheme); - writeCreated(writer, metadata, data, scheme); + writeCountAndSum(writer, name, data, "_count", "_sum", exemplars, scheme); + writeCreated(writer, name, data, scheme); } } private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "info", metadata, scheme); + // OM2 spec: Info MetricFamily name MUST end in _info + String infoName = ensureSuffix(getExpositionBaseMetadataName(metadata, scheme), "_info"); + String baseName = removeSuffix(infoName, "_info"); + writeMetadataWithName(writer, baseName, "info", metadata); for (InfoSnapshot.InfoDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_info", data.getLabels(), scheme); + writeNameAndLabels(writer, infoName, null, data.getLabels(), scheme); writer.write("1"); writeScrapeTimestampAndExemplar(writer, data, null, scheme); } @@ -316,10 +310,11 @@ private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme sche private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "stateset", metadata, scheme); + String name = getExpositionBaseMetadataName(metadata, scheme); + writeMetadataWithName(writer, name, "stateset", metadata); for (StateSetSnapshot.StateSetDataPointSnapshot data : snapshot.getDataPoints()) { for (int i = 0; i < data.size(); i++) { - writer.write(getMetadataName(metadata, scheme)); + writer.write(name); writer.write('{'); Labels labels = data.getLabels(); for (int j = 0; j < labels.size(); j++) { @@ -334,7 +329,7 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingSch if (!labels.isEmpty()) { writer.write(","); } - writer.write(getMetadataName(metadata, scheme)); + writer.write(name); writer.write("=\""); writeEscapedString(writer, data.getName(i)); writer.write("\"} "); @@ -351,9 +346,10 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingSch private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "unknown", metadata, scheme); + String name = getExpositionBaseMetadataName(metadata, scheme); + writeMetadataWithName(writer, name, "unknown", metadata); for (UnknownSnapshot.UnknownDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels(writer, getMetadataName(metadata, scheme), null, data.getLabels(), scheme); + writeNameAndLabels(writer, name, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); if (exemplarsOnAllMetricTypesEnabled) { writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme); @@ -365,7 +361,7 @@ private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingSchem private void writeCountAndSum( Writer writer, - MetricMetadata metadata, + String name, DistributionDataPointSnapshot data, String countSuffix, String sumSuffix, @@ -373,8 +369,7 @@ private void writeCountAndSum( EscapingScheme scheme) throws IOException { if (data.hasCount()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), countSuffix, data.getLabels(), scheme); + writeNameAndLabels(writer, name, countSuffix, data.getLabels(), scheme); writeLong(writer, data.getCount()); if (exemplarsOnAllMetricTypesEnabled) { writeScrapeTimestampAndExemplar(writer, data, exemplars.getLatest(), scheme); @@ -383,19 +378,17 @@ private void writeCountAndSum( } } if (data.hasSum()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), sumSuffix, data.getLabels(), scheme); + writeNameAndLabels(writer, name, sumSuffix, data.getLabels(), scheme); writeDouble(writer, data.getSum()); writeScrapeTimestampAndExemplar(writer, data, null, scheme); } } private void writeCreated( - Writer writer, MetricMetadata metadata, DataPointSnapshot data, EscapingScheme scheme) + Writer writer, String name, DataPointSnapshot data, EscapingScheme scheme) throws IOException { if (createdTimestampsEnabled && data.hasCreatedTimestamp()) { - writeNameAndLabels( - writer, getMetadataName(metadata, scheme), "_created", data.getLabels(), scheme); + writeNameAndLabels(writer, name, "_created", data.getLabels(), scheme); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); if (data.hasScrapeTimestamp()) { writer.write(' '); @@ -466,27 +459,40 @@ private void writeScrapeTimestampAndExemplar( writer.write('\n'); } - private void writeMetadata( - Writer writer, String typeName, MetricMetadata metadata, EscapingScheme scheme) - throws IOException { + private void writeMetadataWithName( + Writer writer, String name, String typeName, MetricMetadata metadata) throws IOException { writer.write("# TYPE "); - writeName(writer, getMetadataName(metadata, scheme), NameType.Metric); + writeName(writer, name, NameType.Metric); writer.write(' '); writer.write(typeName); writer.write('\n'); if (metadata.getUnit() != null) { writer.write("# UNIT "); - writeName(writer, getMetadataName(metadata, scheme), NameType.Metric); + writeName(writer, name, NameType.Metric); writer.write(' '); writeEscapedString(writer, metadata.getUnit().toString()); writer.write('\n'); } if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) { writer.write("# HELP "); - writeName(writer, getMetadataName(metadata, scheme), NameType.Metric); + writeName(writer, name, NameType.Metric); writer.write(' '); writeEscapedString(writer, metadata.getHelp()); writer.write('\n'); } } + + private static String ensureSuffix(String name, String suffix) { + if (name.endsWith(suffix)) { + return name; + } + return name + suffix; + } + + private static String removeSuffix(String name, String suffix) { + if (name.endsWith(suffix)) { + return name.substring(0, name.length() - suffix.length()); + } + return name; + } } diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java index 25f3d9b3d..780f69708 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java @@ -87,7 +87,7 @@ void testGetOpenMetrics2Properties() { } @Test - void testOutputIdenticalToOM1ForCounter() throws IOException { + void testCounterNoTotalSuffix() throws IOException { MetricSnapshots snapshots = MetricSnapshots.of( CounterSnapshot.builder() @@ -101,10 +101,37 @@ void testOutputIdenticalToOM1ForCounter() throws IOException { .build()) .build()); - String om1Output = writeWithOM1(snapshots); String om2Output = writeWithOM2(snapshots); - assertThat(om2Output).isEqualTo(om1Output); + // OM2: name as provided, no _total appending + assertThat(om2Output) + .isEqualTo( + "# TYPE my_counter_seconds counter\n" + + "# UNIT my_counter_seconds seconds\n" + + "# HELP my_counter_seconds Test counter\n" + + "my_counter_seconds{method=\"GET\"} 42.0\n" + + "# EOF\n"); + } + + @Test + void testCounterWithTotalSuffix() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + CounterSnapshot.builder() + .name("requests_total") + .help("Total requests") + .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder().value(100.0).build()) + .build()); + + String om2Output = writeWithOM2(snapshots); + + // OM2: preserves _total if user provided it + assertThat(om2Output) + .isEqualTo( + "# TYPE requests_total counter\n" + + "# HELP requests_total Total requests\n" + + "requests_total 100.0\n" + + "# EOF\n"); } @Test @@ -220,7 +247,7 @@ void testOutputIdenticalToOM1ForStateSet() throws IOException { } @Test - void testOutputIdenticalToOM1WithExemplars() throws IOException { + void testCounterWithExemplars() throws IOException { Exemplar exemplar = Exemplar.builder() .value(100.0) @@ -241,14 +268,20 @@ void testOutputIdenticalToOM1WithExemplars() throws IOException { .build()) .build()); - String om1Output = writeWithOM1(snapshots); String om2Output = writeWithOM2(snapshots); - assertThat(om2Output).isEqualTo(om1Output); + // OM2: no _total, but exemplar is preserved + assertThat(om2Output) + .isEqualTo( + "# TYPE requests counter\n" + + "# HELP requests Total requests\n" + + "requests 1000.0 # {span_id=\"12345\",trace_id=\"abcde\"}" + + " 100.0 1672850685.829\n" + + "# EOF\n"); } @Test - void testOutputIdenticalToOM1WithCreatedTimestamps() throws IOException { + void testCounterWithCreatedTimestamps() throws IOException { MetricSnapshots snapshots = MetricSnapshots.of( CounterSnapshot.builder() @@ -261,16 +294,19 @@ void testOutputIdenticalToOM1WithCreatedTimestamps() throws IOException { .build()) .build()); - OpenMetricsTextFormatWriter om1Writer = - OpenMetricsTextFormatWriter.builder().setCreatedTimestampsEnabled(true).build(); - OpenMetrics2TextFormatWriter om2Writer = OpenMetrics2TextFormatWriter.builder().setCreatedTimestampsEnabled(true).build(); - String om1Output = write(snapshots, om1Writer); String om2Output = write(snapshots, om2Writer); - assertThat(om2Output).isEqualTo(om1Output); + // OM2: no _total, _created uses the counter name directly + assertThat(om2Output) + .isEqualTo( + "# TYPE my_counter counter\n" + + "# HELP my_counter Test counter\n" + + "my_counter 42.0\n" + + "my_counter_created 1672850385.800\n" + + "# EOF\n"); } @Test