From 5f6a683d64177e83638bd82ec637e332ea7f1ac6 Mon Sep 17 00:00:00 2001 From: Angel Alonso Date: Tue, 28 Jan 2025 20:17:28 +0000 Subject: [PATCH 1/4] Add support for ajson scripting engine in Pod collector through json-eval config key Signed-off-by: Angel Alonso --- pkg/collector/http_collector.go | 2 +- pkg/collector/httpmetrics/json_path.go | 30 ++++++++++++++----- pkg/collector/httpmetrics/json_path_test.go | 16 +++++++++- pkg/collector/httpmetrics/pod_metrics.go | 13 +++++++- pkg/collector/httpmetrics/pod_metrics_test.go | 11 +++++++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/pkg/collector/http_collector.go b/pkg/collector/http_collector.go index 7537f69..eacb475 100644 --- a/pkg/collector/http_collector.go +++ b/pkg/collector/http_collector.go @@ -62,7 +62,7 @@ func (p *HTTPCollectorPlugin) NewCollector(_ context.Context, hpa *autoscalingv2 return nil, err } } - jsonPathGetter, err := httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath) + jsonPathGetter, err := httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath, "") if err != nil { return nil, err } diff --git a/pkg/collector/httpmetrics/json_path.go b/pkg/collector/httpmetrics/json_path.go index 72384aa..04f4bb8 100644 --- a/pkg/collector/httpmetrics/json_path.go +++ b/pkg/collector/httpmetrics/json_path.go @@ -16,18 +16,22 @@ import ( // the json path query. type JSONPathMetricsGetter struct { jsonPath string + jsonEval string aggregator AggregatorFunc client *http.Client } // 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 - _, err := ajson.ParseJSONPath(jsonPath) - if err != nil { - return nil, err + if jsonPath != "" { + _, err := ajson.ParseJSONPath(jsonPath) + 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 @@ -67,9 +71,19 @@ func (g *JSONPathMetricsGetter) GetMetric(metricsURL url.URL) (float64, error) { return 0, err } - nodes, err := root.JSONPath(g.jsonPath) - if err != nil { - return 0, err + var nodes []*ajson.Node + if g.jsonPath != "" { + 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 { diff --git a/pkg/collector/httpmetrics/json_path_test.go b/pkg/collector/httpmetrics/json_path_test.go index b2150c3..0005904 100644 --- a/pkg/collector/httpmetrics/json_path_test.go +++ b/pkg/collector/httpmetrics/json_path_test.go @@ -26,6 +26,7 @@ func TestJSONPathMetricsGetter(t *testing.T) { name string jsonResponse []byte jsonPath string + jsonEval string result float64 aggregator AggregatorFunc err error @@ -58,6 +59,19 @@ func TestJSONPathMetricsGetter(t *testing.T) { result: 5, 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", jsonResponse: []byte(`{"metric.value":5}`), @@ -74,7 +88,7 @@ func TestJSONPathMetricsGetter(t *testing.T) { t.Run(tc.name, func(t *testing.T) { server := makeTestHTTPServer(t, tc.jsonResponse) 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) url, err := url.Parse(fmt.Sprintf("%s/metrics", server.URL)) require.NoError(t, err) diff --git a/pkg/collector/httpmetrics/pod_metrics.go b/pkg/collector/httpmetrics/pod_metrics.go index 35bc5f7..e22cc66 100644 --- a/pkg/collector/httpmetrics/pod_metrics.go +++ b/pkg/collector/httpmetrics/pod_metrics.go @@ -33,6 +33,7 @@ func NewPodMetricsJSONPathGetter(config map[string]string) (*PodMetricsJSONPathG getter := PodMetricsJSONPathGetter{} var ( jsonPath string + jsonEval string aggregator AggregatorFunc err error ) @@ -41,6 +42,16 @@ func NewPodMetricsJSONPathGetter(config map[string]string) (*PodMetricsJSONPathG 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 { getter.scheme = v } @@ -93,7 +104,7 @@ func NewPodMetricsJSONPathGetter(config map[string]string) (*PodMetricsJSONPathG connectTimeout = d } - jsonPathGetter, err := NewJSONPathMetricsGetter(CustomMetricsHTTPClient(requestTimeout, connectTimeout), aggregator, jsonPath) + jsonPathGetter, err := NewJSONPathMetricsGetter(CustomMetricsHTTPClient(requestTimeout, connectTimeout), aggregator, jsonPath, jsonEval) if err != nil { return nil, err } diff --git a/pkg/collector/httpmetrics/pod_metrics_test.go b/pkg/collector/httpmetrics/pod_metrics_test.go index 202d79c..60b4f5d 100644 --- a/pkg/collector/httpmetrics/pod_metrics_test.go +++ b/pkg/collector/httpmetrics/pod_metrics_test.go @@ -86,6 +86,17 @@ func TestNewPodJSONPathMetricsGetter(t *testing.T) { port: 9090, rawQuery: "foo=bar&baz=bop", }, 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) { From 5da796fc9affefd3d7d064b4948cf04ca9eec354 Mon Sep 17 00:00:00 2001 From: Angel Alonso Date: Wed, 29 Jan 2025 13:10:43 +0000 Subject: [PATCH 2/4] Also support ajson scripting on the HTTP collector Signed-off-by: Angel Alonso --- pkg/collector/http_collector.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pkg/collector/http_collector.go b/pkg/collector/http_collector.go index eacb475..1fef37e 100644 --- a/pkg/collector/http_collector.go +++ b/pkg/collector/http_collector.go @@ -19,6 +19,7 @@ const ( HTTPMetricNameLegacy = "http" HTTPEndpointAnnotationKey = "endpoint" HTTPJsonPathAnnotationKey = "json-key" + HTTPJsonEvalAnnotationKey = "json-eval" ) type HTTPCollectorPlugin struct{} @@ -31,14 +32,27 @@ func (p *HTTPCollectorPlugin) NewCollector(_ context.Context, hpa *autoscalingv2 collector := &HTTPCollector{ namespace: hpa.Namespace, } + var ( value string 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 { 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 } } - jsonPathGetter, err := httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath, "") + jsonPathGetter, err := httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath, jsonEval) if err != nil { return nil, err } From 876074ab757c9bbfef6ffca2c4f9e02d14128e3f Mon Sep 17 00:00:00 2001 From: Angel Alonso Date: Wed, 29 Jan 2025 13:23:28 +0000 Subject: [PATCH 3/4] Update readme to include json-eval Signed-off-by: Angel Alonso --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index ea96a30..0b354fd 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ metadata: annotations: # metric-config.../ 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/port: "9090" 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 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. + 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 so they must be defined. The `scheme` is optional and defaults to `http`. @@ -825,6 +830,7 @@ metadata: annotations: # metric-config.../ 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/aggregator: "max" 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: - `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_ in the namespace `app-namespace` is called. - `aggregator` is only required if the metric is an array of values and specifies how the values From e0a1ca5de6b94d2a6f3dac8640a934690e98dc9c Mon Sep 17 00:00:00 2001 From: Angel Alonso Date: Wed, 29 Jan 2025 16:31:48 +0000 Subject: [PATCH 4/4] Link back to ajson docs on json-eval description Signed-off-by: Angel Alonso --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b354fd..73c2e41 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ 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. +[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 endpoint is exposed on the pod. The `path` and `port` options do not have default values