diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-model.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-model.txt index 25f90822c..f0ba4c4e8 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-model.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-model.txt @@ -1,2 +1,17 @@ Comparing source compatibility of prometheus-metrics-model-1.7.1-SNAPSHOT.jar against prometheus-metrics-model-1.7.0.jar -No changes. +*** MODIFIED CLASS: PUBLIC FINAL io.prometheus.metrics.model.snapshots.MetricMetadata (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + === UNCHANGED CONSTRUCTOR: PUBLIC MetricMetadata(java.lang.String, java.lang.String, java.lang.String, io.prometheus.metrics.model.snapshots.Unit) + +++ NEW ANNOTATION: java.lang.Deprecated + === UNCHANGED CONSTRUCTOR: PUBLIC MetricMetadata(java.lang.String, java.lang.String, java.lang.String, java.lang.String, io.prometheus.metrics.model.snapshots.Unit) + +++ NEW ANNOTATION: java.lang.Deprecated + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.model.snapshots.MetricMetadata$Builder builder() ++++ NEW CLASS: PUBLIC(+) STATIC(+) FINAL(+) io.prometheus.metrics.model.snapshots.MetricMetadata$Builder (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.model.snapshots.MetricMetadata build() + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.model.snapshots.MetricMetadata$Builder counterSuffix(boolean) + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.model.snapshots.MetricMetadata$Builder help(java.lang.String) + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.model.snapshots.MetricMetadata$Builder name(java.lang.String) + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.model.snapshots.MetricMetadata$Builder unit(io.prometheus.metrics.model.snapshots.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 9be797b0e..c639bf317 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 @@ -36,7 +36,13 @@ protected MetricWithFixedMetadata(Builder builder) { String originalName = builder.originalName; String expositionBaseName = makeExpositionBaseName(originalName, builder.unit); this.metadata = - new MetricMetadata(name, expositionBaseName, originalName, builder.help, builder.unit); + MetricMetadata.builder() + .name(name) + .expositionBaseName(expositionBaseName) + .originalName(originalName) + .help(builder.help) + .unit(builder.unit) + .build(); this.labelNames = Arrays.copyOf(builder.labelNames, builder.labelNames.length); } 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 3a44a2402..8fad68c85 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 @@ -5,7 +5,6 @@ import javax.annotation.Nullable; /** Immutable container for metric metadata: name, help, unit. */ -@StableApi public final class MetricMetadata { /** @@ -51,11 +50,13 @@ public final class MetricMetadata { @Nullable private final Unit unit; /** See {@link #MetricMetadata(String, String, Unit)} */ + @StableApi public MetricMetadata(String name) { this(name, null, null); } /** See {@link #MetricMetadata(String, String, Unit)} */ + @StableApi public MetricMetadata(String name, String help) { this(name, help, null); } @@ -69,10 +70,116 @@ public MetricMetadata(String name, String help) { * @param help optional. May be {@code null}. * @param unit optional. May be {@code null}. */ + @StableApi public MetricMetadata(String name, @Nullable String help, @Nullable Unit unit) { this(name, name, help, unit); } + /** + * Creates a builder for {@link MetricMetadata}. + * + *

Use the builder instead of the multi-arg constructors for cleaner, more readable code: + * + *

{@code
+   * MetricMetadata.builder()
+   *     .name("http_requests")
+   *     .help("Total HTTP requests")
+   *     .unit(Unit.BYTES)
+   *     .counterSuffix(true)
+   *     .build();
+   * }
+ */ + @StableApi + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link MetricMetadata}. */ + public static final class Builder { + @Nullable private String name; + @Nullable private String expositionBaseName; + @Nullable private String originalName; + @Nullable private String help; + @Nullable private Unit unit; + private boolean counterSuffix; + + private Builder() {} + + /** Required. The base metric name (without type suffix like {@code _total}). */ + @StableApi + public Builder name(String name) { + this.name = name; + if (originalName == null) { + this.originalName = name; + } + return this; + } + + /** + * Internal use only. Not part of the stable API. + * + *

Allows internal callers to preserve a separate exposition base name. + */ + public Builder expositionBaseName(String expositionBaseName) { + this.expositionBaseName = expositionBaseName; + return this; + } + + /** + * Internal use only. Not part of the stable API. + * + *

Allows internal callers to preserve the raw name before normalization. + */ + public Builder originalName(String originalName) { + this.originalName = originalName; + return this; + } + + /** Optional. Human-readable description of the metric. */ + @StableApi + public Builder help(@Nullable String help) { + this.help = help; + return this; + } + + /** Optional. The unit of measurement. Appended to the name if not already present. */ + @StableApi + public Builder unit(@Nullable Unit unit) { + this.unit = unit; + return this; + } + + /** + * Optional. When {@code true}, the writer appends {@code _total} to the exposition name. Use + * this for counter metrics, especially UTF-8 names where the writer cannot infer it from the + * snapshot type alone. + */ + @StableApi + public Builder counterSuffix(boolean counterSuffix) { + this.counterSuffix = counterSuffix; + return this; + } + + /** Builds the {@link MetricMetadata}. Throws if {@code name} was not set. */ + @StableApi + public MetricMetadata build() { + if (name == null) { + throw new IllegalArgumentException("name is required"); + } + String baseName = appendUnitIfMissing(name, unit); + String originalName = this.originalName == null ? name : this.originalName; + String expositionBaseName = + appendUnitIfMissing( + this.expositionBaseName == null ? baseName : this.expositionBaseName, unit); + if (counterSuffix + && !expositionBaseName.endsWith("_total") + && !expositionBaseName.endsWith(".total")) { + expositionBaseName = expositionBaseName + "_total"; + } + return new MetricMetadata(baseName, expositionBaseName, originalName, help, unit); + } + } + /** * Constructor with exposition base name. * @@ -82,7 +189,10 @@ public MetricMetadata(String name, @Nullable String help, @Nullable Unit unit) { * format writers for smart-append logic * @param help optional. May be {@code null}. * @param unit optional. May be {@code null}. + * @deprecated Use {@link #builder()} instead. */ + @StableApi + @Deprecated public MetricMetadata( String name, String expositionBaseName, @Nullable String help, @Nullable Unit unit) { this(name, expositionBaseName, expositionBaseName, help, unit); @@ -97,7 +207,10 @@ public MetricMetadata( * @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}. + * @deprecated Use {@link #builder()} instead. */ + @StableApi + @Deprecated public MetricMetadata( String name, String expositionBaseName, @@ -121,6 +234,7 @@ public MetricMetadata( *

The name may contain any Unicode chars. Use {@link #getPrometheusName()} to get the name in * legacy Prometheus format, i.e. with all dots and all invalid chars replaced by underscores. */ + @StableApi public String getName() { return name; } @@ -130,6 +244,7 @@ public String getName() { * *

This is used by Prometheus exposition formats. */ + @StableApi public String getPrometheusName() { return prometheusName; } @@ -139,6 +254,7 @@ public String getPrometheusName() { * called {@code Counter.builder().name("req").unit(BYTES)}, this returns "req" while {@link * #getName()} returns "req_bytes" and {@link #getExpositionBaseName()} returns "req_bytes". */ + @StableApi public String getOriginalName() { return originalName; } @@ -148,6 +264,7 @@ public String getOriginalName() { * if the user called {@code Counter.builder().name("events_total")}, this returns "events_total" * while {@link #getName()} returns "events". */ + @StableApi public String getExpositionBaseName() { return expositionBaseName; } @@ -156,24 +273,35 @@ public String getExpositionBaseName() { * Same as {@link #getExpositionBaseName()} but with all invalid characters and dots replaced by * underscores. */ + @StableApi public String getExpositionBasePrometheusName() { return expositionBasePrometheusName; } + @StableApi @Nullable public String getHelp() { return help; } + @StableApi public boolean hasUnit() { return unit != null; } + @StableApi @Nullable public Unit getUnit() { return unit; } + private static String appendUnitIfMissing(String name, @Nullable Unit unit) { + if (unit != null && !name.endsWith("_" + unit) && !name.endsWith("." + unit)) { + return name + "_" + unit; + } + return name; + } + private void validate() { if (name == null) { throw new IllegalArgumentException("Missing required field: name is null"); @@ -206,11 +334,12 @@ private void validate() { } MetricMetadata escape(EscapingScheme escapingScheme) { - return new MetricMetadata( - PrometheusNaming.escapeName(name, escapingScheme), - PrometheusNaming.escapeName(expositionBaseName, escapingScheme), - PrometheusNaming.escapeName(originalName, escapingScheme), - help, - unit); + return MetricMetadata.builder() + .name(PrometheusNaming.escapeName(name, escapingScheme)) + .expositionBaseName(PrometheusNaming.escapeName(expositionBaseName, escapingScheme)) + .originalName(PrometheusNaming.escapeName(originalName, escapingScheme)) + .help(help) + .unit(unit) + .build(); } } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java index 7fa0df6f0..978f56d43 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java @@ -25,12 +25,13 @@ private static MetricMetadata typedMetadata( String suffix, String dotSuffix) { String baseName = stripSuffix(originalName, suffix, dotSuffix); - return new MetricMetadata( - appendUnitIfMissing(baseName, unit), - appendUnitIfMissing(originalName, unit), - originalName, - help, - unit); + return MetricMetadata.builder() + .name(appendUnitIfMissing(baseName, unit)) + .expositionBaseName(appendUnitIfMissing(originalName, unit)) + .originalName(originalName) + .help(help) + .unit(unit) + .build(); } private static String appendUnitIfMissing(String name, @Nullable Unit unit) { 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 5781eb146..3d83887d5 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 @@ -83,6 +83,7 @@ void testUnitNotDuplicated() { assertThat(sanitizeMetricName("my_counter_bytes", Unit.BYTES)).isEqualTo("my_counter_bytes"); } + @SuppressWarnings("deprecation") @Test void testFiveArgConstructor() { MetricMetadata metadata = @@ -94,6 +95,7 @@ void testFiveArgConstructor() { assertThat(metadata.getUnit()).isEqualTo(Unit.BYTES); } + @SuppressWarnings("deprecation") @Test void testFourArgConstructorDefaultsOriginalName() { MetricMetadata metadata = new MetricMetadata("req_bytes", "req_bytes", "help", Unit.BYTES); @@ -107,4 +109,86 @@ void testThreeArgConstructorDefaultsOriginalName() { assertThat(metadata.getOriginalName()).isEqualTo("req_bytes"); assertThat(metadata.getExpositionBaseName()).isEqualTo("req_bytes"); } + + @Test + void builder_noUnit() { + MetricMetadata m = MetricMetadata.builder().name("requests").help("total requests").build(); + assertThat(m.getName()).isEqualTo("requests"); + assertThat(m.getExpositionBaseName()).isEqualTo("requests"); + assertThat(m.getOriginalName()).isEqualTo("requests"); + assertThat(m.getHelp()).isEqualTo("total requests"); + assertThat(m.getUnit()).isNull(); + } + + @Test + void builder_unitAppendedWhenAbsent() { + MetricMetadata m = MetricMetadata.builder().name("requests").unit(Unit.BYTES).build(); + assertThat(m.getName()).isEqualTo("requests_bytes"); + assertThat(m.getExpositionBaseName()).isEqualTo("requests_bytes"); + assertThat(m.getOriginalName()).isEqualTo("requests"); + } + + @Test + void builder_unitNotDuplicatedWhenPresent() { + MetricMetadata m = MetricMetadata.builder().name("requests_bytes").unit(Unit.BYTES).build(); + assertThat(m.getName()).isEqualTo("requests_bytes"); + assertThat(m.getExpositionBaseName()).isEqualTo("requests_bytes"); + assertThat(m.getOriginalName()).isEqualTo("requests_bytes"); + } + + @Test + void builder_counterSuffixAppended() { + MetricMetadata m = MetricMetadata.builder().name("requests").counterSuffix(true).build(); + assertThat(m.getName()).isEqualTo("requests"); + assertThat(m.getExpositionBaseName()).isEqualTo("requests_total"); + assertThat(m.getOriginalName()).isEqualTo("requests"); + } + + @Test + void builder_counterSuffixAndUnit() { + MetricMetadata m = + MetricMetadata.builder().name("requests").unit(Unit.BYTES).counterSuffix(true).build(); + assertThat(m.getName()).isEqualTo("requests_bytes"); + assertThat(m.getExpositionBaseName()).isEqualTo("requests_bytes_total"); + assertThat(m.getOriginalName()).isEqualTo("requests"); + } + + @Test + void builder_utf8NameWithCounterSuffix() { + MetricMetadata m = MetricMetadata.builder().name("my.requests").counterSuffix(true).build(); + assertThat(m.getName()).isEqualTo("my.requests"); + assertThat(m.getExpositionBaseName()).isEqualTo("my.requests_total"); + assertThat(m.getPrometheusName()).isEqualTo("my_requests"); + assertThat(m.getExpositionBasePrometheusName()).isEqualTo("my_requests_total"); + } + + @Test + void builder_customOriginalAndExpositionBaseName() { + MetricMetadata m = + MetricMetadata.builder() + .name("requests_bytes") + .expositionBaseName("requests_total_bytes") + .originalName("requests_total") + .help("help") + .unit(Unit.BYTES) + .build(); + assertThat(m.getName()).isEqualTo("requests_bytes"); + assertThat(m.getExpositionBaseName()).isEqualTo("requests_total_bytes"); + assertThat(m.getOriginalName()).isEqualTo("requests_total"); + assertThat(m.getHelp()).isEqualTo("help"); + assertThat(m.getUnit()).isEqualTo(Unit.BYTES); + } + + @Test + void builder_nonCounterExpositionBaseEqualsName() { + MetricMetadata m = MetricMetadata.builder().name("active_connections").build(); + assertThat(m.getExpositionBaseName()).isEqualTo(m.getName()); + } + + @Test + void builder_nameRequired() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> MetricMetadata.builder().help("help").build()) + .withMessage("name is required"); + } }