diff --git a/pkg/api/api.go b/pkg/api/api.go index 08da9f1a11..914db28fe5 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 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 ( @@ -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 5852a65cbb..d3b537c919 100644 --- a/pkg/api/handlers.go +++ b/pkg/api/handlers.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "html/template" @@ -353,6 +354,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 +395,35 @@ 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 map[string]map[string]bool + logger log.Logger +} + +type featuresResponse struct { + Status string `json:"status"` + Data map[string]map[string]bool `json:"data"` +} + +func (h *featuresHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request) { + resp := featuresResponse{ + Status: "success", + Data: h.features, + } + // 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(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 cf3b7ee1a7..a6b3e5305e 100644 --- a/pkg/api/handlers_test.go +++ b/pkg/api/handlers_test.go @@ -250,3 +250,80 @@ func TestBuildInfoAPI(t *testing.T) { }) } } + +func TestFeaturesAPI(t *testing.T) { + for _, tc := range []struct { + name string + features map[string]map[string]bool + }{ + { + name: "nil features", + features: nil, + }, + { + 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) { + 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, 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) + + 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 c5b71e68a4..0db0fc02d9 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" @@ -113,6 +114,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 +128,66 @@ func (t *Cortex) initAPI() (services.Service, error) { return nil, nil } +// 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 + } + } + 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, + } + + // 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, + } + + // 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 +} + 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 d24ba43086..1e12eda7d6 100644 --- a/pkg/cortex/modules_test.go +++ b/pkg/cortex/modules_test.go @@ -353,3 +353,57 @@ func setAllSecrets(v reflect.Value, sentinel string) { } } } + +func TestCortexFeatures(t *testing.T) { + tests := []struct { + name string + configFn func(*Config) + experimentalExpected bool + queryStatsExpected bool + }{ + { + name: "default features", + configFn: func(cfg *Config) {}, + experimentalExpected: false, + queryStatsExpected: false, + }, + { + name: "experimental functions and query stats enabled", + configFn: func(cfg *Config) { + cfg.Querier.EnablePromQLExperimentalFunctions = true + cfg.Querier.EnablePerStepStats = true + }, + experimentalExpected: true, + queryStatsExpected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := Config{} + tc.configFn(&cfg) + features := cortexFeatures(cfg) + + // 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"]) + }) + } +} +