diff --git a/x-pack/plugin/prometheus/build.gradle b/x-pack/plugin/prometheus/build.gradle
index 4e557cf535b46..aca6c0f543ba7 100644
--- a/x-pack/plugin/prometheus/build.gradle
+++ b/x-pack/plugin/prometheus/build.gradle
@@ -18,6 +18,8 @@ def protobufVersion = "4.32.0"
dependencies {
compileOnly project(path: xpackModule('core'))
compileOnly project(path: xpackModule('esql'))
+ compileOnly project(path: xpackModule('esql-core'))
+ compileOnly project(':x-pack:plugin:esql:compute')
testImplementation(testArtifact(project(xpackModule('core'))))
testImplementation project(path: xpackModule('esql'))
testImplementation project(path: xpackModule('esql-core'))
diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusErrorResponse.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusErrorResponse.java
new file mode 100644
index 0000000000000..f828a6360a6e9
--- /dev/null
+++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusErrorResponse.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.prometheus.rest;
+
+import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.rest.RestChannel;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+
+import java.io.IOException;
+
+/**
+ * Utility for building and sending Prometheus-format error responses.
+ *
+ *
Error types follow the Prometheus HTTP API specification:
+ * api.go
+ */
+class PrometheusErrorResponse {
+
+ private PrometheusErrorResponse() {}
+
+ /**
+ * Sends a Prometheus-format error response derived from the given exception.
+ * If sending fails, logs a warning and attempts a plain-text fallback response.
+ */
+ static void send(RestChannel channel, Exception e, Logger logger) {
+ try {
+ RestStatus status = ExceptionsHelper.status(e);
+ channel.sendResponse(new RestResponse(status, build(status, e.getMessage())));
+ } catch (Exception inner) {
+ inner.addSuppressed(e);
+ logger.warn("Failed to send error response", inner);
+ try {
+ channel.sendResponse(
+ new RestResponse(
+ RestStatus.INTERNAL_SERVER_ERROR,
+ RestResponse.TEXT_CONTENT_TYPE,
+ new BytesArray("Internal server error")
+ )
+ );
+ } catch (Exception ignored) {}
+ }
+ }
+
+ /**
+ * Builds a Prometheus-format error JSON response body:
+ * {@code {"status":"error","errorType":"","error":""}}
+ */
+ static XContentBuilder build(RestStatus status, String message) throws IOException {
+ XContentBuilder builder = XContentFactory.jsonBuilder();
+ builder.startObject();
+ builder.field("status", "error");
+ builder.field("errorType", mapErrorType(status));
+ builder.field("error", message != null ? message : "unknown error");
+ builder.endObject();
+ return builder;
+ }
+
+ /**
+ * Maps an HTTP status to a Prometheus error type string.
+ */
+ static String mapErrorType(RestStatus status) {
+ return switch (status) {
+ case BAD_REQUEST -> "bad_data";
+ case SERVICE_UNAVAILABLE, REQUEST_TIMEOUT, GATEWAY_TIMEOUT -> "timeout";
+ default -> "execution";
+ };
+ }
+}
diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusQueryRangeResponseListener.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusQueryRangeResponseListener.java
index 393409a3ad443..25f2901ac4e4d 100644
--- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusQueryRangeResponseListener.java
+++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusQueryRangeResponseListener.java
@@ -7,9 +7,7 @@
package org.elasticsearch.xpack.prometheus.rest;
-import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.action.ActionListener;
-import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.logging.LogManager;
import org.elasticsearch.logging.Logger;
import org.elasticsearch.rest.RestChannel;
@@ -40,7 +38,6 @@
class PrometheusQueryRangeResponseListener implements ActionListener {
private static final Logger logger = LogManager.getLogger(PrometheusQueryRangeResponseListener.class);
- private static final String JSON_CONTENT_TYPE = XContentType.JSON.mediaType();
// Column names expected in the ES|QL PROMQL response.
static final String VALUE_COLUMN = "value";
@@ -78,16 +75,7 @@ public void onFailure(Exception e) {
private void sendErrorResponse(Exception e) {
logger.debug("PromQL query_range request failed", e);
- try {
- RestStatus status = ExceptionsHelper.status(e);
- XContentBuilder builder = buildErrorJson(status, e.getMessage());
- channel.sendResponse(new RestResponse(status, builder));
- } catch (Exception inner) {
- logger.error("failed to send error response for PromQL query_range", inner);
- try {
- channel.sendResponse(new RestResponse(RestStatus.INTERNAL_SERVER_ERROR, JSON_CONTENT_TYPE, new BytesArray("{}")));
- } catch (Exception ignored) {}
- }
+ PrometheusErrorResponse.send(channel, e, logger);
}
/**
@@ -263,24 +251,6 @@ private static void writeMetricFields(XContentBuilder builder, String prefix, Ma
}
}
- static XContentBuilder buildErrorJson(RestStatus status, String message) throws IOException {
- XContentBuilder builder = XContentFactory.jsonBuilder();
- builder.startObject();
- builder.field("status", "error");
- builder.field("errorType", mapErrorType(status));
- builder.field("error", message != null ? message : "unknown error");
- builder.endObject();
- return builder;
- }
-
- private static String mapErrorType(RestStatus status) {
- return switch (status) {
- case BAD_REQUEST -> "bad_data";
- case SERVICE_UNAVAILABLE, REQUEST_TIMEOUT, GATEWAY_TIMEOUT -> "timeout";
- default -> "execution";
- };
- }
-
static class SeriesData {
final String rawSeriesJson;
final Map labels;
diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesResponseListener.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesResponseListener.java
new file mode 100644
index 0000000000000..140e938eb2d3f
--- /dev/null
+++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesResponseListener.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.prometheus.rest;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.rest.RestChannel;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.json.JsonXContent;
+import org.elasticsearch.xpack.esql.action.EsqlQueryResponse;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Converts an {@link EsqlQueryResponse} from a {@link org.elasticsearch.xpack.esql.plan.logical.TsInfo} plan into the
+ * Prometheus {@code /api/v1/series} JSON response format.
+ */
+public class PrometheusSeriesResponseListener implements ActionListener {
+
+ private static final Logger logger = LogManager.getLogger(PrometheusSeriesResponseListener.class);
+
+ static final String COL_METRIC_NAME = "metric_name";
+ static final String COL_DIMENSIONS = "dimensions";
+ private static final String LABELS_PREFIX = "labels.";
+ private static final String CONTENT_TYPE = "application/json";
+
+ private final RestChannel channel;
+
+ public PrometheusSeriesResponseListener(RestChannel channel) {
+ this.channel = channel;
+ }
+
+ @Override
+ public void onResponse(EsqlQueryResponse response) {
+ // Do NOT close/decRef the response here: the framework (via respondAndRelease) calls
+ // decRef() after this method returns, which is the correct single release.
+ try {
+ List