Skip to content

Commit

Permalink
Merge pull request #122 from zalando-incubator/http-collector
Browse files Browse the repository at this point in the history
Add basic HTTP collector
  • Loading branch information
arjunrn authored Apr 2, 2020
2 parents 43fa626 + 2d3ddc5 commit a8cd761
Show file tree
Hide file tree
Showing 16 changed files with 928 additions and 357 deletions.
63 changes: 60 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,9 +535,9 @@ spec:
tag-application: my-custom-app
aggregators: avg # comma separated list of aggregation functions, default: last
duration: 5m # default: 10m
target:
averageValue: "30"
type: AverageValue
target:
averageValue: "30"
type: AverageValue
```

The `check-id` specifies the ZMON check to query for the metrics. `key`
Expand Down Expand Up @@ -585,3 +585,60 @@ you need to define a `key` or other `tag` with a "star" query syntax like
`values.*`. This *hack* is in place because it's not allowed to use `*` in the
metric label definitions. If both annotations and corresponding label is
defined, then the annotation takes precedence.

## HTTP Collector

The http collector allows collecting metrics from an external endpoint specified in the HPA.
Currently only `json-path` collection is supported.

### Supported metrics

| Metric | Description | Type | K8s Versions |
| ------------ | -------------- | ------- | -- |
| *custom* | No predefined metrics. Metrics are generated from user defined queries. | Pods | `>=1.12` |

### Example

This is an example of using the HTTP collector to collect metrics from a json
metrics endpoint specified in the annotations.

```yaml
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
name: myapp-hpa
annotations:
# metric-config.<metricType>.<metricName>.<collectorName>/<configKey>
metric-config.external.http.json/json-key: "$.some-metric.value"
metric-config.external.http.json/endpoint: "http://metric-source.app-namespace:8080/metrics"
metric-config.external.http.json/aggregator: "max"
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
minReplicas: 1
maxReplicas: 10
metrics:
- type: External
external:
metric:
name: http
selector:
matchLabels:
identifier: unique-metric-name
target:
averageValue: 1
type: AverageValue
```

The HTTP collector similar to the Pod Metrics collector. The metric name should always be `http`.
This value is also used in the annotations to configure the metrics adapter to query the required
target. The following configuration values are supported:

- `json-key` to specify the JSON path of the metric to be queried
- `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
are aggregated. Currently this option can support the values: `sum`, `max`, `min`, `avg`.

