From 0318b13ba785fc074eebddb744eb6e34ee589a15 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 23 Jul 2026 10:14:32 +0000 Subject: [PATCH 1/4] fix: hide HTTP scrape error details Signed-off-by: Gregor Zeitlinger --- ...prometheus-metrics-exporter-httpserver.txt | 4 +- .../metrics/it/exporter/test/ExporterIT.java | 6 +- .../it/exporter/test/HttpServerIT.java | 9 +++ .../httpserver/HttpExchangeAdapter.java | 58 ++++++++----------- .../exporter/httpserver/HTTPServerTest.java | 21 ++++++- .../httpserver/HttpExchangeAdapterTest.java | 23 ++++++++ 6 files changed, 85 insertions(+), 36 deletions(-) diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt index 17fccaa45..8bc12b84f 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt @@ -1,2 +1,4 @@ Comparing source compatibility of prometheus-metrics-exporter-httpserver-1.8.1-SNAPSHOT.jar against prometheus-metrics-exporter-httpserver-1.8.0.jar -No changes. +*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.HTTPServer (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java index 5a80d8bdf..3ba88dcc1 100644 --- a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java @@ -157,7 +157,11 @@ void testErrorHandling() throws IOException { start("error"); Response response = scrape("GET", ""); assertThat(response.status).isEqualTo(500); - assertThat(response.stringBody()).contains("Simulating an error."); + assertErrorResponseBody(response.stringBody()); + } + + protected void assertErrorResponseBody(String body) { + assertThat(body).contains("Simulating an error."); } @Test diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java index 4c7e61472..d04664718 100644 --- a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java @@ -1,5 +1,7 @@ package io.prometheus.metrics.it.exporter.test; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.IOException; import java.net.URISyntaxException; @@ -7,4 +9,11 @@ class HttpServerIT extends ExporterIT { public HttpServerIT() throws IOException, URISyntaxException { super("exporter-httpserver-sample"); } + + @Override + protected void assertErrorResponseBody(String body) { + assertThat(body) + .isEqualTo("An internal error occurred while scraping metrics.\n") + .doesNotContain("Simulating an error."); + } } diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java index df99837cb..b18dbb83d 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java @@ -6,8 +6,6 @@ import io.prometheus.metrics.exporter.common.PrometheusHttpResponse; import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; -import java.io.StringWriter; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Collections; @@ -18,6 +16,10 @@ public class HttpExchangeAdapter implements PrometheusHttpExchange { + private static final Logger logger = Logger.getLogger(HttpExchangeAdapter.class.getName()); + private static final byte[] ERROR_RESPONSE = + "An internal error occurred while scraping metrics.\n".getBytes(StandardCharsets.UTF_8); + private final HttpExchange httpExchange; private final HttpRequest request = new HttpRequest(); private final HttpResponse response = new HttpResponse(); @@ -92,52 +94,42 @@ public HttpResponse getResponse() { @Override public void handleException(IOException e) throws IOException { - sendErrorResponseWithStackTrace(e); + sendErrorResponse(e); } @Override public void handleException(RuntimeException e) { - sendErrorResponseWithStackTrace(e); + sendErrorResponse(e); } - private void sendErrorResponseWithStackTrace(Exception requestHandlerException) { + private void sendErrorResponse(Exception requestHandlerException) { if (!responseSent) { responseSent = true; + logger.log( + Level.SEVERE, + "The Prometheus metrics HTTPServer caught an Exception during scrape.", + requestHandlerException); try { - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - printWriter.write("An Exception occurred while scraping metrics: "); - requestHandlerException.printStackTrace(new PrintWriter(printWriter)); - byte[] stackTrace = stringWriter.toString().getBytes(StandardCharsets.UTF_8); httpExchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - httpExchange.sendResponseHeaders(500, stackTrace.length); - httpExchange.getResponseBody().write(stackTrace); + httpExchange.sendResponseHeaders(500, ERROR_RESPONSE.length); + httpExchange.getResponseBody().write(ERROR_RESPONSE); } catch (IOException errorWriterException) { - // We want to avoid logging so that we don't mess with application logs when the HTTPServer - // is used in a Java agent. - // However, if we can't even send an error response to the client there's nothing we can do - // but logging a message. - Logger.getLogger(this.getClass().getName()) - .log( - Level.SEVERE, - "The Prometheus metrics HTTPServer caught an Exception during scrape and " - + "failed to send an error response to the client.", - errorWriterException); - Logger.getLogger(this.getClass().getName()) - .log( - Level.SEVERE, - "Original Exception that caused the Prometheus scrape error:", - requestHandlerException); + // If we can't even send an error response to the client, logging is the only remaining + // signal. + logger.log( + Level.SEVERE, + "The Prometheus metrics HTTPServer caught an Exception during scrape and " + + "failed to send an error response to the client.", + errorWriterException); } } else { // If the exception occurs after response headers have been sent, it's too late to respond // with HTTP 500. - Logger.getLogger(this.getClass().getName()) - .log( - Level.SEVERE, - "The Prometheus metrics HTTPServer caught an Exception while trying to send " - + "the metrics response.", - requestHandlerException); + logger.log( + Level.SEVERE, + "The Prometheus metrics HTTPServer caught an Exception while trying to send " + + "the metrics response.", + requestHandlerException); } } diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java index ff2d55048..198e19451 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java @@ -171,7 +171,13 @@ public MetricSnapshots scrape(PrometheusScrapeRequest scrapeRequest) { } }) .buildAndStart(); - run(server, "/metrics", 500, "An Exception occurred while scraping metrics"); + run( + server, + "/metrics", + 500, + "An internal error occurred while scraping metrics.", + "IllegalStateException", + "test"); } @Test @@ -237,6 +243,16 @@ void healthDisabled() throws Exception { private static void run( HTTPServer server, String path, int expectedStatusCode, String expectedBody) throws Exception { + run(server, path, expectedStatusCode, expectedBody, new String[0]); + } + + private static void run( + HTTPServer server, + String path, + int expectedStatusCode, + String expectedBody, + String... unexpectedBody) + throws Exception { // we cannot use try-with-resources or even client.close(), or the test will fail with Java 17 @SuppressWarnings("resource") final HttpClient client = HttpClient.newBuilder().build(); @@ -248,6 +264,9 @@ private static void run( client.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(expectedStatusCode); assertThat(response.body()).contains(expectedBody); + if (unexpectedBody.length > 0) { + assertThat(response.body()).doesNotContain(unexpectedBody); + } } finally { server.stop(); } diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java index 19bc0d66e..8aaef9c42 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java @@ -2,11 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.Test; @@ -46,4 +49,24 @@ void getHeadersWhenNotPresent() { HttpExchangeAdapter adapter = new HttpExchangeAdapter(httpExchange); assertThat(adapter.getRequest().getHeaders("Accept").hasMoreElements()).isFalse(); } + + @Test + void handleExceptionReturnsGenericMessageWithoutStackTrace() throws Exception { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + HttpExchangeAdapter adapter = new HttpExchangeAdapter(httpExchange); + + adapter.handleException(new IllegalStateException("secret failure")); + + String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); + assertThat(body).isEqualTo("An internal error occurred while scraping metrics.\n"); + assertThat(body).doesNotContain("IllegalStateException"); + assertThat(body).doesNotContain("secret failure"); + assertThat(body).doesNotContain("at "); + assertThat(headers.getFirst("Content-Type")).isEqualTo("text/plain; charset=utf-8"); + verify(httpExchange).sendResponseHeaders(500, body.getBytes(StandardCharsets.UTF_8).length); + } } From 2499355109e59e82f3f9e22a4abbfc1c45b63ecb Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 23 Jul 2026 11:17:36 +0000 Subject: [PATCH 2/4] docs: refresh HTTPServer API diff Signed-off-by: Gregor Zeitlinger --- .../prometheus-metrics-exporter-httpserver.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt index 8bc12b84f..17fccaa45 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt @@ -1,4 +1,2 @@ Comparing source compatibility of prometheus-metrics-exporter-httpserver-1.8.1-SNAPSHOT.jar against prometheus-metrics-exporter-httpserver-1.8.0.jar -*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.HTTPServer (not serializable) - === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 - +No changes. From 9ae3d4c06be9c583f6339396fb619f730c2631b4 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 24 Jul 2026 08:10:18 +0000 Subject: [PATCH 3/4] fix: make HTTP scrape error handling configurable Signed-off-by: Gregor Zeitlinger --- ...prometheus-metrics-exporter-httpserver.txt | 15 +++- docs/content/exporters/httpserver.md | 25 ++++++ .../it/exporter/test/HttpServerIT.java | 4 +- .../exporter/httpserver/HTTPServer.java | 23 ++++- .../httpserver/HttpErrorHandlingPolicy.java | 87 +++++++++++++++++++ .../httpserver/HttpExchangeAdapter.java | 32 +++++-- .../exporter/httpserver/MetricsHandler.java | 25 ++++-- .../exporter/httpserver/HTTPServerTest.java | 55 ++++++++++-- .../httpserver/HttpExchangeAdapterTest.java | 67 +++++++++++++- 9 files changed, 305 insertions(+), 28 deletions(-) create mode 100644 prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt index 17fccaa45..6a6912dfc 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt @@ -1,2 +1,15 @@ Comparing source compatibility of prometheus-metrics-exporter-httpserver-1.8.1-SNAPSHOT.jar against prometheus-metrics-exporter-httpserver-1.8.0.jar -No changes. ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy genericResponse() + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy genericResponseWithReporter(java.util.function.Consumer) + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy legacyDetailedResponse() +*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.HTTPServer (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 +*** MODIFIED CLASS: PUBLIC STATIC io.prometheus.metrics.exporter.httpserver.HTTPServer$Builder (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HTTPServer$Builder errorHandlingPolicy(io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy) +*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.MetricsHandler (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + diff --git a/docs/content/exporters/httpserver.md b/docs/content/exporters/httpserver.md index c0db181c9..aaa70c591 100644 --- a/docs/content/exporters/httpserver.md +++ b/docs/content/exporters/httpserver.md @@ -26,6 +26,31 @@ or [inetAddress()](). +## Scrape error handling + +By default, scrape failures return a generic HTTP 500 response. Exception details are not +included in the response or logged, because the server may run inside an application or a +Java agent with its own diagnostic pipeline. + +Configure a reporter to send exception details to an appropriate logging or telemetry sink: + +```java +HTTPServer server = HTTPServer.builder() + .port(9400) + .errorHandlingPolicy( + HttpErrorHandlingPolicy.genericResponseWithReporter( + error -> logger.log(Level.SEVERE, "Prometheus scrape failed", error))) + .buildAndStart(); +``` + +The reporter runs synchronously on the request thread and may be called concurrently. Reporter +runtime exceptions do not prevent the generic HTTP 500 response from being sent. Rate limiting +or deduplication can be implemented in the reporter when needed. + +`HttpErrorHandlingPolicy.legacyDetailedResponse()` restores the previous response containing the +full exception stack trace. This can disclose application internals and must not be used for an +endpoint reachable by untrusted clients. + ## Authentication and HTTPS - [authenticator()]() diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java index d04664718..6cc5bc817 100644 --- a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java @@ -13,7 +13,9 @@ public HttpServerIT() throws IOException, URISyntaxException { @Override protected void assertErrorResponseBody(String body) { assertThat(body) - .isEqualTo("An internal error occurred while scraping metrics.\n") + .isEqualTo( + "An internal error occurred while scraping metrics. " + + "Configure an HTTP error reporter for details.\n") .doesNotContain("Simulating an error."); } } diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java index e93b122b0..c2ada1bbb 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java @@ -61,7 +61,8 @@ private HTTPServer( @Nullable String authenticatedSubjectAttributeName, @Nullable HttpHandler defaultHandler, @Nullable String metricsHandlerPath, - @Nullable Boolean registerHealthHandler) { + @Nullable Boolean registerHealthHandler, + HttpErrorHandlingPolicy errorHandlingPolicy) { if (httpServer.getAddress() == null) { throw new IllegalArgumentException("HttpServer hasn't been bound to an address"); } @@ -85,7 +86,7 @@ private HTTPServer( } registerHandler( metricsPath, - new MetricsHandler(config, registry), + new MetricsHandler(config, registry, errorHandlingPolicy), authenticator, authenticatedSubjectAttributeName); if (registerHealthHandler == null || registerHealthHandler) { @@ -211,6 +212,7 @@ public static class Builder { @Nullable private HttpHandler defaultHandler = null; @Nullable private String metricsHandlerPath = null; @Nullable private Boolean registerHealthHandler = null; + private HttpErrorHandlingPolicy errorHandlingPolicy = HttpErrorHandlingPolicy.genericResponse(); private Builder(PrometheusProperties config) { this.config = config; @@ -295,6 +297,20 @@ public Builder registerHealthHandler(boolean registerHealthHandler) { return this; } + /** + * Configure how exceptions raised while scraping metrics are reported to the client and + * optionally to a caller-supplied diagnostic sink. + * + *

Default is {@link HttpErrorHandlingPolicy#genericResponse()}. + */ + public Builder errorHandlingPolicy(HttpErrorHandlingPolicy errorHandlingPolicy) { + if (errorHandlingPolicy == null) { + throw new NullPointerException("errorHandlingPolicy"); + } + this.errorHandlingPolicy = errorHandlingPolicy; + return this; + } + /** Build and start the HTTPServer. */ public HTTPServer buildAndStart() throws IOException { if (registry == null) { @@ -318,7 +334,8 @@ public HTTPServer buildAndStart() throws IOException { authenticatedSubjectAttributeName, defaultHandler, metricsHandlerPath, - registerHealthHandler); + registerHealthHandler, + errorHandlingPolicy); } private InetSocketAddress makeInetSocketAddress() { diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java new file mode 100644 index 000000000..9dc4194f6 --- /dev/null +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java @@ -0,0 +1,87 @@ +package io.prometheus.metrics.exporter.httpserver; + +import io.prometheus.metrics.annotations.StableApi; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import javax.annotation.Nullable; + +/** + * Controls how the {@link HTTPServer} handles exceptions raised while scraping metrics. + * + *

The default policy is {@link #genericResponse()}, which does not expose exception details and + * does not report the exception. Use {@link #genericResponseWithReporter(Consumer)} to route + * diagnostic details to an application-appropriate sink. + */ +@StableApi +public final class HttpErrorHandlingPolicy { + + private static final byte[] GENERIC_RESPONSE = + ("An internal error occurred while scraping metrics. " + + "Configure an HTTP error reporter for details.\n") + .getBytes(StandardCharsets.UTF_8); + + private final boolean detailedResponse; + @Nullable private final Consumer errorReporter; + + private HttpErrorHandlingPolicy( + boolean detailedResponse, @Nullable Consumer errorReporter) { + this.detailedResponse = detailedResponse; + this.errorReporter = errorReporter; + } + + /** + * Returns the secure default policy. + * + *

Scrape exceptions produce a generic HTTP 500 response and are not reported. This avoids + * exposing exception details to scrape clients or adding an implicit dependency on an + * application's logging configuration. + */ + public static HttpErrorHandlingPolicy genericResponse() { + return new HttpErrorHandlingPolicy(false, null); + } + + /** + * Returns a policy that produces a generic HTTP 500 response and passes scrape exceptions to + * {@code errorReporter}. + * + *

The reporter runs synchronously on the HTTP request thread. It should return promptly and + * must be safe to call concurrently. Runtime exceptions thrown by the reporter are isolated from + * HTTP response handling. + */ + public static HttpErrorHandlingPolicy genericResponseWithReporter( + Consumer errorReporter) { + if (errorReporter == null) { + throw new NullPointerException("errorReporter"); + } + return new HttpErrorHandlingPolicy(false, errorReporter); + } + + /** + * Returns a policy that includes the full exception stack trace in the HTTP 500 response. + * + *

Security warning: This legacy behavior exposes internal exception + * information to scrape clients. Do not use it for endpoints reachable by untrusted clients. + */ + public static HttpErrorHandlingPolicy legacyDetailedResponse() { + return new HttpErrorHandlingPolicy(true, null); + } + + byte[] getErrorResponse(Exception exception) { + if (!detailedResponse) { + return GENERIC_RESPONSE; + } + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + printWriter.write("An Exception occurred while scraping metrics: "); + exception.printStackTrace(printWriter); + return stringWriter.toString().getBytes(StandardCharsets.UTF_8); + } + + void report(Throwable error) { + if (errorReporter != null) { + errorReporter.accept(error); + } + } +} diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java index b18dbb83d..c481a416c 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java @@ -7,7 +7,6 @@ import java.io.IOException; import java.io.OutputStream; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Enumeration; import java.util.List; @@ -17,16 +16,20 @@ public class HttpExchangeAdapter implements PrometheusHttpExchange { private static final Logger logger = Logger.getLogger(HttpExchangeAdapter.class.getName()); - private static final byte[] ERROR_RESPONSE = - "An internal error occurred while scraping metrics.\n".getBytes(StandardCharsets.UTF_8); private final HttpExchange httpExchange; + private final HttpErrorHandlingPolicy errorHandlingPolicy; private final HttpRequest request = new HttpRequest(); private final HttpResponse response = new HttpResponse(); private volatile boolean responseSent = false; public HttpExchangeAdapter(HttpExchange httpExchange) { + this(httpExchange, HttpErrorHandlingPolicy.genericResponse()); + } + + HttpExchangeAdapter(HttpExchange httpExchange, HttpErrorHandlingPolicy errorHandlingPolicy) { this.httpExchange = httpExchange; + this.errorHandlingPolicy = errorHandlingPolicy; } public class HttpRequest implements PrometheusHttpRequest { @@ -105,14 +108,12 @@ public void handleException(RuntimeException e) { private void sendErrorResponse(Exception requestHandlerException) { if (!responseSent) { responseSent = true; - logger.log( - Level.SEVERE, - "The Prometheus metrics HTTPServer caught an Exception during scrape.", - requestHandlerException); + reportException(requestHandlerException); + byte[] errorResponse = errorHandlingPolicy.getErrorResponse(requestHandlerException); try { httpExchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - httpExchange.sendResponseHeaders(500, ERROR_RESPONSE.length); - httpExchange.getResponseBody().write(ERROR_RESPONSE); + httpExchange.sendResponseHeaders(500, errorResponse.length); + httpExchange.getResponseBody().write(errorResponse); } catch (IOException errorWriterException) { // If we can't even send an error response to the client, logging is the only remaining // signal. @@ -121,6 +122,10 @@ private void sendErrorResponse(Exception requestHandlerException) { "The Prometheus metrics HTTPServer caught an Exception during scrape and " + "failed to send an error response to the client.", errorWriterException); + logger.log( + Level.SEVERE, + "Original Exception that caused the Prometheus scrape error:", + requestHandlerException); } } else { // If the exception occurs after response headers have been sent, it's too late to respond @@ -133,6 +138,15 @@ private void sendErrorResponse(Exception requestHandlerException) { } } + private void reportException(Exception requestHandlerException) { + try { + errorHandlingPolicy.report(requestHandlerException); + } catch (RuntimeException ignored) { + // A caller-supplied reporter must not prevent the safe error response from being sent or + // implicitly fall back to application logging. + } + } + @Override public void close() { httpExchange.close(); diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java index 175696b68..e0527cb79 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java @@ -13,25 +13,40 @@ public class MetricsHandler implements HttpHandler { private final PrometheusScrapeHandler prometheusScrapeHandler; + private final HttpErrorHandlingPolicy errorHandlingPolicy; public MetricsHandler() { - prometheusScrapeHandler = new PrometheusScrapeHandler(); + this(new PrometheusScrapeHandler(), HttpErrorHandlingPolicy.genericResponse()); } public MetricsHandler(PrometheusRegistry registry) { - prometheusScrapeHandler = new PrometheusScrapeHandler(registry); + this(new PrometheusScrapeHandler(registry), HttpErrorHandlingPolicy.genericResponse()); } public MetricsHandler(PrometheusProperties config) { - prometheusScrapeHandler = new PrometheusScrapeHandler(config); + this(new PrometheusScrapeHandler(config), HttpErrorHandlingPolicy.genericResponse()); } public MetricsHandler(PrometheusProperties config, PrometheusRegistry registry) { - prometheusScrapeHandler = new PrometheusScrapeHandler(config, registry); + this(new PrometheusScrapeHandler(config, registry), HttpErrorHandlingPolicy.genericResponse()); + } + + MetricsHandler( + PrometheusProperties config, + PrometheusRegistry registry, + HttpErrorHandlingPolicy errorHandlingPolicy) { + this(new PrometheusScrapeHandler(config, registry), errorHandlingPolicy); + } + + private MetricsHandler( + PrometheusScrapeHandler prometheusScrapeHandler, + HttpErrorHandlingPolicy errorHandlingPolicy) { + this.prometheusScrapeHandler = prometheusScrapeHandler; + this.errorHandlingPolicy = errorHandlingPolicy; } @Override public void handle(HttpExchange t) throws IOException { - prometheusScrapeHandler.handleRequest(new HttpExchangeAdapter(t)); + prometheusScrapeHandler.handleRequest(new HttpExchangeAdapter(t, errorHandlingPolicy)); } } diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java index 198e19451..aca6b0b80 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java @@ -25,6 +25,7 @@ import java.security.Principal; import java.util.List; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import javax.security.auth.Subject; import org.junit.jupiter.api.BeforeEach; @@ -160,24 +161,53 @@ void metricsCustomRootPath() throws Exception { @Test void registryThrows() throws Exception { + HTTPServer server = HTTPServer.builder().port(0).registry(throwingRegistry()).buildAndStart(); + run( + server, + "/metrics", + 500, + "Configure an HTTP error reporter for details.", + "IllegalStateException", + "test"); + } + + @Test + void registryExceptionIsPassedToConfiguredReporter() throws Exception { + AtomicReference reportedError = new AtomicReference<>(); HTTPServer server = HTTPServer.builder() .port(0) - .registry( - new PrometheusRegistry() { - @Override - public MetricSnapshots scrape(PrometheusScrapeRequest scrapeRequest) { - throw new IllegalStateException("test"); - } - }) + .registry(throwingRegistry()) + .errorHandlingPolicy( + HttpErrorHandlingPolicy.genericResponseWithReporter(reportedError::set)) .buildAndStart(); + run( server, "/metrics", 500, - "An internal error occurred while scraping metrics.", + "Configure an HTTP error reporter for details.", "IllegalStateException", "test"); + + assertThat(reportedError.get()).isInstanceOf(IllegalStateException.class).hasMessage("test"); + } + + @Test + void registryExceptionCanUseLegacyDetailedResponse() throws Exception { + HTTPServer server = + HTTPServer.builder() + .port(0) + .registry(throwingRegistry()) + .errorHandlingPolicy(HttpErrorHandlingPolicy.legacyDetailedResponse()) + .buildAndStart(); + + run( + server, + "/metrics", + 500, + "IllegalStateException: test", + "Configure an HTTP error reporter for details."); } @Test @@ -246,6 +276,15 @@ private static void run( run(server, path, expectedStatusCode, expectedBody, new String[0]); } + private static PrometheusRegistry throwingRegistry() { + return new PrometheusRegistry() { + @Override + public MetricSnapshots scrape(PrometheusScrapeRequest scrapeRequest) { + throw new IllegalStateException("test"); + } + }; + } + private static void run( HTTPServer server, String path, diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java index 8aaef9c42..c36f1ad73 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java @@ -11,6 +11,7 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; class HttpExchangeAdapterTest { @@ -62,11 +63,75 @@ void handleExceptionReturnsGenericMessageWithoutStackTrace() throws Exception { adapter.handleException(new IllegalStateException("secret failure")); String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); - assertThat(body).isEqualTo("An internal error occurred while scraping metrics.\n"); + assertThat(body) + .isEqualTo( + "An internal error occurred while scraping metrics. " + + "Configure an HTTP error reporter for details.\n"); assertThat(body).doesNotContain("IllegalStateException"); assertThat(body).doesNotContain("secret failure"); assertThat(body).doesNotContain("at "); assertThat(headers.getFirst("Content-Type")).isEqualTo("text/plain; charset=utf-8"); verify(httpExchange).sendResponseHeaders(500, body.getBytes(StandardCharsets.UTF_8).length); } + + @Test + void handleExceptionInvokesConfiguredReporter() { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + AtomicReference reportedError = new AtomicReference<>(); + HttpExchangeAdapter adapter = + new HttpExchangeAdapter( + httpExchange, HttpErrorHandlingPolicy.genericResponseWithReporter(reportedError::set)); + IllegalStateException scrapeException = new IllegalStateException("secret failure"); + + adapter.handleException(scrapeException); + + assertThat(reportedError.get()).isSameAs(scrapeException); + } + + @Test + void reporterFailureDoesNotPreventGenericResponse() { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + HttpExchangeAdapter adapter = + new HttpExchangeAdapter( + httpExchange, + HttpErrorHandlingPolicy.genericResponseWithReporter( + ignored -> { + throw new IllegalStateException("reporter failed"); + })); + + adapter.handleException(new IllegalStateException("secret failure")); + + String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); + assertThat(body) + .contains("Configure an HTTP error reporter for details.") + .doesNotContain("secret failure") + .doesNotContain("reporter failed"); + } + + @Test + void legacyDetailedResponseIncludesStackTrace() { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + HttpExchangeAdapter adapter = + new HttpExchangeAdapter(httpExchange, HttpErrorHandlingPolicy.legacyDetailedResponse()); + + adapter.handleException(new IllegalStateException("diagnostic detail")); + + String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); + assertThat(body) + .contains("An Exception occurred while scraping metrics:") + .contains("IllegalStateException: diagnostic detail") + .contains("at "); + } } From 0f11ca93260cd12638a8f4633c75702e54cb6365 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 24 Jul 2026 08:32:13 +0000 Subject: [PATCH 4/4] refactor: make HTTP error policy builder-based Signed-off-by: Gregor Zeitlinger --- ...prometheus-metrics-exporter-httpserver.txt | 10 ++- docs/content/exporters/httpserver.md | 20 +++-- .../exporter/httpserver/HTTPServer.java | 4 +- .../httpserver/HttpErrorHandlingPolicy.java | 90 +++++++++++-------- .../httpserver/HttpExchangeAdapter.java | 2 +- .../exporter/httpserver/MetricsHandler.java | 8 +- .../exporter/httpserver/HTTPServerTest.java | 7 +- .../httpserver/HttpExchangeAdapterTest.java | 27 ++++-- 8 files changed, 105 insertions(+), 63 deletions(-) diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt index 6a6912dfc..146c5a471 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt @@ -2,9 +2,13 @@ Comparing source compatibility of prometheus-metrics-exporter-httpserver-1.8.1-S +++ NEW CLASS: PUBLIC(+) FINAL(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy (not serializable) +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. +++ NEW SUPERCLASS: java.lang.Object - +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy genericResponse() - +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy genericResponseWithReporter(java.util.function.Consumer) - +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy legacyDetailedResponse() + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder builder() ++++ NEW CLASS: PUBLIC(+) STATIC(+) FINAL(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy build() + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder errorReporter(java.util.function.Consumer) + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder unsafeDebugResponse(boolean) *** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.HTTPServer (not serializable) === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 *** MODIFIED CLASS: PUBLIC STATIC io.prometheus.metrics.exporter.httpserver.HTTPServer$Builder (not serializable) diff --git a/docs/content/exporters/httpserver.md b/docs/content/exporters/httpserver.md index aaa70c591..e5b24de8b 100644 --- a/docs/content/exporters/httpserver.md +++ b/docs/content/exporters/httpserver.md @@ -38,8 +38,9 @@ Configure a reporter to send exception details to an appropriate logging or tele HTTPServer server = HTTPServer.builder() .port(9400) .errorHandlingPolicy( - HttpErrorHandlingPolicy.genericResponseWithReporter( - error -> logger.log(Level.SEVERE, "Prometheus scrape failed", error))) + HttpErrorHandlingPolicy.builder() + .errorReporter(error -> logger.log(Level.SEVERE, "Prometheus scrape failed", error)) + .build()) .buildAndStart(); ``` @@ -47,9 +48,18 @@ The reporter runs synchronously on the request thread and may be called concurre runtime exceptions do not prevent the generic HTTP 500 response from being sent. Rate limiting or deduplication can be implemented in the reporter when needed. -`HttpErrorHandlingPolicy.legacyDetailedResponse()` restores the previous response containing the -full exception stack trace. This can disclose application internals and must not be used for an -endpoint reachable by untrusted clients. +For local debugging, an unsafe response containing the full exception stack trace can be enabled +explicitly: + +```java +HttpErrorHandlingPolicy.builder() + .unsafeDebugResponse(true) + .build() +``` + +This setting is independent of the error reporter, so both can be configured when needed. The +unsafe debug response can disclose application internals and must not be enabled for an endpoint +reachable by untrusted clients. ## Authentication and HTTPS diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java index c2ada1bbb..7759aac1b 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java @@ -212,7 +212,7 @@ public static class Builder { @Nullable private HttpHandler defaultHandler = null; @Nullable private String metricsHandlerPath = null; @Nullable private Boolean registerHealthHandler = null; - private HttpErrorHandlingPolicy errorHandlingPolicy = HttpErrorHandlingPolicy.genericResponse(); + private HttpErrorHandlingPolicy errorHandlingPolicy = HttpErrorHandlingPolicy.builder().build(); private Builder(PrometheusProperties config) { this.config = config; @@ -301,7 +301,7 @@ public Builder registerHealthHandler(boolean registerHealthHandler) { * Configure how exceptions raised while scraping metrics are reported to the client and * optionally to a caller-supplied diagnostic sink. * - *

Default is {@link HttpErrorHandlingPolicy#genericResponse()}. + *

Default is {@code HttpErrorHandlingPolicy.builder().build()}. */ public Builder errorHandlingPolicy(HttpErrorHandlingPolicy errorHandlingPolicy) { if (errorHandlingPolicy == null) { diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java index 9dc4194f6..cad813438 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java @@ -10,9 +10,9 @@ /** * Controls how the {@link HTTPServer} handles exceptions raised while scraping metrics. * - *

The default policy is {@link #genericResponse()}, which does not expose exception details and - * does not report the exception. Use {@link #genericResponseWithReporter(Consumer)} to route - * diagnostic details to an application-appropriate sink. + *

The default policy built by {@link #builder()} does not expose exception details and does not + * report the exception. Configure the builder to route diagnostic details to an + * application-appropriate sink. */ @StableApi public final class HttpErrorHandlingPolicy { @@ -22,54 +22,28 @@ public final class HttpErrorHandlingPolicy { + "Configure an HTTP error reporter for details.\n") .getBytes(StandardCharsets.UTF_8); - private final boolean detailedResponse; + private final boolean unsafeDebugResponse; @Nullable private final Consumer errorReporter; private HttpErrorHandlingPolicy( - boolean detailedResponse, @Nullable Consumer errorReporter) { - this.detailedResponse = detailedResponse; + boolean unsafeDebugResponse, @Nullable Consumer errorReporter) { + this.unsafeDebugResponse = unsafeDebugResponse; this.errorReporter = errorReporter; } /** - * Returns the secure default policy. + * Returns a builder for configuring scrape error handling. * - *

Scrape exceptions produce a generic HTTP 500 response and are not reported. This avoids + *

The builder defaults to a generic HTTP 500 response with no error reporter. This avoids * exposing exception details to scrape clients or adding an implicit dependency on an * application's logging configuration. */ - public static HttpErrorHandlingPolicy genericResponse() { - return new HttpErrorHandlingPolicy(false, null); - } - - /** - * Returns a policy that produces a generic HTTP 500 response and passes scrape exceptions to - * {@code errorReporter}. - * - *

The reporter runs synchronously on the HTTP request thread. It should return promptly and - * must be safe to call concurrently. Runtime exceptions thrown by the reporter are isolated from - * HTTP response handling. - */ - public static HttpErrorHandlingPolicy genericResponseWithReporter( - Consumer errorReporter) { - if (errorReporter == null) { - throw new NullPointerException("errorReporter"); - } - return new HttpErrorHandlingPolicy(false, errorReporter); - } - - /** - * Returns a policy that includes the full exception stack trace in the HTTP 500 response. - * - *

Security warning: This legacy behavior exposes internal exception - * information to scrape clients. Do not use it for endpoints reachable by untrusted clients. - */ - public static HttpErrorHandlingPolicy legacyDetailedResponse() { - return new HttpErrorHandlingPolicy(true, null); + public static Builder builder() { + return new Builder(); } byte[] getErrorResponse(Exception exception) { - if (!detailedResponse) { + if (!unsafeDebugResponse) { return GENERIC_RESPONSE; } StringWriter stringWriter = new StringWriter(); @@ -84,4 +58,46 @@ void report(Throwable error) { errorReporter.accept(error); } } + + /** Builder for {@link HttpErrorHandlingPolicy}. */ + public static final class Builder { + + private boolean unsafeDebugResponse = false; + @Nullable private Consumer errorReporter; + + private Builder() {} + + /** + * Pass scrape exceptions to {@code errorReporter}. + * + *

The reporter runs synchronously on the HTTP request thread. It should return promptly and + * must be safe to call concurrently. Runtime exceptions thrown by the reporter are isolated + * from HTTP response handling. + */ + public Builder errorReporter(Consumer errorReporter) { + if (errorReporter == null) { + throw new NullPointerException("errorReporter"); + } + this.errorReporter = errorReporter; + return this; + } + + /** + * Configure whether the HTTP 500 response includes the full exception stack trace. + * + *

Security warning: Setting this to {@code true} exposes internal exception + * information to scrape clients. Do not enable it for endpoints reachable by untrusted clients. + * + *

This setting is independent of {@link #errorReporter(Consumer)}. + */ + public Builder unsafeDebugResponse(boolean unsafeDebugResponse) { + this.unsafeDebugResponse = unsafeDebugResponse; + return this; + } + + /** Build the policy. */ + public HttpErrorHandlingPolicy build() { + return new HttpErrorHandlingPolicy(unsafeDebugResponse, errorReporter); + } + } } diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java index c481a416c..fce6eba1f 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java @@ -24,7 +24,7 @@ public class HttpExchangeAdapter implements PrometheusHttpExchange { private volatile boolean responseSent = false; public HttpExchangeAdapter(HttpExchange httpExchange) { - this(httpExchange, HttpErrorHandlingPolicy.genericResponse()); + this(httpExchange, HttpErrorHandlingPolicy.builder().build()); } HttpExchangeAdapter(HttpExchange httpExchange, HttpErrorHandlingPolicy errorHandlingPolicy) { diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java index e0527cb79..b3ebf8ae0 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java @@ -16,19 +16,19 @@ public class MetricsHandler implements HttpHandler { private final HttpErrorHandlingPolicy errorHandlingPolicy; public MetricsHandler() { - this(new PrometheusScrapeHandler(), HttpErrorHandlingPolicy.genericResponse()); + this(new PrometheusScrapeHandler(), HttpErrorHandlingPolicy.builder().build()); } public MetricsHandler(PrometheusRegistry registry) { - this(new PrometheusScrapeHandler(registry), HttpErrorHandlingPolicy.genericResponse()); + this(new PrometheusScrapeHandler(registry), HttpErrorHandlingPolicy.builder().build()); } public MetricsHandler(PrometheusProperties config) { - this(new PrometheusScrapeHandler(config), HttpErrorHandlingPolicy.genericResponse()); + this(new PrometheusScrapeHandler(config), HttpErrorHandlingPolicy.builder().build()); } public MetricsHandler(PrometheusProperties config, PrometheusRegistry registry) { - this(new PrometheusScrapeHandler(config, registry), HttpErrorHandlingPolicy.genericResponse()); + this(new PrometheusScrapeHandler(config, registry), HttpErrorHandlingPolicy.builder().build()); } MetricsHandler( diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java index aca6b0b80..59a3048aa 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java @@ -179,7 +179,7 @@ void registryExceptionIsPassedToConfiguredReporter() throws Exception { .port(0) .registry(throwingRegistry()) .errorHandlingPolicy( - HttpErrorHandlingPolicy.genericResponseWithReporter(reportedError::set)) + HttpErrorHandlingPolicy.builder().errorReporter(reportedError::set).build()) .buildAndStart(); run( @@ -194,12 +194,13 @@ void registryExceptionIsPassedToConfiguredReporter() throws Exception { } @Test - void registryExceptionCanUseLegacyDetailedResponse() throws Exception { + void registryExceptionCanUseUnsafeDebugResponse() throws Exception { HTTPServer server = HTTPServer.builder() .port(0) .registry(throwingRegistry()) - .errorHandlingPolicy(HttpErrorHandlingPolicy.legacyDetailedResponse()) + .errorHandlingPolicy( + HttpErrorHandlingPolicy.builder().unsafeDebugResponse(true).build()) .buildAndStart(); run( diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java index c36f1ad73..4ec01c84c 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java @@ -84,7 +84,8 @@ void handleExceptionInvokesConfiguredReporter() { AtomicReference reportedError = new AtomicReference<>(); HttpExchangeAdapter adapter = new HttpExchangeAdapter( - httpExchange, HttpErrorHandlingPolicy.genericResponseWithReporter(reportedError::set)); + httpExchange, + HttpErrorHandlingPolicy.builder().errorReporter(reportedError::set).build()); IllegalStateException scrapeException = new IllegalStateException("secret failure"); adapter.handleException(scrapeException); @@ -102,10 +103,12 @@ void reporterFailureDoesNotPreventGenericResponse() { HttpExchangeAdapter adapter = new HttpExchangeAdapter( httpExchange, - HttpErrorHandlingPolicy.genericResponseWithReporter( - ignored -> { - throw new IllegalStateException("reporter failed"); - })); + HttpErrorHandlingPolicy.builder() + .errorReporter( + ignored -> { + throw new IllegalStateException("reporter failed"); + }) + .build()); adapter.handleException(new IllegalStateException("secret failure")); @@ -117,21 +120,29 @@ void reporterFailureDoesNotPreventGenericResponse() { } @Test - void legacyDetailedResponseIncludesStackTrace() { + void unsafeDebugResponseIncludesStackTraceAndInvokesReporter() { HttpExchange httpExchange = mock(HttpExchange.class); Headers headers = new Headers(); ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); when(httpExchange.getResponseHeaders()).thenReturn(headers); when(httpExchange.getResponseBody()).thenReturn(responseBody); + AtomicReference reportedError = new AtomicReference<>(); HttpExchangeAdapter adapter = - new HttpExchangeAdapter(httpExchange, HttpErrorHandlingPolicy.legacyDetailedResponse()); + new HttpExchangeAdapter( + httpExchange, + HttpErrorHandlingPolicy.builder() + .unsafeDebugResponse(true) + .errorReporter(reportedError::set) + .build()); - adapter.handleException(new IllegalStateException("diagnostic detail")); + IllegalStateException scrapeException = new IllegalStateException("diagnostic detail"); + adapter.handleException(scrapeException); String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); assertThat(body) .contains("An Exception occurred while scraping metrics:") .contains("IllegalStateException: diagnostic detail") .contains("at "); + assertThat(reportedError.get()).isSameAs(scrapeException); } }