Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions pkg/api/handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"bytes"
"context"
"encoding/json"
"html/template"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
77 changes: 77 additions & 0 deletions pkg/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
})
}
64 changes: 64 additions & 0 deletions pkg/cortex/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions pkg/cortex/modules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
}
}

Loading