Skip to content

Commit

Permalink
feat: Dynatrace scaler (#5685)
Browse files Browse the repository at this point in the history
* Add first scaler version

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* small refactor for response validation

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Add 'from' property, rename host/token

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Add parsing tests

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* update changelog

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Update CHANGELOG.md

Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com>

* Update values type to float64

Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com>

* Remove unnecessary conversion

Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com>

* e2e tests

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es>
Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Update dynatrace_test.go

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Fix bad templating for e2e tests

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Revert unnecessary (?) template variable change

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Apply suggestions from code review

Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es>

* Update tests/scalers/dynatrace/dynatrace_test.go

Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es>

* Do not allow token to be passed in scaledobject trigger

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Remove bad secret, tweak dynakube test config

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Rename property in response parsing

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Update tests/scalers/dynatrace/dynatrace_test.go

Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es>

* use new operator secret, update template variable naming

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* forgotten correct variable definition

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* try default value in query for e2e tests

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* fix missing closing parenthesis, bad indenting

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

* Update e2e test to use custom metrics

Signed-off-by: Jorge Turrado <jorge.turrado@scrm.lidl>

* Close the body to fix static checks

Signed-off-by: Jorge Turrado <jorge.turrado@scrm.lidl>

* use declarative scaler config

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>

---------

Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com>
Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com>
Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es>
Signed-off-by: Jorge Turrado <jorge.turrado@scrm.lidl>
Co-authored-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es>
Co-authored-by: Jorge Turrado <jorge.turrado@scrm.lidl>
  • Loading branch information
3 people authored Jul 30, 2024
1 parent 03b6b83 commit 12a529d
Show file tree
Hide file tree
Showing 5 changed files with 503 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio

- TODO ([#XXX](https://github.com/kedacore/keda/issues/XXX))
- **General**: Add --ca-dir flag to KEDA operator to specify directories with CA certificates for scalers to authenticate TLS connections (defaults to /custom/ca) ([#5860](https://github.com/kedacore/keda/issues/5860))
- **General**: Add Dynatrace Scaler ([#5685](https://github.com/kedacore/keda/pull/5685))
- **General**: Declarative parsing of scaler config ([#5037](https://github.com/kedacore/keda/issues/5037)|[#5797](https://github.com/kedacore/keda/issues/5797))
- **General**: Introduce new Splunk Scaler ([#5904](https://github.com/kedacore/keda/issues/5904))
- **General**: Provide CloudEvents around the management of ScaledObjects resources ([#3522](https://github.com/kedacore/keda/issues/3522))
Expand Down
195 changes: 195 additions & 0 deletions pkg/scalers/dynatrace_scaler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package scalers

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"strings"

"github.com/go-logr/logr"
v2 "k8s.io/api/autoscaling/v2"
"k8s.io/metrics/pkg/apis/external_metrics"

"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig"
kedautil "github.com/kedacore/keda/v2/pkg/util"
)

const (
dynatraceMetricDataPointsAPI = "api/v2/metrics/query"
)

type dynatraceScaler struct {
metricType v2.MetricTargetType
metadata *dynatraceMetadata
httpClient *http.Client
logger logr.Logger
}

type dynatraceMetadata struct {
Host string `keda:"name=host, order=triggerMetadata;authParams"`
Token string `keda:"name=token, order=authParams"`
MetricSelector string `keda:"name=metricSelector, order=triggerMetadata"`
FromTimestamp string `keda:"name=from, order=triggerMetadata, default=now-2h, optional"`
Threshold float64 `keda:"name=threshold, order=triggerMetadata"`
ActivationThreshold float64 `keda:"name=activationThreshold, order=triggerMetadata, optional"`
TriggerIndex int
}

// Model of relevant part of Dynatrace's Metric Data Points API Response
// as per https://docs.dynatrace.com/docs/dynatrace-api/environment-api/metric-v2/get-data-points#definition--MetricData
type dynatraceResponse struct {
Result []struct {
Data []struct {
Values []float64 `json:"values"`
} `json:"data"`
} `json:"result"`
}

func NewDynatraceScaler(config *scalersconfig.ScalerConfig) (Scaler, error) {
metricType, err := GetMetricTargetType(config)
if err != nil {
return nil, fmt.Errorf("error getting scaler metric type: %w", err)
}

logger := InitializeLogger(config, "dynatrace_scaler")

meta, err := parseDynatraceMetadata(config)
if err != nil {
return nil, fmt.Errorf("error parsing dynatrace metadata: %w", err)
}

httpClient := kedautil.CreateHTTPClient(config.GlobalHTTPTimeout, false)

logMsg := fmt.Sprintf("Initializing Dynatrace Scaler (Host: %s)", meta.Host)

logger.Info(logMsg)

return &dynatraceScaler{
metricType: metricType,
metadata: meta,
httpClient: httpClient,
logger: logger}, nil
}

func parseDynatraceMetadata(config *scalersconfig.ScalerConfig) (*dynatraceMetadata, error) {
meta := dynatraceMetadata{}

meta.TriggerIndex = config.TriggerIndex
if err := config.TypedConfig(&meta); err != nil {
return nil, fmt.Errorf("error parsing dynatrace metadata: %w", err)
}
return &meta, nil
}

func (s *dynatraceScaler) Close(context.Context) error {
if s.httpClient != nil {
s.httpClient.CloseIdleConnections()
}
return nil
}

// Validate that response object contains the minimum expected structure
// as per https://docs.dynatrace.com/docs/dynatrace-api/environment-api/metric-v2/get-data-points#definition--MetricData
func validateDynatraceResponse(response *dynatraceResponse) error {
if len(response.Result) == 0 {
return errors.New("dynatrace response does not contain any results")
}
if len(response.Result[0].Data) == 0 {
return errors.New("dynatrace response does not contain any metric series")
}
if len(response.Result[0].Data[0].Values) == 0 {
return errors.New("dynatrace response does not contain any values for the metric series")
}
return nil
}

func (s *dynatraceScaler) GetMetricValue(ctx context.Context) (float64, error) {
/*
* Build request
*/
var req *http.Request
var err error

// Append host information to appropriate API endpoint
// Trailing slashes are removed from provided host information to avoid double slashes in the URL
dynatraceAPIURL := fmt.Sprintf("%s/%s", strings.TrimRight(s.metadata.Host, "/"), dynatraceMetricDataPointsAPI)

// Add query parameters to the URL
url, _ := neturl.Parse(dynatraceAPIURL)
queryString := url.Query()
queryString.Set("metricSelector", s.metadata.MetricSelector)
queryString.Set("from", s.metadata.FromTimestamp)
url.RawQuery = queryString.Encode()

req, err = http.NewRequestWithContext(ctx, "GET", url.String(), nil)
if err != nil {
return 0, err
}

// Authentication header as per https://docs.dynatrace.com/docs/dynatrace-api/basics/dynatrace-api-authentication#authenticate
req.Header.Add("Authorization", fmt.Sprintf("Api-Token %s", s.metadata.Token))

/*
* Execute request
*/
r, err := s.httpClient.Do(req)
if err != nil {
return 0, err
}
defer r.Body.Close()

if r.StatusCode != http.StatusOK {
msg := fmt.Sprintf("%s: api returned %d", r.Request.URL.Path, r.StatusCode)
return 0, errors.New(msg)
}

/*
* Parse response
*/
b, err := io.ReadAll(r.Body)
if err != nil {
return 0, err
}
var dynatraceResponse *dynatraceResponse
err = json.Unmarshal(b, &dynatraceResponse)
if err != nil {
return -1, fmt.Errorf("unable to parse Dynatrace Metric Data Points API response: %w", err)
}

err = validateDynatraceResponse(dynatraceResponse)
if err != nil {
return 0, err
}

return dynatraceResponse.Result[0].Data[0].Values[0], nil
}

func (s *dynatraceScaler) GetMetricsAndActivity(ctx context.Context, metricName string) ([]external_metrics.ExternalMetricValue, bool, error) {
val, err := s.GetMetricValue(ctx)

if err != nil {
s.logger.Error(err, "error executing Dynatrace query")
return []external_metrics.ExternalMetricValue{}, false, err
}

metric := GenerateMetricInMili(metricName, val)

return []external_metrics.ExternalMetricValue{metric}, val > s.metadata.ActivationThreshold, nil
}

func (s *dynatraceScaler) GetMetricSpecForScaling(context.Context) []v2.MetricSpec {
externalMetric := &v2.ExternalMetricSource{
Metric: v2.MetricIdentifier{
Name: GenerateMetricNameWithIndex(s.metadata.TriggerIndex, kedautil.NormalizeString("dynatrace")),
},
Target: GetMetricTargetMili(s.metricType, s.metadata.Threshold),
}
metricSpec := v2.MetricSpec{
External: externalMetric, Type: externalMetricType,
}
return []v2.MetricSpec{metricSpec}
}
74 changes: 74 additions & 0 deletions pkg/scalers/dynatrace_scaler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package scalers

import (
"context"
"fmt"
"testing"

"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig"
)

type dynatraceMetadataTestData struct {
metadata map[string]string
authParams map[string]string
errorCase bool
}

type dynatraceMetricIdentifier struct {
metadataTestData *dynatraceMetadataTestData
triggerIndex int
name string
}

var testDynatraceMetadata = []dynatraceMetadataTestData{
{map[string]string{}, map[string]string{}, true},
// all properly formed
{map[string]string{"threshold": "100", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, false},
// malformed threshold
{map[string]string{"threshold": "abc", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true},
// malformed activationThreshold
{map[string]string{"activationThreshold": "abc", "threshold": "100", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true},
// missing threshold
{map[string]string{"metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true},
// missing metricsSelector
{map[string]string{"threshold": "100"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true},
// missing token (must come from auth params)
{map[string]string{"token": "foo", "threshold": "100", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234"}, true},
}

var dynatraceMetricIdentifiers = []dynatraceMetricIdentifier{
{&testDynatraceMetadata[1], 0, "s0-dynatrace"},
{&testDynatraceMetadata[1], 1, "s1-dynatrace"},
}

func TestDynatraceParseMetadata(t *testing.T) {
for _, testData := range testDynatraceMetadata {
_, err := parseDynatraceMetadata(&scalersconfig.ScalerConfig{TriggerMetadata: testData.metadata, AuthParams: testData.authParams})
if err != nil && !testData.errorCase {
fmt.Printf("X: %s", testData.metadata)
t.Error("Expected success but got error", err)
}
if testData.errorCase && err == nil {
fmt.Printf("X: %s", testData.metadata)
t.Error("Expected error but got success")
}
}
}
func TestDynatraceGetMetricSpecForScaling(t *testing.T) {
for _, testData := range dynatraceMetricIdentifiers {
meta, err := parseDynatraceMetadata(&scalersconfig.ScalerConfig{TriggerMetadata: testData.metadataTestData.metadata, AuthParams: testData.metadataTestData.authParams, TriggerIndex: testData.triggerIndex})
if err != nil {
t.Fatal("Could not parse metadata:", err)
}
mockNewRelicScaler := dynatraceScaler{
metadata: meta,
httpClient: nil,
}

metricSpec := mockNewRelicScaler.GetMetricSpecForScaling(context.Background())
metricName := metricSpec[0].External.Metric.Name
if metricName != testData.name {
t.Error("Wrong External metric source name:", metricName)
}
}
}
2 changes: 2 additions & 0 deletions pkg/scaling/scalers_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ func buildScaler(ctx context.Context, client client.Client, triggerType string,
return scalers.NewCronScaler(config)
case "datadog":
return scalers.NewDatadogScaler(ctx, config)
case "dynatrace":
return scalers.NewDynatraceScaler(config)
case "elasticsearch":
return scalers.NewElasticsearchScaler(config)
case "etcd":
Expand Down
Loading

0 comments on commit 12a529d

Please sign in to comment.