Skip to content

Commit f794288

Browse files
authored
feat: add OTel preserve_names for scrape-time suffix handling (#1956)
## Summary Adds `preserve_names` configuration to the OpenTelemetry exporter. When enabled, metric names are passed through exactly as the user wrote them instead of stripping `_total` and unit suffixes. Part of #1942. ### Key changes - Add `preserve_names` to `ExporterOpenTelemetryProperties` - `MetricDataFactory` uses `originalName` + `preserve_names` to decide naming - `OtelAutoConfig` wires the new property ### Key table | User provides | OTel | OTel preserve_names | |---|---|---| | `Counter("events")` | `events` | `events` | | `Counter("events_total")` | `events` | `events_total` | | `Counter("req").unit(BYTES)` | name `req`, unit `By` | name `req`, unit `By` | | `Counter("req_bytes").unit(BYTES)` | name `req`, unit `By` | name `req_bytes`, unit `By` | | `Gauge("events_total")` | `events_total` | `events_total` | ### PR stack 1. Core model + OM1/protobuf writers (#1955) 2. **This PR** — OTel `preserve_names` 3. OM2 writer no-suffix (independent) ## Test plan - [x] `mise run compile` passes - [x] Tests for `preserve_names=true` with units, unit already in name, and without unit - [x] `OtelAutoConfigTest` covers new property wiring Part of #1912. --------- Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent 5a5106c commit f794288

File tree

8 files changed

+167
-18
lines changed

8 files changed

+167
-18
lines changed

prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterOpenTelemetryProperties.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class ExporterOpenTelemetryProperties {
4646
private static final String SERVICE_VERSION = "service_version";
4747
private static final String RESOURCE_ATTRIBUTES =
4848
"resource_attributes"; // otel.resource.attributes
49+
private static final String PRESERVE_NAMES = "preserve_names";
4950
private static final String PREFIX = "io.prometheus.exporter.opentelemetry";
5051

5152
@Nullable private final String endpoint;
@@ -58,6 +59,7 @@ public class ExporterOpenTelemetryProperties {
5859
@Nullable private final String serviceInstanceId;
5960
@Nullable private final String serviceVersion;
6061
private final Map<String, String> resourceAttributes;
62+
@Nullable private final Boolean preserveNames;
6163

6264
private ExporterOpenTelemetryProperties(
6365
@Nullable String protocol,
@@ -69,7 +71,8 @@ private ExporterOpenTelemetryProperties(
6971
@Nullable String serviceNamespace,
7072
@Nullable String serviceInstanceId,
7173
@Nullable String serviceVersion,
72-
Map<String, String> resourceAttributes) {
74+
Map<String, String> resourceAttributes,
75+
@Nullable Boolean preserveNames) {
7376
this.protocol = protocol;
7477
this.endpoint = endpoint;
7578
this.headers = headers;
@@ -80,6 +83,7 @@ private ExporterOpenTelemetryProperties(
8083
this.serviceInstanceId = serviceInstanceId;
8184
this.serviceVersion = serviceVersion;
8285
this.resourceAttributes = resourceAttributes;
86+
this.preserveNames = preserveNames;
8387
}
8488

8589
@Nullable
@@ -130,6 +134,16 @@ public Map<String, String> getResourceAttributes() {
130134
return resourceAttributes;
131135
}
132136

137+
/**
138+
* When {@code true}, metric names are preserved as-is (including suffixes like {@code _total}).
139+
* When {@code false} (default), standard OTel name normalization is applied (stripping unit
140+
* suffix).
141+
*/
142+
@Nullable
143+
public Boolean getPreserveNames() {
144+
return preserveNames;
145+
}
146+
133147
/**
134148
* Note that this will remove entries from {@code propertySource}. This is because we want to know
135149
* if there are unused properties remaining after all properties have been loaded.
@@ -147,6 +161,7 @@ static ExporterOpenTelemetryProperties load(PropertySource propertySource)
147161
String serviceVersion = Util.loadString(PREFIX, SERVICE_VERSION, propertySource);
148162
Map<String, String> resourceAttributes =
149163
Util.loadMap(PREFIX, RESOURCE_ATTRIBUTES, propertySource);
164+
Boolean preserveNames = Util.loadBoolean(PREFIX, PRESERVE_NAMES, propertySource);
150165
return new ExporterOpenTelemetryProperties(
151166
protocol,
152167
endpoint,
@@ -157,7 +172,8 @@ static ExporterOpenTelemetryProperties load(PropertySource propertySource)
157172
serviceNamespace,
158173
serviceInstanceId,
159174
serviceVersion,
160-
resourceAttributes);
175+
resourceAttributes,
176+
preserveNames);
161177
}
162178

163179
public static Builder builder() {
@@ -176,6 +192,7 @@ public static class Builder {
176192
@Nullable private String serviceInstanceId;
177193
@Nullable private String serviceVersion;
178194
private final Map<String, String> resourceAttributes = new HashMap<>();
195+
@Nullable private Boolean preserveNames;
179196

180197
private Builder() {}
181198

@@ -318,6 +335,15 @@ public Builder resourceAttribute(String name, String value) {
318335
return this;
319336
}
320337

338+
/**
339+
* When {@code true}, metric names are preserved as-is (including suffixes like {@code _total}).
340+
* When {@code false} (default), standard OTel name normalization is applied.
341+
*/
342+
public Builder preserveNames(boolean preserveNames) {
343+
this.preserveNames = preserveNames;
344+
return this;
345+
}
346+
321347
public ExporterOpenTelemetryProperties build() {
322348
return new ExporterOpenTelemetryProperties(
323349
protocol,
@@ -329,7 +355,8 @@ public ExporterOpenTelemetryProperties build() {
329355
serviceNamespace,
330356
serviceInstanceId,
331357
serviceVersion,
332-
resourceAttributes);
358+
resourceAttributes,
359+
preserveNames);
333360
}
334361
}
335362
}

prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OpenTelemetryExporter.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public static class Builder {
4141
@Nullable String serviceInstanceId;
4242
@Nullable String serviceVersion;
4343
final Map<String, String> resourceAttributes = new HashMap<>();
44+
@Nullable Boolean preserveNames;
4445

4546
private Builder(PrometheusProperties config) {
4647
this.config = config;
@@ -194,6 +195,15 @@ public Builder resourceAttribute(String name, String value) {
194195
return this;
195196
}
196197

198+
/**
199+
* When {@code true}, metric names are preserved as-is (including suffixes like {@code _total}).
200+
* When {@code false} (default), standard OTel name normalization is applied.
201+
*/
202+
public Builder preserveNames(boolean preserveNames) {
203+
this.preserveNames = preserveNames;
204+
return this;
205+
}
206+
197207
public OpenTelemetryExporter buildAndStart() {
198208
if (registry == null) {
199209
registry = PrometheusRegistry.defaultRegistry;

prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/OtelAutoConfig.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ static MetricReader createReader(
3838
instrumentationScopeInfo);
3939

4040
MetricReader reader = requireNonNull(readerRef.get());
41+
boolean preserveNames = resolvePreserveNames(builder, config);
4142
reader.register(
42-
new PrometheusMetricProducer(registry, instrumentationScopeInfo, getResourceField(sdk)));
43+
new PrometheusMetricProducer(
44+
registry, instrumentationScopeInfo, getResourceField(sdk), preserveNames));
4345
return reader;
4446
}
4547

@@ -107,6 +109,15 @@ private static Attributes otelResourceAttributes(
107109
return builder.build();
108110
}
109111

112+
static boolean resolvePreserveNames(
113+
OpenTelemetryExporter.Builder builder, PrometheusProperties config) {
114+
if (builder.preserveNames != null) {
115+
return builder.preserveNames;
116+
}
117+
Boolean fromConfig = config.getExporterOpenTelemetryProperties().getPreserveNames();
118+
return fromConfig != null && fromConfig;
119+
}
120+
110121
static Resource getResourceField(AutoConfiguredOpenTelemetrySdk sdk) {
111122
try {
112123
Method method = AutoConfiguredOpenTelemetrySdk.class.getDeclaredMethod("getResource");

prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/PrometheusMetricProducer.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,17 @@ class PrometheusMetricProducer implements CollectionRegistration {
2929
private final PrometheusRegistry registry;
3030
private final Resource resource;
3131
private final InstrumentationScopeInfo instrumentationScopeInfo;
32+
private final boolean preserveNames;
3233

3334
public PrometheusMetricProducer(
3435
PrometheusRegistry registry,
3536
InstrumentationScopeInfo instrumentationScopeInfo,
36-
Resource resource) {
37+
Resource resource,
38+
boolean preserveNames) {
3739
this.registry = registry;
3840
this.instrumentationScopeInfo = instrumentationScopeInfo;
3941
this.resource = resource;
42+
this.preserveNames = preserveNames;
4043
}
4144

4245
@Override
@@ -57,7 +60,8 @@ public Collection<MetricData> collectAllMetrics() {
5760
new MetricDataFactory(
5861
resourceWithTargetInfo,
5962
scopeFromInfo != null ? scopeFromInfo : instrumentationScopeInfo,
60-
System.currentTimeMillis());
63+
System.currentTimeMillis(),
64+
preserveNames);
6165
for (MetricSnapshot snapshot : snapshots) {
6266
if (snapshot instanceof CounterSnapshot) {
6367
addUnlessNull(result, factory.create((CounterSnapshot) snapshot));

prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/MetricDataFactory.java

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ public class MetricDataFactory {
1717
private final Resource resource;
1818
private final InstrumentationScopeInfo instrumentationScopeInfo;
1919
private final long currentTimeMillis;
20+
private final boolean preserveNames;
2021

2122
public MetricDataFactory(
2223
Resource resource,
2324
InstrumentationScopeInfo instrumentationScopeInfo,
24-
long currentTimeMillis) {
25+
long currentTimeMillis,
26+
boolean preserveNames) {
2527
this.resource = resource;
2628
this.instrumentationScopeInfo = instrumentationScopeInfo;
2729
this.currentTimeMillis = currentTimeMillis;
30+
this.preserveNames = preserveNames;
2831
}
2932

3033
@Nullable
@@ -36,7 +39,8 @@ public MetricData create(CounterSnapshot snapshot) {
3639
snapshot.getMetadata(),
3740
new PrometheusCounter(snapshot, currentTimeMillis),
3841
instrumentationScopeInfo,
39-
resource);
42+
resource,
43+
preserveNames);
4044
}
4145

4246
@Nullable
@@ -48,7 +52,8 @@ public MetricData create(GaugeSnapshot snapshot) {
4852
snapshot.getMetadata(),
4953
new PrometheusGauge(snapshot, currentTimeMillis),
5054
instrumentationScopeInfo,
51-
resource);
55+
resource,
56+
preserveNames);
5257
}
5358

5459
@Nullable
@@ -60,13 +65,15 @@ public MetricData create(HistogramSnapshot snapshot) {
6065
snapshot.getMetadata(),
6166
new PrometheusNativeHistogram(snapshot, currentTimeMillis),
6267
instrumentationScopeInfo,
63-
resource);
68+
resource,
69+
preserveNames);
6470
} else if (firstDataPoint.hasClassicHistogramData()) {
6571
return new PrometheusMetricData<>(
6672
snapshot.getMetadata(),
6773
new PrometheusClassicHistogram(snapshot, currentTimeMillis),
6874
instrumentationScopeInfo,
69-
resource);
75+
resource,
76+
preserveNames);
7077
}
7178
}
7279
return null;
@@ -81,7 +88,8 @@ public MetricData create(SummarySnapshot snapshot) {
8188
snapshot.getMetadata(),
8289
new PrometheusSummary(snapshot, currentTimeMillis),
8390
instrumentationScopeInfo,
84-
resource);
91+
resource,
92+
preserveNames);
8593
}
8694

8795
@Nullable
@@ -93,7 +101,8 @@ public MetricData create(InfoSnapshot snapshot) {
93101
snapshot.getMetadata(),
94102
new PrometheusInfo(snapshot, currentTimeMillis),
95103
instrumentationScopeInfo,
96-
resource);
104+
resource,
105+
preserveNames);
97106
}
98107

