Skip to content

Commit 0020301

Browse files
committed
fix: harden scrape edge cases
Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent 4a6958a commit 0020301

21 files changed

Lines changed: 395 additions & 110 deletions

File tree

‎docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt‎

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,11 @@ void testErrorHandling() throws IOException {
157157
start("error");
158158
Response response = scrape("GET", "");
159159
assertThat(response.status).isEqualTo(500);
160-
assertThat(response.stringBody()).contains("Simulating an error.");
160+
assertErrorResponseBody(response.stringBody());
161+
}
162+
163+
protected void assertErrorResponseBody(String body) {
164+
assertThat(body).contains("Simulating an error.");
161165
}
162166

163167
@Test
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
package io.prometheus.metrics.it.exporter.test;
22

3+
import static org.assertj.core.api.Assertions.assertThat;
4+
35
import java.io.IOException;
46
import java.net.URISyntaxException;
57

68
class HttpServerIT extends ExporterIT {
79
public HttpServerIT() throws IOException, URISyntaxException {
810
super("exporter-httpserver-sample");
911
}
12+
13+
@Override
14+
protected void assertErrorResponseBody(String body) {
15+
assertThat(body)
16+
.isEqualTo("An internal error occurred while scraping metrics.\n")
17+
.doesNotContain("Simulating an error.");
18+
}
1019
}

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,8 @@ static ExporterPushgatewayProperties load(PropertySource propertySource)
9494
if (scheme != null) {
9595
if (!scheme.equals("http") && !scheme.equals("https")) {
9696
throw new PrometheusPropertiesException(
97-
String.format(
98-
"%s.%s: Illegal value. Expecting 'http' or 'https'. Found: %s",
99-
PREFIX, SCHEME, scheme));
97+
Util.invalidValueMessage(
98+
PREFIX + "." + SCHEME, "Illegal value. Expecting 'http' or 'https'.", scheme));
10099
}
101100
}
102101

