Skip to content

Commit 5ba3c39

Browse files
tjquinnobarchetta
authored andcommitted
Improve Prometheus output performance (helidon-io#10850)
1 parent 6647972 commit 5ba3c39

File tree

3 files changed

+228
-33
lines changed

3 files changed

+228
-33
lines changed

metrics/providers/micrometer/src/main/java/io/helidon/metrics/providers/micrometer/MicrometerPrometheusFormatter.java

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import java.util.Objects;
2121
import java.util.Optional;
2222
import java.util.Set;
23-
import java.util.function.Predicate;
23+
import java.util.regex.Pattern;
2424

2525
import io.helidon.common.media.type.MediaType;
2626
import io.helidon.common.media.type.MediaTypes;
@@ -49,16 +49,34 @@ public class MicrometerPrometheusFormatter implements MeterRegistryFormatter {
4949
public static final Map<MediaType, String> MEDIA_TYPE_TO_FORMAT = Map.of(
5050
MediaTypes.TEXT_PLAIN, TextFormat.CONTENT_TYPE_004,
5151
MediaTypes.APPLICATION_OPENMETRICS_TEXT, TextFormat.CONTENT_TYPE_OPENMETRICS_100);
52+
53+
private static final Pattern SPECIAL_CHARACTERS_MAPPED_TO_UNDERSCORE_PATTERN = Pattern.compile("[-+.!?@#$%^&*`'\\s]+");
54+
private static final Pattern NON_DIGIT_OR_UNDERSCORE_PREFIX_PATTERN = Pattern.compile("^[0-9_]+.*");
55+
private static final Pattern NON_IDENTIFIER_PATTERN = Pattern.compile("[^A-Za-z0-9_:]");
56+
5257
private final String scopeTagName;
53-
private final Iterable<String> scopeSelection;
54-
private final Iterable<String> meterNameSelection;
58+
private final Set<String> scopes;
59+
private final Set<String> meterNames;
5560
private final MediaType resultMediaType;
5661
private final MeterRegistry meterRegistry;
5762

5863
private MicrometerPrometheusFormatter(Builder builder) {
5964
scopeTagName = builder.scopeTagName;
60-
scopeSelection = builder.scopeSelection;
61-
meterNameSelection = builder.meterNameSelection;
65+
meterNames = (builder.meterNameSelection instanceof Set<String> namesSet)
66+
? namesSet
67+
: new HashSet<>() {
68+
{
69+
builder.meterNameSelection.forEach(this::add);
70+
}
71+
};
72+
73+
scopes = (builder.scopeSelection instanceof Set<String> scopesSet)
74+
? scopesSet
75+
: new HashSet<>() {
76+
{
77+
builder.scopeSelection.forEach(this::add);
78+
}
79+
};
6280
resultMediaType = builder.resultMediaType;
6381
meterRegistry = Objects.requireNonNullElseGet(builder.meterRegistry,
6482
io.helidon.metrics.api.Metrics::globalRegistry);
@@ -84,15 +102,15 @@ public static String normalizeNameToPrometheus(String name) {
84102
String result = name;
85103

86104
// Convert special characters to underscores.
87-
result = result.replaceAll("[-+.!?@#$%^&*`'\\s]+", "_");
105+
result = SPECIAL_CHARACTERS_MAPPED_TO_UNDERSCORE_PATTERN.matcher(result).replaceAll("_");
88106

89107
// Prometheus simple client adds the prefix "m_" if a meter name starts with a digit or an underscore.
90-
if (result.matches("^[0-9_]+.*")) {
108+
if (NON_DIGIT_OR_UNDERSCORE_PREFIX_PATTERN.matcher(result).matches()) {
91109
result = "m_" + result;
92110
}
93111

94112
// Replace non-identifier characters.
95-
result = result.replaceAll("[^A-Za-z0-9_:]", "_");
113+
result = NON_IDENTIFIER_PATTERN.matcher(result).replaceAll("_");
96114

97115
return result;
98116
}
@@ -123,17 +141,24 @@ public Optional<Object> format() {
123141
Optional<PrometheusMeterRegistry> prometheusMeterRegistry = prometheusMeterRegistry(meterRegistry);
124142
if (prometheusMeterRegistry.isPresent()) {
125143

126-
// Scraping the Prometheus registry lets us limit the output to include only specified names.
127-
Set<String> meterNamesOfInterest = meterNamesOfInterest(prometheusMeterRegistry.get(),
128-
scopeSelection,
129-
meterNameSelection);
130-
if (meterNamesOfInterest.isEmpty()) {
131-
return Optional.empty();
144+
/*
145+
Optimize for the no-selection case (neither scope nor name selections were requested).
146+
*/
147+
Set<String> meterNamesOfInterest;
148+
149+
if (meterNames.isEmpty() && scopes.isEmpty()) {
150+
meterNamesOfInterest = null; // The Prometheus registry's scrape method treats null as "match all names."
151+
} else {
152+
meterNamesOfInterest = meterNamesOfInterest(prometheusMeterRegistry.get(),
153+
scopes,
154+
meterNames);
155+
if (meterNamesOfInterest.isEmpty()) {
156+
return Optional.empty();
157+
}
132158
}
133159

134160
String prometheusOutput = prometheusMeterRegistry.get()
135-
.scrape(MicrometerPrometheusFormatter.MEDIA_TYPE_TO_FORMAT.get(
136-
resultMediaType),
161+
.scrape(MicrometerPrometheusFormatter.MEDIA_TYPE_TO_FORMAT.get(resultMediaType),
137162
meterNamesOfInterest);
138163

139164
return prometheusOutput.isBlank() ? Optional.empty() : Optional.of(prometheusOutput);
@@ -166,31 +191,23 @@ public Optional<Object> formatMetadata() {
166191
* </p>
167192
*
168193
* @param prometheusMeterRegistry Prometheus meter registry to query
169-
* @param scopeSelection scope names to select
170-
* @param meterNameSelection meter names to select
194+
* @param scopes scope names to select
195+
* @param names meter names to select
171196
* @return set of matching meter names (with units and suffixes as needed) to match the names as stored in the meter registry
172197
*/
173198
Set<String> meterNamesOfInterest(PrometheusMeterRegistry prometheusMeterRegistry,
174-
Iterable<String> scopeSelection,
175-
Iterable<String> meterNameSelection) {
199+
Set<String> scopes,
200+
Set<String> names) {
176201

177202
Set<String> result = new HashSet<>();
178203

179-
var scopes = new HashSet<>();
180-
scopeSelection.forEach(scopes::add);
181-
182-
var names = new HashSet<>();
183-
meterNameSelection.forEach(names::add);
184-
185-
Predicate<Meter> scopePredicate = scopes.isEmpty() || scopeTagName == null || scopeTagName.isBlank()
186-
? m -> true
187-
: m -> scopes.contains(m.getId().getTag(scopeTagName));
188-
189-
Predicate<String> namePredicate = names.isEmpty() ? n -> true : names::contains;
190-
191204
for (Meter meter : prometheusMeterRegistry.getMeters()) {
192205
String meterName = meter.getId().getName();
193-
if (!namePredicate.test(meterName) || !scopePredicate.test(meter)) {
206+
if ((!names.isEmpty() && !names.contains(meterName))
207+
|| (!scopes.isEmpty()
208+
&& scopeTagName != null
209+
&& !scopeTagName.isBlank()
210+
&& !scopes.contains(meter.getId().getTag(scopeTagName)))) {
194211
continue;
195212
}
196213
Set<String> allUnitsForMeterName = new HashSet<>();

metrics/providers/micrometer/src/test/java/io/helidon/metrics/providers/micrometer/TestPrometheusFormatting.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,92 @@ void testMeterNameWithColon() {
238238
endsWith(OPENMETRICS_EOF)));
239239
}
240240

241+
@Test
242+
void testMeterNameWithSpecialChars() {
243+
Counter counterWithDashes = meterRegistry.getOrCreate(Counter.builder("counter-with-dashes"));
244+
counterWithDashes.increment(3L);
245+
246+
Counter counterWithUmlauts = meterRegistry.getOrCreate(Counter.builder("counter-with-umlaut-äöü"));
247+
counterWithUmlauts.increment(4L);
248+
var formatter = MicrometerPrometheusFormatter.builder(meterRegistry)
249+
.resultMediaType(MediaTypes.APPLICATION_OPENMETRICS_TEXT)
250+
.scopeTagName(SCOPE_TAG_NAME)
251+
.build();
252+
253+
Optional<Object> output = formatter.format();
254+
assertThat("With dashes",
255+
checkAndCast(output),
256+
allOf(
257+
containsString(scopeExpr("counter_with_dashes_total",
258+
"this_scope",
259+
"app",
260+
"3.0")),
261+
containsString(scopeExpr("counter_with_umlaut_____total",
262+
"this_scope",
263+
"app",
264+
"4.0"))));
265+
266+
267+
}
268+
269+
@Test
270+
void testSelectiveByName() {
271+
Counter counter = meterRegistry.getOrCreate(Counter.builder("counterByName"));
272+
counter.increment();
273+
274+
var formatter = MicrometerPrometheusFormatter.builder(meterRegistry)
275+
.resultMediaType(MediaTypes.APPLICATION_OPENMETRICS_TEXT)
276+
.meterNameSelection(Set.of("counterByName"))
277+
.scopeTagName(SCOPE_TAG_NAME)
278+
.build();
279+
280+
Optional<Object> output = formatter.format();
281+
assertThat("Selective by name",
282+
checkAndCast(output),
283+
containsString(scopeExpr("counterByName_total",
284+
"this_scope",
285+
"app",
286+
"1.0")));
287+
}
288+
289+
@Test
290+
void testSelectiveByNameAndScope() {
291+
Counter counter = meterRegistry.getOrCreate(Counter.builder("counterByNameAndScope"));
292+
counter.increment(6L);
293+
294+
var formatter = MicrometerPrometheusFormatter.builder(meterRegistry)
295+
.resultMediaType(MediaTypes.APPLICATION_OPENMETRICS_TEXT)
296+
.meterNameSelection(Set.of("counterByNameAndScope"))
297+
.scopeSelection(Set.of("app"))
298+
.scopeTagName(SCOPE_TAG_NAME)
299+
.build();
300+
301+
Optional<Object> output = formatter.format();
302+
assertThat("Selective by name and scope",
303+
checkAndCast(output),
304+
containsString(scopeExpr("counterByNameAndScope_total",
305+
"this_scope",
306+
"app",
307+
"6.0")));
308+
}
309+
310+
@Test
311+
void testSelectNonExistentScope() {
312+
Counter counter = meterRegistry.getOrCreate(Counter.builder("counterByBadScope"));
313+
counter.increment(7L);
314+
315+
var formatter = MicrometerPrometheusFormatter.builder(meterRegistry)
316+
.resultMediaType(MediaTypes.APPLICATION_OPENMETRICS_TEXT)
317+
.scopeSelection(Set.of("missing"))
318+
.scopeTagName(SCOPE_TAG_NAME)
319+
.build();
320+
321+
Optional<Object> output = formatter.format();
322+
assertThat("Selective by non-existent scope",
323+
output,
324+
OptionalMatcher.optionalEmpty());
325+
}
326+
241327
private static String scopeExpr(String meterName, String key, String value, String suffix) {
242328
return meterName + "{" + key + "=\"" + value + "\"} " + suffix;
243329
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2025 Oracle and/or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.helidon.metrics.providers.micrometer;
17+
18+
import java.util.Arrays;
19+
import java.util.Optional;
20+
import java.util.Random;
21+
import java.util.concurrent.TimeUnit;
22+
23+
import io.helidon.common.media.type.MediaTypes;
24+
import io.helidon.metrics.api.Counter;
25+
import io.helidon.metrics.api.DistributionSummary;
26+
import io.helidon.metrics.api.MeterRegistry;
27+
import io.helidon.metrics.api.Metrics;
28+
import io.helidon.metrics.api.Timer;
29+
30+
import org.junit.jupiter.api.Disabled;
31+
import org.junit.jupiter.api.Test;
32+
33+
/**
34+
* Retained for ad hoc simple performance testing of Prometheus formatting. To get a rough timing, uncomment the @Disabled
35+
* annotation below, run the test, and look in the test's output file in target/surefire to see the timings.
36+
* This test does not attempt to detect any failure condition; it simply runs the code to capture timing.
37+
*/
38+
class TestPrometheusPerf {
39+
40+
@Disabled
41+
@Test
42+
void testPerf() {
43+
int loops = 40;
44+
45+
// Warmup
46+
registerAndFormat(10);
47+
48+
// Timed test
49+
double[] results = registerAndFormat(loops);
50+
51+
double totalElapsedMillis = 0.0;
52+
for (double result : results) {
53+
totalElapsedMillis += result;
54+
}
55+
56+
System.err.println("Durations:" + Arrays.toString(results));
57+
System.err.println("Mean: " + totalElapsedMillis / (double) loops);
58+
}
59+
60+
private static double[] registerAndFormat(int loops) {
61+
62+
double[] result = new double[loops];
63+
64+
for (int loop = 0; loop < loops; loop++) {
65+
MeterRegistry meterRegistry = Metrics.globalRegistry();
66+
meterRegistry.close();
67+
68+
Random random = new Random();
69+
for (int i = 0; i < 400; i++) {
70+
Counter c = meterRegistry.getOrCreate(Counter.builder("ctr" + i));
71+
c.increment();
72+
73+
Timer t = meterRegistry.getOrCreate(Timer.builder("tmr" + i));
74+
t.record(123, TimeUnit.MILLISECONDS);
75+
76+
DistributionSummary ds = meterRegistry.getOrCreate(DistributionSummary.builder("dist" + i));
77+
ds.record(random.nextDouble());
78+
}
79+
80+
long start = System.nanoTime();
81+
MicrometerPrometheusFormatter formatter = MicrometerPrometheusFormatter.builder(meterRegistry)
82+
.resultMediaType(MediaTypes.APPLICATION_OPENMETRICS_TEXT)
83+
.build();
84+
Optional<Object> outputOpt = formatter.format();
85+
result[loop] = (System.nanoTime() - start) / 1000.0 / 1000.0;
86+
meterRegistry.close();
87+
System.err.println(outputOpt.toString());
88+
}
89+
90+
return result;
91+
}
92+
}

0 commit comments

Comments
 (0)