Skip to content

Commit 95d42fb

Browse files
committed
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 <kayush2k02@gmail.com>
1 parent b20d24a commit 95d42fb

5 files changed

Lines changed: 149 additions & 104 deletions

File tree

pkg/api/api.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,9 @@ type Config struct {
8686

8787
QuerierDefaultCodec string `yaml:"querier_default_codec"`
8888

89-
// Features is a list of enabled feature names to be exposed via the /api/v1/features endpoint.
90-
// This is injected by the upstream caller.
91-
Features []string `yaml:"-"`
89+
// Features is a map of feature categories to their feature flags, matching the Prometheus
90+
// features.json format. This is injected by the upstream caller.
91+
Features map[string]map[string]bool `yaml:"-"`
9292
}
9393

9494
var (

pkg/api/handlers.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"html/template"
@@ -396,29 +397,33 @@ func (h *buildInfoHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request
396397
}
397398

398399
type featuresHandler struct {
399-
features []string
400+
features map[string]map[string]bool
400401
logger log.Logger
401402
}
402403

403404
type featuresResponse struct {
404-
Status string `json:"status"`
405-
Data []string `json:"data"`
405+
Status string `json:"status"`
406+
Data map[string]map[string]bool `json:"data"`
406407
}
407408

408409
func (h *featuresHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request) {
409410
resp := featuresResponse{
410411
Status: "success",
411412
Data: h.features,
412413
}
413-
output, err := json.Marshal(resp)
414-
if err != nil {
414+
// Use a non-HTML-escaping encoder to avoid escaping PromQL operators
415+
// like >=, <=, etc., matching the Prometheus features endpoint behavior.
416+
var buf bytes.Buffer
417+
enc := json.NewEncoder(&buf)
418+
enc.SetEscapeHTML(false)
419+
if err := enc.Encode(resp); err != nil {
415420
level.Error(h.logger).Log("msg", "marshal features response", "error", err)
416421
http.Error(writer, err.Error(), http.StatusInternalServerError)
417422
return
418423
}
419424
writer.Header().Set("Content-Type", "application/json")
420425
writer.WriteHeader(http.StatusOK)
421-
if _, err := writer.Write(output); err != nil {
426+
if _, err := writer.Write(buf.Bytes()); err != nil {
422427
level.Error(h.logger).Log("msg", "write features response", "error", err)
423428
}
424429
}

pkg/api/handlers_test.go

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -253,28 +253,30 @@ func TestBuildInfoAPI(t *testing.T) {
253253

254254
func TestFeaturesAPI(t *testing.T) {
255255
for _, tc := range []struct {
256-
name string
257-
features []string
258-
expectedStatus int
259-
expectedFeatures []string
256+
name string
257+
features map[string]map[string]bool
260258
}{
261259
{
262-
name: "no features enabled",
263-
features: nil,
264-
expectedStatus: 200,
265-
expectedFeatures: nil,
266-
},
267-
{
268-
name: "single feature enabled",
269-
features: []string{"remote_write_v2"},
270-
expectedStatus: 200,
271-
expectedFeatures: []string{"remote_write_v2"},
260+
name: "nil features",
261+
features: nil,
272262
},
273263
{
274-
name: "multiple features enabled",
275-
features: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"},
276-
expectedStatus: 200,
277-
expectedFeatures: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"},
264+
name: "populated features",
265+
features: map[string]map[string]bool{
266+
"api": {
267+
"query_stats": true,
268+
"label_values_match": true,
269+
},
270+
"promql_operators": {
271+
">=": true,
272+
"<=": true,
273+
},
274+
"promql_functions": {
275+
"abs": true,
276+
"ceil": true,
277+
"floor": true,
278+
},
279+
},
278280
},
279281
} {
280282
t.Run(tc.name, func(t *testing.T) {
@@ -287,14 +289,41 @@ func TestFeaturesAPI(t *testing.T) {
287289
req := httptest.NewRequest("GET", "/api/v1/features", nil)
288290
handler.ServeHTTP(writer, req)
289291

290-
assert.Equal(t, tc.expectedStatus, writer.Code)
292+
assert.Equal(t, 200, writer.Code)
291293
assert.Equal(t, "application/json", writer.Header().Get("Content-Type"))
292294

293295
var resp featuresResponse
294296
err := json.Unmarshal(writer.Body.Bytes(), &resp)
295297
require.NoError(t, err)
296298
assert.Equal(t, "success", resp.Status)
297-
assert.Equal(t, tc.expectedFeatures, resp.Data)
299+
300+
if tc.features == nil {
301+
assert.Nil(t, resp.Data)
302+
} else {
303+
require.NotNil(t, resp.Data)
304+
for category, featureMap := range tc.features {
305+
assert.Equal(t, featureMap, resp.Data[category], "category %s mismatch", category)
306+
}
307+
}
298308
})
299309
}
310+
311+
// Verify that PromQL operators like >= and <= are NOT HTML-escaped.
312+
t.Run("operators not html escaped", func(t *testing.T) {
313+
handler := &featuresHandler{
314+
features: map[string]map[string]bool{
315+
"promql_operators": {">=": true, "<=": true},
316+
},
317+
logger: &FakeLogger{},
318+
}
319+
writer := httptest.NewRecorder()
320+
req := httptest.NewRequest("GET", "/api/v1/features", nil)
321+
handler.ServeHTTP(writer, req)
322+
323+
body := writer.Body.String()
324+
assert.Contains(t, body, `">="`)
325+
assert.Contains(t, body, `"<="`)
326+
assert.NotContains(t, body, `\u003e`)
327+
assert.NotContains(t, body, `\u003c`)
328+
})
300329
}

pkg/cortex/modules.go

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/prometheus/client_golang/prometheus"
1919
"github.com/prometheus/common/model"
2020
"github.com/prometheus/prometheus/promql"
21+
"github.com/prometheus/prometheus/promql/parser"
2122
"github.com/prometheus/prometheus/rules"
2223
prom_storage "github.com/prometheus/prometheus/storage"
2324
"github.com/thanos-io/objstore"
@@ -127,30 +128,61 @@ func (t *Cortex) initAPI() (services.Service, error) {
127128
return nil, nil
128129
}
129130

130-
// cortexFeatures returns a list of feature names that are enabled in the given config.
131-
func cortexFeatures(cfg Config) []string {
132-
var features []string
133-
134-
if cfg.Distributor.RemoteWriteV2Enabled {
135-
features = append(features, "remote_write_v2")
136-
}
137-
if cfg.Distributor.UseStreamPush {
138-
features = append(features, "streaming_ingestion")
139-
}
140-
if cfg.Querier.EnableParquetQueryable {
141-
features = append(features, "parquet_queryable")
142-
}
143-
if cfg.TenantFederation.Enabled {
144-
features = append(features, "tenant_federation")
131+
// cortexFeatures returns a Prometheus-compatible features map based on the given config.
132+
// The response format matches Prometheus's GET /api/v1/features endpoint, providing
133+
// clients like Grafana with accurate capability discovery.
134+
func cortexFeatures(cfg Config) map[string]map[string]bool {
135+
features := make(map[string]map[string]bool)
136+
137+
experimentalFunctions := cfg.Querier.EnablePromQLExperimentalFunctions
138+
139+
// Build promql_functions from the vendored Prometheus parser.
140+
promqlFunctions := make(map[string]bool, len(parser.Functions))
141+
for name, fn := range parser.Functions {
142+
if fn.Experimental {
143+
promqlFunctions[name] = experimentalFunctions
144+
} else {
145+
promqlFunctions[name] = true
146+
}
145147
}
146-
if cfg.Querier.DistributedExecEnabled {
147-
features = append(features, "distributed_execution")
148+
features["promql_functions"] = promqlFunctions
149+
150+
// PromQL language features supported by Cortex.
151+
features["promql"] = map[string]bool{
152+
"at_modifier": true,
153+
"negative_offset": true,
154+
"offset": true,
155+
"subqueries": true,
156+
"bool": true,
157+
"by": true,
158+
"without": true,
159+
"on": true,
160+
"ignoring": true,
161+
"group_left": true,
162+
"group_right": true,
163+
"per_step_stats": cfg.Querier.EnablePerStepStats,
148164
}
149-
if cfg.Querier.EnablePromQLExperimentalFunctions {
150-
features = append(features, "promql_experimental_functions")
165+
166+
// PromQL operators supported by Cortex.
167+
features["promql_operators"] = map[string]bool{
168+
"+": true, "-": true, "*": true, "/": true, "%": true, "^": true,
169+
"==": true, "!=": true, ">": true, "<": true, ">=": true, "<=": true,
170+
"=~": true, "!~": true, "@": true,
171+
"and": true, "or": true, "unless": true,
172+
"sum": true, "avg": true, "count": true, "min": true, "max": true,
173+
"group": true, "stddev": true, "stdvar": true,
174+
"topk": true, "bottomk": true, "count_values": true, "quantile": true,
175+
"atan2": true,
176+
"limitk": false,
177+
"limit_ratio": false,
151178
}
152-
if cfg.API.BuildInfoEnabled() {
153-
features = append(features, "build_info")
179+
180+
// API features relevant to Cortex as a Prometheus-compatible query backend.
181+
features["api"] = map[string]bool{
182+
"query_stats": cfg.Querier.EnablePerStepStats,
183+
"label_values_match": true,
184+
"time_range_labels": true,
185+
"time_range_series": true,
154186
}
155187

156188
return features

pkg/cortex/modules_test.go

Lines changed: 33 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -356,66 +356,25 @@ func setAllSecrets(v reflect.Value, sentinel string) {
356356

357357
func TestCortexFeatures(t *testing.T) {
358358
tests := []struct {
359-
name string
360-
configFn func(*Config)
361-
expectedFeatures []string
359+
name string
360+
configFn func(*Config)
361+
experimentalExpected bool
362+
queryStatsExpected bool
362363
}{
363364
{
364-
name: "no features enabled",
365-
configFn: func(cfg *Config) {},
366-
expectedFeatures: nil,
365+
name: "default features",
366+
configFn: func(cfg *Config) {},
367+
experimentalExpected: false,
368+
queryStatsExpected: false,
367369
},
368370
{
369-
name: "remote_write_v2 enabled",
370-
configFn: func(cfg *Config) {
371-
cfg.Distributor.RemoteWriteV2Enabled = true
372-
},
373-
expectedFeatures: []string{"remote_write_v2"},
374-
},
375-
{
376-
name: "streaming_ingestion enabled",
377-
configFn: func(cfg *Config) {
378-
cfg.Distributor.UseStreamPush = true
379-
},
380-
expectedFeatures: []string{"streaming_ingestion"},
381-
},
382-
{
383-
name: "parquet_queryable enabled",
384-
configFn: func(cfg *Config) {
385-
cfg.Querier.EnableParquetQueryable = true
386-
},
387-
expectedFeatures: []string{"parquet_queryable"},
388-
},
389-
{
390-
name: "tenant_federation enabled",
391-
configFn: func(cfg *Config) {
392-
cfg.TenantFederation.Enabled = true
393-
},
394-
expectedFeatures: []string{"tenant_federation"},
395-
},
396-
{
397-
name: "distributed_execution enabled",
398-
configFn: func(cfg *Config) {
399-
cfg.Querier.DistributedExecEnabled = true
400-
},
401-
expectedFeatures: []string{"distributed_execution"},
402-
},
403-
{
404-
name: "promql_experimental_functions enabled",
371+
name: "experimental functions and query stats enabled",
405372
configFn: func(cfg *Config) {
406373
cfg.Querier.EnablePromQLExperimentalFunctions = true
374+
cfg.Querier.EnablePerStepStats = true
407375
},
408-
expectedFeatures: []string{"promql_experimental_functions"},
409-
},
410-
{
411-
name: "multiple features enabled",
412-
configFn: func(cfg *Config) {
413-
cfg.Distributor.RemoteWriteV2Enabled = true
414-
cfg.Distributor.UseStreamPush = true
415-
cfg.TenantFederation.Enabled = true
416-
cfg.Querier.EnableParquetQueryable = true
417-
},
418-
expectedFeatures: []string{"remote_write_v2", "streaming_ingestion", "parquet_queryable", "tenant_federation"},
376+
experimentalExpected: true,
377+
queryStatsExpected: true,
419378
},
420379
}
421380

@@ -424,7 +383,27 @@ func TestCortexFeatures(t *testing.T) {
424383
cfg := Config{}
425384
tc.configFn(&cfg)
426385
features := cortexFeatures(cfg)
427-
assert.Equal(t, tc.expectedFeatures, features)
386+
387+
// Check API category
388+
require.Contains(t, features, "api")
389+
assert.Equal(t, tc.queryStatsExpected, features["api"]["query_stats"])
390+
assert.True(t, features["api"]["label_values_match"])
391+
392+
// Check PromQL category
393+
require.Contains(t, features, "promql")
394+
assert.Equal(t, tc.queryStatsExpected, features["promql"]["per_step_stats"])
395+
assert.True(t, features["promql"]["subqueries"])
396+
397+
// Check PromQL Operators
398+
require.Contains(t, features, "promql_operators")
399+
assert.True(t, features["promql_operators"]["+"])
400+
assert.False(t, features["promql_operators"]["limitk"])
401+
402+
// Check PromQL Functions
403+
require.Contains(t, features, "promql_functions")
404+
assert.True(t, features["promql_functions"]["abs"])
405+
assert.Equal(t, tc.experimentalExpected, features["promql_functions"]["info"])
428406
})
429407
}
430408
}
409+

0 commit comments

Comments
 (0)