@@ -119,10 +118,10 @@ static ExporterPushgatewayProperties load(PropertySource propertySource)
119118
return EscapingScheme.DOTS_ESCAPING;
120119
default:
121120
throw new PrometheusPropertiesException(
122-
String.format(
123-
"%s.%s: Illegal value. Expecting 'allow-utf-8', 'values', 'underscores', "
124-
+ "or 'dots'. Found: %s",
125-
PREFIX, ESCAPING_SCHEME, scheme));
121+
Util.invalidValueMessage(
122+
PREFIX + "." + ESCAPING_SCHEME,
123+
"Illegal value. Expecting 'allow-utf-8', 'values', 'underscores', or 'dots'.",
124+
scheme));
126125
}
127126
}
128127

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ static Boolean loadBoolean(String prefix, String propertyName, PropertySource pr
2424
String fullKey = prefix.isEmpty() ? propertyName : prefix + "." + propertyName;
2525
if (!"true".equalsIgnoreCase(property) && !"false".equalsIgnoreCase(property)) {
2626
throw new PrometheusPropertiesException(
27-
String.format("%s: Expecting 'true' or 'false'. Found: %s", fullKey, property));
27+
invalidValueMessage(fullKey, "Expecting 'true' or 'false'.", property));
2828
}
2929
return Boolean.parseBoolean(property);
3030
}
@@ -88,7 +88,8 @@ static List<Double> loadDoubleList(
8888
}
8989
} catch (NumberFormatException e) {
9090
throw new PrometheusPropertiesException(
91-
fullKey + "=" + property + ": Expecting comma separated list of double values");
91+
invalidValueMessage(
92+
fullKey, "Expecting comma separated list of double values", property));
9293
}
9394
}
9495
return Arrays.asList(result);
@@ -130,7 +131,7 @@ static Integer loadInteger(String prefix, String propertyName, PropertySource pr
130131
return Integer.parseInt(property);
131132
} catch (NumberFormatException e) {
132133
throw new PrometheusPropertiesException(
133-
fullKey + "=" + property + ": Expecting integer value");
134+
invalidValueMessage(fullKey, "Expecting integer value", property));
134135
}
135136
}
136137
return null;
@@ -146,7 +147,7 @@ static Double loadDouble(String prefix, String propertyName, PropertySource prop
146147
return Double.parseDouble(property);
147148
} catch (NumberFormatException e) {
148149
throw new PrometheusPropertiesException(
149-
fullKey + "=" + property + ": Expecting double value");
150+
invalidValueMessage(fullKey, "Expecting double value", property));
150151
}
151152
}
152153
return null;
@@ -162,7 +163,7 @@ static Long loadLong(String prefix, String propertyName, PropertySource property
162163
return Long.parseLong(property);
163164
} catch (NumberFormatException e) {
164165
throw new PrometheusPropertiesException(
165-
fullKey + "=" + property + ": Expecting long value");
166+
invalidValueMessage(fullKey, "Expecting long value", property));
166167
}
167168
}
168169
return null;
@@ -197,4 +198,52 @@ static <T extends Number> void assertValue(
197198
throw new PrometheusPropertiesException(fullMessage);
198199
}
199200
}
201+
202+
static String invalidValueMessage(String fullKey, String message, String found) {
203+
String separator = message.endsWith(".") ? " " : ". ";
204+
return fullKey + ": " + message + separator + "Found: " + escape(found);
205+
}
206+
207+
static String escape(String value) {
208+
StringBuilder result = new StringBuilder(value.length() + 2);
209+
result.append('"');
210+
int maxLength = Math.min(value.length(), 100);
211+
for (int i = 0; i < maxLength; i++) {
212+
char c = value.charAt(i);
213+
switch (c) {
214+
case '\b':
215+
result.append("\\b");
216+
break;
217+
case '\t':
218+
result.append("\\t");
219+
break;
220+
case '\n':
221+
result.append("\\n");
222+
break;
223+
case '\f':
224+
result.append("\\f");
225+
break;
226+
case '\r':
227+
result.append("\\r");
228+
break;
229+
case '"':
230+
result.append("\\\"");
231+
break;
232+
case '\\':
233+
result.append("\\\\");
234+
break;
235+
default:
236+
if (c < 0x20 || c == 0x7f) {
237+
result.append(String.format("\\u%04x", (int) c));
238+
} else {
239+
result.append(c);
240+
}
241+
}
242+
}
243+
if (value.length() > maxLength) {
244+
result.append("...");
245+
}
246+
result.append('"');
247+
return result.toString();
248+
}
200249
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ void load() {
2828
Map.of("io.prometheus.exporter.include_created_timestamps", "invalid"))))
2929
.withMessage(
3030
"io.prometheus.exporter.include_created_timestamps: Expecting 'true' or 'false'. Found:"
31-
+ " invalid");
31+
+ " \"invalid\"");
3232
assertThatExceptionOfType(PrometheusPropertiesException.class)
3333
.isThrownBy(
3434
() ->
@@ -37,7 +37,7 @@ void load() {
3737
Map.of("io.prometheus.exporter.exemplars_on_all_metric_types", "invalid"))))
3838
.withMessage(
3939
"io.prometheus.exporter.exemplars_on_all_metric_types: Expecting 'true' or 'false'."
40-
+ " Found: invalid");
40+
+ " Found: \"invalid\"");
4141
}
4242

4343
private static ExporterProperties load(Map<String, String> map) {
@@ -85,6 +85,6 @@ void prometheusTimestampsInMs() {
8585
Map.of("io.prometheus.exporter.prometheus_timestamps_in_ms", "invalid"))))
8686
.withMessage(
8787
"io.prometheus.exporter.prometheus_timestamps_in_ms: Expecting 'true' or 'false'."
88-
+ " Found: invalid");
88+
+ " Found: \"invalid\"");
8989
}
9090
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ void load() {
2727
.isThrownBy(() -> load(Map.of("io.prometheus.exporter.pushgateway.scheme", "foo")))
2828
.withMessage(
2929
"io.prometheus.exporter.pushgateway.scheme: Illegal value. Expecting 'http' or 'https'."
30-
+ " Found: foo");
30+
+ " Found: \"foo\"");
3131
}
3232

