Skip to content

Commit 935c07e

Browse files
committed
[SPARK-53325] Support Prometheus 2.0 text-based-format and best practices for metrics naming
### What changes were proposed in this pull request? This PR adds support for Prometheus text-based-format and best practices for metrics naming Existing format ``` metrics_jvm_bufferPool_direct_capacity_Number{type="gauges"} 98348 metrics_jvm_bufferPool_direct_capacity_Value{type="gauges"} 98348 metrics_jvm_bufferPool_direct_count_Number{type="gauges"} 41 metrics_jvm_bufferPool_direct_count_Value{type="gauges"} 41 metrics_kubernetes_client_http_response_latency_nanos_Count{type="histograms"} 26910 metrics_kubernetes_client_http_response_latency_nanos_Max{type="histograms"} 232417143 metrics_kubernetes_client_http_response_latency_nanos_Mean{type="histograms"} 1.1410164260725182E7 metrics_kubernetes_client_http_response_latency_nanos_Min{type="histograms"} 2931711 metrics_kubernetes_client_http_response_latency_nanos_50thPercentile{type="histograms"} 7559152.0 metrics_kubernetes_client_http_response_latency_nanos_75thPercentile{type="histograms"} 9440850.0 metrics_kubernetes_client_http_response_latency_nanos_95thPercentile{type="histograms"} 1.2576766E7 metrics_kubernetes_client_http_response_latency_nanos_98thPercentile{type="histograms"} 1.34034482E8 metrics_kubernetes_client_http_response_latency_nanos_99thPercentile{type="histograms"} 1.34034482E8 metrics_kubernetes_client_http_response_latency_nanos_999thPercentile{type="histograms"} 1.34034482E8 metrics_kubernetes_client_http_response_latency_nanos_StdDev{type="histograms"} 2.177784612259799E7 metrics_kubernetes_client_pods_get_Count{type="counters"} 8967 metrics_kubernetes_client_pods_get_MeanRate{type="counters"} 0.02678169644780033 metrics_kubernetes_client_pods_get_OneMinuteRate{type="counters"} 0.049758750361204154 metrics_kubernetes_client_pods_get_FiveMinuteRate{type="counters"} 0.035255140329213855 metrics_kubernetes_client_pods_get_FifteenMinuteRate{type="counters"} 0.02931221844089468 ``` with this patch, operator would be able to export format matching Prometheus 2.0 recommended practice like ``` # HELP jvm_bufferpool_direct_capacity Gauge metric # TYPE jvm_bufferpool_direct_capacity gauge jvm_bufferpool_direct_capacity 256092 # HELP jvm_bufferpool_direct_count Gauge metric # TYPE jvm_bufferpool_direct_count gauge jvm_bufferpool_direct_count 44 # HELP kubernetes_client_2xx_total Meter count # TYPE kubernetes_client_2xx_total counter kubernetes_client_2xx_total 130 # HELP kubernetes_client_http_response_latency Histogram metric # TYPE kubernetes_client_http_response_latency histogram kubernetes_client_http_response_latency_seconds_bucket{le="0.5"} 0.000104422 kubernetes_client_http_response_latency_seconds_bucket{le="0.75"} 0.000128422 kubernetes_client_http_response_latency_seconds_bucket{le="0.95"} 0.000139544 kubernetes_client_http_response_latency_seconds_bucket{le="0.98"} 0.000169124 kubernetes_client_http_response_latency_seconds_bucket{le="0.99"} 0.066452639 kubernetes_client_http_response_latency_seconds_count 2000 kubernetes_client_http_response_latency_seconds_sum 0.456670434 ``` ### Why are the changes needed? It's Prometheus 2.0 best practice for using the next format with necessary comments. Also, some common scrapers (like Datadog) rely on these metadata (e.g. # HELP and # TYPE) to parse metrics correctly. They may skip metrics if these are missing. ### Does this PR introduce _any_ user-facing change? New functionalities becomes available (for metrics format) ### How was this patch tested? CIs / curl on :19090/prometheus to validate the format ### Was this patch authored or co-authored using generative AI tooling? No
1 parent b89c5cc commit 935c07e