99108
@Nullable
@@ -105,7 +114,8 @@ public MetricData create(StateSetSnapshot snapshot) {
105114
snapshot.getMetadata(),
106115
new PrometheusStateSet(snapshot, currentTimeMillis),
107116
instrumentationScopeInfo,
108-
resource);
117+
resource,
118+
preserveNames);
109119
}
110120

111121
@Nullable
@@ -117,6 +127,7 @@ public MetricData create(UnknownSnapshot snapshot) {
117127
snapshot.getMetadata(),
118128
new PrometheusUnknown(snapshot, currentTimeMillis),
119129
instrumentationScopeInfo,
120-
resource);
130+
resource,
131+
preserveNames);
121132
}
122133
}

prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusMetricData.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ class PrometheusMetricData<T extends PrometheusData<?>> implements MetricData {
2323
MetricMetadata metricMetadata,
2424
T data,
2525
InstrumentationScopeInfo instrumentationScopeInfo,
26-
Resource resource) {
26+
Resource resource,
27+
boolean preserveNames) {
2728
this.instrumentationScopeInfo = instrumentationScopeInfo;
2829
this.resource = resource;
29-
this.name = getNameWithoutUnit(metricMetadata);
30+
this.name =
31+
preserveNames ? metricMetadata.getOriginalName() : getNameWithoutUnit(metricMetadata);
3032
this.description = metricMetadata.getHelp();
3133
this.unit = convertUnit(metricMetadata.getUnit());
3234
this.data = data;

prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import io.opentelemetry.sdk.resources.Resource;
1111
import io.opentelemetry.sdk.testing.assertj.MetricAssert;
1212
import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions;
13+
import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader;
1314
import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension;
1415
import io.prometheus.metrics.core.metrics.Counter;
1516
import io.prometheus.metrics.core.metrics.Gauge;
@@ -23,6 +24,7 @@
2324
import io.prometheus.metrics.model.snapshots.Unit;
2425
import io.prometheus.metrics.model.snapshots.UnknownSnapshot;
2526
import java.lang.reflect.Field;
27+
import java.util.ArrayList;
2628
import java.util.Collections;
2729
import java.util.List;
2830
import org.junit.jupiter.api.BeforeEach;
@@ -47,7 +49,8 @@ void setUp() throws IllegalAccessException, NoSuchFieldException {
4749
new PrometheusMetricProducer(
4850
registry,
4951
InstrumentationScopeInfo.create("test"),
50-
Resource.create(Attributes.builder().put("staticRes", "value").build()));
52+
Resource.create(Attributes.builder().put("staticRes", "value").build()),
53+
false);
5154

5255
reader.register(prometheusMetricProducer);
5356
}
@@ -324,6 +327,60 @@ void metricsWithoutDataPointsAreNotExported() {
324327
assertThat(metrics).isEmpty();
325328
}
326329

