From b20d24ab01c90766ae960a3032583687e4be0697 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Mon, 11 May 2026 23:13:21 +0530 Subject: [PATCH 1/2] Add GET /api/v1/features endpoint (#7467) This commit introduces the GET /api/v1/features endpoint to expose which capabilities are enabled in the Cortex instance. The endpoint is registered on both the legacy and prometheus HTTP prefixes and returns a JSON payload listing features such as remote_write_v2, streaming_ingestion, parquet_queryable, tenant_federation, and distributed_execution, based on the current Cortex configuration. Signed-off-by: Ayush Kumar --- pkg/api/api.go | 14 +++++++ pkg/api/handlers.go | 33 +++++++++++++++++ pkg/api/handlers_test.go | 48 ++++++++++++++++++++++++ pkg/cortex/modules.go | 32 ++++++++++++++++ pkg/cortex/modules_test.go | 75 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+) diff --git a/pkg/api/api.go b/pkg/api/api.go index 08da9f1a11f..a9a02c930d5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -85,6 +85,10 @@ type Config struct { buildInfoEnabled bool `yaml:"build_info_enabled"` QuerierDefaultCodec string `yaml:"querier_default_codec"` + + // Features is a list of enabled feature names to be exposed via the /api/v1/features endpoint. + // This is injected by the upstream caller. + Features []string `yaml:"-"` } var ( @@ -116,6 +120,11 @@ func (cfg *Config) Validate() error { return nil } +// BuildInfoEnabled returns true if the build info API is enabled. +func (cfg *Config) BuildInfoEnabled() bool { + return cfg.buildInfoEnabled +} + // Push either wraps the distributor push function as configured or returns the distributor push directly. func (cfg *Config) wrapDistributorPush(d *distributor.Distributor) push.Func { if cfg.DistributorPushWrapper != nil { @@ -484,6 +493,11 @@ func (a *API) RegisterQueryAPI(handler http.Handler) { a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/status/buildinfo"), infoHandler, true, "GET") a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/status/buildinfo"), infoHandler, true, "GET") } + + // Register /api/v1/features endpoint on both Prometheus and legacy HTTP prefixes. + fHandler := &featuresHandler{features: a.cfg.Features, logger: a.logger} + a.RegisterRoute(path.Join(a.cfg.PrometheusHTTPPrefix, "/api/v1/features"), fHandler, true, "GET") + a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/api/v1/features"), fHandler, true, "GET") } // RegisterQueryFrontendHandler registers the Prometheus routes supported by the diff --git a/pkg/api/handlers.go b/pkg/api/handlers.go index 5852a65cbb1..fd06aa254b1 100644 --- a/pkg/api/handlers.go +++ b/pkg/api/handlers.go @@ -353,6 +353,11 @@ func NewQuerierHandler( router.Path(path.Join(legacyPrefix, "/api/v1/status/buildinfo")).Methods("GET").Handler(legacyPromRouter) } + // Register /api/v1/features endpoint on the internal querier router. + fHandler := &featuresHandler{features: cfg.Features, logger: logger} + router.Path(path.Join(prefix, "/api/v1/features")).Methods("GET").Handler(fHandler) + router.Path(path.Join(legacyPrefix, "/api/v1/features")).Methods("GET").Handler(fHandler) + // Track execution time. return stats.NewWallTimeMiddleware().Wrap(router) } @@ -389,3 +394,31 @@ func (h *buildInfoHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request level.Error(h.logger).Log("msg", "write build info response", "error", err) } } + +type featuresHandler struct { + features []string + logger log.Logger +} + +type featuresResponse struct { + Status string `json:"status"` + Data []string `json:"data"` +} + +func (h *featuresHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request) { + resp := featuresResponse{ + Status: "success", + Data: h.features, + } + output, err := json.Marshal(resp) + if err != nil { + level.Error(h.logger).Log("msg", "marshal features response", "error", err) + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + if _, err := writer.Write(output); err != nil { + level.Error(h.logger).Log("msg", "write features response", "error", err) + } +} diff --git a/pkg/api/handlers_test.go b/pkg/api/handlers_test.go index cf3b7ee1a75..50b2d4c9b6e 100644 --- a/pkg/api/handlers_test.go +++ b/pkg/api/handlers_test.go @@ -250,3 +250,51 @@ func TestBuildInfoAPI(t *testing.T) { }) } } + +func TestFeaturesAPI(t *testing.T) { + for _, tc := range []struct { + name string + features []string + expectedStatus int + expectedFeatures []string + }{ + { + name: "no features enabled", + features: nil, + expectedStatus: 200, + expectedFeatures: nil, + }, + { + name: "single feature enabled", + features: []string{"remote_write_v2"}, + expectedStatus: 200, + expectedFeatures: []string{"remote_write_v2"}, + }, + { + name: "multiple features enabled", + features: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"}, + expectedStatus: 200, + expectedFeatures: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + handler := &featuresHandler{ + features: tc.features, + logger: &FakeLogger{}, + } + + writer := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/features", nil) + handler.ServeHTTP(writer, req) + + assert.Equal(t, tc.expectedStatus, writer.Code) + assert.Equal(t, "application/json", writer.Header().Get("Content-Type")) + + var resp featuresResponse + err := json.Unmarshal(writer.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "success", resp.Status) + assert.Equal(t, tc.expectedFeatures, resp.Data) + }) + } +} diff --git a/pkg/cortex/modules.go b/pkg/cortex/modules.go index c5b71e68a49..c9d6aeb0bea 100644 --- a/pkg/cortex/modules.go +++ b/pkg/cortex/modules.go @@ -113,6 +113,9 @@ func (t *Cortex) initAPI() (services.Service, error) { t.Cfg.API.ServerPrefix = t.Cfg.Server.PathPrefix t.Cfg.API.LegacyHTTPPrefix = t.Cfg.HTTPPrefix + // Compute the list of enabled features from the root config. + t.Cfg.API.Features = cortexFeatures(t.Cfg) + a, err := api.New(t.Cfg.API, t.Cfg.Server, t.Server, util_log.Logger) if err != nil { return nil, err @@ -124,6 +127,35 @@ func (t *Cortex) initAPI() (services.Service, error) { return nil, nil } +// cortexFeatures returns a list of feature names that are enabled in the given config. +func cortexFeatures(cfg Config) []string { + var features []string + + if cfg.Distributor.RemoteWriteV2Enabled { + features = append(features, "remote_write_v2") + } + if cfg.Distributor.UseStreamPush { + features = append(features, "streaming_ingestion") + } + if cfg.Querier.EnableParquetQueryable { + features = append(features, "parquet_queryable") + } + if cfg.TenantFederation.Enabled { + features = append(features, "tenant_federation") + } + if cfg.Querier.DistributedExecEnabled { + features = append(features, "distributed_execution") + } + if cfg.Querier.EnablePromQLExperimentalFunctions { + features = append(features, "promql_experimental_functions") + } + if cfg.API.BuildInfoEnabled() { + features = append(features, "build_info") + } + + return features +} + func (t *Cortex) initServer() (services.Service, error) { // Cortex handles signals on its own. DisableSignalHandling(&t.Cfg.Server) diff --git a/pkg/cortex/modules_test.go b/pkg/cortex/modules_test.go index d24ba430865..42ef5effc02 100644 --- a/pkg/cortex/modules_test.go +++ b/pkg/cortex/modules_test.go @@ -353,3 +353,78 @@ func setAllSecrets(v reflect.Value, sentinel string) { } } } + +func TestCortexFeatures(t *testing.T) { + tests := []struct { + name string + configFn func(*Config) + expectedFeatures []string + }{ + { + name: "no features enabled", + configFn: func(cfg *Config) {}, + expectedFeatures: nil, + }, + { + name: "remote_write_v2 enabled", + configFn: func(cfg *Config) { + cfg.Distributor.RemoteWriteV2Enabled = true + }, + expectedFeatures: []string{"remote_write_v2"}, + }, + { + name: "streaming_ingestion enabled", + configFn: func(cfg *Config) { + cfg.Distributor.UseStreamPush = true + }, + expectedFeatures: []string{"streaming_ingestion"}, + }, + { + name: "parquet_queryable enabled", + configFn: func(cfg *Config) { + cfg.Querier.EnableParquetQueryable = true + }, + expectedFeatures: []string{"parquet_queryable"}, + }, + { + name: "tenant_federation enabled", + configFn: func(cfg *Config) { + cfg.TenantFederation.Enabled = true + }, + expectedFeatures: []string{"tenant_federation"}, + }, + { + name: "distributed_execution enabled", + configFn: func(cfg *Config) { + cfg.Querier.DistributedExecEnabled = true + }, + expectedFeatures: []string{"distributed_execution"}, + }, + { + name: "promql_experimental_functions enabled", + configFn: func(cfg *Config) { + cfg.Querier.EnablePromQLExperimentalFunctions = true + }, + expectedFeatures: []string{"promql_experimental_functions"}, + }, + { + name: "multiple features enabled", + configFn: func(cfg *Config) { + cfg.Distributor.RemoteWriteV2Enabled = true + cfg.Distributor.UseStreamPush = true + cfg.TenantFederation.Enabled = true + cfg.Querier.EnableParquetQueryable = true + }, + expectedFeatures: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := Config{} + tc.configFn(&cfg) + features := cortexFeatures(cfg) + assert.Equal(t, tc.expectedFeatures, features) + }) + } +} From 95d42fb0589149100bd2925a8d045fa21a2f4151 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Wed, 13 May 2026 16:05:48 +0530 Subject: [PATCH 2/2] Update features endpoint to use Prometheus format Refactored the GET /api/v1/features endpoint to return the standard Prometheus nested map format (map[string]map[string]bool) instead of a flat string slice. This aligns the discovery endpoint with Prometheus's client expectations. - Removed Cortex-specific features (e.g. parquet_queryable) - Dynamically build promql_functions from the parser - Updated unit tests and the handler JSON encoder Signed-off-by: Ayush Kumar --- pkg/api/api.go | 6 +-- pkg/api/handlers.go | 17 +++++--- pkg/api/handlers_test.go | 69 +++++++++++++++++++++--------- pkg/cortex/modules.go | 74 +++++++++++++++++++++++--------- pkg/cortex/modules_test.go | 87 +++++++++++++++----------------------- 5 files changed, 149 insertions(+), 104 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index a9a02c930d5..914db28fe50 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -86,9 +86,9 @@ type Config struct { QuerierDefaultCodec string `yaml:"querier_default_codec"` - // Features is a list of enabled feature names to be exposed via the /api/v1/features endpoint. - // This is injected by the upstream caller. - Features []string `yaml:"-"` + // Features is a map of feature categories to their feature flags, matching the Prometheus + // features.json format. This is injected by the upstream caller. + Features map[string]map[string]bool `yaml:"-"` } var ( diff --git a/pkg/api/handlers.go b/pkg/api/handlers.go index fd06aa254b1..d3b537c919d 100644 --- a/pkg/api/handlers.go +++ b/pkg/api/handlers.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "html/template" @@ -396,13 +397,13 @@ func (h *buildInfoHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request } type featuresHandler struct { - features []string + features map[string]map[string]bool logger log.Logger } type featuresResponse struct { - Status string `json:"status"` - Data []string `json:"data"` + Status string `json:"status"` + Data map[string]map[string]bool `json:"data"` } func (h *featuresHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request) { @@ -410,15 +411,19 @@ func (h *featuresHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request) Status: "success", Data: h.features, } - output, err := json.Marshal(resp) - if err != nil { + // Use a non-HTML-escaping encoder to avoid escaping PromQL operators + // like >=, <=, etc., matching the Prometheus features endpoint behavior. + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(resp); err != nil { level.Error(h.logger).Log("msg", "marshal features response", "error", err) http.Error(writer, err.Error(), http.StatusInternalServerError) return } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - if _, err := writer.Write(output); err != nil { + if _, err := writer.Write(buf.Bytes()); err != nil { level.Error(h.logger).Log("msg", "write features response", "error", err) } } diff --git a/pkg/api/handlers_test.go b/pkg/api/handlers_test.go index 50b2d4c9b6e..a6b3e5305e8 100644 --- a/pkg/api/handlers_test.go +++ b/pkg/api/handlers_test.go @@ -253,28 +253,30 @@ func TestBuildInfoAPI(t *testing.T) { func TestFeaturesAPI(t *testing.T) { for _, tc := range []struct { - name string - features []string - expectedStatus int - expectedFeatures []string + name string + features map[string]map[string]bool }{ { - name: "no features enabled", - features: nil, - expectedStatus: 200, - expectedFeatures: nil, - }, - { - name: "single feature enabled", - features: []string{"remote_write_v2"}, - expectedStatus: 200, - expectedFeatures: []string{"remote_write_v2"}, + name: "nil features", + features: nil, }, { - name: "multiple features enabled", - features: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"}, - expectedStatus: 200, - expectedFeatures: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"}, + name: "populated features", + features: map[string]map[string]bool{ + "api": { + "query_stats": true, + "label_values_match": true, + }, + "promql_operators": { + ">=": true, + "<=": true, + }, + "promql_functions": { + "abs": true, + "ceil": true, + "floor": true, + }, + }, }, } { t.Run(tc.name, func(t *testing.T) { @@ -287,14 +289,41 @@ func TestFeaturesAPI(t *testing.T) { req := httptest.NewRequest("GET", "/api/v1/features", nil) handler.ServeHTTP(writer, req) - assert.Equal(t, tc.expectedStatus, writer.Code) + assert.Equal(t, 200, writer.Code) assert.Equal(t, "application/json", writer.Header().Get("Content-Type")) var resp featuresResponse err := json.Unmarshal(writer.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, "success", resp.Status) - assert.Equal(t, tc.expectedFeatures, resp.Data) + + if tc.features == nil { + assert.Nil(t, resp.Data) + } else { + require.NotNil(t, resp.Data) + for category, featureMap := range tc.features { + assert.Equal(t, featureMap, resp.Data[category], "category %s mismatch", category) + } + } }) } + + // Verify that PromQL operators like >= and <= are NOT HTML-escaped. + t.Run("operators not html escaped", func(t *testing.T) { + handler := &featuresHandler{ + features: map[string]map[string]bool{ + "promql_operators": {">=": true, "<=": true}, + }, + logger: &FakeLogger{}, + } + writer := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/features", nil) + handler.ServeHTTP(writer, req) + + body := writer.Body.String() + assert.Contains(t, body, `">="`) + assert.Contains(t, body, `"<="`) + assert.NotContains(t, body, `\u003e`) + assert.NotContains(t, body, `\u003c`) + }) } diff --git a/pkg/cortex/modules.go b/pkg/cortex/modules.go index c9d6aeb0bea..0db0fc02d9a 100644 --- a/pkg/cortex/modules.go +++ b/pkg/cortex/modules.go @@ -18,6 +18,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/prometheus/promql" + "github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/rules" prom_storage "github.com/prometheus/prometheus/storage" "github.com/thanos-io/objstore" @@ -127,30 +128,61 @@ func (t *Cortex) initAPI() (services.Service, error) { return nil, nil } -// cortexFeatures returns a list of feature names that are enabled in the given config. -func cortexFeatures(cfg Config) []string { - var features []string - - if cfg.Distributor.RemoteWriteV2Enabled { - features = append(features, "remote_write_v2") - } - if cfg.Distributor.UseStreamPush { - features = append(features, "streaming_ingestion") - } - if cfg.Querier.EnableParquetQueryable { - features = append(features, "parquet_queryable") - } - if cfg.TenantFederation.Enabled { - features = append(features, "tenant_federation") +// cortexFeatures returns a Prometheus-compatible features map based on the given config. +// The response format matches Prometheus's GET /api/v1/features endpoint, providing +// clients like Grafana with accurate capability discovery. +func cortexFeatures(cfg Config) map[string]map[string]bool { + features := make(map[string]map[string]bool) + + experimentalFunctions := cfg.Querier.EnablePromQLExperimentalFunctions + + // Build promql_functions from the vendored Prometheus parser. + promqlFunctions := make(map[string]bool, len(parser.Functions)) + for name, fn := range parser.Functions { + if fn.Experimental { + promqlFunctions[name] = experimentalFunctions + } else { + promqlFunctions[name] = true + } } - if cfg.Querier.DistributedExecEnabled { - features = append(features, "distributed_execution") + features["promql_functions"] = promqlFunctions + + // PromQL language features supported by Cortex. + features["promql"] = map[string]bool{ + "at_modifier": true, + "negative_offset": true, + "offset": true, + "subqueries": true, + "bool": true, + "by": true, + "without": true, + "on": true, + "ignoring": true, + "group_left": true, + "group_right": true, + "per_step_stats": cfg.Querier.EnablePerStepStats, } - if cfg.Querier.EnablePromQLExperimentalFunctions { - features = append(features, "promql_experimental_functions") + + // PromQL operators supported by Cortex. + features["promql_operators"] = map[string]bool{ + "+": true, "-": true, "*": true, "/": true, "%": true, "^": true, + "==": true, "!=": true, ">": true, "<": true, ">=": true, "<=": true, + "=~": true, "!~": true, "@": true, + "and": true, "or": true, "unless": true, + "sum": true, "avg": true, "count": true, "min": true, "max": true, + "group": true, "stddev": true, "stdvar": true, + "topk": true, "bottomk": true, "count_values": true, "quantile": true, + "atan2": true, + "limitk": false, + "limit_ratio": false, } - if cfg.API.BuildInfoEnabled() { - features = append(features, "build_info") + + // API features relevant to Cortex as a Prometheus-compatible query backend. + features["api"] = map[string]bool{ + "query_stats": cfg.Querier.EnablePerStepStats, + "label_values_match": true, + "time_range_labels": true, + "time_range_series": true, } return features diff --git a/pkg/cortex/modules_test.go b/pkg/cortex/modules_test.go index 42ef5effc02..1e12eda7d68 100644 --- a/pkg/cortex/modules_test.go +++ b/pkg/cortex/modules_test.go @@ -356,66 +356,25 @@ func setAllSecrets(v reflect.Value, sentinel string) { func TestCortexFeatures(t *testing.T) { tests := []struct { - name string - configFn func(*Config) - expectedFeatures []string + name string + configFn func(*Config) + experimentalExpected bool + queryStatsExpected bool }{ { - name: "no features enabled", - configFn: func(cfg *Config) {}, - expectedFeatures: nil, + name: "default features", + configFn: func(cfg *Config) {}, + experimentalExpected: false, + queryStatsExpected: false, }, { - name: "remote_write_v2 enabled", - configFn: func(cfg *Config) { - cfg.Distributor.RemoteWriteV2Enabled = true - }, - expectedFeatures: []string{"remote_write_v2"}, - }, - { - name: "streaming_ingestion enabled", - configFn: func(cfg *Config) { - cfg.Distributor.UseStreamPush = true - }, - expectedFeatures: []string{"streaming_ingestion"}, - }, - { - name: "parquet_queryable enabled", - configFn: func(cfg *Config) { - cfg.Querier.EnableParquetQueryable = true - }, - expectedFeatures: []string{"parquet_queryable"}, - }, - { - name: "tenant_federation enabled", - configFn: func(cfg *Config) { - cfg.TenantFederation.Enabled = true - }, - expectedFeatures: []string{"tenant_federation"}, - }, - { - name: "distributed_execution enabled", - configFn: func(cfg *Config) { - cfg.Querier.DistributedExecEnabled = true - }, - expectedFeatures: []string{"distributed_execution"}, - }, - { - name: "promql_experimental_functions enabled", + name: "experimental functions and query stats enabled", configFn: func(cfg *Config) { cfg.Querier.EnablePromQLExperimentalFunctions = true + cfg.Querier.EnablePerStepStats = true }, - expectedFeatures: []string{"promql_experimental_functions"}, - }, - { - name: "multiple features enabled", - configFn: func(cfg *Config) { - cfg.Distributor.RemoteWriteV2Enabled = true - cfg.Distributor.UseStreamPush = true - cfg.TenantFederation.Enabled = true - cfg.Querier.EnableParquetQueryable = true - }, - expectedFeatures: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"}, + experimentalExpected: true, + queryStatsExpected: true, }, } @@ -424,7 +383,27 @@ func TestCortexFeatures(t *testing.T) { cfg := Config{} tc.configFn(&cfg) features := cortexFeatures(cfg) - assert.Equal(t, tc.expectedFeatures, features) + + // Check API category + require.Contains(t, features, "api") + assert.Equal(t, tc.queryStatsExpected, features["api"]["query_stats"]) + assert.True(t, features["api"]["label_values_match"]) + + // Check PromQL category + require.Contains(t, features, "promql") + assert.Equal(t, tc.queryStatsExpected, features["promql"]["per_step_stats"]) + assert.True(t, features["promql"]["subqueries"]) + + // Check PromQL Operators + require.Contains(t, features, "promql_operators") + assert.True(t, features["promql_operators"]["+"]) + assert.False(t, features["promql_operators"]["limitk"]) + + // Check PromQL Functions + require.Contains(t, features, "promql_functions") + assert.True(t, features["promql_functions"]["abs"]) + assert.Equal(t, tc.experimentalExpected, features["promql_functions"]["info"]) }) } } +