mirror of
https://github.com/fluxcd/flagger.git
synced 2026-04-15 06:57:34 +00:00
Merge pull request #1013 from GregoireW/testing
Add dynatrace metric provider
This commit is contained in:
@@ -1108,6 +1108,7 @@ spec:
|
||||
- cloudwatch
|
||||
- newrelic
|
||||
- graphite
|
||||
- dynatrace
|
||||
address:
|
||||
description: API address of this provider
|
||||
type: string
|
||||
|
||||
@@ -561,3 +561,51 @@ spec:
|
||||
|> count()
|
||||
|> yield(name: "count")
|
||||
```
|
||||
|
||||
## Dynatrace
|
||||
|
||||
You can create custom metric checks using the Dynatrace provider.
|
||||
|
||||
Create a secret with your Dynatrace token:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: dynatrace
|
||||
namespace: istio-system
|
||||
data:
|
||||
dynatrace_token: ZHQwYz...
|
||||
```
|
||||
|
||||
Dynatrace metric template example:
|
||||
|
||||
```yaml
|
||||
apiVersion: flagger.app/v1beta1
|
||||
kind: MetricTemplate
|
||||
metadata:
|
||||
name: response-time-95pct
|
||||
namespace: istio-system
|
||||
spec:
|
||||
provider:
|
||||
type: dynatrace
|
||||
address: https://xxxxxxxx.live.dynatrace.com
|
||||
secretRef:
|
||||
name: dynatrace
|
||||
query: |
|
||||
builtin:service.response.time:filter(eq(dt.entity.service,SERVICE-ABCDEFG0123456789)):percentile(95)
|
||||
```
|
||||
|
||||
Reference the template in the canary analysis:
|
||||
|
||||
```yaml
|
||||
analysis:
|
||||
metrics:
|
||||
- name: "response-time-95pct"
|
||||
templateRef:
|
||||
name: response-time-95pct
|
||||
namespace: istio-system
|
||||
thresholdRange:
|
||||
max: 1000
|
||||
interval: 1m
|
||||
```
|
||||
|
||||
@@ -1108,6 +1108,7 @@ spec:
|
||||
- cloudwatch
|
||||
- newrelic
|
||||
- graphite
|
||||
- dynatrace
|
||||
address:
|
||||
description: API address of this provider
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
Copyright 2020 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
|
||||
)
|
||||
|
||||
// https://www.dynatrace.com/support/help/dynatrace-api/environment-api/metric-v2/get-all-metrics/
|
||||
const (
|
||||
dynatraceMetricsQueryPath = "/api/v2/metrics/query"
|
||||
dynatraceValidationPath = "/api/v2/metrics?pageSize=1"
|
||||
|
||||
dynatraceAPITokenSecretKey = "dynatrace_token"
|
||||
dynatraceAuthorizationHeaderKey = "Authorization"
|
||||
dynatraceAuthorizationHeaderType = "Api-Token"
|
||||
|
||||
dynatraceDeltaMultiplierOnMetricInterval = 10
|
||||
)
|
||||
|
||||
// DynatraceProvider executes dynatrace queries
|
||||
type DynatraceProvider struct {
|
||||
metricsQueryEndpoint string
|
||||
apiValidationEndpoint string
|
||||
|
||||
timeout time.Duration
|
||||
token string
|
||||
fromDelta int64
|
||||
}
|
||||
|
||||
type dynatraceResponse struct {
|
||||
Result []struct {
|
||||
Data []struct {
|
||||
Timestamps []int64 `json:"timestamps"`
|
||||
Values []float64 `json:"values"`
|
||||
} `json:"data"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// NewDynatraceProvider takes a canary spec, a provider spec and the credentials map, and
|
||||
// returns a Dynatrace client ready to execute queries against the API
|
||||
func NewDynatraceProvider(metricInterval string,
|
||||
provider flaggerv1.MetricTemplateProvider,
|
||||
credentials map[string][]byte) (*DynatraceProvider, error) {
|
||||
|
||||
address := provider.Address
|
||||
if address == "" {
|
||||
return nil, fmt.Errorf("dynatrace endpoint is not set")
|
||||
}
|
||||
|
||||
dt := DynatraceProvider{
|
||||
timeout: 5 * time.Second,
|
||||
metricsQueryEndpoint: address + dynatraceMetricsQueryPath,
|
||||
apiValidationEndpoint: address + dynatraceValidationPath,
|
||||
}
|
||||
|
||||
if b, ok := credentials[dynatraceAPITokenSecretKey]; ok {
|
||||
dt.token = string(b)
|
||||
} else {
|
||||
return nil, fmt.Errorf("dynatrace credentials does not contain dynatrace_token")
|
||||
}
|
||||
|
||||
md, err := time.ParseDuration(metricInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing metric interval: %w", err)
|
||||
}
|
||||
|
||||
dt.fromDelta = int64(dynatraceDeltaMultiplierOnMetricInterval * md.Milliseconds())
|
||||
return &dt, nil
|
||||
}
|
||||
|
||||
// RunQuery executes the dynatrace query against DynatraceProvider.metricsQueryEndpoint
|
||||
// and returns the the first result as float64
|
||||
func (p *DynatraceProvider) RunQuery(query string) (float64, error) {
|
||||
|
||||
req, err := http.NewRequest("GET", p.metricsQueryEndpoint, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error http.NewRequest: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set(dynatraceAuthorizationHeaderKey, fmt.Sprintf("%s %s", dynatraceAuthorizationHeaderType, p.token))
|
||||
|
||||
now := time.Now().Unix() * 1000
|
||||
q := req.URL.Query()
|
||||
q.Add("metricSelector", query)
|
||||
q.Add("resolution", "Inf")
|
||||
q.Add("from", strconv.FormatInt(now-p.fromDelta, 10))
|
||||
q.Add("to", strconv.FormatInt(now, 10))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
ctx, cancel := context.WithTimeout(req.Context(), p.timeout)
|
||||
defer cancel()
|
||||
r, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error reading body: %w", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("error response: %s: %w", string(b), err)
|
||||
}
|
||||
|
||||
var res dynatraceResponse
|
||||
if err := json.Unmarshal(b, &res); err != nil {
|
||||
return 0, fmt.Errorf("error unmarshaling result: %w, '%s'", err, string(b))
|
||||
}
|
||||
|
||||
if len(res.Result) < 1 {
|
||||
return 0, fmt.Errorf("invalid response: %s: %w", string(b), ErrNoValuesFound)
|
||||
}
|
||||
|
||||
data := res.Result[0].Data
|
||||
if len(data) < 1 {
|
||||
return 0, fmt.Errorf("invalid response: %s: %w", string(b), ErrNoValuesFound)
|
||||
}
|
||||
|
||||
vs := data[len(data)-1]
|
||||
if len(vs.Values) < 1 {
|
||||
return 0, fmt.Errorf("invalid response: %s: %w", string(b), ErrNoValuesFound)
|
||||
}
|
||||
|
||||
return vs.Values[0], nil
|
||||
}
|
||||
|
||||
// IsOnline calls the Dynatrace's metrics endpoint with token
|
||||
// and returns an error if the endpoint fails
|
||||
func (p *DynatraceProvider) IsOnline() (bool, error) {
|
||||
req, err := http.NewRequest("GET", p.apiValidationEndpoint, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error http.NewRequest: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set(dynatraceAuthorizationHeaderKey, fmt.Sprintf("%s %s", dynatraceAuthorizationHeaderType, p.token))
|
||||
|
||||
ctx, cancel := context.WithTimeout(req.Context(), p.timeout)
|
||||
defer cancel()
|
||||
r, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error reading body: %w", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("error response: %s", string(b))
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
Copyright 2020 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
|
||||
)
|
||||
|
||||
func TestNewDynatraceProvider(t *testing.T) {
|
||||
token := "token"
|
||||
cs := map[string][]byte{
|
||||
dynatraceAPITokenSecretKey: []byte(token),
|
||||
}
|
||||
|
||||
mi := "100s"
|
||||
md, err := time.ParseDuration(mi)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = NewDynatraceProvider("100s", flaggerv1.MetricTemplateProvider{}, cs)
|
||||
require.Error(t, err)
|
||||
|
||||
dp, err := NewDynatraceProvider("100s", flaggerv1.MetricTemplateProvider{Address: "https://mySampleEnv.live.dynatrace.com"}, cs)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://mySampleEnv.live.dynatrace.com/api/v2/metrics/query", dp.metricsQueryEndpoint)
|
||||
assert.Equal(t, "https://mySampleEnv.live.dynatrace.com/api/v2/metrics?pageSize=1", dp.apiValidationEndpoint)
|
||||
assert.Equal(t, int64(md.Milliseconds()*dynatraceDeltaMultiplierOnMetricInterval), dp.fromDelta)
|
||||
assert.Equal(t, token, dp.token)
|
||||
}
|
||||
|
||||
func TestDynatraceProvider_RunQuery(t *testing.T) {
|
||||
token := "token"
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := 1.11111
|
||||
eq := `builtin:host.cpu.usage:filter(eq(Host,HOST-0990886B7D39FE29))`
|
||||
now := time.Now().Unix() * 1000
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
aq := r.URL.Query().Get("metricSelector")
|
||||
assert.Equal(t, eq, aq)
|
||||
assert.Equal(t, "Api-Token token", r.Header.Get(dynatraceAuthorizationHeaderKey))
|
||||
|
||||
from, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
||||
if assert.NoError(t, err) {
|
||||
assert.Less(t, from, now)
|
||||
}
|
||||
|
||||
to, err := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
|
||||
if assert.NoError(t, err) {
|
||||
assert.GreaterOrEqual(t, to, now)
|
||||
}
|
||||
|
||||
resultTemplate := `
|
||||
{
|
||||
"totalCount": 1,
|
||||
"nextPageKey": null,
|
||||
"result": [
|
||||
{
|
||||
"metricId": "builtin:host.cpu.usage",
|
||||
"data": [
|
||||
{
|
||||
"dimensions": [
|
||||
"HOST-0990886B7D39FE29"
|
||||
],
|
||||
"timestamps": [
|
||||
1589455320000
|
||||
],
|
||||
"values": [
|
||||
%f
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
json := fmt.Sprintf(resultTemplate, expected)
|
||||
w.Write([]byte(json))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
dp, err := NewDynatraceProvider("1m",
|
||||
flaggerv1.MetricTemplateProvider{Address: ts.URL},
|
||||
map[string][]byte{
|
||||
dynatraceAPITokenSecretKey: []byte(token),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := dp.RunQuery(eq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, f)
|
||||
})
|
||||
|
||||
t.Run("no values", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json := fmt.Sprintf(`{"series": [{"pointlist": []}]}`)
|
||||
w.Write([]byte(json))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
dp, err := NewDynatraceProvider("1m",
|
||||
flaggerv1.MetricTemplateProvider{Address: ts.URL},
|
||||
map[string][]byte{
|
||||
dynatraceAPITokenSecretKey: []byte(token),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = dp.RunQuery("")
|
||||
require.True(t, errors.Is(err, ErrNoValuesFound))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDynatraceProvider_IsOnline(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
code int
|
||||
errExpected bool
|
||||
}{
|
||||
{code: http.StatusOK, errExpected: false},
|
||||
{code: http.StatusUnauthorized, errExpected: true},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%d", c.code), func(t *testing.T) {
|
||||
token := "token"
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Api-Token token", r.Header.Get(dynatraceAuthorizationHeaderKey))
|
||||
w.WriteHeader(c.code)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
dp, err := NewDynatraceProvider("1m",
|
||||
flaggerv1.MetricTemplateProvider{Address: ts.URL},
|
||||
map[string][]byte{
|
||||
dynatraceAPITokenSecretKey: []byte(token),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = dp.IsOnline()
|
||||
if c.errExpected {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ func (factory Factory) Provider(
|
||||
return NewStackDriverProvider(provider, credentials)
|
||||
case "influxdb":
|
||||
return NewInfluxdbProvider(provider, credentials)
|
||||
case "dynatrace":
|
||||
return NewDynatraceProvider(metricInterval, provider, credentials)
|
||||
default:
|
||||
return NewPrometheusProvider(provider, credentials)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user