330+
@Test
331+
void preserveNamesWithUnit() {
332+
InMemoryMetricReader reader = InMemoryMetricReader.create();
333+
PrometheusRegistry preserveRegistry = new PrometheusRegistry();
334+
reader.register(
335+
new PrometheusMetricProducer(
336+
preserveRegistry,
337+
InstrumentationScopeInfo.create("test"),
338+
Resource.create(Attributes.builder().put("staticRes", "value").build()),
339+
true));
340+
341+
Counter.builder().name("req").unit(Unit.BYTES).register(preserveRegistry).inc();
342+
343+
List<MetricData> metrics = new ArrayList<>(reader.collectAllMetrics());
344+
assertThat(metrics).hasSize(1);
345+
OpenTelemetryAssertions.assertThat(metrics.get(0)).hasName("req").hasUnit("By");
346+
}
347+
348+
@Test
349+
void preserveNamesWithUnitAlreadyInName() {
350+
InMemoryMetricReader reader = InMemoryMetricReader.create();
351+
PrometheusRegistry preserveRegistry = new PrometheusRegistry();
352+
reader.register(
353+
new PrometheusMetricProducer(
354+
preserveRegistry,
355+
InstrumentationScopeInfo.create("test"),
356+
Resource.create(Attributes.builder().put("staticRes", "value").build()),
357+
true));
358+
359+
Counter.builder().name("req_bytes").unit(Unit.BYTES).register(preserveRegistry).inc();
360+
361+
List<MetricData> metrics = new ArrayList<>(reader.collectAllMetrics());
362+
assertThat(metrics).hasSize(1);
363+
OpenTelemetryAssertions.assertThat(metrics.get(0)).hasName("req_bytes").hasUnit("By");
364+
}
365+
366+
@Test
367+
void preserveNamesWithoutUnit() {
368+
InMemoryMetricReader reader = InMemoryMetricReader.create();
369+
PrometheusRegistry preserveRegistry = new PrometheusRegistry();
370+
reader.register(
371+
new PrometheusMetricProducer(
372+
preserveRegistry,
373+
InstrumentationScopeInfo.create("test"),
374+
Resource.create(Attributes.builder().put("staticRes", "value").build()),
375+
true));
376+
377+
Counter.builder().name("events_total").register(preserveRegistry).inc();
378+
379+
List<MetricData> metrics = new ArrayList<>(reader.collectAllMetrics());
380+
assertThat(metrics).hasSize(1);
381+
OpenTelemetryAssertions.assertThat(metrics.get(0)).hasName("events_total");
382+
}
383+
327384
private MetricAssert metricAssert() {
328385
List<MetricData> metrics = testing.getMetrics();
329386
assertThat(metrics).hasSize(1);

0 commit comments

Comments
 (0)