Merge pull request #783 from Adirael/feature/support-json-eval

Add support for ajson scripting engine
This commit is contained in:
Noor Muhammad Malik
2025-02-04 12:16:17 +01:00
committed by GitHub
6 changed files with 86 additions and 14 deletions

View File

@ -116,6 +116,7 @@ metadata:
annotations: annotations:
# metric-config.<metricType>.<metricName>.<collectorType>/<configKey> # metric-config.<metricType>.<metricName>.<collectorType>/<configKey>
metric-config.pods.requests-per-second.json-path/json-key: "$.http_server.rps" metric-config.pods.requests-per-second.json-path/json-key: "$.http_server.rps"
metric-config.pods.requests-per-second.json-path/json-eval: "ceil($['active processes'] / $['total processes'] * 100)" # cannot use both json-eval and json-key
metric-config.pods.requests-per-second.json-path/path: /metrics metric-config.pods.requests-per-second.json-path/path: /metrics
metric-config.pods.requests-per-second.json-path/port: "9090" metric-config.pods.requests-per-second.json-path/port: "9090"
metric-config.pods.requests-per-second.json-path/scheme: "https" metric-config.pods.requests-per-second.json-path/scheme: "https"
@ -158,6 +159,10 @@ The json-path query support depends on the
See the README for possible queries. It's expected that the metric you query See the README for possible queries. It's expected that the metric you query
returns something that can be turned into a `float64`. returns something that can be turned into a `float64`.
The `json-eval` configuration option allows for more complex calculations to be
performed on the extracted metric. The `json-eval` expression is evaluated using
[ajson's script engine](https://github.com/spyzhov/ajson?tab=readme-ov-file#script-engine).
The other configuration options `path`, `port` and `scheme` specify where the metrics The other configuration options `path`, `port` and `scheme` specify where the metrics
endpoint is exposed on the pod. The `path` and `port` options do not have default values endpoint is exposed on the pod. The `path` and `port` options do not have default values
so they must be defined. The `scheme` is optional and defaults to `http`. so they must be defined. The `scheme` is optional and defaults to `http`.
@ -825,6 +830,7 @@ metadata:
annotations: annotations:
# metric-config.<metricType>.<metricName>.<collectorType>/<configKey> # metric-config.<metricType>.<metricName>.<collectorType>/<configKey>
metric-config.external.unique-metric-name.json-path/json-key: "$.some-metric.value" metric-config.external.unique-metric-name.json-path/json-key: "$.some-metric.value"
metric-config.external.unique-metric-name.json-path/json-eval: ceil($['active processes'] / $['total processes'] * 100) # cannot use both json-eval and json-key
metric-config.external.unique-metric-name.json-path/endpoint: "http://metric-source.app-namespace:8080/metrics" metric-config.external.unique-metric-name.json-path/endpoint: "http://metric-source.app-namespace:8080/metrics"
metric-config.external.unique-metric-name.json-path/aggregator: "max" metric-config.external.unique-metric-name.json-path/aggregator: "max"
metric-config.external.unique-metric-name.json-path/interval: "60s" # optional metric-config.external.unique-metric-name.json-path/interval: "60s" # optional
@ -852,6 +858,8 @@ The HTTP collector similar to the Pod Metrics collector. The following
configuration values are supported: configuration values are supported:
- `json-key` to specify the JSON path of the metric to be queried - `json-key` to specify the JSON path of the metric to be queried
- `json-eval` to specify an evaluate string to [evaluate on the script engine](https://github.com/spyzhov/ajson?tab=readme-ov-file#script-engine),
cannot be used in conjunction with `json-key`
- `endpoint` the fully formed path to query for the metric. In the above example a Kubernetes _Service_ - `endpoint` the fully formed path to query for the metric. In the above example a Kubernetes _Service_
in the namespace `app-namespace` is called. in the namespace `app-namespace` is called.
- `aggregator` is only required if the metric is an array of values and specifies how the values - `aggregator` is only required if the metric is an array of values and specifies how the values

View File

@ -19,6 +19,7 @@ const (
HTTPMetricNameLegacy = "http" HTTPMetricNameLegacy = "http"
HTTPEndpointAnnotationKey = "endpoint" HTTPEndpointAnnotationKey = "endpoint"
HTTPJsonPathAnnotationKey = "json-key" HTTPJsonPathAnnotationKey = "json-key"
HTTPJsonEvalAnnotationKey = "json-eval"
) )
type HTTPCollectorPlugin struct{} type HTTPCollectorPlugin struct{}
@ -31,14 +32,27 @@ func (p *HTTPCollectorPlugin) NewCollector(_ context.Context, hpa *autoscalingv2
collector := &HTTPCollector{ collector := &HTTPCollector{
namespace: hpa.Namespace, namespace: hpa.Namespace,
} }
var ( var (
value string value string
ok bool ok bool
jsonPath string
jsonEval string
) )
if value, ok = config.Config[HTTPJsonPathAnnotationKey]; !ok {
return nil, fmt.Errorf("config value %s not found", HTTPJsonPathAnnotationKey) if value, ok = config.Config[HTTPJsonPathAnnotationKey]; ok {
jsonPath = value
}
if value, ok = config.Config[HTTPJsonEvalAnnotationKey]; ok {
jsonEval = value
}
if jsonPath == "" && jsonEval == "" {
return nil, fmt.Errorf("config value %s or %s not found", HTTPJsonPathAnnotationKey, HTTPJsonEvalAnnotationKey)
}
if jsonPath != "" && jsonEval != "" {
return nil, fmt.Errorf("config value %s and %s cannot be used together", HTTPJsonPathAnnotationKey, HTTPJsonEvalAnnotationKey)
} }
jsonPath := value
if value, ok = config.Config[HTTPEndpointAnnotationKey]; !ok { if value, ok = config.Config[HTTPEndpointAnnotationKey]; !ok {
return nil, fmt.Errorf("config value %s not found", HTTPEndpointAnnotationKey) return nil, fmt.Errorf("config value %s not found", HTTPEndpointAnnotationKey)
@ -62,7 +76,7 @@ func (p *HTTPCollectorPlugin) NewCollector(_ context.Context, hpa *autoscalingv2
return nil, err return nil, err
} }
} }
jsonPathGetter, err := httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath) jsonPathGetter, err := httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath, jsonEval)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -16,18 +16,22 @@ import (
// the json path query. // the json path query.
type JSONPathMetricsGetter struct { type JSONPathMetricsGetter struct {
jsonPath string jsonPath string
jsonEval string
aggregator AggregatorFunc aggregator AggregatorFunc
client *http.Client client *http.Client
} }
// NewJSONPathMetricsGetter initializes a new JSONPathMetricsGetter. // NewJSONPathMetricsGetter initializes a new JSONPathMetricsGetter.
func NewJSONPathMetricsGetter(httpClient *http.Client, aggregatorFunc AggregatorFunc, jsonPath string) (*JSONPathMetricsGetter, error) { func NewJSONPathMetricsGetter(httpClient *http.Client, aggregatorFunc AggregatorFunc, jsonPath string, jsonEval string) (*JSONPathMetricsGetter, error) {
// check that jsonPath parses // check that jsonPath parses
_, err := ajson.ParseJSONPath(jsonPath) if jsonPath != "" {
if err != nil { _, err := ajson.ParseJSONPath(jsonPath)
return nil, err if err != nil {
return nil, err
}
} }
return &JSONPathMetricsGetter{client: httpClient, aggregator: aggregatorFunc, jsonPath: jsonPath}, nil
return &JSONPathMetricsGetter{client: httpClient, aggregator: aggregatorFunc, jsonPath: jsonPath, jsonEval: jsonEval}, nil
} }
var DefaultRequestTimeout = 15 * time.Second var DefaultRequestTimeout = 15 * time.Second
@ -67,9 +71,19 @@ func (g *JSONPathMetricsGetter) GetMetric(metricsURL url.URL) (float64, error) {
return 0, err return 0, err
} }
nodes, err := root.JSONPath(g.jsonPath) var nodes []*ajson.Node
if err != nil { if g.jsonPath != "" {
return 0, err nodes, err = root.JSONPath(g.jsonPath)
if err != nil {
return 0, err
}
} else {
result, err := ajson.Eval(root, g.jsonEval)
nodes = append(nodes, result)
if err != nil {
return 0, err
}
} }
if len(nodes) == 0 { if len(nodes) == 0 {

View File

@ -26,6 +26,7 @@ func TestJSONPathMetricsGetter(t *testing.T) {
name string name string
jsonResponse []byte jsonResponse []byte
jsonPath string jsonPath string
jsonEval string
result float64 result float64
aggregator AggregatorFunc aggregator AggregatorFunc
err error err error
@ -58,6 +59,19 @@ func TestJSONPathMetricsGetter(t *testing.T) {
result: 5, result: 5,
aggregator: Average, aggregator: Average,
}, },
{
name: "evaluated script",
jsonResponse: []byte(`{"active processes":1,"total processes":10}`),
jsonEval: "ceil($['active processes'] / $['total processes'] * 100)",
result: 10,
aggregator: Average,
},
{
name: "invalid script should error",
jsonResponse: []byte(`{"active processes":1,"total processes":10}`),
jsonEval: "ceil($['active processes'] ) $['total processes'] * 100)",
err: errors.New("wrong request: formula has no left parentheses"),
},
{ {
name: "json path not resulting in array or number should lead to error", name: "json path not resulting in array or number should lead to error",
jsonResponse: []byte(`{"metric.value":5}`), jsonResponse: []byte(`{"metric.value":5}`),
@ -74,7 +88,7 @@ func TestJSONPathMetricsGetter(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
server := makeTestHTTPServer(t, tc.jsonResponse) server := makeTestHTTPServer(t, tc.jsonResponse)
defer server.Close() defer server.Close()
getter, err := NewJSONPathMetricsGetter(DefaultMetricsHTTPClient(), tc.aggregator, tc.jsonPath) getter, err := NewJSONPathMetricsGetter(DefaultMetricsHTTPClient(), tc.aggregator, tc.jsonPath, tc.jsonEval)
require.NoError(t, err) require.NoError(t, err)
url, err := url.Parse(fmt.Sprintf("%s/metrics", server.URL)) url, err := url.Parse(fmt.Sprintf("%s/metrics", server.URL))
require.NoError(t, err) require.NoError(t, err)

View File

@ -33,6 +33,7 @@ func NewPodMetricsJSONPathGetter(config map[string]string) (*PodMetricsJSONPathG
getter := PodMetricsJSONPathGetter{} getter := PodMetricsJSONPathGetter{}
var ( var (
jsonPath string jsonPath string
jsonEval string
aggregator AggregatorFunc aggregator AggregatorFunc
err error err error
) )
@ -41,6 +42,16 @@ func NewPodMetricsJSONPathGetter(config map[string]string) (*PodMetricsJSONPathG
jsonPath = v jsonPath = v
} }
if v, ok := config["json-eval"]; ok {
jsonEval = v
}
if jsonPath == "" && jsonEval == "" {
return nil, fmt.Errorf("config value json-key or json-eval must be set")
} else if jsonPath != "" && jsonEval != "" {
return nil, fmt.Errorf("config value json-key and json-eval are mutually exclusive")
}
if v, ok := config["scheme"]; ok { if v, ok := config["scheme"]; ok {
getter.scheme = v getter.scheme = v
} }
@ -93,7 +104,7 @@ func NewPodMetricsJSONPathGetter(config map[string]string) (*PodMetricsJSONPathG
connectTimeout = d connectTimeout = d
} }
jsonPathGetter, err := NewJSONPathMetricsGetter(CustomMetricsHTTPClient(requestTimeout, connectTimeout), aggregator, jsonPath) jsonPathGetter, err := NewJSONPathMetricsGetter(CustomMetricsHTTPClient(requestTimeout, connectTimeout), aggregator, jsonPath, jsonEval)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -86,6 +86,17 @@ func TestNewPodJSONPathMetricsGetter(t *testing.T) {
port: 9090, port: 9090,
rawQuery: "foo=bar&baz=bop", rawQuery: "foo=bar&baz=bop",
}, getterWithRawQuery) }, getterWithRawQuery)
configErrorMixedPathEval := map[string]string{
"json-key": "{}",
"json-eval": "avg($.values)",
"scheme": "http",
"path": "/metrics",
"port": "9090",
}
_, err6 := NewPodMetricsJSONPathGetter(configErrorMixedPathEval)
require.Error(t, err6)
} }
func TestBuildMetricsURL(t *testing.T) { func TestBuildMetricsURL(t *testing.T) {