File tree

4 files changed

+445
-7
lines changed

4 files changed

+445
-7
lines changed

docs/config_properties.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
| spark.kubernetes.operator.metrics.clientMetricsEnabled | Boolean | true | false | Enable KubernetesClient metrics for measuring the HTTP traffic to the Kubernetes API Server. Since the metrics is collected via Okhttp interceptors, can be disabled when opt in customized interceptors. |
3030
| spark.kubernetes.operator.metrics.clientMetricsGroupByResponseCodeEnabled | Boolean | true | false | When enabled, additional metrics group by http response code group(1xx, 2xx, 3xx, 4xx, 5xx) received from API server will be added. Users can disable it when their monitoring system can combine lower level kubernetes.client.http.response.<3-digit-response-code> metrics. |
3131
| spark.kubernetes.operator.metrics.port | Integer | 19090 | false | The port used for checking metrics |
32+
| spark.kubernetes.operator.enablePrometheusTextBasedFormat | Boolean | true | false | Whether or not to enable text-based format for Prometheus 2.0, as recommended by https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format |
33+
| spark.kubernetes.operator.enableSanitizePrometheusMetricsName | Boolean | true | false | Whether or not to enable automatic name sanitizing for all metrics based on best-practice guide from Prometheus https://prometheus.io/docs/practices/naming/ |
3234
| spark.kubernetes.operator.health.probePort | Integer | 19091 | false | The port used for health/readiness check probe status. |
3335
| spark.kubernetes.operator.health.sentinelExecutorPoolSize | Integer | 3 | false | Size of executor service in Sentinel Managers to check the health of sentinel resources. |
3436
| spark.kubernetes.operator.health.sentinelResourceReconciliationDelaySeconds | Integer | 60 | true | Allowed max time(seconds) between spec update and reconciliation for sentinel resources. |

spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,30 @@ public final class SparkOperatorConf {
334334
.defaultValue(19090)
335335
.build();
336336

337+
public static final ConfigOption<Boolean> EnablePrometheusTextBasedFormat =
338+
ConfigOption.<Boolean>builder()
339+
.key("spark.kubernetes.operator.enablePrometheusTextBasedFormat")
340+
.enableDynamicOverride(false)
341+
.description(
342+
"Whether or not to enable text-based format for Prometheus 2.0, as "
343+
+ "recommended by "
344+
+ "https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format")
345+
.typeParameterClass(Boolean.class)
346+
.defaultValue(true)
347+
.build();
348+
349+
public static final ConfigOption<Boolean> EnableSanitizePrometheusMetricsName =
350+
ConfigOption.<Boolean>builder()
351+
.key("spark.kubernetes.operator.enableSanitizePrometheusMetricsName")
352+
.enableDynamicOverride(false)
353+
.description(
354+
"Whether or not to enable automatic name sanitizing for all metrics based on "
355+
+ "best-practice guide from Prometheus "
356+
+ "https://prometheus.io/docs/practices/naming/")
357+
.typeParameterClass(Boolean.class)
358+
.defaultValue(true)
359+
.build();
360+
337361
public static final ConfigOption<Integer> OPERATOR_PROBE_PORT =
338362
ConfigOption.<Integer>builder()
339363
.key("spark.kubernetes.operator.health.probePort")

spark-operator/src/main/java/org/apache/spark/k8s/operator/metrics/PrometheusPullModelHandler.java

Lines changed: 284 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,38 @@
2929
import java.util.Map;
3030
import java.util.Properties;
3131

32+
import com.codahale.metrics.Counter;
33+
import com.codahale.metrics.Gauge;
34+
import com.codahale.metrics.Histogram;
35+
import com.codahale.metrics.Meter;
3236
import com.codahale.metrics.MetricRegistry;
37+
import com.codahale.metrics.Snapshot;
38+
import com.codahale.metrics.Timer;
3339
import com.sun.net.httpserver.HttpExchange;
3440
import com.sun.net.httpserver.HttpHandler;
3541
import jakarta.servlet.http.HttpServletRequest;
42+
import lombok.Getter;
3643
import lombok.extern.slf4j.Slf4j;
44+
import org.apache.commons.lang3.StringUtils;
3745

46+
import org.apache.spark.k8s.operator.config.SparkOperatorConf;
3847
import org.apache.spark.metrics.sink.PrometheusServlet;
3948

4049
/** Serves as simple Prometheus sink (pull model), presenting metrics snapshot as HttpHandler. */
4150
@Slf4j
4251
public class PrometheusPullModelHandler extends PrometheusServlet implements HttpHandler {
4352
private static final String EMPTY_RECORD_VALUE = "[]";
53+
@Getter private final MetricRegistry registry;
54+
@Getter private final boolean enablePrometheusTextBasedFormat;
55+
@Getter private final boolean enableSanitizePrometheusMetricsName;
4456

4557
public PrometheusPullModelHandler(Properties properties, MetricRegistry registry) {
4658
super(properties, registry);
59+
this.registry = registry;
60+
this.enablePrometheusTextBasedFormat =
61+
SparkOperatorConf.EnablePrometheusTextBasedFormat.getValue();
62+
this.enableSanitizePrometheusMetricsName =
63+
SparkOperatorConf.EnableSanitizePrometheusMetricsName.getValue();
4764
}
4865

4966
@Override
@@ -58,13 +75,21 @@ public void stop() {
5875

5976
@Override
6077
public void handle(HttpExchange exchange) throws IOException {
61-
HttpServletRequest httpServletRequest = null;
62-
String value = getMetricsSnapshot(httpServletRequest);
63-
sendMessage(
64-
exchange,
65-
HTTP_OK,
66-
String.join("\n", filterNonEmptyRecords(value)),
67-
Map.of("Content-Type", Collections.singletonList("text/plain;version=0.0.4")));
78+
if (SparkOperatorConf.EnablePrometheusTextBasedFormat.getValue()) {
79+
sendMessage(
80+
exchange,
81+
HTTP_OK,
82+
formatMetricsSnapshot(),
83+
Map.of("Content-Type", Collections.singletonList("text/plain;version=0.0.4")));
84+
} else {
85+
HttpServletRequest httpServletRequest = null;
86+
String value = getMetricsSnapshot(httpServletRequest);
87+
sendMessage(
88+
exchange,
89+
HTTP_OK,
90+
String.join("\n", filterNonEmptyRecords(value)),
91+
Map.of("Content-Type", Collections.singletonList("text/plain;version=0.0.4")));
92+
}
6893
}
6994

7095
protected List<String> filterNonEmptyRecords(String metricsSnapshot) {
@@ -82,4 +107,256 @@ protected List<String> filterNonEmptyRecords(String metricsSnapshot) {
82107
}
83108
return filteredRecords;
84109
}
110+
111+
protected String formatMetricsSnapshot() {
112+
Map<String, Gauge> gauges = registry.getGauges();
113+
Map<String, Counter> counters = registry.getCounters();
114+
Map<String, Histogram> histograms = registry.getHistograms();
115+
Map<String, Meter> meters = registry.getMeters();
116+
Map<String, Timer> timers = registry.getTimers();
117+
118+
StringBuilder stringBuilder = new StringBuilder();
119+
120+
for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
121+
appendIfNotEmpty(stringBuilder, formatGauge(entry.getKey(), entry.getValue()));
122+
}
123+
124+
// Counters
125+
for (Map.Entry<String, Counter> entry : counters.entrySet()) {
126+
String name = sanitize(entry.getKey()) + "_total";
127+
Counter counter = entry.getValue();
128+
appendIfNotEmpty(stringBuilder, formatCounter(name, counter));
129+
}
130+
131+
// Histograms
132+
for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
133+
appendIfNotEmpty(stringBuilder, formatHistogram(entry.getKey(), entry.getValue()));
134+
}
135+
136+
// Meters
137+
for (Map.Entry<String, Meter> entry : meters.entrySet()) {
138+
appendIfNotEmpty(stringBuilder, formatMeter(entry.getKey(), entry.getValue()));
139+
}
140+
141+
// Timers (Meter + Histogram in nanoseconds)
142+
for (Map.Entry<String, Timer> entry : timers.entrySet()) {
143+
appendIfNotEmpty(stringBuilder, formatTimer(entry.getKey(), entry.getValue()));
144+
}
145+
return stringBuilder.toString();
146+
}
147+
148+
protected void appendIfNotEmpty(StringBuilder stringBuilder, String value) {
149+
if (StringUtils.isNotEmpty(value)) {
150+
stringBuilder.append(value);
151+
}
152+
}
153+
154+
protected String formatGauge(String name, Gauge gauge) {
155+
if (gauge != null
156+
&& gauge.getValue() != null
157+
&& !EMPTY_RECORD_VALUE.equals(gauge.getValue())
158+
&& gauge.getValue() instanceof Number) {
159+
String formattedName = sanitize(name);
160+
return "# HELP "
161+
+ formattedName
162+
+ " Gauge metric\n"
163+
+ "# TYPE "
164+
+ formattedName
165+
+ " gauge\n"
166+
+ sanitize(formattedName)
167+
+ ' '
168+
+ gauge.getValue()
169+
+ "\n\n";
170+
}
171+
return null;
172+
}
173+
174+
protected String formatCounter(String name, Counter counter) {
175+
if (counter != null) {
176+
return "# HELP "
177+
+ name
178+
+ " Counter metric\n"
179+
+ "# TYPE "
180+
+ name
181+
+ " counter\n"
182+
+ name
183+
+ " "
184+
+ counter.getCount()
185+
+ "\n\n";
186+
}
187+
return null;
188+
}
189+
190+
protected String formatHistogram(String name, Histogram histogram) {
191+
if (histogram != null && histogram.getSnapshot() != null) {
192+
StringBuilder stringBuilder = new StringBuilder(300);
193+
String baseName = sanitize(name);
194+
Snapshot snap = histogram.getSnapshot();
195+
long count = histogram.getCount();
196+
stringBuilder
197+
.append("# HELP ")
198+
.append(baseName)
199+
.append(" Histogram metric\n# TYPE ")
200+
.append(baseName)
201+
.append(" histogram\n");
202+
boolean isNanosHistogram = baseName.contains("nanos");
203+
if (isNanosHistogram) {
204+
baseName = nanosMetricsNameToSeconds(baseName);
205+
}
206+
appendBucket(
207+
stringBuilder,
208+
baseName,
209+
"le=\"0.5\"",
210+
isNanosHistogram ? nanosToSeconds(snap.getMedian()) : snap.getMean());
211+
appendBucket(
212+
stringBuilder,
213+
baseName,
214+
"le=\"0.75\"",
215+
isNanosHistogram ? nanosToSeconds(snap.get75thPercentile()) : snap.get75thPercentile());
216+
appendBucket(
217+
stringBuilder,
218+
baseName,
219+
"le=\"0.95\"",
220+
isNanosHistogram ? nanosToSeconds(snap.get95thPercentile()) : snap.get95thPercentile());
221+
appendBucket(
222+
stringBuilder,
223+
baseName,
224+
"le=\"0.98\"",
225+
isNanosHistogram ? nanosToSeconds(snap.get98thPercentile()) : snap.get98thPercentile());
226+
appendBucket(
227+
stringBuilder,
228+
baseName,
229+
"le=\"0.99\"",
230+
isNanosHistogram ? nanosToSeconds(snap.get99thPercentile()) : snap.get99thPercentile());
231+
double sum =
232+
isNanosHistogram ? nanosToSeconds(snap.getMean() * count) : snap.getMean() * count;
233+
stringBuilder
234+
.append(baseName)
235+
.append("_count ")
236+
.append(count)
237+
.append('\n')
238+
.append(baseName)
239+
.append("_sum ")
240+
.append(sum)
241+
.append("\n\n");
242+
return stringBuilder.toString();
243+
}
244+
return null;
245+
}
246+
247+
protected String formatMeter(String name, Meter meter) {
248+
if (meter != null) {
249+
StringBuilder stringBuilder = new StringBuilder(200);
250+
String baseName = sanitize(name);
251+
stringBuilder
252+
.append("# HELP ")
253+
.append(baseName)
254+
.append("_total Meter count\n# TYPE ")
255+
.append(baseName)
256+
.append("_total counter\n")
257+
.append(baseName)
258+
.append("_total ")
259+
.append(meter.getCount())
260+
.append("\n\n# TYPE ")
261+
.append(baseName)
262+
.append("_rate gauge\n")
263+
.append(baseName)
264+
.append("_rate{interval=\"1m\"} ")
265+
.append(meter.getOneMinuteRate())
266+
.append('\n')
267+
.append(baseName)
268+
.append("_rate{interval=\"5m\"} ")
269+
.append(meter.getFiveMinuteRate())
270+
.append('\n')
271+
.append(baseName)
272+
.append("_rate{interval=\"15m\"} ")
273+
.append(meter.getFifteenMinuteRate())
274+
.append("\n\n");
275+
return stringBuilder.toString();
276+
}
277+
return null;
278+
}
279+
280+
protected String formatTimer(String name, Timer timer) {
281+
if (timer != null && timer.getSnapshot() != null) {
282+
StringBuilder stringBuilder = new StringBuilder(300);
283+
String baseName = sanitize(name);
284+
Snapshot snap = timer.getSnapshot();
285+
long count = timer.getCount();
286+
stringBuilder
287+
.append("# HELP ")
288+
.append(baseName)
289+
.append("_duration_seconds Timer histogram\n# TYPE ")
290+
.append(baseName)
291+
.append("_duration_seconds histogram\n");
292+
appendBucket(
293+
stringBuilder,
294+
baseName + "_duration_seconds",
295+
"le=\"0.5\"",
296+
nanosToSeconds(snap.getMedian()));
297+
appendBucket(
298+
stringBuilder,
299+
baseName + "_duration_seconds",
300+
"le=\"0.75\"",
301+
nanosToSeconds(snap.get75thPercentile()));
302+
appendBucket(
303+
stringBuilder,
304+
baseName + "_duration_seconds",
305+
"le=\"0.95\"",
306+
nanosToSeconds(snap.get95thPercentile()));
307+
appendBucket(
308+
stringBuilder,
309+
baseName + "_duration_seconds",
310+
"le=\"0.98\"",
311+
nanosToSeconds(snap.get98thPercentile()));
312+
appendBucket(
313+
stringBuilder,
314+
baseName + "_duration_seconds",
315+
"le=\"0.99\"",
316+
nanosToSeconds(snap.get99thPercentile()));
317+
stringBuilder
318+
.append(baseName)
319+
.append("_duration_seconds_count ")
320+
.append(count)
321+
.append('\n')
322+
.append(baseName)
323+
.append("_duration_seconds_sum ")
324+
.append(nanosToSeconds(snap.getMean() * count))
325+
.append("\n\n# TYPE ")
326+
.append(baseName)
327+
.append("_calls_total counter\n")
328+
.append(baseName)
329+
.append("_calls_total ")
330+
.append(count)
331+
.append("\n\n");
332+
return stringBuilder.toString();
333+
}
334+
return null;
335+
}
336+
337+
protected void appendBucket(
338+
StringBuilder builder, String baseName, String leLabel, double value) {
339+
builder
340+
.append(baseName)
341+
.append("_bucket{")
342+
.append(leLabel)
343+
.append("} ")
344+
.append(value)
345+
.append('\n');
346+
}
347+
348+
protected double nanosToSeconds(double nanos) {
349+
return nanos / 1_000_000_000.0;
350+
}
351+
352+
protected String sanitize(String name) {
353+
if (enableSanitizePrometheusMetricsName) {
354+
return name.replaceAll("[^a-zA-Z0-9_:]", "_").toLowerCase();
355+
}
356+
return name;
357+
}
358+
359+
protected String nanosMetricsNameToSeconds(String name) {
360+
return name.replaceAll("_nanos", "_seconds");
361+
}
85362
}

0 commit comments

Comments
 (0)