Skip to content

Commit b1aa8b3

Browse files
feat: preserve metric suffixes in OpenMetrics 2.0 (#2529)
## Summary Add an opt-in OpenMetrics 2.0 compatibility setting for applications migrating from the existing client_java naming behavior. - Add `io.prometheus.openmetrics2.suffixes`. - Keep `suffixes=false` as the default so OM2 preserves names exactly as registered. - With `suffixes=true`, append missing unit suffixes and `_total` for counters. - Avoid duplicating suffixes already present in metric names. - Apply the selected naming behavior consistently to metadata and samples. - Document the compatibility setting and migration path. Fixes #2518. ## Why OpenMetrics 2.0 uses a "name in, name out" model. For example: ```java Counter.builder() .name("requests") .unit(Unit.BYTES) .build(); ``` OM2 exposes this as `requests` by default. Existing client_java applications may rely on the OM1-style exposition name `requests_bytes_total`, so switching formats can otherwise rename series and affect queries, alerts, recording rules, and dashboards. Applications that need a gradual migration can temporarily enable compatibility naming: ```properties io.prometheus.openmetrics2.suffixes=true ``` This exposes `requests_bytes_total` while producers and downstream consumers migrate. Once registrations use the intended names explicitly, compatibility mode can be disabled without changing the exposed series. ## Implementation When compatibility naming is enabled, the writer reuses `MetricMetadata.expositionBaseName`, which contains the normalized unit suffix and preserves explicitly provided suffixes. Counter names receive `_total` only when it is missing. The existing OpenMetrics 1.0 summary-writing flow is reused with the selected metric name so summary metadata and samples follow the same configuration. ## Validation All current GitHub Actions checks pass, including build, lint, API diff, Java compatibility, integration, native, and acceptance tests. --------- Signed-off-by: Arnab Nandy <arnab_nandy7@yahoo.com> Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com> Co-authored-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent daa9f3a commit b1aa8b3

12 files changed

Lines changed: 164 additions & 51 deletions

File tree

‎docs/apidiffs/current_vs_latest/prometheus-metrics-config.txt‎

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/content/config/config.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,11 @@ This works for all Metrics properties.
146146
| io.prometheus.openmetrics2.composite_values | [OpenMetrics2Properties.getCompositeValues()](</client_java/api/io/prometheus/metrics/config/OpenMetrics2Properties.html#getCompositeValues()>) | (1) |
147147
| io.prometheus.openmetrics2.exemplar_compliance | [OpenMetrics2Properties.getExemplarCompliance()](</client_java/api/io/prometheus/metrics/config/OpenMetrics2Properties.html#getExemplarCompliance()>) | (1) |
148148
| io.prometheus.openmetrics2.native_histograms | [OpenMetrics2Properties.getNativeHistograms()](</client_java/api/io/prometheus/metrics/config/OpenMetrics2Properties.html#getNativeHistograms()>) | (1) |
149+
| io.prometheus.openmetrics2.suffixes | [OpenMetrics2Properties.getSuffixes()](</client_java/api/io/prometheus/metrics/config/OpenMetrics2Properties.html#getSuffixes()>) | (1) |
149150

150151
(1) Boolean value, `true` or `false`. `enabled=true` switches OpenMetrics responses to the OM2
151-
writer, preserving metric names as written by the application. The other OM2 properties remain
152-
opt-in. All OpenMetrics 2.0 flags are experimental and default to `false`.
152+
writer. All OM2 properties default to `false`; set `suffixes=true` to preserve OM1 series names
153+
during migration. All OpenMetrics 2.0 flags are experimental.
153154

154155
## Exporter Filter Properties
155156

‎docs/content/exporters/openmetrics2.md‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ only need to configure the sub-flags you want.
3535
With `enabled=true` alone:
3636

3737
- OpenMetrics requests use the OM2 writer.
38-
- Metric names are preserved as written by the application.
38+
- Registered metric names are preserved; the required `_info` suffix is still emitted for info
39+
metrics.
3940
- Optional OM2 features such as `composite_values`, `exemplar_compliance`, and
4041
`native_histograms` remain off.
4142

@@ -56,37 +57,43 @@ PrometheusProperties properties = PrometheusProperties.builder()
5657

5758
## Naming Behavior
5859

59-
OpenMetrics 2.0 removes OM1 suffix rewriting.
60+
By default, the OpenMetrics 2.0 writer preserves registered metric names:
6061

61-
- Counters do not get `_total` appended automatically.
62-
- Units do not get appended automatically.
63-
- Info metrics still end in `_info` because that is required by the spec.
62+
- Counter and unit suffixes are not appended.
63+
- Info metrics end in `_info` because that is required by the spec.
64+
65+
Set `suffixes=true` to append missing counter and unit suffixes. Existing suffixes are not
66+
duplicated.
6467

6568
Examples:
6669

67-
| Metric builder input | OM1 output | OM2 output |
68-
| ---------------------------------- | ----------------- | -------------- |
69-
| `Counter("events")` | `events_total` | `events` |
70-
| `Counter("events_total")` | `events_total` | `events_total` |
71-
| `Counter("req").unit(BYTES)` | `req_bytes_total` | `req` |
72-
| `Counter("req_bytes").unit(BYTES)` | `req_bytes_total` | `req_bytes` |
73-
| `Info("target")` | `target_info` | `target_info` |
70+
| Metric builder input | OM1 output | Default OM2 output |
71+
| ---------------------------------- | ----------------- | ------------------ |
72+
| `Counter("events")` | `events_total` | `events` |
73+
| `Counter("events_total")` | `events_total` | `events_total` |
74+
| `Counter("req").unit(BYTES)` | `req_bytes_total` | `req` |
75+
| `Counter("req_bytes").unit(BYTES)` | `req_bytes_total` | `req_bytes` |
76+
| `Info("target")` | `target_info` | `target_info` |
77+
78+
To retain the previous OM1-compatible suffix behavior during migration, set:
7479

75-
This means OpenMetrics 2.0 does not apply OM1 suffix behavior such as appending `_total` or unit
76-
suffixes, while the legacy OpenMetrics 1.0 and Prometheus text formats keep that existing suffix
77-
behavior.
80+
```properties
81+
io.prometheus.openmetrics2.suffixes=true
82+
```
7883

7984
## Feature Flags
8085

81-
All OpenMetrics 2.0 flags default to `false`.
86+
OpenMetrics 2.0 feature flags default to `false`. Set `suffixes=true` to preserve OM1 series names
87+
while migrating.
8288

8389
| Property | Effect |
8490
| ------------------------------------------------ | -------------------------------------------------------------------------------------- |
85-
| `io.prometheus.openmetrics2.enabled` | Metric names are preserved as written by the application. |
91+
| `io.prometheus.openmetrics2.enabled` | Enable the OpenMetrics 2.0 writer. |
8692
| `io.prometheus.openmetrics2.content_negotiation` | Apply OM2 behavior only when the scraper requests `version=2.0.0`. |
8793
| `io.prometheus.openmetrics2.composite_values` | Emit histograms, summaries, and gauge histograms as single composite lines with `st@`. |
8894
| `io.prometheus.openmetrics2.exemplar_compliance` | Emit only OM2-compliant exemplars with timestamps. |
8995
| `io.prometheus.openmetrics2.native_histograms` | Emit OM2 native histogram text fields. |
96+
| `io.prometheus.openmetrics2.suffixes` | Append counter and unit suffixes to preserve OM1 series names (opt-in). |
9097

9198
Enable all flags at once:
9299

@@ -104,6 +111,7 @@ io.prometheus.openmetrics2.content_negotiation=true
104111
io.prometheus.openmetrics2.composite_values=true
105112
io.prometheus.openmetrics2.exemplar_compliance=true
106113
io.prometheus.openmetrics2.native_histograms=true
114+
io.prometheus.openmetrics2.suffixes=true
107115
```
108116

109117
## Content Negotiation

‎docs/content/getting-started/metric-types.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ For the default OpenMetrics 1.0 and Prometheus text formats, counters are expose
4141
`_total` suffix. You can name a counter either `service_time_seconds` or
4242
`service_time_seconds_total`; the exposed name will be `service_time_seconds_total` in both cases.
4343

44-
The experimental OpenMetrics 2.0 writer behaves differently: It preserves metric names instead of
45-
appending `_total` or unit suffixes automatically. In OpenMetrics 2.0, `_total` is recommended for
46-
counters, but not enforced by the Java client.
44+
The experimental OpenMetrics 2.0 writer preserves registered metric names by default. Set
45+
`io.prometheus.openmetrics2.suffixes=true` to append `_total` and unit suffixes for compatibility
46+
when migrating from OpenMetrics 1.0.
4747

4848
## Gauge
4949

‎prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/OpenMetrics2Properties.java‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,28 @@ public class OpenMetrics2Properties {
1616
private static final String COMPOSITE_VALUES = "composite_values";
1717
private static final String EXEMPLAR_COMPLIANCE = "exemplar_compliance";
1818
private static final String NATIVE_HISTOGRAMS = "native_histograms";
19+
private static final String SUFFIXES = "suffixes";
1920

2021
@Nullable private final Boolean enabled;
2122
@Nullable private final Boolean contentNegotiation;
2223
@Nullable private final Boolean compositeValues;
2324
@Nullable private final Boolean exemplarCompliance;
2425
@Nullable private final Boolean nativeHistograms;
26+
@Nullable private final Boolean suffixes;
2527

2628
private OpenMetrics2Properties(
2729
@Nullable Boolean enabled,
2830
@Nullable Boolean contentNegotiation,
2931
@Nullable Boolean compositeValues,
3032
@Nullable Boolean exemplarCompliance,
31-
@Nullable Boolean nativeHistograms) {
33+
@Nullable Boolean nativeHistograms,
34+
@Nullable Boolean suffixes) {
3235
this.enabled = enabled;
3336
this.contentNegotiation = contentNegotiation;
3437
this.compositeValues = compositeValues;
3538
this.exemplarCompliance = exemplarCompliance;
3639
this.nativeHistograms = nativeHistograms;
40+
this.suffixes = suffixes;
3741
}
3842

3943
/**
@@ -64,6 +68,11 @@ public boolean getNativeHistograms() {
6468
return nativeHistograms != null && nativeHistograms;
6569
}
6670

71+
/** Append unit and type suffixes to metric names. Default is {@code false}. */
72+
public boolean getSuffixes() {
73+
return suffixes != null && suffixes;
74+
}
75+
6776
/**
6877
* Note that this will remove entries from {@code propertySource}. This is because we want to know
6978
* if there are unused properties remaining after all properties have been loaded.
@@ -75,8 +84,14 @@ static OpenMetrics2Properties load(PropertySource propertySource)
7584
Boolean compositeValues = Util.loadBoolean(PREFIX, COMPOSITE_VALUES, propertySource);
7685
Boolean exemplarCompliance = Util.loadBoolean(PREFIX, EXEMPLAR_COMPLIANCE, propertySource);
7786
Boolean nativeHistograms = Util.loadBoolean(PREFIX, NATIVE_HISTOGRAMS, propertySource);
87+
Boolean suffixes = Util.loadBoolean(PREFIX, SUFFIXES, propertySource);
7888
return new OpenMetrics2Properties(
79-
enabled, contentNegotiation, compositeValues, exemplarCompliance, nativeHistograms);
89+
enabled,
90+
contentNegotiation,
91+
compositeValues,
92+
exemplarCompliance,
93+
nativeHistograms,
94+
suffixes);
8095
}
8196

8297
public static Builder builder() {
@@ -90,6 +105,7 @@ public static class Builder {
90105
@Nullable private Boolean compositeValues;
91106
@Nullable private Boolean exemplarCompliance;
92107
@Nullable private Boolean nativeHistograms;
108+
@Nullable private Boolean suffixes;
93109

94110
private Builder() {}
95111

@@ -123,6 +139,12 @@ public Builder nativeHistograms(boolean nativeHistograms) {
123139
return this;
124140
}
125141

142+
/** See {@link #getSuffixes()} */
143+
public Builder suffixes(boolean suffixes) {
144+
this.suffixes = suffixes;
145+
return this;
146+
}
147+
126148
/** Enable all OpenMetrics 2.0 features */
127149
public Builder enableAll() {
128150
this.enabled = true;
@@ -135,7 +157,12 @@ public Builder enableAll() {
135157

136158
public OpenMetrics2Properties build() {
137159
return new OpenMetrics2Properties(
138-
enabled, contentNegotiation, compositeValues, exemplarCompliance, nativeHistograms);
160+
enabled,
161+
contentNegotiation,
162+
compositeValues,
163+
exemplarCompliance,
164+
nativeHistograms,
165+
suffixes);
139166
}
140167
}
141168
}

‎prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/OpenMetrics2PropertiesTest.java‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ void load() {
2424
"io.prometheus.openmetrics2.exemplar_compliance",
2525
"true",
2626
"io.prometheus.openmetrics2.native_histograms",
27-
"true")));
27+
"true",
28+
"io.prometheus.openmetrics2.suffixes",
29+
"false")));
2830
assertThat(properties.getEnabled()).isTrue();
2931
assertThat(properties.getContentNegotiation()).isTrue();
3032
assertThat(properties.getCompositeValues()).isTrue();
3133
assertThat(properties.getExemplarCompliance()).isTrue();
3234
assertThat(properties.getNativeHistograms()).isTrue();
35+
assertThat(properties.getSuffixes()).isFalse();
3336
}
3437

3538
@Test
@@ -68,6 +71,10 @@ void loadInvalidValue() {
6871
new HashMap<>(
6972
Map.of("io.prometheus.openmetrics2.native_histograms", "invalid"))))
7073
.withMessage("io.prometheus.openmetrics2.native_histograms: Expecting 'true' or 'false'.");
74+
assertThatExceptionOfType(PrometheusPropertiesException.class)
75+
.isThrownBy(
76+
() -> load(new HashMap<>(Map.of("io.prometheus.openmetrics2.suffixes", "invalid"))))
77+
.withMessage("io.prometheus.openmetrics2.suffixes: Expecting 'true' or 'false'.");
7178
}
7279

7380
private static OpenMetrics2Properties load(Map<String, String> map) {
@@ -85,12 +92,14 @@ void builder() {
8592
.compositeValues(false)
8693
.exemplarCompliance(true)
8794
.nativeHistograms(false)
95+
.suffixes(false)
8896
.build();
8997
assertThat(properties.getEnabled()).isTrue();
9098
assertThat(properties.getContentNegotiation()).isTrue();
9199
assertThat(properties.getCompositeValues()).isFalse();
92100
assertThat(properties.getExemplarCompliance()).isTrue();
93101
assertThat(properties.getNativeHistograms()).isFalse();
102+
assertThat(properties.getSuffixes()).isFalse();
94103
}
95104

96105
@Test
@@ -101,6 +110,7 @@ void builderEnableAll() {
101110
assertThat(properties.getCompositeValues()).isTrue();
102111
assertThat(properties.getExemplarCompliance()).isTrue();
103112
assertThat(properties.getNativeHistograms()).isTrue();
113+
assertThat(properties.getSuffixes()).isFalse();
104114
}
105115

106116
@Test
@@ -111,6 +121,7 @@ void defaultValues() {
111121
assertThat(properties.getCompositeValues()).isFalse();
112122
assertThat(properties.getExemplarCompliance()).isFalse();
113123
assertThat(properties.getNativeHistograms()).isFalse();
124+
assertThat(properties.getSuffixes()).isFalse();
114125
}
115126

116127
@Test
@@ -121,5 +132,6 @@ void partialConfiguration() {
121132
assertThat(properties.getCompositeValues()).isTrue();
122133
assertThat(properties.getExemplarCompliance()).isFalse();
123134
assertThat(properties.getNativeHistograms()).isFalse();
135+
assertThat(properties.getSuffixes()).isFalse();
124136
}
125137
}

‎prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,12 @@ void testOpenMetrics2PropertiesLoading() {
181181
properties.put("io.prometheus.openmetrics2.composite_values", "false");
182182
properties.put("io.prometheus.openmetrics2.exemplar_compliance", "true");
183183
properties.put("io.prometheus.openmetrics2.native_histograms", "false");
184+
properties.put("io.prometheus.openmetrics2.suffixes", "false");
184185
PrometheusProperties config = PrometheusPropertiesLoader.load(properties);
185186
assertThat(config.getOpenMetrics2Properties().getContentNegotiation()).isTrue();
186187
assertThat(config.getOpenMetrics2Properties().getCompositeValues()).isFalse();
187188
assertThat(config.getOpenMetrics2Properties().getExemplarCompliance()).isTrue();
188189
assertThat(config.getOpenMetrics2Properties().getNativeHistograms()).isFalse();
190+
assertThat(config.getOpenMetrics2Properties().getSuffixes()).isFalse();
189191
}
190192
}

‎prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/OpenMetrics2TextFormatWriterTest.java‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,38 @@
1616

1717
class OpenMetrics2TextFormatWriterTest {
1818

19+
@Test
20+
void preservesNamesByDefault() throws IOException {
21+
Counter counter = Counter.builder().name("requests").unit(Unit.BYTES).build();
22+
counter.inc();
23+
24+
String output =
25+
writeWithWriter(
26+
MetricSnapshots.of(counter.collect()), OpenMetrics2TextFormatWriter.create());
27+
28+
assertThat(output)
29+
.contains("# TYPE requests counter\n")
30+
.contains("# UNIT requests bytes\n")
31+
.containsPattern("(?m)^requests 1\\.0 st@\\d+\\.\\d{3}$");
32+
}
33+
34+
@Test
35+
void suffixesCanBeEnabledForOm1Compatibility() throws IOException {
36+
Counter counter = Counter.builder().name("requests").unit(Unit.BYTES).build();
37+
counter.inc();
38+
39+
OpenMetrics2TextFormatWriter writer =
40+
OpenMetrics2TextFormatWriter.builder()
41+
.setOpenMetrics2Properties(OpenMetrics2Properties.builder().suffixes(true).build())
42+
.build();
43+
String output = writeWithWriter(MetricSnapshots.of(counter.collect()), writer);
44+
45+
assertThat(output)
46+
.contains("# TYPE requests_bytes_total counter\n")
47+
.contains("# UNIT requests_bytes_total bytes\n")
48+
.containsPattern("(?m)^requests_bytes_total 1\\.0 st@\\d+\\.\\d{3}$");
49+
}
50+
1951
@Test
2052
void counterPreservesOriginalNameWhenUnitIsConfigured() throws IOException {
2153
Counter counter =
@@ -93,23 +125,27 @@ void nativeHistogramPreservesOriginalNameWhenUnitIsConfigured() throws IOExcepti
93125
}
94126

95127
private String writeWithOM1(MetricSnapshots snapshots) throws IOException {
96-
return write(snapshots, OpenMetricsTextFormatWriter.create());
128+
return writeWithWriter(snapshots, OpenMetricsTextFormatWriter.create());
97129
}
98130

99131
private String writeWithOM2(MetricSnapshots snapshots) throws IOException {
100-
return write(snapshots, OpenMetrics2TextFormatWriter.create());
132+
OpenMetrics2TextFormatWriter writer =
133+
OpenMetrics2TextFormatWriter.builder()
134+
.setOpenMetrics2Properties(OpenMetrics2Properties.builder().suffixes(false).build())
135+
.build();
136+
return writeWithWriter(snapshots, writer);
101137
}
102138

103139
private String writeWithNativeHistograms(MetricSnapshots snapshots) throws IOException {
104140
OpenMetrics2TextFormatWriter writer =
105141
OpenMetrics2TextFormatWriter.builder()
106142
.setOpenMetrics2Properties(
107-
OpenMetrics2Properties.builder().nativeHistograms(true).build())
143+
OpenMetrics2Properties.builder().nativeHistograms(true).suffixes(false).build())
108144
.build();
109-
return write(snapshots, writer);
145+
return writeWithWriter(snapshots, writer);
110146
}
111147

112-
private String write(MetricSnapshots snapshots, ExpositionFormatWriter writer)
148+
private String writeWithWriter(MetricSnapshots snapshots, ExpositionFormatWriter writer)
113149
throws IOException {
114150
ByteArrayOutputStream out = new ByteArrayOutputStream();
115151
writer.write(out, snapshots, EscapingScheme.ALLOW_UTF8);

‎prometheus-metrics-exporter-common/src/test/java/io/prometheus/metrics/exporter/common/PrometheusScrapeHandlerTest.java‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,14 @@ void testRejectsTooLongQueryString() throws IOException {
196196
}
197197

198198
@Test
199-
void testOpenMetrics2DoesNotEscapeUtf8NamesByDefault() throws IOException {
199+
void testOpenMetrics2PreservesNamesAndDoesNotEscapeUtf8ByDefault() throws IOException {
200200
for (boolean contentNegotiation : new boolean[] {true, false}) {
201201
String body =
202202
scrapeUtf8Counter(contentNegotiation, "application/openmetrics-text;version=2.0.0");
203-
assertThat(body).contains("\"my.counter\"").doesNotContain("my_counter");
203+
assertThat(body)
204+
.contains("\"my.counter\"")
205+
.doesNotContain("my.counter_total")
206+
.doesNotContain("my_counter");
204207
}
205208
}
206209

0 commit comments

Comments
 (0)