3333
@Test
@@ -60,7 +60,7 @@ void loadWithInvalidEscapingScheme() {
6060
() -> load(Map.of("io.prometheus.exporter.pushgateway.escaping_scheme", "invalid")))
6161
.withMessage(
6262
"io.prometheus.exporter.pushgateway.escaping_scheme: Illegal value. Expecting"
63-
+ " 'allow-utf-8', 'values', 'underscores', or 'dots'. Found: invalid");
63+
+ " 'allow-utf-8', 'values', 'underscores', or 'dots'. Found: \"invalid\"");
6464
}
6565

6666
@Test

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ void loadInvalidValue() {
3838
.isThrownBy(
3939
() -> load(new HashMap<>(Map.of("io.prometheus.openmetrics2.enabled", "invalid"))))
4040
.withMessage(
41-
"io.prometheus.openmetrics2.enabled: Expecting 'true' or 'false'. Found: invalid");
41+
"io.prometheus.openmetrics2.enabled: Expecting 'true' or 'false'. Found: \"invalid\"");
4242
assertThatExceptionOfType(PrometheusPropertiesException.class)
4343
.isThrownBy(
4444
() ->
@@ -47,7 +47,7 @@ void loadInvalidValue() {
4747
Map.of("io.prometheus.openmetrics2.content_negotiation", "invalid"))))
4848
.withMessage(
4949
"io.prometheus.openmetrics2.content_negotiation: Expecting 'true' or 'false'. Found:"
50-
+ " invalid");
50+
+ " \"invalid\"");
5151
assertThatExceptionOfType(PrometheusPropertiesException.class)
5252
.isThrownBy(
5353
() ->
@@ -56,7 +56,7 @@ void loadInvalidValue() {
5656
Map.of("io.prometheus.openmetrics2.composite_values", "invalid"))))
5757
.withMessage(
5858
"io.prometheus.openmetrics2.composite_values: Expecting 'true' or 'false'. Found:"
59-
+ " invalid");
59+
+ " \"invalid\"");
6060
assertThatExceptionOfType(PrometheusPropertiesException.class)
6161
.isThrownBy(
6262
() ->
@@ -65,7 +65,7 @@ void loadInvalidValue() {
6565
Map.of("io.prometheus.openmetrics2.exemplar_compliance", "invalid"))))
6666
.withMessage(
6767
"io.prometheus.openmetrics2.exemplar_compliance: Expecting 'true' or 'false'. Found:"
68-
+ " invalid");
68+
+ " \"invalid\"");
6969
assertThatExceptionOfType(PrometheusPropertiesException.class)
7070
.isThrownBy(
7171
() ->
@@ -74,7 +74,7 @@ void loadInvalidValue() {
7474
Map.of("io.prometheus.openmetrics2.native_histograms", "invalid"))))
7575
.withMessage(
7676
"io.prometheus.openmetrics2.native_histograms: Expecting 'true' or 'false'. Found:"
77-
+ " invalid");
77+
+ " \"invalid\"");
7878
}
7979

8080
private static OpenMetrics2Properties load(Map<String, String> map) {

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ void loadOptionalDuration_invalidNumber_throws() {
5151

5252
assertThatExceptionOfType(PrometheusPropertiesException.class)
5353
.isThrownBy(() -> Util.loadOptionalDuration("", "foo", propertySource))
54-
.withMessage("foo=abc: Expecting long value");
54+
.withMessage("foo: Expecting long value. Found: \"abc\"");
55+
}
56+
57+
@Test
58+
void invalidValueMessageEscapesRawValue() {
59+
Map<Object, Object> regularProperties = new HashMap<>(Map.of("foo", "bad\n\"value"));
60+
PropertySource propertySource = new PropertySource(regularProperties);
61+
62+
assertThatExceptionOfType(PrometheusPropertiesException.class)
63+
.isThrownBy(() -> Util.loadBoolean("", "foo", propertySource))
64+
.withMessage("foo: Expecting 'true' or 'false'. Found: \"bad\\n\\\"value\"");
5565
}
5666
}

