From 88d693af82d41656a991546b4cef555c5171ffdf Mon Sep 17 00:00:00 2001 From: Florian Bacher Date: Mon, 28 Aug 2023 14:23:02 +0200 Subject: [PATCH] feat(metrics-operator): adapt to changes in DQL API (#1948) Signed-off-by: Florian Bacher --- .../providers/dynatrace/client/common.go | 4 +- .../common/providers/dynatrace/common.go | 4 +- .../providers/dynatrace/dynatrace_dql.go | 88 ++- .../providers/dynatrace/dynatrace_dql_test.go | 504 +++++++++++++++--- 4 files changed, 488 insertions(+), 112 deletions(-) diff --git a/metrics-operator/controllers/common/providers/dynatrace/client/common.go b/metrics-operator/controllers/common/providers/dynatrace/client/common.go index d734c341cf..ece37b4c3d 100644 --- a/metrics-operator/controllers/common/providers/dynatrace/client/common.go +++ b/metrics-operator/controllers/common/providers/dynatrace/client/common.go @@ -12,7 +12,7 @@ var ErrRequestFailed = errors.New("the API returned a response with a status out var ErrAuthenticationFailed = errors.New("could not retrieve an OAuth token from the API") const ( - defaultAuthURL = "https://sso-dev.dynatracelabs.com/sso/oauth2/token" + defaultAuthURL = "https://dev.token.internal.dynatracelabs.com/sso/oauth2/token" oAuthGrantType = "grant_type" oAuthGrantTypeClientCredentials = "client_credentials" oAuthScope = "scope" @@ -35,7 +35,7 @@ func validateOAuthSecret(token string) error { } secret := split[2] if secretLen := len(secret); secretLen != 64 { - return fmt.Errorf("length of secret is not equal to 64: %w", ErrClientSecretInvalid) + return fmt.Errorf("length of secret '%s' is %d, which is not equal to 64: %w", secret, secretLen, ErrClientSecretInvalid) } return nil } diff --git a/metrics-operator/controllers/common/providers/dynatrace/common.go b/metrics-operator/controllers/common/providers/dynatrace/common.go index 2f3aacb8aa..08dc835eae 100644 --- a/metrics-operator/controllers/common/providers/dynatrace/common.go +++ b/metrics-operator/controllers/common/providers/dynatrace/common.go @@ -33,11 +33,11 @@ func getDTSecret(ctx context.Context, provider metricsapi.KeptnMetricsProvider, return "", err } - token := dtCredsSecret.Data[provider.Spec.SecretKeyRef.Key] + token := string(dtCredsSecret.Data[provider.Spec.SecretKeyRef.Key]) if len(token) == 0 { return "", fmt.Errorf("secret contains invalid key %s", provider.Spec.SecretKeyRef.Key) } - return string(token), nil + return strings.Trim(token, "\n"), nil } func urlEncodeQuery(query string) string { diff --git a/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql.go b/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql.go index d6c22fdab2..8b25dca6d8 100644 --- a/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql.go +++ b/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql.go @@ -47,11 +47,7 @@ type DynatraceDQLResult struct { } type DQLResult struct { - Records []DQLRecord `json:"records"` -} - -type DQLRecord struct { - Value DQLMetric `json:"value"` + Records []map[string]any `json:"records"` } type DQLMetric struct { @@ -64,8 +60,8 @@ type DQLMetric struct { type DQLRequest struct { Query string `json:"query"` - DefaultTimeframeStart string `json:"defaultTimeframeStart"` - DefaultTimeframeEnd string `json:"defaultTimeframeEnd"` + DefaultTimeframeStart string `json:"defaultTimeframeStart,omitempty"` + DefaultTimeframeEnd string `json:"defaultTimeframeEnd,omitempty"` Timezone string `json:"timezone"` Locale string `json:"locale"` FetchTimeoutSeconds int `json:"fetchTimeoutSeconds"` @@ -107,17 +103,51 @@ func (d *keptnDynatraceDQLProvider) EvaluateQuery(ctx context.Context, metric me return "", nil, err } + if len(results.Records) == 0 { + return "", nil, ErrInvalidResult + } + if len(results.Records) > 1 { d.log.Info("More than a single result, the first one will be used") } - r := fmt.Sprintf("%f", results.Records[0].Value.Avg) + value := extractValueFromRecord(results.Records[0]) + b, err := json.Marshal(results) if err != nil { d.log.Error(err, "Error marshaling DQL results") } - return r, b, nil + return value, b, nil +} + +// extractValueFromRecord extracts the latest value of a record. +// This is intended for timeseries queries that return a single metric +func extractValueFromRecord(record map[string]any) string { + for _, item := range record { + if valuesArr, ok := toFloatArray(item); ok { + return fmt.Sprintf("%f", valuesArr[len(valuesArr)-1]) + } + } + return "" +} + +func toFloatArray(obj any) ([]float64, bool) { + valuesArr, ok := obj.([]any) + if !ok { + return nil, false + } + res := make([]float64, len(valuesArr)) + for index, val := range valuesArr { + if floatVal, ok := val.(float64); ok { + res[index] = floatVal + } else if intVal, ok := val.(int); ok { + res[index] = float64(intVal) + } else { + return nil, false + } + } + return res, true } func (d *keptnDynatraceDQLProvider) EvaluateQueryForStep(ctx context.Context, metric metricsapi.KeptnMetric, provider metricsapi.KeptnMetricsProvider) ([]string, []byte, error) { @@ -126,7 +156,11 @@ func (d *keptnDynatraceDQLProvider) EvaluateQueryForStep(ctx context.Context, me return nil, nil, err } - r := d.getResultSlice(results) + if len(results.Records) == 0 { + return nil, nil, ErrInvalidResult + } + + r := extractValuesFromRecord(results.Records[0]) b, err := json.Marshal(results) if err != nil { d.log.Error(err, "Error marshaling DQL results") @@ -153,9 +187,25 @@ func (d *keptnDynatraceDQLProvider) getResults(ctx context.Context, metric metri return results, nil } +// extractValuesFromRecord extracts all values of a record. +// This is intended for timeseries queries that return multiple values for a single metric, i.e. the individual +// data points of a time series +func extractValuesFromRecord(record map[string]any) []string { + for _, item := range record { + if valuesArr, ok := toFloatArray(item); ok { + valuesStrArr := make([]string, len(valuesArr)) + for index, val := range valuesArr { + valuesStrArr[index] = fmt.Sprintf("%f", val) + } + return valuesStrArr + } + } + return []string{} +} + func (d *keptnDynatraceDQLProvider) parseDQLResults(b []byte, status int) (*DQLResult, error) { results := &DQLResult{} - if status == 200 { + if status == http.StatusOK { r := &DynatraceDQLResult{} err := json.Unmarshal(b, &r) if err != nil { @@ -225,8 +275,8 @@ func (d *keptnDynatraceDQLProvider) postDQL(ctx context.Context, metric metricsa if err != nil { return nil, 0, err } - payload.DefaultTimeframeStart = time.Now().Add(-intervalDuration).Format(time.RFC3339) - payload.DefaultTimeframeEnd = time.Now().Format(time.RFC3339) + payload.DefaultTimeframeStart = time.Now().UTC().Add(-intervalDuration).Format(time.RFC3339) + payload.DefaultTimeframeEnd = time.Now().UTC().Format(time.RFC3339) } payloadBytes, err := json.Marshal(payload) @@ -287,15 +337,3 @@ func (d *keptnDynatraceDQLProvider) retrieveDQLResults(ctx context.Context, hand } return result, nil } - -func (d *keptnDynatraceDQLProvider) getResultSlice(result *DQLResult) []string { - if len(result.Records) == 0 { - return nil - } - // Initialize resultSlice with the correct length - resultSlice := make([]string, len(result.Records)) - for index, r := range result.Records { - resultSlice[index] = fmt.Sprintf("%f", r.Value.Max) - } - return resultSlice -} diff --git a/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go b/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go index 91871f490f..72de385abd 100644 --- a/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go +++ b/metrics-operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go @@ -3,6 +3,7 @@ package dynatrace import ( "context" + "encoding/json" "errors" "strings" "sync" @@ -23,17 +24,325 @@ import ( const dqlRequestHandler = `{"requestToken": "my-token"}` -const dqlPayload = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":36.50,\"min\":36.50,\"avg\":36.50,\"max\":36.50},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:11:00.000Z\",\"end\":\"2023-01-31T09:12:00.`00Z\"},\"Container\":\"frontend\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"632df64d-474c-4410-968d-666f639ad358\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" -const dqlPayloadEmpty = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" -const dqlPayloadNotFinished = "{\"state\":\"\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":36.50,\"min\":36.78336878333334,\"avg\":36.50,\"max\":36.50},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:11:00.000Z\",\"end\":\"2023-01-31T09:12:00.`00Z\"},\"Container\":\"frontend\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"632df64d-474c-4410-968d-666f639ad358\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" +const dqlPayload = `{ + "state": "SUCCEEDED", + "progress": 100, + "result": { + "records": [ + { + "timeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "interval": "3600000000000", + "avg(dt.host.cpu.usage)": [ + 20.44886593058413, + 20.43724084597563, + 20.417480020188446 + ] + } + ], + "types": [ + { + "indexRange": [ + 0, + 0 + ], + "mappings": { + "timeframe": { + "type": "timeframe" + }, + "interval": { + "type": "duration" + }, + "avg(dt.host.cpu.usage)": { + "type": "array", + "types": [ + { + "indexRange": [ + 0, + 2 + ], + "mappings": { + "element": { + "type": "double" + } + } + } + ] + } + } + } + ], + "metadata": { + "grail": { + "canonicalQuery": "timeseries interval:1h, avg(dt.host.cpu.usage)", + "timezone": "Z", + "query": "timeseries avg(dt.host.cpu.usage), interval:1h", + "scannedRecords": 0, + "dqlVersion": "V1_0", + "scannedBytes": 0, + "analysisTimeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "locale": "en_US", + "executionTimeMilliseconds": 72, + "notifications": [], + "queryId": "76f215bf-7ef0-4374-8fd6-4677aaf4d816", + "sampled": false + }, + "metrics": [ + { + "metric.key": "dt.host.cpu.usage", + "displayName": "CPU usage %", + "description": "Percentage of CPU time when CPU was utilized. A value close to 100% means most host processing resources are in use, and host CPUs can’t handle additional work", + "unit": "%", + "fieldName": "avg(dt.host.cpu.usage)" + } + ] + } + } +}` + +const dqlPayloadNotFinished = `{ + "state": "", + "progress": 100, + "result": { + "records": [], + "types": [ + { + "indexRange": [ + 0, + 0 + ], + "mappings": { + "timeframe": { + "type": "timeframe" + }, + "interval": { + "type": "duration" + }, + "avg(dt.host.cpu.usage)": { + "type": "array", + "types": [ + { + "indexRange": [ + 0, + 2 + ], + "mappings": { + "element": { + "type": "double" + } + } + } + ] + } + } + } + ], + "metadata": { + "grail": { + "canonicalQuery": "timeseries interval:1h, avg(dt.host.cpu.usage)", + "timezone": "Z", + "query": "timeseries avg(dt.host.cpu.usage), interval:1h", + "scannedRecords": 0, + "dqlVersion": "V1_0", + "scannedBytes": 0, + "analysisTimeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "locale": "en_US", + "executionTimeMilliseconds": 72, + "notifications": [], + "queryId": "76f215bf-7ef0-4374-8fd6-4677aaf4d816", + "sampled": false + }, + "metrics": [ + { + "metric.key": "dt.host.cpu.usage", + "displayName": "CPU usage %", + "description": "Percentage of CPU time when CPU was utilized. A value close to 100% means most host processing resources are in use, and host CPUs can’t handle additional work", + "unit": "%", + "fieldName": "avg(dt.host.cpu.usage)" + } + ] + } + } +}` + +const dqlPayloadEmpty = `{ + "state": "SUCCEEDED", + "progress": 100, + "result": { + "records": [], + "types": [ + { + "indexRange": [ + 0, + 0 + ], + "mappings": { + "timeframe": { + "type": "timeframe" + }, + "interval": { + "type": "duration" + }, + "avg(dt.host.cpu.usage)": { + "type": "array", + "types": [ + { + "indexRange": [ + 0, + 2 + ], + "mappings": { + "element": { + "type": "double" + } + } + } + ] + } + } + } + ], + "metadata": { + "grail": { + "canonicalQuery": "timeseries interval:1h, avg(dt.host.cpu.usage)", + "timezone": "Z", + "query": "timeseries avg(dt.host.cpu.usage), interval:1h", + "scannedRecords": 0, + "dqlVersion": "V1_0", + "scannedBytes": 0, + "analysisTimeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "locale": "en_US", + "executionTimeMilliseconds": 72, + "notifications": [], + "queryId": "76f215bf-7ef0-4374-8fd6-4677aaf4d816", + "sampled": false + }, + "metrics": [ + { + "metric.key": "dt.host.cpu.usage", + "displayName": "CPU usage %", + "description": "Percentage of CPU time when CPU was utilized. A value close to 100% means most host processing resources are in use, and host CPUs can’t handle additional work", + "unit": "%", + "fieldName": "avg(dt.host.cpu.usage)" + } + ] + } + } +}` + +const dqlPayloadMultipleRecords = `{ + "state": "SUCCEEDED", + "progress": 100, + "result": { + "records": [ + { + "timeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "interval": "3600000000000", + "avg(dt.host.cpu.usage)": [ + 20.44886593058413, + 20.43724084597563, + 20.417480020188446 + ] + }, + { + "timeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "interval": "3600000000000", + "avg(some-other-metric)": [ + 30.44886593058413, + 30.43724084597563, + 30.417480020188446 + ] + } + ], + "types": [ + { + "indexRange": [ + 0, + 0 + ], + "mappings": { + "timeframe": { + "type": "timeframe" + }, + "interval": { + "type": "duration" + }, + "avg(dt.host.cpu.usage)": { + "type": "array", + "types": [ + { + "indexRange": [ + 0, + 2 + ], + "mappings": { + "element": { + "type": "double" + } + } + } + ] + } + } + } + ], + "metadata": { + "grail": { + "canonicalQuery": "timeseries interval:1h, avg(dt.host.cpu.usage)", + "timezone": "Z", + "query": "timeseries avg(dt.host.cpu.usage), interval:1h", + "scannedRecords": 0, + "dqlVersion": "V1_0", + "scannedBytes": 0, + "analysisTimeframe": { + "start": "2023-08-23T11:00:00.000000000Z", + "end": "2023-08-23T14:00:00.000000000Z" + }, + "locale": "en_US", + "executionTimeMilliseconds": 72, + "notifications": [], + "queryId": "76f215bf-7ef0-4374-8fd6-4677aaf4d816", + "sampled": false + }, + "metrics": [ + { + "metric.key": "dt.host.cpu.usage", + "displayName": "CPU usage %", + "description": "Percentage of CPU time when CPU was utilized. A value close to 100% means most host processing resources are in use, and host CPUs can’t handle additional work", + "unit": "%", + "fieldName": "avg(dt.host.cpu.usage)" + } + ] + } + } +}` + +// const dqlPayload = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":36.50,\"min\":36.50,\"avg\":36.50,\"max\":36.50},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:11:00.000Z\",\"end\":\"2023-01-31T09:12:00.`00Z\"},\"Container\":\"frontend\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"632df64d-474c-4410-968d-666f639ad358\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" +// const dqlPayloadEmpty = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" +// const dqlPayloadNotFinished = "{\"state\":\"\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":36.50,\"min\":36.78336878333334,\"avg\":36.50,\"max\":36.50},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:11:00.000Z\",\"end\":\"2023-01-31T09:12:00.`00Z\"},\"Container\":\"frontend\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"632df64d-474c-4410-968d-666f639ad358\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" const dqlPayloadError = "{\"error\":{\"code\":403,\"message\":\"Token is missing required scope\"}}" -const dqlPayloadTooManyItems = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":6.293549483333334,\"min\":6.293549483333334,\"avg\":6.293549483333334,\"max\":6.293549483333334},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:07:00.000Z\",\"end\":\"2023-01-31T09:08:00.000Z\"},\"Container\":\"loginservice\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"easytrade\",\"k8s.pod.uid\":\"fc084e57-11a0-4a95-b8a0-76191c31d839\"},{\"value\":{\"count\":1,\"sum\":1.0421756,\"min\":1.0421756,\"avg\":1.0421756,\"max\":1.0421756},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:07:00.000Z\",\"end\":\"2023-01-31T09:08:00.000Z\"},\"Container\":\"frontendreverseproxy\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"easytrade\",\"k8s.pod.uid\":\"41b5d6e0-98fc-4dce-a1b4-bb269a03d72b\"},{\"value\":{\"count\":1,\"sum\":6.3881383000000005,\"min\":6.3881383000000005,\"avg\":6.3881383000000005,\"max\":6.3881383000000005},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:07:00.000Z\",\"end\":\"2023-01-31T09:08:00.000Z\"},\"Container\":\"shippingservice\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"96fcf9d7-748a-47f7-b1b3-ca6427e20edd\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,3]}]}}" - var ErrUnexpected = errors.New("unexpected path") //nolint:dupl -func TestGetDQL_EvaluateQuery200(t *testing.T) { +func TestGetDQL_EvaluateQueryResultAvailableImmediately(t *testing.T) { mockClient := &fake.DTAPIClientMock{} @@ -41,10 +350,6 @@ func TestGetDQL_EvaluateQuery200(t *testing.T) { if strings.Contains(path, "query:execute") { return []byte(dqlPayload), 200, nil } - // the second if can be left out as in this case the dql provider will return the result without needing to call query:poll - if strings.Contains(path, "query:poll") { - return []byte(dqlPayload), 202, nil - } return nil, 0, ErrUnexpected } @@ -65,13 +370,13 @@ func TestGetDQL_EvaluateQuery200(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, "36.500000", result) + require.Equal(t, "20.417480", result) require.Len(t, mockClient.DoCalls(), 1) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") } -func TestGetDQL_EvaluateQuery202(t *testing.T) { +func TestGetDQL_EvaluateQueryResultAvailableViaRequestToken(t *testing.T) { mockClient := &fake.DTAPIClientMock{} @@ -103,7 +408,7 @@ func TestGetDQL_EvaluateQuery202(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, "36.500000", result) + require.Equal(t, "20.417480", result) require.Len(t, mockClient.DoCalls(), 2) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -116,12 +421,20 @@ func TestGetDQL_EvaluateQueryWithRange(t *testing.T) { mockClient.DoFunc = func(ctx context.Context, path string, method string, payload []byte) ([]byte, int, error) { if strings.Contains(path, "query:execute") { + reqPayload := &DQLRequest{} + err := json.Unmarshal(payload, reqPayload) + + require.Nil(t, err) + + parsedStartTime, err := time.Parse(time.RFC3339, reqPayload.DefaultTimeframeStart) + require.Nil(t, err) + parsedEndTime, err := time.Parse(time.RFC3339, reqPayload.DefaultTimeframeEnd) + require.Nil(t, err) + + require.WithinDuration(t, time.Now().UTC(), parsedEndTime, 5*time.Second) + require.Equal(t, 5*time.Minute, parsedEndTime.Sub(parsedStartTime)) return []byte(dqlPayload), 200, nil } - // the second if can be left out as in this case the dql provider will return the result without needing to call query:poll - if strings.Contains(path, "query:poll") { - return []byte(dqlPayload), 202, nil - } return nil, 0, ErrUnexpected } @@ -147,7 +460,7 @@ func TestGetDQL_EvaluateQueryWithRange(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, "36.500000", result) + require.Equal(t, "20.417480", result) require.Len(t, mockClient.DoCalls(), 1) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -205,7 +518,7 @@ func TestGetDQLMultipleRecords_EvaluateQuery(t *testing.T) { } if strings.Contains(path, "query:poll") { - return []byte(dqlPayloadTooManyItems), 202, nil + return []byte(dqlPayloadMultipleRecords), 202, nil } return nil, 0, ErrUnexpected @@ -227,7 +540,7 @@ func TestGetDQLMultipleRecords_EvaluateQuery(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, "6.293549", result) + require.Equal(t, "20.417480", result) require.Len(t, mockClient.DoCalls(), 2) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -473,7 +786,7 @@ func TestGetDQL_EvaluateQueryForStep200(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, []string{"36.500000"}, result) + require.Equal(t, []string{"20.448866", "20.437241", "20.417480"}, result) require.Len(t, mockClient.DoCalls(), 1) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -511,7 +824,7 @@ func TestGetDQL_EvaluateQueryForStep202(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, []string{"36.500000"}, result) + require.Equal(t, []string{"20.448866", "20.437241", "20.417480"}, result) require.Len(t, mockClient.DoCalls(), 2) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -555,7 +868,7 @@ func TestGetDQL_EvaluateQueryForStepWithRange(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, []string{"36.500000"}, result) + require.Equal(t, []string{"20.448866", "20.437241", "20.417480"}, result) require.Len(t, mockClient.DoCalls(), 2) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -573,7 +886,7 @@ func TestGetDQLMultipleRecords_EvaluateQueryForStep(t *testing.T) { } if strings.Contains(path, "query:poll") { - return []byte(dqlPayloadTooManyItems), 202, nil + return []byte(dqlPayloadMultipleRecords), 202, nil } return nil, 0, ErrUnexpected @@ -595,7 +908,7 @@ func TestGetDQLMultipleRecords_EvaluateQueryForStep(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, raw) - require.Equal(t, []string{"6.293549", "1.042176", "6.388138"}, result) + require.Equal(t, []string{"20.448866", "20.437241", "20.417480"}, result) require.Len(t, mockClient.DoCalls(), 2) require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") @@ -809,74 +1122,99 @@ func TestGetDQLEmptyPayload_EvaluateQueryForStep(t *testing.T) { require.Equal(t, []string(nil), result) } -func TestGetResultForSlice_HappyPath(t *testing.T) { - - namespace := "keptn-lifecycle-toolkit-system" - - mySecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-secret", - Namespace: namespace, - }, - Data: map[string][]byte{ - "my-key": []byte("dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"), - }, - Type: corev1.SecretTypeOpaque, +func TestExtractValuesFromRecord(t *testing.T) { + type args struct { + records map[string]any } - fakeClient := k8sfake.NewClientBuilder().WithScheme(clientgoscheme.Scheme).WithObjects(mySecret).Build() - dqlProvider := NewKeptnDynatraceDQLProvider( - fakeClient, - WithLogger(logr.New(klog.NewKlogr().GetSink())), - ) - result := &DQLResult{ - Records: []DQLRecord{ - { - Value: DQLMetric{ - Count: 1, - Sum: 25.0, - Min: 25.0, - Avg: 25.0, - Max: 25.0, + tests := []struct { + name string + args args + want []string + }{ + { + name: "values available", + args: args{ + records: map[string]any{ + "avg(cpu-usage)": []any{ + 25, + 13, + }, }, }, - { - Value: DQLMetric{ - Count: 1, - Sum: 13.0, - Min: 13.0, - Avg: 13.0, - Max: 13.0, - }, + want: []string{"25.000000", "13.000000"}, + }, + { + name: "no values", + args: args{ + records: map[string]any{}, }, + want: []string{}, }, } - resultSlice := dqlProvider.getResultSlice(result) - require.NotZero(t, resultSlice) - require.Equal(t, []string{"25.000000", "13.000000"}, resultSlice) -} - -func TestGetResultForSlice_Empty(t *testing.T) { - namespace := "keptn-lifecycle-toolkit-system" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := extractValuesFromRecord(tt.args.records) + require.Equal(t, tt.want, res) + }) + } +} - mySecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-secret", - Namespace: namespace, +func Test_toFloatArray(t *testing.T) { + type args struct { + obj any + } + tests := []struct { + name string + args args + wantRes []float64 + wantOk bool + }{ + { + name: "array of float64 values", + args: args{ + obj: []any{ + 13.0, + 12.0, + }, + }, + wantRes: []float64{ + 13.0, + 12.0, + }, + wantOk: true, }, - Data: map[string][]byte{ - "my-key": []byte("dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"), + { + name: "array of int values", + args: args{ + obj: []any{ + 13, + 12, + }, + }, + wantRes: []float64{ + 13.0, + 12.0, + }, + wantOk: true, + }, + { + name: "array of string values", + args: args{ + obj: []any{ + "foo", + "bar", + }, + }, + wantRes: nil, + wantOk: false, }, - Type: corev1.SecretTypeOpaque, } - fakeClient := k8sfake.NewClientBuilder().WithScheme(clientgoscheme.Scheme).WithObjects(mySecret).Build() - dqlProvider := NewKeptnDynatraceDQLProvider( - fakeClient, - WithLogger(logr.New(klog.NewKlogr().GetSink())), - ) - result := &DQLResult{ - Records: []DQLRecord{}, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, ok := toFloatArray(tt.args.obj) + require.Equal(t, tt.wantRes, res) + require.Equal(t, tt.wantOk, ok) + }) } - resultSlice := dqlProvider.getResultSlice(result) - require.Equal(t, []string(nil), resultSlice) }