8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ require (
github.com/googleapis/gnostic v0.2.0 // indirect
github.com/influxdata/influxdb-client-go v0.1.5
github.com/kubernetes-incubator/custom-metrics-apiserver v0.0.0-20200323093244-5046ce1afe6b
github.com/lib/pq v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852
github.com/onsi/ginkgo v1.11.0 // indirect
github.com/onsi/gomega v1.8.1 // indirect
github.com/prometheus/client_golang v1.5.1
github.com/prometheus/common v0.4.1
github.com/prometheus/common v0.9.1
github.com/sirupsen/logrus v1.5.0
github.com/spf13/cobra v0.0.7
github.com/stretchr/testify v1.5.1
github.com/zalando-incubator/cluster-lifecycle-manager v0.0.0-20180921141935-824b77fb1f84
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 // indirect
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
golang.org/x/tools v0.0.0-20200204192400-7124308813f3 // indirect
honnef.co/go/tools v0.0.1-2020.1.3 // indirect
k8s.io/api v0.17.3
k8s.io/apimachinery v0.17.4
k8s.io/client-go v0.17.3
Expand Down
58 changes: 33 additions & 25 deletions go.sum

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions pkg/annotations/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ func TestParser(t *testing.T) {
"org-id": "deadbeef",
},
},
{
Name: "http metrics",
Annotations: map[string]string{
"metric-config.external.http.json/json-key": "$.metric.value",
"metric-config.external.http.json/endpoint": "http://metric-source.source-namespace.svc.cluster.local:8000/metrics",
"metric-config.external.http.json/aggregator": "avg",
},
MetricName: "http",
MetricType: autoscalingv2.ExternalMetricSourceType,
ExpectedConfig: map[string]string{
"json-key": "$.metric.value",
"endpoint": "http://metric-source.source-namespace.svc.cluster.local:8000/metrics",
"aggregator": "avg",
},
},
} {
t.Run(tc.Name, func(t *testing.T) {
hpaMap := make(AnnotationConfigMap)
Expand Down
103 changes: 103 additions & 0 deletions pkg/collector/http_collector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package collector

import (
"fmt"
"net/url"
"time"

"github.com/zalando-incubator/kube-metrics-adapter/pkg/collector/httpmetrics"

"github.com/oliveagle/jsonpath"
"k8s.io/api/autoscaling/v2beta2"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/metrics/pkg/apis/external_metrics"
)

const (
HTTPMetricName = "http"
HTTPEndpointAnnotationKey = "endpoint"
HTTPJsonPathAnnotationKey = "json-key"
identifierLabel = "identifier"
)

type HTTPCollectorPlugin struct{}

func NewHTTPCollectorPlugin() (*HTTPCollectorPlugin, error) {
return &HTTPCollectorPlugin{}, nil
}

func (p *HTTPCollectorPlugin) NewCollector(_ *v2beta2.HorizontalPodAutoscaler, config *MetricConfig, interval time.Duration) (Collector, error) {
collector := &HTTPCollector{}
var (
value string
ok bool
)
if value, ok = config.Config[HTTPJsonPathAnnotationKey]; !ok {
return nil, fmt.Errorf("config value %s not found", HTTPJsonPathAnnotationKey)
}
jsonPath, err := jsonpath.Compile(value)
if err != nil {
return nil, fmt.Errorf("failed to parse json path: %v", err)
}
collector.jsonPath = jsonPath
if value, ok = config.Config[HTTPEndpointAnnotationKey]; !ok {
return nil, fmt.Errorf("config value %s not found", HTTPEndpointAnnotationKey)
}
collector.endpoint, err = url.Parse(value)
if err != nil {
return nil, err
}
collector.interval = interval
collector.metricType = config.Type
if config.Metric.Selector == nil || config.Metric.Selector.MatchLabels == nil {
return nil, fmt.Errorf("no label selector specified for metric: %s", config.Metric.Name)
}
if _, ok := config.Metric.Selector.MatchLabels[identifierLabel]; !ok {
return nil, fmt.Errorf("%s is not specified as a label for metric %s", identifierLabel, config.Metric.Name)
}
collector.metric = config.Metric
var aggFunc httpmetrics.AggregatorFunc

if val, ok := config.Config["aggregator"]; ok {
aggFunc, err = httpmetrics.ParseAggregator(val)
if err != nil {
return nil, err
}
}
collector.metricsGetter = httpmetrics.NewJSONPathMetricsGetter(httpmetrics.DefaultMetricsHTTPClient(), aggFunc, jsonPath)
return collector, nil
}

type HTTPCollector struct {
endpoint *url.URL
jsonPath *jsonpath.Compiled
interval time.Duration
metricType v2beta2.MetricSourceType
metricsGetter *httpmetrics.JSONPathMetricsGetter
metric v2beta2.MetricIdentifier
}

func (c *HTTPCollector) GetMetrics() ([]CollectedMetric, error) {
metric, err := c.metricsGetter.GetMetric(*c.endpoint)
if err != nil {
return nil, err
}

value := CollectedMetric{
Type: c.metricType,
External: external_metrics.ExternalMetricValue{
MetricName: c.metric.Name,
MetricLabels: c.metric.Selector.MatchLabels,
Timestamp: metav1.Time{
Time: time.Now(),
},
Value: *resource.NewMilliQuantity(int64(metric*1000), resource.DecimalSI),
},
}
return []CollectedMetric{value}, nil
}

func (c *HTTPCollector) Interval() time.Duration {
return c.interval
}
94 changes: 94 additions & 0 deletions pkg/collector/http_collector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package collector

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
"k8s.io/api/autoscaling/v2beta2"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type testExternalMetricsHandler struct {
values []int64
test *testing.T
}

func (t testExternalMetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
response, err := json.Marshal(testMetricResponse{t.values})
require.NoError(t.test, err)
w.Header().Set("Content-Type", "application/json")
_, err = w.Write(response)
require.NoError(t.test, err)
}

func makeHTTPTestServer(t *testing.T, values []int64) string {
server := httptest.NewServer(&testExternalMetricsHandler{values: values, test: t})
return server.URL
}

func TestHTTPCollector(t *testing.T) {
for _, tc := range []struct {
name string
values []int64
output int
aggregator string
}{
{
name: "basic",
values: []int64{3},
output: 3,
aggregator: "sum",
},
{
name: "sum",
values: []int64{3, 5, 6},
aggregator: "sum",
output: 14,
},
{
name: "average",
values: []int64{3, 5, 6},
aggregator: "sum",
output: 14,
},
} {
t.Run(tc.name, func(t *testing.T) {
testServer := makeHTTPTestServer(t, tc.values)
plugin, err := NewHTTPCollectorPlugin()
require.NoError(t, err)
testConfig := makeTestHTTPCollectorConfig(testServer, tc.aggregator)
collector, err := plugin.NewCollector(nil, testConfig, testInterval)
require.NoError(t, err)
metrics, err := collector.GetMetrics()
require.NoError(t, err)
require.NotNil(t, metrics)
require.Len(t, metrics, 1)
require.EqualValues(t, metrics[0].External.Value.Value(), tc.output)
})
}
}

func makeTestHTTPCollectorConfig(endpoint, aggregator string) *MetricConfig {
config := &MetricConfig{
MetricTypeName: MetricTypeName{
Type: v2beta2.ExternalMetricSourceType,
Metric: v2beta2.MetricIdentifier{
Name: "test-metric",
Selector: &v1.LabelSelector{
MatchLabels: map[string]string{identifierLabel: "test-metric"},
},
},
},
Config: map[string]string{
HTTPJsonPathAnnotationKey: "$.values",
HTTPEndpointAnnotationKey: endpoint,
},
}
if aggregator != "" {
config.Config["aggregator"] = aggregator
}
return config
}
65 changes: 65 additions & 0 deletions pkg/collector/httpmetrics/aggregator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package httpmetrics

import (
"fmt"
"math"
)

type AggregatorFunc func(...float64) float64

// Average implements the average mathematical function over a slice of float64
func Average(values ...float64) float64 {
sum := Sum(values...)
return sum / float64(len(values))
}

// Minimum implements the absolute minimum mathematical function over a slice of float64
func Minimum(values ...float64) float64 {
// initialized with positive infinity, all finite numbers are smaller than it
curMin := math.Inf(1)
for _, v := range values {
if v < curMin {
curMin = v
}
}
return curMin
}

// Maximum implements the absolute maximum mathematical function over a slice of float64
func Maximum(values ...float64) float64 {
// initialized with negative infinity, all finite numbers are bigger than it
curMax := math.Inf(-1)
for _, v := range values {
if v > curMax {
curMax = v
}
}
return curMax
}

// Sum implements the summation mathematical function over a slice of float64
func Sum(values ...float64) float64 {
res := 0.0

for _, v := range values {
res += v
}

return res
}

// reduce will reduce a slice of numbers given a aggregator function's name. If it's empty or not recognized, an error is returned.
func ParseAggregator(aggregator string) (AggregatorFunc, error) {
switch aggregator {
case "avg":
return Average, nil
case "min":
return Minimum, nil
case "max":
return Maximum, nil
case "sum":
return Sum, nil
default:
return nil, fmt.Errorf("aggregator function: %s is unknown", aggregator)
}
}
Loading

0 comments on commit a8cd761

Please sign in to comment.