‎prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Buffer.java‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.prometheus.metrics.model.snapshots.DataPointSnapshot;
44
import java.util.Arrays;
5+
import java.util.concurrent.TimeUnit;
56
import java.util.concurrent.atomic.AtomicLong;
67
import java.util.concurrent.locks.Condition;
78
import java.util.concurrent.locks.ReentrantLock;
@@ -18,6 +19,7 @@
1819
class Buffer {
1920

2021
private static final long bufferActiveBit = 1L << 63;
22+
private static final long DEFAULT_MAX_SPIN_WAIT_NANOS = TimeUnit.SECONDS.toNanos(1);
2123
// Tracking observation counts requires an AtomicLong for coordination between recording and
2224
// collecting. AtomicLong does much worse under contention than the LongAdder instances used
2325
// elsewhere to hold aggregated state. To improve, we stripe the AtomicLong into N instances,
@@ -34,16 +36,22 @@ class Buffer {
3436
ReentrantLock appendLock = new ReentrantLock();
3537
ReentrantLock runLock = new ReentrantLock();
3638
Condition bufferFilled = appendLock.newCondition();
39+
private final long maxSpinWaitNanos;
3740

3841
Buffer() {
42+
this(DEFAULT_MAX_SPIN_WAIT_NANOS);
43+
}
44+
45+
Buffer(long maxSpinWaitNanos) {
46+
this.maxSpinWaitNanos = maxSpinWaitNanos;
3947
stripedObservationCounts = new AtomicLong[Runtime.getRuntime().availableProcessors()];
4048
for (int i = 0; i < stripedObservationCounts.length; i++) {
4149
stripedObservationCounts[i] = new AtomicLong(0);
4250
}
4351
}
4452

4553
boolean append(double value) {
46-
int index = Math.abs((int) Thread.currentThread().getId()) % stripedObservationCounts.length;
54+
int index = stripeIndex(Thread.currentThread().getId(), stripedObservationCounts.length);
4755
AtomicLong observationCountForThread = stripedObservationCounts[index];
4856
long count = observationCountForThread.incrementAndGet();
4957
if ((count & bufferActiveBit) == 0) {
@@ -54,6 +62,10 @@ boolean append(double value) {
5462
}
5563
}
5664

65+
static int stripeIndex(long threadId, int stripeCount) {
66+
return (int) Math.floorMod(threadId, stripeCount);
67+
}
68+
5769
private void doAppend(double amount) {
5870
appendLock.lock();
5971
try {
@@ -74,14 +86,15 @@ void reset() {
7486
reset = true;
7587
}
7688

77-
@SuppressWarnings("ThreadPriorityCheck")
89+
@SuppressWarnings({"NullAway", "ThreadPriorityCheck"})
7890
<T extends DataPointSnapshot> T run(
7991
Function<Long, Boolean> complete,
8092
Supplier<T> createResult,
8193
Consumer<Double> observeFunction) {
8294
double[] buffer;
8395
int bufferSize;
8496
T result;
97+
boolean timedOut = false;
8598

8699
runLock.lock();
87100
try {
@@ -91,14 +104,19 @@ <T extends DataPointSnapshot> T run(
91104
expectedCount += observationCount.getAndAdd(bufferActiveBit);
92105
}
93106

107+
long deadline = System.nanoTime() + maxSpinWaitNanos;
94108
while (!complete.apply(expectedCount)) {
95109
// Wait until all in-flight threads have added their observations to the histogram /
96110
// summary.
97111
// we can't use a condition here, because the other thread doesn't have a lock as it's on
98112
// the fast path.
113+
if (System.nanoTime() - deadline >= 0) {
114+
timedOut = true;
115+
break;
116+
}
99117
Thread.yield();
100118
}
101-
result = createResult.get();
119+
result = timedOut ? null : createResult.get();
102120

103121
// Signal that the buffer is inactive.
104122
long expectedBufferSize = 0;
@@ -137,6 +155,9 @@ <T extends DataPointSnapshot> T run(
137155
for (int i = 0; i < bufferSize; i++) {
138156
observeFunction.accept(buffer[i]);
139157
}
158+
if (timedOut) {
159+
throw new IllegalStateException("Timed out while waiting for in-flight observations.");
160+
}
140161
return result;
141162
}
142163
}

0 commit comments

Comments
 (0)