From bfc33edad9b19e9f0fb406f4d1a11b6c23c1f7f2 Mon Sep 17 00:00:00 2001 From: Siddarth Gundu Date: Mon, 18 Aug 2025 07:34:10 +0530 Subject: [PATCH 1/5] Add support for /api/v1/parse_query API (experimental) Signed-off-by: Siddarth Gundu --- docs/api/_index.md | 16 ++++++++++++++++ pkg/api/api.go | 2 ++ pkg/api/handlers.go | 2 ++ 3 files changed, 20 insertions(+) diff --git a/docs/api/_index.md b/docs/api/_index.md index 64a6aab3f0c..eb036f850e3 100644 --- a/docs/api/_index.md +++ b/docs/api/_index.md @@ -38,6 +38,7 @@ For the sake of clarity, in this document we have grouped API endpoints by servi | [Range query](#range-query) | Querier, Query-frontend || `GET,POST /api/v1/query_range` | | [Exemplar query](#exemplar-query) | Querier, Query-frontend || `GET,POST /api/v1/query_exemplars` | | [Format query](#format-query) | Querier, Query-frontend || `GET,POST /api/v1/format-query` | +| [Parse query](#parse-query) | Querier, Query-frontend || `GET,POST /api/v1/parse_query` | | [Get series by label matchers](#get-series-by-label-matchers) | Querier, Query-frontend || `GET,POST /api/v1/series` | | [Get label names](#get-label-names) | Querier, Query-frontend || `GET,POST /api/v1/labels` | | [Get label values](#get-label-values) | Querier, Query-frontend || `GET /api/v1/label/{name}/values` | @@ -384,6 +385,21 @@ _For more information, please check out the Prometheus [fomatting query expressi _Requires [authentication](#authentication)._ +### Parse query + +``` +GET,POST /api/v1/parse_query + +# Legacy +GET,POST /api/v1/parse_query +``` + +Prometheus-compatible parse query endpoint. This endpoint is **experimental**, it parses a PromQL expression and returns it as a JSON-formatted AST (abstract syntax tree) representation. + +_For more information, please check out the Prometheus [Parsing query expressions](https://prometheus.io/docs/prometheus/latest/querying/api/#parsing-a-promql-expressions-into-a-abstract-syntax-tree-ast) documentation._ + +_Requires [authentication](#authentication)._ + ### Get series by label matchers ``` diff --git a/pkg/api/api.go b/pkg/api/api.go index 1339caeff28..ebe64440f9c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -437,6 +437,7 @@ func (a *API) RegisterQueryAPI(handler http.Handler) { a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/query_range"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/query_exemplars"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/format_query"), hf, true, "GET", "POST") + a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/parse_query"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/labels"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/label/{name}/values"), hf, true, "GET") a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/series"), hf, true, "GET", "POST", "DELETE") @@ -448,6 +449,7 @@ func (a *API) RegisterQueryAPI(handler http.Handler) { a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/query_range"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/query_exemplars"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/format_query"), hf, true, "GET", "POST") + a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/parse_query"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/labels"), hf, true, "GET", "POST") a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/label/{name}/values"), hf, true, "GET") a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/series"), hf, true, "GET", "POST", "DELETE") diff --git a/pkg/api/handlers.go b/pkg/api/handlers.go index cfa7419adb5..54a55318542 100644 --- a/pkg/api/handlers.go +++ b/pkg/api/handlers.go @@ -295,6 +295,7 @@ func NewQuerierHandler( router.Path(path.Join(prefix, "/api/v1/query_range")).Methods("GET", "POST").Handler(queryAPI.Wrap(queryAPI.RangeQueryHandler)) router.Path(path.Join(prefix, "/api/v1/query_exemplars")).Methods("GET", "POST").Handler(promRouter) router.Path(path.Join(prefix, "/api/v1/format_query")).Methods("GET", "POST").Handler(promRouter) + router.Path(path.Join(prefix, "/api/v1/parse_query")).Methods("GET", "POST").Handler(promRouter) router.Path(path.Join(prefix, "/api/v1/labels")).Methods("GET", "POST").Handler(promRouter) router.Path(path.Join(prefix, "/api/v1/label/{name}/values")).Methods("GET").Handler(promRouter) router.Path(path.Join(prefix, "/api/v1/series")).Methods("GET", "POST", "DELETE").Handler(promRouter) @@ -309,6 +310,7 @@ func NewQuerierHandler( router.Path(path.Join(legacyPrefix, "/api/v1/query_range")).Methods("GET", "POST").Handler(queryAPI.Wrap(queryAPI.RangeQueryHandler)) router.Path(path.Join(legacyPrefix, "/api/v1/query_exemplars")).Methods("GET", "POST").Handler(legacyPromRouter) router.Path(path.Join(legacyPrefix, "/api/v1/format_query")).Methods("GET", "POST").Handler(legacyPromRouter) + router.Path(path.Join(legacyPrefix, "/api/v1/parse_query")).Methods("GET", "POST").Handler(legacyPromRouter) router.Path(path.Join(legacyPrefix, "/api/v1/labels")).Methods("GET", "POST").Handler(legacyPromRouter) router.Path(path.Join(legacyPrefix, "/api/v1/label/{name}/values")).Methods("GET").Handler(legacyPromRouter) router.Path(path.Join(legacyPrefix, "/api/v1/series")).Methods("GET", "POST", "DELETE").Handler(legacyPromRouter) From 6796d47469dde85ebe37fdeb71fe908ba010ea3b Mon Sep 17 00:00:00 2001 From: Siddarth Gundu Date: Mon, 18 Aug 2025 21:21:37 +0530 Subject: [PATCH 2/5] Integration: Add test for parse query API Signed-off-by: Siddarth Gundu --- integration/parse_query_api_test.go | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 integration/parse_query_api_test.go diff --git a/integration/parse_query_api_test.go b/integration/parse_query_api_test.go new file mode 100644 index 00000000000..06db800a922 --- /dev/null +++ b/integration/parse_query_api_test.go @@ -0,0 +1,135 @@ +//go:build requires_docker +// +build requires_docker + +package integration + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cortexproject/cortex/integration/e2e" + e2edb "github.com/cortexproject/cortex/integration/e2e/db" + "github.com/cortexproject/cortex/integration/e2ecortex" +) + +func TestParseQueryAPIQuerier(t *testing.T) { + s, err := e2e.NewScenario(networkName) + require.NoError(t, err) + defer s.Close() + + // Start dependencies. + consul := e2edb.NewConsul() + minio := e2edb.NewMinio(9000, bucketName) + require.NoError(t, s.StartAndWaitReady(consul, minio)) + + flags := mergeFlags(BlocksStorageFlags(), map[string]string{ + "-auth.enabled": "true", + }) + + distributor := e2ecortex.NewDistributor("distributor", e2ecortex.RingStoreConsul, consul.NetworkHTTPEndpoint(), flags, "") + ingester := e2ecortex.NewIngester("ingester", e2ecortex.RingStoreConsul, consul.NetworkHTTPEndpoint(), flags, "") + require.NoError(t, s.StartAndWaitReady(distributor, ingester)) + + // Wait until the distributor has updated the ring. + require.NoError(t, distributor.WaitSumMetrics(e2e.Equals(512), "cortex_ring_tokens_total")) + + querier := e2ecortex.NewQuerier("querier", e2ecortex.RingStoreConsul, consul.NetworkHTTPEndpoint(), flags, "") + require.NoError(t, s.StartAndWaitReady(querier)) + + // Wait until the querier has updated the ring. + require.NoError(t, querier.WaitSumMetrics(e2e.Equals(512), "cortex_ring_tokens_total")) + + endpoint := fmt.Sprintf("http://%s/api/prom/api/v1/parse_query?query=foo/bar", querier.HTTPEndpoint()) + + req, err := http.NewRequest("GET", endpoint, nil) + require.NoError(t, err) + req.Header.Set("X-Scope-OrgID", "user-1") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var parsed struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &parsed)) + require.Equal(t, "success", parsed.Status) + + // check for AST contents. + require.Contains(t, string(parsed.Data), "\"op\":\"/\"") + require.Contains(t, string(parsed.Data), `"lhs":{"matchers":[{"name":"__name__","type":"=","value":"foo"}]`) + require.Contains(t, string(parsed.Data), `"rhs":{"matchers":[{"name":"__name__","type":"=","value":"bar"}]`) +} + +func TestParseQueryAPIQueryFrontend(t *testing.T) { + s, err := e2e.NewScenario(networkName) + require.NoError(t, err) + defer s.Close() + + // Start dependencies. + consul := e2edb.NewConsul() + minio := e2edb.NewMinio(9000, bucketName) + require.NoError(t, s.StartAndWaitReady(consul, minio)) + + flags := mergeFlags(BlocksStorageFlags(), map[string]string{ + "-auth.enabled": "true", + }) + + // Start the query-frontend. + queryFrontend := e2ecortex.NewQueryFrontend("query-frontend", flags, "") + require.NoError(t, s.Start(queryFrontend)) + + distributor := e2ecortex.NewDistributor("distributor", e2ecortex.RingStoreConsul, consul.NetworkHTTPEndpoint(), flags, "") + ingester := e2ecortex.NewIngester("ingester", e2ecortex.RingStoreConsul, consul.NetworkHTTPEndpoint(), flags, "") + require.NoError(t, s.StartAndWaitReady(distributor, ingester)) + + // Wait until both the distributor updated the ring. + require.NoError(t, distributor.WaitSumMetrics(e2e.Equals(512), "cortex_ring_tokens_total")) + + querier := e2ecortex.NewQuerier("querierWithFrontend", e2ecortex.RingStoreConsul, consul.NetworkHTTPEndpoint(), mergeFlags(flags, map[string]string{ + "-querier.frontend-address": queryFrontend.NetworkGRPCEndpoint(), + }), "") + + require.NoError(t, s.StartAndWaitReady(querier)) + require.NoError(t, s.WaitReady(queryFrontend)) + + require.NoError(t, querier.WaitSumMetrics(e2e.Equals(512), "cortex_ring_tokens_total")) + + endpoint := fmt.Sprintf("http://%s/api/prom/api/v1/parse_query?query=foo/bar", queryFrontend.HTTPEndpoint()) + + req, err := http.NewRequest("GET", endpoint, nil) + require.NoError(t, err) + req.Header.Set("X-Scope-OrgID", "user-1") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var parsed struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &parsed)) + require.Equal(t, "success", parsed.Status) + + // check for AST contents. + require.Contains(t, string(parsed.Data), "\"op\":\"/\"") + require.Contains(t, string(parsed.Data), `"lhs":{"matchers":[{"name":"__name__","type":"=","value":"foo"}]`) + require.Contains(t, string(parsed.Data), `"rhs":{"matchers":[{"name":"__name__","type":"=","value":"bar"}]`) +} From 4ebef6d8be95989883bd783cf63af3b45d0a61fc Mon Sep 17 00:00:00 2001 From: Siddarth Gundu Date: Mon, 18 Aug 2025 21:24:08 +0530 Subject: [PATCH 3/5] docs: fix typo of format query api in the endpoints table Signed-off-by: Siddarth Gundu --- docs/api/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/_index.md b/docs/api/_index.md index eb036f850e3..73462ec2585 100644 --- a/docs/api/_index.md +++ b/docs/api/_index.md @@ -37,7 +37,7 @@ For the sake of clarity, in this document we have grouped API endpoints by servi | [Instant query](#instant-query) | Querier, Query-frontend || `GET,POST /api/v1/query` | | [Range query](#range-query) | Querier, Query-frontend || `GET,POST /api/v1/query_range` | | [Exemplar query](#exemplar-query) | Querier, Query-frontend || `GET,POST /api/v1/query_exemplars` | -| [Format query](#format-query) | Querier, Query-frontend || `GET,POST /api/v1/format-query` | +| [Format query](#format-query) | Querier, Query-frontend || `GET,POST /api/v1/format_query` | | [Parse query](#parse-query) | Querier, Query-frontend || `GET,POST /api/v1/parse_query` | | [Get series by label matchers](#get-series-by-label-matchers) | Querier, Query-frontend || `GET,POST /api/v1/series` | | [Get label names](#get-label-names) | Querier, Query-frontend || `GET,POST /api/v1/labels` | From 795aa1a3747658bce4601e15948b3c7b7dfdbe73 Mon Sep 17 00:00:00 2001 From: Siddarth Gundu Date: Mon, 18 Aug 2025 21:47:48 +0530 Subject: [PATCH 4/5] update changelog Signed-off-by: Siddarth Gundu --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1979ef6f0..c26b4308e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## master / unreleased +* [FEATURE] Query Frontend: Add support for /api/v1/parse_query API (experimental) to parse a PromQL expression and return it as a JSON-formatted AST (abstract syntax tree). #6978 * [CHANGE] StoreGateway/Alertmanager: Add default 5s connection timeout on client. #6603 * [CHANGE] Ingester: Remove EnableNativeHistograms config flag and instead gate keep through new per-tenant limit at ingestion. #6718 * [CHANGE] Validate a tenantID when to use a single tenant resolver. #6727 From 71f7ed94385598b81d86b2c223945ce2ddf23d77 Mon Sep 17 00:00:00 2001 From: Siddarth Gundu Date: Tue, 19 Aug 2025 00:18:44 +0530 Subject: [PATCH 5/5] move entry for proper grouping Signed-off-by: Siddarth Gundu --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c26b4308e1b..161a62fe319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## master / unreleased -* [FEATURE] Query Frontend: Add support for /api/v1/parse_query API (experimental) to parse a PromQL expression and return it as a JSON-formatted AST (abstract syntax tree). #6978 * [CHANGE] StoreGateway/Alertmanager: Add default 5s connection timeout on client. #6603 * [CHANGE] Ingester: Remove EnableNativeHistograms config flag and instead gate keep through new per-tenant limit at ingestion. #6718 * [CHANGE] Validate a tenantID when to use a single tenant resolver. #6727 @@ -24,6 +23,7 @@ * [FEATURE] Querier: Allow choosing PromQL engine via header. #6777 * [FEATURE] Querier: Support for configuring query optimizers and enabling XFunctions in the Thanos engine. #6873 * [FEATURE] Query Frontend: Add support /api/v1/format_query API for formatting queries. #6893 +* [FEATURE] Query Frontend: Add support for /api/v1/parse_query API (experimental) to parse a PromQL expression and return it as a JSON-formatted AST (abstract syntax tree). #6978 * [ENHANCEMENT] Ingester: Add `cortex_ingester_tsdb_wal_replay_unknown_refs_total` and `cortex_ingester_tsdb_wbl_replay_unknown_refs_total` metrics to track unknown series references during wal/wbl replaying. #6945 * [ENHANCEMENT] Ruler: Emit an error message when the rule synchronization fails. #6902 * [ENHANCEMENT] Querier: Support snappy and zstd response compression for `-querier.response-compression` flag. #6848