Cloudwatch: Migrate queries that use multiple stats to one query per stat (#36925)

* migrate queries that use multiple stats - squash commits

* fix typo
This commit is contained in:
Erik Sundell 2021-09-08 16:06:43 +02:00 committed by GitHub
parent ae9343f8ae
commit 5e38b02f94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 2304 additions and 1493 deletions

View File

@ -21,7 +21,7 @@ func (e *cloudWatchExecutor) executeAnnotationQuery(ctx context.Context, model *
namespace := model.Get("namespace").MustString("") namespace := model.Get("namespace").MustString("")
metricName := model.Get("metricName").MustString("") metricName := model.Get("metricName").MustString("")
dimensions := model.Get("dimensions").MustMap() dimensions := model.Get("dimensions").MustMap()
statistics := parseStatistics(model) statistic := model.Get("statistic").MustString()
period := int64(model.Get("period").MustInt(0)) period := int64(model.Get("period").MustInt(0))
if period == 0 && !usePrefixMatch { if period == 0 && !usePrefixMatch {
period = 300 period = 300
@ -45,9 +45,9 @@ func (e *cloudWatchExecutor) executeAnnotationQuery(ctx context.Context, model *
if err != nil { if err != nil {
return nil, errutil.Wrap("failed to call cloudwatch:DescribeAlarms", err) return nil, errutil.Wrap("failed to call cloudwatch:DescribeAlarms", err)
} }
alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistics, period) alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistic, period)
} else { } else {
if region == "" || namespace == "" || metricName == "" || len(statistics) == 0 { if region == "" || namespace == "" || metricName == "" || statistic == "" {
return result, errors.New("invalid annotations query") return result, errors.New("invalid annotations query")
} }
@ -64,21 +64,19 @@ func (e *cloudWatchExecutor) executeAnnotationQuery(ctx context.Context, model *
} }
} }
} }
for _, s := range statistics { params := &cloudwatch.DescribeAlarmsForMetricInput{
params := &cloudwatch.DescribeAlarmsForMetricInput{ Namespace: aws.String(namespace),
Namespace: aws.String(namespace), MetricName: aws.String(metricName),
MetricName: aws.String(metricName), Dimensions: qd,
Dimensions: qd, Statistic: aws.String(statistic),
Statistic: aws.String(s), Period: aws.Int64(period),
Period: aws.Int64(period), }
} resp, err := cli.DescribeAlarmsForMetric(params)
resp, err := cli.DescribeAlarmsForMetric(params) if err != nil {
if err != nil { return nil, errutil.Wrap("failed to call cloudwatch:DescribeAlarmsForMetric", err)
return nil, errutil.Wrap("failed to call cloudwatch:DescribeAlarmsForMetric", err) }
} for _, alarm := range resp.MetricAlarms {
for _, alarm := range resp.MetricAlarms { alarmNames = append(alarmNames, alarm.AlarmName)
alarmNames = append(alarmNames, alarm.AlarmName)
}
} }
} }
@ -133,7 +131,7 @@ func transformAnnotationToTable(annotations []map[string]string, query backend.D
} }
func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string, func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string,
dimensions map[string]interface{}, statistics []string, period int64) []*string { dimensions map[string]interface{}, statistic string, period int64) []*string {
alarmNames := make([]*string, 0) alarmNames := make([]*string, 0)
for _, alarm := range alarms.MetricAlarms { for _, alarm := range alarms.MetricAlarms {
@ -144,33 +142,24 @@ func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, met
continue continue
} }
match := true matchDimension := true
if len(dimensions) != 0 { if len(dimensions) != 0 {
if len(alarm.Dimensions) != len(dimensions) { if len(alarm.Dimensions) != len(dimensions) {
match = false matchDimension = false
} else { } else {
for _, d := range alarm.Dimensions { for _, d := range alarm.Dimensions {
if _, ok := dimensions[*d.Name]; !ok { if _, ok := dimensions[*d.Name]; !ok {
match = false matchDimension = false
} }
} }
} }
} }
if !match { if !matchDimension {
continue continue
} }
if len(statistics) != 0 { if *alarm.Statistic != statistic {
found := false continue
for _, s := range statistics {
if *alarm.Statistic == s {
found = true
break
}
}
if !found {
continue
}
} }
if period != 0 && *alarm.Period != period { if period != 0 && *alarm.Period != period {

View File

@ -1,24 +1,27 @@
package cloudwatch package cloudwatch
import ( import (
"encoding/json"
"fmt"
"net/url"
"strings" "strings"
"time"
) )
type cloudWatchQuery struct { type cloudWatchQuery struct {
RefId string RefId string
Region string Region string
Id string Id string
Namespace string Namespace string
MetricName string MetricName string
Stats string Statistic string
Expression string Expression string
ReturnData bool ReturnData bool
Dimensions map[string][]string Dimensions map[string][]string
Period int Period int
Alias string Alias string
MatchExact bool MatchExact bool
UsedExpression string UsedExpression string
RequestExceededMaxLimit bool
} }
func (q *cloudWatchQuery) isMathExpression() bool { func (q *cloudWatchQuery) isMathExpression() bool {
@ -69,3 +72,51 @@ func (q *cloudWatchQuery) isMultiValuedDimensionExpression() bool {
return false return false
} }
func (q *cloudWatchQuery) buildDeepLink(startTime time.Time, endTime time.Time) (string, error) {
if q.isMathExpression() {
return "", nil
}
link := &cloudWatchLink{
Title: q.RefId,
View: "timeSeries",
Stacked: false,
Region: q.Region,
Start: startTime.UTC().Format(time.RFC3339),
End: endTime.UTC().Format(time.RFC3339),
}
if q.isSearchExpression() {
link.Metrics = []interface{}{&metricExpression{Expression: q.UsedExpression}}
} else {
metricStat := []interface{}{q.Namespace, q.MetricName}
for dimensionKey, dimensionValues := range q.Dimensions {
metricStat = append(metricStat, dimensionKey, dimensionValues[0])
}
metricStat = append(metricStat, &metricStatMeta{
Stat: q.Statistic,
Period: q.Period,
})
link.Metrics = []interface{}{metricStat}
}
linkProps, err := json.Marshal(link)
if err != nil {
return "", fmt.Errorf("could not marshal link: %w", err)
}
url, err := url.Parse(fmt.Sprintf(`https://%s.console.aws.amazon.com/cloudwatch/deeplink.js`, q.Region))
if err != nil {
return "", fmt.Errorf("unable to parse CloudWatch console deep link")
}
fragment := url.Query()
fragment.Set("graph", string(linkProps))
query := url.Query()
query.Set("region", q.Region)
url.RawQuery = query.Encode()
return fmt.Sprintf(`%s#metricsV2:%s`, url.String(), fragment.Encode()), nil
}

View File

@ -12,7 +12,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "SEARCH(someexpression)", Expression: "SEARCH(someexpression)",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
} }
@ -26,7 +26,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "", Expression: "",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
MatchExact: true, MatchExact: true,
@ -44,7 +44,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "", Expression: "",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
Dimensions: map[string][]string{ Dimensions: map[string][]string{
@ -61,7 +61,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "", Expression: "",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
Dimensions: map[string][]string{ Dimensions: map[string][]string{
@ -79,7 +79,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "", Expression: "",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
Dimensions: map[string][]string{ Dimensions: map[string][]string{
@ -97,7 +97,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "", Expression: "",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
MatchExact: false, MatchExact: false,
@ -123,7 +123,7 @@ func TestCloudWatchQuery(t *testing.T) {
RefId: "A", RefId: "A",
Region: "us-east-1", Region: "us-east-1",
Expression: "", Expression: "",
Stats: "Average", Statistic: "Average",
Period: 300, Period: 300,
Id: "id1", Id: "id1",
MatchExact: false, MatchExact: false,

View File

@ -8,7 +8,7 @@ import (
) )
func (e *cloudWatchExecutor) buildMetricDataInput(startTime time.Time, endTime time.Time, func (e *cloudWatchExecutor) buildMetricDataInput(startTime time.Time, endTime time.Time,
queries map[string]*cloudWatchQuery) (*cloudwatch.GetMetricDataInput, error) { queries []*cloudWatchQuery) (*cloudwatch.GetMetricDataInput, error) {
metricDataInput := &cloudwatch.GetMetricDataInput{ metricDataInput := &cloudwatch.GetMetricDataInput{
StartTime: aws.Time(startTime), StartTime: aws.Time(startTime),
EndTime: aws.Time(endTime), EndTime: aws.Time(endTime),

View File

@ -20,7 +20,7 @@ func (e *cloudWatchExecutor) buildMetricDataQuery(query *cloudWatchQuery) (*clou
mdq.Expression = aws.String(query.Expression) mdq.Expression = aws.String(query.Expression)
} else { } else {
if query.isSearchExpression() { if query.isSearchExpression() {
mdq.Expression = aws.String(buildSearchExpression(query, query.Stats)) mdq.Expression = aws.String(buildSearchExpression(query, query.Statistic))
} else { } else {
mdq.MetricStat = &cloudwatch.MetricStat{ mdq.MetricStat = &cloudwatch.MetricStat{
Metric: &cloudwatch.Metric{ Metric: &cloudwatch.Metric{
@ -37,7 +37,7 @@ func (e *cloudWatchExecutor) buildMetricDataQuery(query *cloudWatchQuery) (*clou
Value: aws.String(values[0]), Value: aws.String(values[0]),
}) })
} }
mdq.MetricStat.Stat = aws.String(query.Stats) mdq.MetricStat.Stat = aws.String(query.Statistic)
} }
} }

View File

@ -0,0 +1,49 @@
package cloudwatch
import "github.com/aws/aws-sdk-go/service/cloudwatch"
// queryRowResponse represents the GetMetricData response for a query row in the query editor.
type queryRowResponse struct {
ID string
RequestExceededMaxLimit bool
PartialData bool
Labels []string
HasArithmeticError bool
ArithmeticErrorMessage string
Metrics map[string]*cloudwatch.MetricDataResult
StatusCode string
}
func newQueryRowResponse(id string) queryRowResponse {
return queryRowResponse{
ID: id,
RequestExceededMaxLimit: false,
PartialData: false,
HasArithmeticError: false,
ArithmeticErrorMessage: "",
Labels: []string{},
Metrics: map[string]*cloudwatch.MetricDataResult{},
}
}
func (q *queryRowResponse) addMetricDataResult(mdr *cloudwatch.MetricDataResult) {
label := *mdr.Label
q.Labels = append(q.Labels, label)
q.Metrics[label] = mdr
q.StatusCode = *mdr.StatusCode
}
func (q *queryRowResponse) appendTimeSeries(mdr *cloudwatch.MetricDataResult) {
if _, exists := q.Metrics[*mdr.Label]; !exists {
q.Metrics[*mdr.Label] = &cloudwatch.MetricDataResult{}
}
metric := q.Metrics[*mdr.Label]
metric.Timestamps = append(metric.Timestamps, mdr.Timestamps...)
metric.Values = append(metric.Values, mdr.Values...)
q.StatusCode = *mdr.StatusCode
}
func (q *queryRowResponse) addArithmeticError(message *string) {
q.HasArithmeticError = true
q.ArithmeticErrorMessage = *message
}

View File

@ -1,236 +0,0 @@
package cloudwatch
import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
// returns a map of queries with query id as key. In the case a q request query
// has more than one statistic defined, one cloudwatchQuery will be created for each statistic.
// If the query doesn't have an Id defined by the user, we'll give it an with format `query[RefId]`. In the case
// the incoming query had more than one stat, it will ge an id like `query[RefId]_[StatName]`, eg queryC_Average
func (e *cloudWatchExecutor) transformRequestQueriesToCloudWatchQueries(requestQueries []*requestQuery) (
map[string]*cloudWatchQuery, error) {
plog.Debug("Transforming CloudWatch request queries")
cloudwatchQueries := make(map[string]*cloudWatchQuery)
for _, requestQuery := range requestQueries {
for _, stat := range requestQuery.Statistics {
id := requestQuery.Id
if id == "" {
id = fmt.Sprintf("query%s", requestQuery.RefId)
}
if len(requestQuery.Statistics) > 1 {
id = fmt.Sprintf("%s_%v", id, strings.ReplaceAll(*stat, ".", "_"))
}
if _, ok := cloudwatchQueries[id]; ok {
return nil, fmt.Errorf("error in query %q - query ID %q is not unique", requestQuery.RefId, id)
}
query := &cloudWatchQuery{
Id: id,
RefId: requestQuery.RefId,
Region: requestQuery.Region,
Namespace: requestQuery.Namespace,
MetricName: requestQuery.MetricName,
Dimensions: requestQuery.Dimensions,
Stats: *stat,
Period: requestQuery.Period,
Alias: requestQuery.Alias,
Expression: requestQuery.Expression,
ReturnData: requestQuery.ReturnData,
MatchExact: requestQuery.MatchExact,
}
cloudwatchQueries[id] = query
}
}
return cloudwatchQueries, nil
}
func (e *cloudWatchExecutor) transformQueryResponsesToQueryResult(cloudwatchResponses []*cloudwatchResponse, requestQueries []*requestQuery, startTime time.Time, endTime time.Time) (map[string]*backend.DataResponse, error) {
responsesByRefID := make(map[string][]*cloudwatchResponse)
refIDs := sort.StringSlice{}
for _, res := range cloudwatchResponses {
refIDs = append(refIDs, res.RefId)
responsesByRefID[res.RefId] = append(responsesByRefID[res.RefId], res)
}
// Ensure stable results
refIDs.Sort()
results := make(map[string]*backend.DataResponse)
for _, refID := range refIDs {
responses := responsesByRefID[refID]
queryResult := backend.DataResponse{}
frames := make(data.Frames, 0, len(responses))
requestExceededMaxLimit := false
partialData := false
var executedQueries []executedQuery
for _, response := range responses {
frames = append(frames, response.DataFrames...)
requestExceededMaxLimit = requestExceededMaxLimit || response.RequestExceededMaxLimit
partialData = partialData || response.PartialData
if requestExceededMaxLimit {
frames[0].AppendNotices(data.Notice{
Severity: data.NoticeSeverityWarning,
Text: "cloudwatch GetMetricData error: Maximum number of allowed metrics exceeded. Your search may have been limited",
})
}
if partialData {
frames[0].AppendNotices(data.Notice{
Severity: data.NoticeSeverityWarning,
Text: "cloudwatch GetMetricData error: Too many datapoints requested - your search has been limited. Please try to reduce the time range",
})
}
executedQueries = append(executedQueries, executedQuery{
Expression: response.Expression,
ID: response.Id,
Period: response.Period,
})
}
sort.Slice(frames, func(i, j int) bool {
return frames[i].Name < frames[j].Name
})
eq, err := json.Marshal(executedQueries)
if err != nil {
return nil, fmt.Errorf("could not marshal executedString struct: %w", err)
}
link, err := buildDeepLink(refID, requestQueries, executedQueries, startTime, endTime)
if err != nil {
return nil, fmt.Errorf("could not build deep link: %w", err)
}
createDataLinks := func(link string) []data.DataLink {
return []data.DataLink{{
Title: "View in CloudWatch console",
TargetBlank: true,
URL: link,
}}
}
for _, frame := range frames {
if frame.Meta != nil {
frame.Meta.ExecutedQueryString = string(eq)
} else {
frame.Meta = &data.FrameMeta{
ExecutedQueryString: string(eq),
}
}
if link == "" || len(frame.Fields) < 2 {
continue
}
if frame.Fields[1].Config == nil {
frame.Fields[1].Config = &data.FieldConfig{}
}
frame.Fields[1].Config.Links = createDataLinks(link)
}
queryResult.Frames = frames
results[refID] = &queryResult
}
return results, nil
}
// buildDeepLink generates a deep link from Grafana to the CloudWatch console. The link params are based on
// metric(s) for a given query row in the Query Editor.
func buildDeepLink(refID string, requestQueries []*requestQuery, executedQueries []executedQuery, startTime time.Time,
endTime time.Time) (string, error) {
if isMathExpression(executedQueries) {
return "", nil
}
requestQuery := &requestQuery{}
for _, rq := range requestQueries {
if rq.RefId == refID {
requestQuery = rq
break
}
}
metricItems := []interface{}{}
cloudWatchLinkProps := &cloudWatchLink{
Title: refID,
View: "timeSeries",
Stacked: false,
Region: requestQuery.Region,
Start: startTime.UTC().Format(time.RFC3339),
End: endTime.UTC().Format(time.RFC3339),
}
expressions := []interface{}{}
for _, meta := range executedQueries {
if strings.Contains(meta.Expression, "SEARCH(") {
expressions = append(expressions, &metricExpression{Expression: meta.Expression})
}
}
if len(expressions) != 0 {
cloudWatchLinkProps.Metrics = expressions
} else {
for _, stat := range requestQuery.Statistics {
metricStat := []interface{}{requestQuery.Namespace, requestQuery.MetricName}
for dimensionKey, dimensionValues := range requestQuery.Dimensions {
metricStat = append(metricStat, dimensionKey, dimensionValues[0])
}
metricStat = append(metricStat, &metricStatMeta{
Stat: *stat,
Period: requestQuery.Period,
})
metricItems = append(metricItems, metricStat)
}
cloudWatchLinkProps.Metrics = metricItems
}
linkProps, err := json.Marshal(cloudWatchLinkProps)
if err != nil {
return "", fmt.Errorf("could not marshal link: %w", err)
}
url, err := url.Parse(fmt.Sprintf(`https://%s.console.aws.amazon.com/cloudwatch/deeplink.js`, requestQuery.Region))
if err != nil {
return "", fmt.Errorf("unable to parse CloudWatch console deep link")
}
fragment := url.Query()
fragment.Set("", string(linkProps))
q := url.Query()
q.Set("region", requestQuery.Region)
url.RawQuery = q.Encode()
link := fmt.Sprintf(`%s#metricsV2:graph%s`, url.String(), fragment.Encode())
return link, nil
}
func isMathExpression(executedQueries []executedQuery) bool {
isMathExpression := false
for _, query := range executedQueries {
if strings.Contains(query.Expression, "SEARCH(") {
return false
} else if query.Expression != "" {
isMathExpression = true
}
}
return isMathExpression
}

View File

@ -1,249 +0,0 @@
package cloudwatch
import (
"net/url"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestQueryTransformer(t *testing.T) {
executor := newExecutor(nil, nil, &setting.Cfg{}, fakeSessionCache{})
t.Run("One cloudwatchQuery is generated when its request query has one stat", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average"}),
Period: 600,
Id: "",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.NoError(t, err)
assert.Len(t, res, 1)
})
t.Run("Two cloudwatchQuery is generated when there's two stats", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average", "Sum"}),
Period: 600,
Id: "",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.NoError(t, err)
assert.Len(t, res, 2)
})
t.Run("id is given by user that will be used in the cloudwatch query", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average"}),
Period: 600,
Id: "myid",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.Nil(t, err)
assert.Equal(t, len(res), 1)
assert.Contains(t, res, "myid")
})
t.Run("ID is not given by user", func(t *testing.T) {
t.Run("ID will be generated based on ref ID if query only has one stat", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average"}),
Period: 600,
Id: "",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.NoError(t, err)
assert.Len(t, res, 1)
assert.Contains(t, res, "queryD")
})
t.Run("ID will be generated based on ref and stat name if query has two stats", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average", "Sum"}),
Period: 600,
Id: "",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.NoError(t, err)
assert.Len(t, res, 2)
assert.Contains(t, res, "queryD_Sum")
assert.Contains(t, res, "queryD_Average")
})
})
t.Run("dot should be removed when query has more than one stat and one of them is a percentile", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
Period: 600,
Id: "",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.NoError(t, err)
assert.Len(t, res, 2)
assert.Contains(t, res, "queryD_p46_32")
})
t.Run("should return an error if two queries have the same id", func(t *testing.T) {
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
Period: 600,
Id: "myId",
},
{
RefId: "E",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
Period: 600,
Id: "myId",
},
}
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries)
require.Nil(t, res)
assert.Error(t, err)
})
requestQueries := []*requestQuery{
{
RefId: "D",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Sum"}),
Period: 600,
Id: "myId",
},
{
RefId: "E",
Region: "us-east-1",
Namespace: "ec2",
MetricName: "CPUUtilization",
Statistics: aws.StringSlice([]string{"Average", "p46.32"}),
Period: 600,
Id: "myId",
},
}
t.Run("A deep link that reference two metric stat metrics is created based on a request query with two stats", func(t *testing.T) {
start, err := time.Parse(time.RFC3339, "2018-03-15T13:00:00Z")
require.NoError(t, err)
end, err := time.Parse(time.RFC3339, "2018-03-18T13:34:00Z")
require.NoError(t, err)
executedQueries := []executedQuery{{
Expression: ``,
ID: "D",
Period: 600,
}}
link, err := buildDeepLink("E", requestQueries, executedQueries, start, end)
require.NoError(t, err)
parsedURL, err := url.Parse(link)
require.NoError(t, err)
decodedLink, err := url.PathUnescape(parsedURL.String())
require.NoError(t, err)
expected := `https://us-east-1.console.aws.amazon.com/cloudwatch/deeplink.js?region=us-east-1#metricsV2:graph={"view":"timeSeries","stacked":false,"title":"E","start":"2018-03-15T13:00:00Z","end":"2018-03-18T13:34:00Z","region":"us-east-1","metrics":[["ec2","CPUUtilization",{"stat":"Average","period":600}],["ec2","CPUUtilization",{"stat":"p46.32","period":600}]]}`
assert.Equal(t, expected, decodedLink)
})
t.Run("A deep link that reference an expression based metric is created based on a request query with one stat", func(t *testing.T) {
start, err := time.Parse(time.RFC3339, "2018-03-15T13:00:00Z")
require.NoError(t, err)
end, err := time.Parse(time.RFC3339, "2018-03-18T13:34:00Z")
require.NoError(t, err)
executedQueries := []executedQuery{{
Expression: `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization"', 'Sum', 600))`,
ID: "D",
Period: 600,
}}
link, err := buildDeepLink("E", requestQueries, executedQueries, start, end)
require.NoError(t, err)
parsedURL, err := url.Parse(link)
require.NoError(t, err)
decodedLink, err := url.PathUnescape(parsedURL.String())
require.NoError(t, err)
expected := `https://us-east-1.console.aws.amazon.com/cloudwatch/deeplink.js?region=us-east-1#metricsV2:graph={"view":"timeSeries","stacked":false,"title":"E","start":"2018-03-15T13:00:00Z","end":"2018-03-18T13:34:00Z","region":"us-east-1","metrics":[{"expression":"REMOVE_EMPTY(SEARCH('Namespace=\"AWS/EC2\"+MetricName=\"CPUUtilization\"',+'Sum',+600))"}]}`
assert.Equal(t, expected, decodedLink)
})
t.Run("A deep link is not built in case any of the executedQueries are math expressions", func(t *testing.T) {
start, err := time.Parse(time.RFC3339, "2018-03-15T13:00:00Z")
require.NoError(t, err)
end, err := time.Parse(time.RFC3339, "2018-03-18T13:34:00Z")
require.NoError(t, err)
executedQueries := []executedQuery{{
Expression: `a * 2`,
ID: "D",
Period: 600,
}}
link, err := buildDeepLink("E", requestQueries, executedQueries, start, end)
require.NoError(t, err)
parsedURL, err := url.Parse(link)
require.NoError(t, err)
decodedLink, err := url.PathUnescape(parsedURL.String())
require.NoError(t, err)
assert.Equal(t, "", decodedLink)
})
}

View File

@ -10,15 +10,19 @@ import (
"strings" "strings"
"time" "time"
"github.com/aws/aws-sdk-go/aws"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
) )
// Parses the json queries and returns a requestQuery. The requestQuery has a 1 to 1 mapping to a query editor row // parseQueries parses the json queries and returns a map of cloudWatchQueries by region. The cloudWatchQuery has a 1 to 1 mapping to a query editor row
func (e *cloudWatchExecutor) parseQueries(queries []backend.DataQuery, startTime time.Time, endTime time.Time) (map[string][]*requestQuery, error) { func (e *cloudWatchExecutor) parseQueries(queries []backend.DataQuery, startTime time.Time, endTime time.Time) (map[string][]*cloudWatchQuery, error) {
requestQueries := make(map[string][]*requestQuery) requestQueries := make(map[string][]*cloudWatchQuery)
for _, query := range queries { migratedQueries, err := migrateLegacyQuery(queries, startTime, endTime)
if err != nil {
return nil, err
}
for _, query := range migratedQueries {
model, err := simplejson.NewJson(query.JSON) model, err := simplejson.NewJson(query.JSON)
if err != nil { if err != nil {
return nil, &queryError{err: err, RefID: query.RefID} return nil, &queryError{err: err, RefID: query.RefID}
@ -36,7 +40,7 @@ func (e *cloudWatchExecutor) parseQueries(queries []backend.DataQuery, startTime
} }
if _, exist := requestQueries[query.Region]; !exist { if _, exist := requestQueries[query.Region]; !exist {
requestQueries[query.Region] = make([]*requestQuery, 0) requestQueries[query.Region] = []*cloudWatchQuery{}
} }
requestQueries[query.Region] = append(requestQueries[query.Region], query) requestQueries[query.Region] = append(requestQueries[query.Region], query)
} }
@ -44,7 +48,41 @@ func (e *cloudWatchExecutor) parseQueries(queries []backend.DataQuery, startTime
return requestQueries, nil return requestQueries, nil
} }
func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time, endTime time.Time) (*requestQuery, error) { // migrateLegacyQuery migrates queries that has a `statistics` field to use the `statistic` field instead.
// This migration is also done in the frontend, so this should only ever be needed for alerting queries
// In case the query used more than one stat, the first stat in the slice will be used in the statistic field
// Read more here https://github.com/grafana/grafana/issues/30629
func migrateLegacyQuery(queries []backend.DataQuery, startTime time.Time, endTime time.Time) ([]*backend.DataQuery, error) {
migratedQueries := []*backend.DataQuery{}
for _, q := range queries {
query := q
model, err := simplejson.NewJson(query.JSON)
if err != nil {
return nil, err
}
_, err = model.Get("statistic").String()
// If there's not a statistic property in the json, we know it's the legacy format and then it has to be migrated
if err != nil {
stats, err := model.Get("statistics").StringArray()
if err != nil {
return nil, fmt.Errorf("query must have either statistic or statistics field")
}
model.Del("statistics")
model.Set("statistic", stats[0])
query.JSON, err = model.MarshalJSON()
if err != nil {
return nil, err
}
}
migratedQueries = append(migratedQueries, &query)
}
return migratedQueries, nil
}
func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time, endTime time.Time) (*cloudWatchQuery, error) {
plog.Debug("Parsing request query", "query", model) plog.Debug("Parsing request query", "query", model)
reNumber := regexp.MustCompile(`^\d+$`) reNumber := regexp.MustCompile(`^\d+$`)
region, err := model.Get("region").String() region, err := model.Get("region").String()
@ -63,7 +101,11 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse dimensions: %v", err) return nil, fmt.Errorf("failed to parse dimensions: %v", err)
} }
statistics := parseStatistics(model)
statistic, err := model.Get("statistic").String()
if err != nil {
return nil, fmt.Errorf("failed to parse statistic: %v", err)
}
p := model.Get("period").MustString("") p := model.Get("period").MustString("")
var period int var period int
@ -94,6 +136,12 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time
} }
id := model.Get("id").MustString("") id := model.Get("id").MustString("")
if id == "" {
// Why not just use refId if id is not specified in the frontend? When specifying an id in the editor,
// and alphabetical must be used. The id must be unique, so if an id like for example a, b or c would be used,
// it would likely collide with some ref id. That's why the `query` prefix is used.
id = fmt.Sprintf("query%s", refId)
}
expression := model.Get("expression").MustString("") expression := model.Get("expression").MustString("")
alias := model.Get("alias").MustString() alias := model.Get("alias").MustString()
returnData := !model.Get("hide").MustBool(false) returnData := !model.Get("hide").MustBool(false)
@ -107,19 +155,20 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time
matchExact := model.Get("matchExact").MustBool(true) matchExact := model.Get("matchExact").MustBool(true)
return &requestQuery{ return &cloudWatchQuery{
RefId: refId, RefId: refId,
Region: region, Region: region,
Namespace: namespace, Id: id,
MetricName: metricName, Namespace: namespace,
Dimensions: dimensions, MetricName: metricName,
Statistics: aws.StringSlice(statistics), Statistic: statistic,
Period: period, Expression: expression,
Alias: alias, ReturnData: returnData,
Id: id, Dimensions: dimensions,
Expression: expression, Period: period,
ReturnData: returnData, Alias: alias,
MatchExact: matchExact, MatchExact: matchExact,
UsedExpression: "",
}, nil }, nil
} }
@ -136,15 +185,6 @@ func getRetainedPeriods(timeSince time.Duration) []int {
} }
} }
func parseStatistics(model *simplejson.Json) []string {
var statistics []string
for _, s := range model.Get("statistics").MustArray() {
statistics = append(statistics, s.(string))
}
return statistics
}
func parseDimensions(model *simplejson.Json) (map[string][]string, error) { func parseDimensions(model *simplejson.Json) (map[string][]string, error) {
parsedDimensions := make(map[string][]string) parsedDimensions := make(map[string][]string)
for k, v := range model.Get("dimensions").MustMap() { for k, v := range model.Get("dimensions").MustMap() {

View File

@ -4,14 +4,51 @@ import (
"testing" "testing"
"time" "time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/tsdb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestRequestParser(t *testing.T) { func TestRequestParser(t *testing.T) {
timeRange := plugins.NewDataTimeRange("now-1h", "now-2h") t.Run("Query migration ", func(t *testing.T) {
t.Run("legacy statistics field is migrated", func(t *testing.T) {
startTime := time.Now()
endTime := startTime.Add(2 * time.Hour)
oldQuery := &backend.DataQuery{
MaxDataPoints: 0,
QueryType: "timeSeriesQuery",
Interval: 0,
}
oldQuery.RefID = "A"
oldQuery.JSON = []byte(`{
"region": "us-east-1",
"namespace": "ec2",
"metricName": "CPUUtilization",
"dimensions": {
"InstanceId": ["test"]
},
"statistics": ["Average", "Sum"],
"period": "600",
"hide": false
}`)
migratedQueries, err := migrateLegacyQuery([]backend.DataQuery{*oldQuery}, startTime, endTime)
require.NoError(t, err)
assert.Equal(t, 1, len(migratedQueries))
migratedQuery := migratedQueries[0]
assert.Equal(t, "A", migratedQuery.RefID)
model, err := simplejson.NewJson(migratedQuery.JSON)
require.NoError(t, err)
assert.Equal(t, "Average", model.Get("statistic").MustString())
res, err := model.Get("statistic").Array()
assert.Error(t, err)
assert.Nil(t, res)
})
})
timeRange := tsdb.NewTimeRange("now-1h", "now-2h")
from, err := timeRange.ParseFrom() from, err := timeRange.ParseFrom()
require.NoError(t, err) require.NoError(t, err)
to, err := timeRange.ParseTo() to, err := timeRange.ParseTo()
@ -29,9 +66,9 @@ func TestRequestParser(t *testing.T) {
"InstanceId": []interface{}{"test"}, "InstanceId": []interface{}{"test"},
"InstanceType": []interface{}{"test2", "test3"}, "InstanceType": []interface{}{"test2", "test3"},
}, },
"statistics": []interface{}{"Average"}, "statistic": "Average",
"period": "600", "period": "600",
"hide": false, "hide": false,
}) })
res, err := parseRequestQuery(query, "ref1", from, to) res, err := parseRequestQuery(query, "ref1", from, to)
@ -40,7 +77,7 @@ func TestRequestParser(t *testing.T) {
assert.Equal(t, "ref1", res.RefId) assert.Equal(t, "ref1", res.RefId)
assert.Equal(t, "ec2", res.Namespace) assert.Equal(t, "ec2", res.Namespace)
assert.Equal(t, "CPUUtilization", res.MetricName) assert.Equal(t, "CPUUtilization", res.MetricName)
assert.Empty(t, res.Id) assert.Equal(t, "queryref1", res.Id)
assert.Empty(t, res.Expression) assert.Empty(t, res.Expression)
assert.Equal(t, 600, res.Period) assert.Equal(t, 600, res.Period)
assert.True(t, res.ReturnData) assert.True(t, res.ReturnData)
@ -48,8 +85,7 @@ func TestRequestParser(t *testing.T) {
assert.Len(t, res.Dimensions["InstanceId"], 1) assert.Len(t, res.Dimensions["InstanceId"], 1)
assert.Len(t, res.Dimensions["InstanceType"], 2) assert.Len(t, res.Dimensions["InstanceType"], 2)
assert.Equal(t, "test3", res.Dimensions["InstanceType"][1]) assert.Equal(t, "test3", res.Dimensions["InstanceType"][1])
assert.Len(t, res.Statistics, 1) assert.Equal(t, "Average", res.Statistic)
assert.Equal(t, "Average", *res.Statistics[0])
}) })
t.Run("Old dimensions structure (backwards compatibility)", func(t *testing.T) { t.Run("Old dimensions structure (backwards compatibility)", func(t *testing.T) {
@ -64,9 +100,9 @@ func TestRequestParser(t *testing.T) {
"InstanceId": "test", "InstanceId": "test",
"InstanceType": "test2", "InstanceType": "test2",
}, },
"statistics": []interface{}{"Average"}, "statistic": "Average",
"period": "600", "period": "600",
"hide": false, "hide": false,
}) })
res, err := parseRequestQuery(query, "ref1", from, to) res, err := parseRequestQuery(query, "ref1", from, to)
@ -75,7 +111,7 @@ func TestRequestParser(t *testing.T) {
assert.Equal(t, "ref1", res.RefId) assert.Equal(t, "ref1", res.RefId)
assert.Equal(t, "ec2", res.Namespace) assert.Equal(t, "ec2", res.Namespace)
assert.Equal(t, "CPUUtilization", res.MetricName) assert.Equal(t, "CPUUtilization", res.MetricName)
assert.Empty(t, res.Id) assert.Equal(t, "queryref1", res.Id)
assert.Empty(t, res.Expression) assert.Empty(t, res.Expression)
assert.Equal(t, 600, res.Period) assert.Equal(t, 600, res.Period)
assert.True(t, res.ReturnData) assert.True(t, res.ReturnData)
@ -83,7 +119,7 @@ func TestRequestParser(t *testing.T) {
assert.Len(t, res.Dimensions["InstanceId"], 1) assert.Len(t, res.Dimensions["InstanceId"], 1)
assert.Len(t, res.Dimensions["InstanceType"], 1) assert.Len(t, res.Dimensions["InstanceType"], 1)
assert.Equal(t, "test2", res.Dimensions["InstanceType"][0]) assert.Equal(t, "test2", res.Dimensions["InstanceType"][0])
assert.Equal(t, "Average", *res.Statistics[0]) assert.Equal(t, "Average", res.Statistic)
}) })
t.Run("Period defined in the editor by the user is being used when time range is short", func(t *testing.T) { t.Run("Period defined in the editor by the user is being used when time range is short", func(t *testing.T) {
@ -98,11 +134,11 @@ func TestRequestParser(t *testing.T) {
"InstanceId": "test", "InstanceId": "test",
"InstanceType": "test2", "InstanceType": "test2",
}, },
"statistics": []interface{}{"Average"}, "statistic": "Average",
"hide": false, "hide": false,
}) })
query.Set("period", "900") query.Set("period", "900")
timeRange := plugins.NewDataTimeRange("now-1h", "now-2h") timeRange := tsdb.NewTimeRange("now-1h", "now-2h")
from, err := timeRange.ParseFrom() from, err := timeRange.ParseFrom()
require.NoError(t, err) require.NoError(t, err)
to, err := timeRange.ParseTo() to, err := timeRange.ParseTo()
@ -125,9 +161,9 @@ func TestRequestParser(t *testing.T) {
"InstanceId": "test", "InstanceId": "test",
"InstanceType": "test2", "InstanceType": "test2",
}, },
"statistics": []interface{}{"Average"}, "statistic": "Average",
"hide": false, "hide": false,
"period": "auto", "period": "auto",
}) })
t.Run("Time range is 5 minutes", func(t *testing.T) { t.Run("Time range is 5 minutes", func(t *testing.T) {

View File

@ -8,86 +8,119 @@ import (
"time" "time"
"github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/components/simplejson"
) )
func (e *cloudWatchExecutor) parseResponse(metricDataOutputs []*cloudwatch.GetMetricDataOutput, func (e *cloudWatchExecutor) parseResponse(startTime time.Time, endTime time.Time, metricDataOutputs []*cloudwatch.GetMetricDataOutput,
queries map[string]*cloudWatchQuery) ([]*cloudwatchResponse, error) { queries []*cloudWatchQuery) ([]*responseWrapper, error) {
// Map from result ID -> label -> result aggregatedResponse := aggregateResponse(metricDataOutputs)
mdrs := make(map[string]map[string]*cloudwatch.MetricDataResult) queriesById := map[string]*cloudWatchQuery{}
labels := map[string][]string{} for _, query := range queries {
for _, mdo := range metricDataOutputs { queriesById[query.Id] = query
requestExceededMaxLimit := false
for _, message := range mdo.Messages {
if *message.Code == "MaxMetricsExceeded" {
requestExceededMaxLimit = true
}
}
for _, r := range mdo.MetricDataResults {
id := *r.Id
label := *r.Label
if _, exists := mdrs[id]; !exists {
mdrs[id] = make(map[string]*cloudwatch.MetricDataResult)
mdrs[id][label] = r
labels[id] = append(labels[id], label)
} else if _, exists := mdrs[id][label]; !exists {
mdrs[id][label] = r
labels[id] = append(labels[id], label)
} else {
mdr := mdrs[id][label]
mdr.Timestamps = append(mdr.Timestamps, r.Timestamps...)
mdr.Values = append(mdr.Values, r.Values...)
if *r.StatusCode == "Complete" {
mdr.StatusCode = r.StatusCode
}
}
queries[id].RequestExceededMaxLimit = requestExceededMaxLimit
}
} }
cloudWatchResponses := make([]*cloudwatchResponse, 0, len(mdrs)) results := []*responseWrapper{}
for id, lr := range mdrs { for id, response := range aggregatedResponse {
query := queries[id] queryRow := queriesById[id]
frames, partialData, err := parseMetricResults(lr, labels[id], query) dataRes := backend.DataResponse{}
if response.HasArithmeticError {
dataRes.Error = fmt.Errorf("ArithmeticError in query %q: %s", queryRow.RefId, response.ArithmeticErrorMessage)
}
var err error
dataRes.Frames, err = buildDataFrames(startTime, endTime, response, queryRow)
if err != nil { if err != nil {
return nil, err return nil, err
} }
response := &cloudwatchResponse{ results = append(results, &responseWrapper{
DataFrames: frames, DataResponse: &dataRes,
Period: query.Period, RefId: queryRow.RefId,
Expression: query.UsedExpression, })
RefId: query.RefId,
Id: query.Id,
RequestExceededMaxLimit: query.RequestExceededMaxLimit,
PartialData: partialData,
}
cloudWatchResponses = append(cloudWatchResponses, response)
} }
return cloudWatchResponses, nil return results, nil
} }
func parseMetricResults(results map[string]*cloudwatch.MetricDataResult, labels []string, func aggregateResponse(getMetricDataOutputs []*cloudwatch.GetMetricDataOutput) map[string]queryRowResponse {
query *cloudWatchQuery) (data.Frames, bool, error) { responseByID := make(map[string]queryRowResponse)
partialData := false for _, gmdo := range getMetricDataOutputs {
frames := data.Frames{} requestExceededMaxLimit := false
for _, label := range labels { for _, message := range gmdo.Messages {
result := results[label] if *message.Code == "MaxMetricsExceeded" {
if *result.StatusCode != "Complete" { requestExceededMaxLimit = true
partialData = true
}
for _, message := range result.Messages {
if *message.Code == "ArithmeticError" {
return nil, false, fmt.Errorf("ArithmeticError in query %q: %s", query.RefId, *message.Value)
} }
} }
for _, r := range gmdo.MetricDataResults {
id := *r.Id
label := *r.Label
response := newQueryRowResponse(id)
if _, exists := responseByID[id]; exists {
response = responseByID[id]
}
for _, message := range r.Messages {
if *message.Code == "ArithmeticError" {
response.addArithmeticError(message.Value)
}
}
if _, exists := response.Metrics[label]; !exists {
response.addMetricDataResult(r)
} else {
response.appendTimeSeries(r)
}
response.RequestExceededMaxLimit = response.RequestExceededMaxLimit || requestExceededMaxLimit
responseByID[id] = response
}
}
return responseByID
}
func getLabels(cloudwatchLabel string, query *cloudWatchQuery) data.Labels {
dims := make([]string, 0, len(query.Dimensions))
for k := range query.Dimensions {
dims = append(dims, k)
}
sort.Strings(dims)
labels := data.Labels{}
for _, dim := range dims {
values := query.Dimensions[dim]
if len(values) == 1 && values[0] != "*" {
labels[dim] = values[0]
} else {
for _, value := range values {
if value == cloudwatchLabel || value == "*" {
labels[dim] = cloudwatchLabel
} else if strings.Contains(cloudwatchLabel, value) {
labels[dim] = value
}
}
}
}
return labels
}
func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse queryRowResponse,
query *cloudWatchQuery) (data.Frames, error) {
frames := data.Frames{}
for _, label := range aggregatedResponse.Labels {
metric := aggregatedResponse.Metrics[label]
deepLink, err := query.buildDeepLink(startTime, endTime)
if err != nil {
return nil, err
}
// In case a multi-valued dimension is used and the cloudwatch query yields no values, create one empty time // In case a multi-valued dimension is used and the cloudwatch query yields no values, create one empty time
// series for each dimension value. Use that dimension value to expand the alias field // series for each dimension value. Use that dimension value to expand the alias field
if len(result.Values) == 0 && query.isMultiValuedDimensionExpression() { if len(metric.Values) == 0 && query.isMultiValuedDimensionExpression() {
series := 0 series := 0
multiValuedDimension := "" multiValuedDimension := ""
for key, values := range query.Dimensions { for key, values := range query.Dimensions {
@ -98,18 +131,18 @@ func parseMetricResults(results map[string]*cloudwatch.MetricDataResult, labels
} }
for _, value := range query.Dimensions[multiValuedDimension] { for _, value := range query.Dimensions[multiValuedDimension] {
tags := map[string]string{multiValuedDimension: value} labels := map[string]string{multiValuedDimension: value}
for key, values := range query.Dimensions { for key, values := range query.Dimensions {
if key != multiValuedDimension && len(values) > 0 { if key != multiValuedDimension && len(values) > 0 {
tags[key] = values[0] labels[key] = values[0]
} }
} }
timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []*time.Time{}) timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []*time.Time{})
valueField := data.NewField(data.TimeSeriesValueFieldName, tags, []*float64{}) valueField := data.NewField(data.TimeSeriesValueFieldName, labels, []*float64{})
frameName := formatAlias(query, query.Stats, tags, label) frameName := formatAlias(query, query.Statistic, labels, label)
valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName}) valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName, Links: createDataLinks(deepLink)})
emptyFrame := data.Frame{ emptyFrame := data.Frame{
Name: frameName, Name: frameName,
@ -118,66 +151,63 @@ func parseMetricResults(results map[string]*cloudwatch.MetricDataResult, labels
valueField, valueField,
}, },
RefID: query.RefId, RefID: query.RefId,
Meta: createMeta(query),
} }
frames = append(frames, &emptyFrame) frames = append(frames, &emptyFrame)
} }
} else { continue
dims := make([]string, 0, len(query.Dimensions))
for k := range query.Dimensions {
dims = append(dims, k)
}
sort.Strings(dims)
tags := data.Labels{}
for _, dim := range dims {
values := query.Dimensions[dim]
if len(values) == 1 && values[0] != "*" {
tags[dim] = values[0]
} else {
for _, value := range values {
if value == label || value == "*" {
tags[dim] = label
} else if strings.Contains(label, value) {
tags[dim] = value
}
}
}
}
timestamps := []*time.Time{}
points := []*float64{}
for j, t := range result.Timestamps {
if j > 0 {
expectedTimestamp := result.Timestamps[j-1].Add(time.Duration(query.Period) * time.Second)
if expectedTimestamp.Before(*t) {
timestamps = append(timestamps, &expectedTimestamp)
points = append(points, nil)
}
}
val := result.Values[j]
timestamps = append(timestamps, t)
points = append(points, val)
}
timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, timestamps)
valueField := data.NewField(data.TimeSeriesValueFieldName, tags, points)
frameName := formatAlias(query, query.Stats, tags, label)
valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName})
frame := data.Frame{
Name: frameName,
Fields: []*data.Field{
timeField,
valueField,
},
RefID: query.RefId,
}
frames = append(frames, &frame)
} }
labels := getLabels(label, query)
timestamps := []*time.Time{}
points := []*float64{}
for j, t := range metric.Timestamps {
if j > 0 {
expectedTimestamp := metric.Timestamps[j-1].Add(time.Duration(query.Period) * time.Second)
if expectedTimestamp.Before(*t) {
timestamps = append(timestamps, &expectedTimestamp)
points = append(points, nil)
}
}
val := metric.Values[j]
timestamps = append(timestamps, t)
points = append(points, val)
}
timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, timestamps)
valueField := data.NewField(data.TimeSeriesValueFieldName, labels, points)
frameName := formatAlias(query, query.Statistic, labels, label)
valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName, Links: createDataLinks(deepLink)})
frame := data.Frame{
Name: frameName,
Fields: []*data.Field{
timeField,
valueField,
},
RefID: query.RefId,
Meta: createMeta(query),
}
if aggregatedResponse.RequestExceededMaxLimit {
frame.AppendNotices(data.Notice{
Severity: data.NoticeSeverityWarning,
Text: "cloudwatch GetMetricData error: Maximum number of allowed metrics exceeded. Your search may have been limited",
})
}
if aggregatedResponse.StatusCode != "Complete" {
frame.AppendNotices(data.Notice{
Severity: data.NoticeSeverityWarning,
Text: "cloudwatch GetMetricData error: Too many datapoints requested - your search has been limited. Please try to reduce the time range",
})
}
frames = append(frames, &frame)
} }
return frames, partialData, nil return frames, nil
} }
func formatAlias(query *cloudWatchQuery, stat string, dimensions map[string]string, label string) string { func formatAlias(query *cloudWatchQuery, stat string, dimensions map[string]string, label string) string {
@ -231,3 +261,25 @@ func formatAlias(query *cloudWatchQuery, stat string, dimensions map[string]stri
return string(result) return string(result)
} }
func createDataLinks(link string) []data.DataLink {
dataLinks := []data.DataLink{}
if link != "" {
dataLinks = append(dataLinks, data.DataLink{
Title: "View in CloudWatch console",
TargetBlank: true,
URL: link,
})
}
return dataLinks
}
func createMeta(query *cloudWatchQuery) *data.FrameMeta {
return &data.FrameMeta{
ExecutedQueryString: query.UsedExpression,
Custom: simplejson.NewFromAny(map[string]interface{}{
"period": query.Period,
"id": query.Id,
}),
}
}

View File

@ -1,6 +1,8 @@
package cloudwatch package cloudwatch
import ( import (
"encoding/json"
"io/ioutil"
"testing" "testing"
"time" "time"
@ -10,40 +12,86 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func loadGetMetricDataOutputsFromFile() ([]*cloudwatch.GetMetricDataOutput, error) {
var getMetricDataOutputs []*cloudwatch.GetMetricDataOutput
jsonBody, err := ioutil.ReadFile("./test-data/multiple-outputs.json")
if err != nil {
return getMetricDataOutputs, err
}
err = json.Unmarshal(jsonBody, &getMetricDataOutputs)
return getMetricDataOutputs, err
}
func TestCloudWatchResponseParser(t *testing.T) { func TestCloudWatchResponseParser(t *testing.T) {
startTime := time.Now()
endTime := startTime.Add(2 * time.Hour)
t.Run("when aggregating response", func(t *testing.T) {
getMetricDataOutputs, err := loadGetMetricDataOutputsFromFile()
require.NoError(t, err)
aggregatedResponse := aggregateResponse(getMetricDataOutputs)
t.Run("response for id a", func(t *testing.T) {
idA := "a"
t.Run("should have two labels", func(t *testing.T) {
assert.Len(t, aggregatedResponse[idA].Labels, 2)
assert.Len(t, aggregatedResponse[idA].Metrics, 2)
})
t.Run("should have points for label1 taken from both getMetricDataOutputs", func(t *testing.T) {
assert.Len(t, aggregatedResponse[idA].Metrics["label1"].Values, 10)
})
t.Run("should have statuscode 'Complete'", func(t *testing.T) {
assert.Equal(t, "Complete", aggregatedResponse[idA].StatusCode)
})
t.Run("should have exceeded request limit", func(t *testing.T) {
assert.True(t, aggregatedResponse[idA].RequestExceededMaxLimit)
})
})
t.Run("response for id b", func(t *testing.T) {
idB := "b"
t.Run("should have statuscode is 'Partial'", func(t *testing.T) {
assert.Equal(t, "Partial", aggregatedResponse[idB].StatusCode)
})
t.Run("should have an arithmetic error and an error message", func(t *testing.T) {
assert.True(t, aggregatedResponse[idB].HasArithmeticError)
assert.Equal(t, "One or more data-points have been dropped due to non-numeric values (NaN, -Infinite, +Infinite)", aggregatedResponse[idB].ArithmeticErrorMessage)
})
})
})
t.Run("Expand dimension value using exact match", func(t *testing.T) { t.Run("Expand dimension value using exact match", func(t *testing.T) {
timestamp := time.Unix(0, 0) timestamp := time.Unix(0, 0)
labels := []string{"lb1", "lb2"} response := &queryRowResponse{
mdrs := map[string]*cloudwatch.MetricDataResult{ Labels: []string{"lb1", "lb2"},
"lb1": { Metrics: map[string]*cloudwatch.MetricDataResult{
Id: aws.String("id1"), "lb1": {
Label: aws.String("lb1"), Id: aws.String("id1"),
Timestamps: []*time.Time{ Label: aws.String("lb1"),
aws.Time(timestamp), Timestamps: []*time.Time{
aws.Time(timestamp.Add(60 * time.Second)), aws.Time(timestamp),
aws.Time(timestamp.Add(180 * time.Second)), aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
Values: []*float64{ "lb2": {
aws.Float64(10), Id: aws.String("id2"),
aws.Float64(20), Label: aws.String("lb2"),
aws.Float64(30), Timestamps: []*time.Time{
aws.Time(timestamp),
aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
StatusCode: aws.String("Complete"),
},
"lb2": {
Id: aws.String("id2"),
Label: aws.String("lb2"),
Timestamps: []*time.Time{
aws.Time(timestamp),
aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
} }
@ -56,15 +104,14 @@ func TestCloudWatchResponseParser(t *testing.T) {
"LoadBalancer": {"lb1", "lb2"}, "LoadBalancer": {"lb1", "lb2"},
"TargetGroup": {"tg"}, "TargetGroup": {"tg"},
}, },
Stats: "Average", Statistic: "Average",
Period: 60, Period: 60,
Alias: "{{LoadBalancer}} Expanded", Alias: "{{LoadBalancer}} Expanded",
} }
frames, partialData, err := parseMetricResults(mdrs, labels, query) frames, err := buildDataFrames(startTime, endTime, *response, query)
require.NoError(t, err) require.NoError(t, err)
frame1 := frames[0] frame1 := frames[0]
assert.False(t, partialData)
assert.Equal(t, "lb1 Expanded", frame1.Name) assert.Equal(t, "lb1 Expanded", frame1.Name)
assert.Equal(t, "lb1", frame1.Fields[1].Labels["LoadBalancer"]) assert.Equal(t, "lb1", frame1.Fields[1].Labels["LoadBalancer"])
@ -75,39 +122,40 @@ func TestCloudWatchResponseParser(t *testing.T) {
t.Run("Expand dimension value using substring", func(t *testing.T) { t.Run("Expand dimension value using substring", func(t *testing.T) {
timestamp := time.Unix(0, 0) timestamp := time.Unix(0, 0)
labels := []string{"lb1 Sum", "lb2 Average"} response := &queryRowResponse{
mdrs := map[string]*cloudwatch.MetricDataResult{ Labels: []string{"lb1 Sum", "lb2 Average"},
"lb1 Sum": { Metrics: map[string]*cloudwatch.MetricDataResult{
Id: aws.String("id1"), "lb1 Sum": {
Label: aws.String("lb1 Sum"), Id: aws.String("id1"),
Timestamps: []*time.Time{ Label: aws.String("lb1 Sum"),
aws.Time(timestamp), Timestamps: []*time.Time{
aws.Time(timestamp.Add(60 * time.Second)), aws.Time(timestamp),
aws.Time(timestamp.Add(180 * time.Second)), aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
Values: []*float64{ "lb2 Average": {
aws.Float64(10), Id: aws.String("id2"),
aws.Float64(20), Label: aws.String("lb2 Average"),
aws.Float64(30), Timestamps: []*time.Time{
aws.Time(timestamp),
aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
StatusCode: aws.String("Complete"), }}
},
"lb2 Average": {
Id: aws.String("id2"),
Label: aws.String("lb2 Average"),
Timestamps: []*time.Time{
aws.Time(timestamp),
aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
},
}
query := &cloudWatchQuery{ query := &cloudWatchQuery{
RefId: "refId1", RefId: "refId1",
@ -118,15 +166,14 @@ func TestCloudWatchResponseParser(t *testing.T) {
"LoadBalancer": {"lb1", "lb2"}, "LoadBalancer": {"lb1", "lb2"},
"TargetGroup": {"tg"}, "TargetGroup": {"tg"},
}, },
Stats: "Average", Statistic: "Average",
Period: 60, Period: 60,
Alias: "{{LoadBalancer}} Expanded", Alias: "{{LoadBalancer}} Expanded",
} }
frames, partialData, err := parseMetricResults(mdrs, labels, query) frames, err := buildDataFrames(startTime, endTime, *response, query)
require.NoError(t, err) require.NoError(t, err)
frame1 := frames[0] frame1 := frames[0]
assert.False(t, partialData)
assert.Equal(t, "lb1 Expanded", frame1.Name) assert.Equal(t, "lb1 Expanded", frame1.Name)
assert.Equal(t, "lb1", frame1.Fields[1].Labels["LoadBalancer"]) assert.Equal(t, "lb1", frame1.Fields[1].Labels["LoadBalancer"])
@ -137,37 +184,39 @@ func TestCloudWatchResponseParser(t *testing.T) {
t.Run("Expand dimension value using wildcard", func(t *testing.T) { t.Run("Expand dimension value using wildcard", func(t *testing.T) {
timestamp := time.Unix(0, 0) timestamp := time.Unix(0, 0)
labels := []string{"lb3", "lb4"} response := &queryRowResponse{
mdrs := map[string]*cloudwatch.MetricDataResult{ Labels: []string{"lb3", "lb4"},
"lb3": { Metrics: map[string]*cloudwatch.MetricDataResult{
Id: aws.String("lb3"), "lb3": {
Label: aws.String("lb3"), Id: aws.String("lb3"),
Timestamps: []*time.Time{ Label: aws.String("lb3"),
aws.Time(timestamp), Timestamps: []*time.Time{
aws.Time(timestamp.Add(60 * time.Second)), aws.Time(timestamp),
aws.Time(timestamp.Add(180 * time.Second)), aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
Values: []*float64{ "lb4": {
aws.Float64(10), Id: aws.String("lb4"),
aws.Float64(20), Label: aws.String("lb4"),
aws.Float64(30), Timestamps: []*time.Time{
aws.Time(timestamp),
aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
StatusCode: aws.String("Complete"),
},
"lb4": {
Id: aws.String("lb4"),
Label: aws.String("lb4"),
Timestamps: []*time.Time{
aws.Time(timestamp),
aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
} }
@ -180,35 +229,35 @@ func TestCloudWatchResponseParser(t *testing.T) {
"LoadBalancer": {"*"}, "LoadBalancer": {"*"},
"TargetGroup": {"tg"}, "TargetGroup": {"tg"},
}, },
Stats: "Average", Statistic: "Average",
Period: 60, Period: 60,
Alias: "{{LoadBalancer}} Expanded", Alias: "{{LoadBalancer}} Expanded",
} }
frames, partialData, err := parseMetricResults(mdrs, labels, query) frames, err := buildDataFrames(startTime, endTime, *response, query)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, partialData)
assert.Equal(t, "lb3 Expanded", frames[0].Name) assert.Equal(t, "lb3 Expanded", frames[0].Name)
assert.Equal(t, "lb4 Expanded", frames[1].Name) assert.Equal(t, "lb4 Expanded", frames[1].Name)
}) })
t.Run("Expand dimension value when no values are returned and a multi-valued template variable is used", func(t *testing.T) { t.Run("Expand dimension value when no values are returned and a multi-valued template variable is used", func(t *testing.T) {
timestamp := time.Unix(0, 0) timestamp := time.Unix(0, 0)
labels := []string{"lb3"} response := &queryRowResponse{
mdrs := map[string]*cloudwatch.MetricDataResult{ Labels: []string{"lb3"},
"lb3": { Metrics: map[string]*cloudwatch.MetricDataResult{
Id: aws.String("lb3"), "lb3": {
Label: aws.String("lb3"), Id: aws.String("lb3"),
Timestamps: []*time.Time{ Label: aws.String("lb3"),
aws.Time(timestamp), Timestamps: []*time.Time{
aws.Time(timestamp.Add(60 * time.Second)), aws.Time(timestamp),
aws.Time(timestamp.Add(180 * time.Second)), aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{},
StatusCode: aws.String("Complete"),
}, },
Values: []*float64{},
StatusCode: aws.String("Complete"),
}, },
} }
query := &cloudWatchQuery{ query := &cloudWatchQuery{
RefId: "refId1", RefId: "refId1",
Region: "us-east-1", Region: "us-east-1",
@ -217,14 +266,13 @@ func TestCloudWatchResponseParser(t *testing.T) {
Dimensions: map[string][]string{ Dimensions: map[string][]string{
"LoadBalancer": {"lb1", "lb2"}, "LoadBalancer": {"lb1", "lb2"},
}, },
Stats: "Average", Statistic: "Average",
Period: 60, Period: 60,
Alias: "{{LoadBalancer}} Expanded", Alias: "{{LoadBalancer}} Expanded",
} }
frames, partialData, err := parseMetricResults(mdrs, labels, query) frames, err := buildDataFrames(startTime, endTime, *response, query)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, partialData)
assert.Len(t, frames, 2) assert.Len(t, frames, 2)
assert.Equal(t, "lb1 Expanded", frames[0].Name) assert.Equal(t, "lb1 Expanded", frames[0].Name)
assert.Equal(t, "lb2 Expanded", frames[1].Name) assert.Equal(t, "lb2 Expanded", frames[1].Name)
@ -232,18 +280,20 @@ func TestCloudWatchResponseParser(t *testing.T) {
t.Run("Expand dimension value when no values are returned and a multi-valued template variable and two single-valued dimensions are used", func(t *testing.T) { t.Run("Expand dimension value when no values are returned and a multi-valued template variable and two single-valued dimensions are used", func(t *testing.T) {
timestamp := time.Unix(0, 0) timestamp := time.Unix(0, 0)
labels := []string{"lb3"} response := &queryRowResponse{
mdrs := map[string]*cloudwatch.MetricDataResult{ Labels: []string{"lb3"},
"lb3": { Metrics: map[string]*cloudwatch.MetricDataResult{
Id: aws.String("lb3"), "lb3": {
Label: aws.String("lb3"), Id: aws.String("lb3"),
Timestamps: []*time.Time{ Label: aws.String("lb3"),
aws.Time(timestamp), Timestamps: []*time.Time{
aws.Time(timestamp.Add(60 * time.Second)), aws.Time(timestamp),
aws.Time(timestamp.Add(180 * time.Second)), aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{},
StatusCode: aws.String("Complete"),
}, },
Values: []*float64{},
StatusCode: aws.String("Complete"),
}, },
} }
@ -257,14 +307,13 @@ func TestCloudWatchResponseParser(t *testing.T) {
"InstanceType": {"micro"}, "InstanceType": {"micro"},
"Resource": {"res"}, "Resource": {"res"},
}, },
Stats: "Average", Statistic: "Average",
Period: 60, Period: 60,
Alias: "{{LoadBalancer}} Expanded {{InstanceType}} - {{Resource}}", Alias: "{{LoadBalancer}} Expanded {{InstanceType}} - {{Resource}}",
} }
frames, partialData, err := parseMetricResults(mdrs, labels, query) frames, err := buildDataFrames(startTime, endTime, *response, query)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, partialData)
assert.Len(t, frames, 2) assert.Len(t, frames, 2)
assert.Equal(t, "lb1 Expanded micro - res", frames[0].Name) assert.Equal(t, "lb1 Expanded micro - res", frames[0].Name)
assert.Equal(t, "lb2 Expanded micro - res", frames[1].Name) assert.Equal(t, "lb2 Expanded micro - res", frames[1].Name)
@ -272,22 +321,24 @@ func TestCloudWatchResponseParser(t *testing.T) {
t.Run("Parse cloudwatch response", func(t *testing.T) { t.Run("Parse cloudwatch response", func(t *testing.T) {
timestamp := time.Unix(0, 0) timestamp := time.Unix(0, 0)
labels := []string{"lb"} response := &queryRowResponse{
mdrs := map[string]*cloudwatch.MetricDataResult{ Labels: []string{"lb"},
"lb": { Metrics: map[string]*cloudwatch.MetricDataResult{
Id: aws.String("id1"), "lb": {
Label: aws.String("lb"), Id: aws.String("id1"),
Timestamps: []*time.Time{ Label: aws.String("lb"),
aws.Time(timestamp), Timestamps: []*time.Time{
aws.Time(timestamp.Add(60 * time.Second)), aws.Time(timestamp),
aws.Time(timestamp.Add(180 * time.Second)), aws.Time(timestamp.Add(60 * time.Second)),
aws.Time(timestamp.Add(180 * time.Second)),
},
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
Values: []*float64{
aws.Float64(10),
aws.Float64(20),
aws.Float64(30),
},
StatusCode: aws.String("Complete"),
}, },
} }
@ -300,15 +351,14 @@ func TestCloudWatchResponseParser(t *testing.T) {
"LoadBalancer": {"lb"}, "LoadBalancer": {"lb"},
"TargetGroup": {"tg"}, "TargetGroup": {"tg"},
}, },
Stats: "Average", Statistic: "Average",
Period: 60, Period: 60,
Alias: "{{namespace}}_{{metric}}_{{stat}}", Alias: "{{namespace}}_{{metric}}_{{stat}}",
} }
frames, partialData, err := parseMetricResults(mdrs, labels, query) frames, err := buildDataFrames(startTime, endTime, *response, query)
require.NoError(t, err) require.NoError(t, err)
frame := frames[0] frame := frames[0]
assert.False(t, partialData)
assert.Equal(t, "AWS/ApplicationELB_TargetResponseTime_Average", frame.Name) assert.Equal(t, "AWS/ApplicationELB_TargetResponseTime_Average", frame.Name)
assert.Equal(t, "Time", frame.Fields[0].Name) assert.Equal(t, "Time", frame.Fields[0].Name)
assert.Equal(t, "lb", frame.Fields[1].Labels["LoadBalancer"]) assert.Equal(t, "lb", frame.Fields[1].Labels["LoadBalancer"])

View File

@ -0,0 +1,96 @@
[
{
"Messages": null,
"MetricDataResults": [
{
"Id": "a",
"Label": "label1",
"Messages": null,
"StatusCode": "Complete",
"Timestamps": [
"2021-01-15T19:44:00Z",
"2021-01-15T19:59:00Z",
"2021-01-15T20:14:00Z",
"2021-01-15T20:29:00Z",
"2021-01-15T20:44:00Z"
],
"Values": [
0.1333395078879982,
0.244268469636633,
0.15574387947267768,
0.14447563659125626,
0.15519743138527173
]
},
{
"Id": "a",
"Label": "label2",
"Messages": null,
"StatusCode": "Complete",
"Timestamps": [
"2021-01-15T19:44:00Z"
],
"Values": [
0.1333395078879982
]
},
{
"Id": "b",
"Label": "label2",
"Messages": null,
"StatusCode": "Complete",
"Timestamps": [
"2021-01-15T19:44:00Z"
],
"Values": [
0.1333395078879982
]
}
],
"NextToken": null
},
{
"Messages": [
{ "Code": "", "Value": null },
{ "Code": "MaxMetricsExceeded", "Value": null }
],
"MetricDataResults": [
{
"Id": "a",
"Label": "label1",
"Messages": null,
"StatusCode": "Complete",
"Timestamps": [
"2021-01-15T19:44:00Z",
"2021-01-15T19:59:00Z",
"2021-01-15T20:14:00Z",
"2021-01-15T20:29:00Z",
"2021-01-15T20:44:00Z"
],
"Values": [
0.1333395078879982,
0.244268469636633,
0.15574387947267768,
0.14447563659125626,
0.15519743138527173
]
},
{
"Id": "b",
"Label": "label2",
"Messages": [{
"Code": "ArithmeticError",
"Value": "One or more data-points have been dropped due to non-numeric values (NaN, -Infinite, +Infinite)"
}],
"StatusCode": "Partial",
"Timestamps": [
"2021-01-15T19:44:00Z"
],
"Values": [
0.1333395078879982
]
}
],
"NextToken": null
}
]

View File

@ -21,7 +21,6 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *ba
if len(req.Queries) == 0 { if len(req.Queries) == 0 {
return nil, fmt.Errorf("request contains no queries") return nil, fmt.Errorf("request contains no queries")
} }
// startTime and endTime are always the same for all queries // startTime and endTime are always the same for all queries
startTime := req.Queries[0].TimeRange.From startTime := req.Queries[0].TimeRange.From
endTime := req.Queries[0].TimeRange.To endTime := req.Queries[0].TimeRange.To
@ -62,12 +61,7 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *ba
return err return err
} }
queries, err := e.transformRequestQueriesToCloudWatchQueries(requestQueries) metricDataInput, err := e.buildMetricDataInput(startTime, endTime, requestQueries)
if err != nil {
return err
}
metricDataInput, err := e.buildMetricDataInput(startTime, endTime, queries)
if err != nil { if err != nil {
return err return err
} }
@ -77,22 +71,15 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *ba
return err return err
} }
responses, err := e.parseResponse(mdo, queries) res, err := e.parseResponse(startTime, endTime, mdo, requestQueries)
if err != nil { if err != nil {
return err return err
} }
res, err := e.transformQueryResponsesToQueryResult(responses, requestQueries, startTime, endTime) for _, responseWrapper := range res {
if err != nil { resultChan <- responseWrapper
return err
} }
for refID, queryRes := range res {
resultChan <- &responseWrapper{
DataResponse: queryRes,
RefId: refID,
}
}
return nil return nil
}) })
} }

View File

@ -2,37 +2,8 @@ package cloudwatch
import ( import (
"fmt" "fmt"
"github.com/grafana/grafana-plugin-sdk-go/data"
) )
type requestQuery struct {
RefId string
Region string
Id string
Namespace string
MetricName string
Statistics []*string
QueryType string
Expression string
ReturnData bool
Dimensions map[string][]string
ExtendedStatistics []*string
Period int
Alias string
MatchExact bool
}
type cloudwatchResponse struct {
DataFrames data.Frames
Id string
RefId string
Expression string
RequestExceededMaxLimit bool
PartialData bool
Period int
}
type queryError struct { type queryError struct {
err error err error
RefID string RefID string
@ -42,11 +13,6 @@ func (e *queryError) Error() string {
return fmt.Sprintf("error parsing query %q, %s", e.RefID, e.err) return fmt.Sprintf("error parsing query %q, %s", e.RefID, e.err)
} }
type executedQuery struct {
Expression, ID string
Period int
}
type cloudWatchLink struct { type cloudWatchLink struct {
View string `json:"view"` View string `json:"view"`
Stacked bool `json:"stacked"` Stacked bool `json:"stacked"`

View File

@ -1452,6 +1452,115 @@ describe('DashboardModel', () => {
`); `);
}); });
}); });
describe('migrating legacy CloudWatch queries', () => {
let model: any;
let panelTargets: any;
beforeEach(() => {
model = new DashboardModel({
annotations: {
list: [
{
actionPrefix: '',
alarmNamePrefix: '',
alias: '',
dimensions: {
InstanceId: 'i-123',
},
enable: true,
expression: '',
iconColor: 'red',
id: '',
matchExact: true,
metricName: 'CPUUtilization',
name: 'test',
namespace: 'AWS/EC2',
period: '',
prefixMatching: false,
region: 'us-east-2',
statistics: ['Minimum', 'Sum'],
},
],
},
panels: [
{
gridPos: {
h: 8,
w: 12,
x: 0,
y: 0,
},
id: 4,
options: {
legend: {
calcs: [],
displayMode: 'list',
placement: 'bottom',
},
tooltipOptions: {
mode: 'single',
},
},
targets: [
{
alias: '',
dimensions: {
InstanceId: 'i-123',
},
expression: '',
id: '',
matchExact: true,
metricName: 'CPUUtilization',
namespace: 'AWS/EC2',
period: '',
refId: 'A',
region: 'default',
statistics: ['Average', 'Minimum', 'p12.21'],
},
{
alias: '',
dimensions: {
InstanceId: 'i-123',
},
expression: '',
hide: false,
id: '',
matchExact: true,
metricName: 'CPUUtilization',
namespace: 'AWS/EC2',
period: '',
refId: 'B',
region: 'us-east-2',
statistics: ['Sum'],
},
],
title: 'Panel Title',
type: 'timeseries',
},
],
});
panelTargets = model.panels[0].targets;
});
it('multiple stats query should have been split into three', () => {
expect(panelTargets.length).toBe(4);
});
it('new stats query should get the right statistic', () => {
expect(panelTargets[0].statistic).toBe('Average');
expect(panelTargets[1].statistic).toBe('Sum');
expect(panelTargets[2].statistic).toBe('Minimum');
expect(panelTargets[3].statistic).toBe('p12.21');
});
it('new stats queries should be put in the end of the array', () => {
expect(panelTargets[0].refId).toBe('A');
expect(panelTargets[1].refId).toBe('B');
expect(panelTargets[2].refId).toBe('C');
expect(panelTargets[3].refId).toBe('D');
});
});
}); });
function createRow(options: any, panelDescriptions: any[]) { function createRow(options: any, panelDescriptions: any[]) {

View File

@ -20,6 +20,8 @@ import {
ValueMapping, ValueMapping,
getActiveThreshold, getActiveThreshold,
DataTransformerConfig, DataTransformerConfig,
AnnotationQuery,
DataQuery,
} from '@grafana/data'; } from '@grafana/data';
// Constants // Constants
import { import {
@ -39,6 +41,11 @@ import { plugin as gaugePanelPlugin } from 'app/plugins/panel/gauge/module';
import { getStandardFieldConfigs, getStandardOptionEditors } from '@grafana/ui'; import { getStandardFieldConfigs, getStandardOptionEditors } from '@grafana/ui';
import { labelsToFieldsTransformer } from '../../../../../packages/grafana-data/src/transformations/transformers/labelsToFields'; import { labelsToFieldsTransformer } from '../../../../../packages/grafana-data/src/transformations/transformers/labelsToFields';
import { mergeTransformer } from '../../../../../packages/grafana-data/src/transformations/transformers/merge'; import { mergeTransformer } from '../../../../../packages/grafana-data/src/transformations/transformers/merge';
import {
migrateMultipleStatsMetricsQuery,
migrateMultipleStatsAnnotationQuery,
} from 'app/plugins/datasource/cloudwatch/migrations';
import { CloudWatchMetricsQuery, CloudWatchAnnotationQuery } from 'app/plugins/datasource/cloudwatch/types';
standardEditorsRegistry.setInit(getStandardOptionEditors); standardEditorsRegistry.setInit(getStandardOptionEditors);
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
@ -695,6 +702,31 @@ export class DashboardMigrator {
} }
} }
// Migrates metric queries and/or annotation queries that use more than one statistic.
// E.g query.statistics = ['Max', 'Min'] will be migrated to two queries - query1.statistic = 'Max' and query2.statistic = 'Min'
// New queries, that were created during migration, are put at the end of the array.
migrateCloudWatchQueries() {
for (const panel of this.dashboard.panels) {
for (const target of panel.targets) {
if (isLegacyCloudWatchQuery(target)) {
const newQueries = migrateMultipleStatsMetricsQuery(target, [...panel.targets]);
for (const newQuery of newQueries) {
panel.targets.push(newQuery);
}
}
}
}
for (const annotation of this.dashboard.annotations.list) {
if (isLegacyCloudWatchAnnotationQuery(annotation)) {
const newAnnotationQueries = migrateMultipleStatsAnnotationQuery(annotation);
for (const newAnnotationQuery of newAnnotationQueries) {
this.dashboard.annotations.list.push(newAnnotationQuery);
}
}
}
}
upgradeToGridLayout(old: any) { upgradeToGridLayout(old: any) {
let yPos = 0; let yPos = 0;
const widthFactor = GRID_COLUMN_COUNT / 12; const widthFactor = GRID_COLUMN_COUNT / 12;
@ -1010,6 +1042,25 @@ function upgradeValueMappingsForPanel(panel: PanelModel) {
return panel; return panel;
} }
function isLegacyCloudWatchQuery(target: DataQuery): target is CloudWatchMetricsQuery {
return (
target.hasOwnProperty('dimensions') &&
target.hasOwnProperty('namespace') &&
target.hasOwnProperty('region') &&
target.hasOwnProperty('statistics')
);
}
function isLegacyCloudWatchAnnotationQuery(target: AnnotationQuery<DataQuery>): target is CloudWatchAnnotationQuery {
return (
target.hasOwnProperty('dimensions') &&
target.hasOwnProperty('namespace') &&
target.hasOwnProperty('region') &&
target.hasOwnProperty('prefixMatching') &&
target.hasOwnProperty('statistics')
);
}
function upgradeValueMappings(oldMappings: any, thresholds?: ThresholdsConfig): ValueMapping[] | undefined { function upgradeValueMappings(oldMappings: any, thresholds?: ThresholdsConfig): ValueMapping[] | undefined {
if (!oldMappings) { if (!oldMappings) {
return undefined; return undefined;

View File

@ -1016,6 +1016,7 @@ export class DashboardModel {
private updateSchema(old: any) { private updateSchema(old: any) {
const migrator = new DashboardMigrator(this); const migrator = new DashboardMigrator(this);
migrator.updateSchema(old); migrator.updateSchema(old);
migrator.migrateCloudWatchQueries();
} }
resetOriginalTime() { resetOriginalTime() {

View File

@ -1,5 +1,5 @@
import { defaultsDeep } from 'lodash'; import { defaultsDeep } from 'lodash';
import { AnnotationQuery } from './types'; import { CloudWatchAnnotationQuery } from './types';
export class CloudWatchAnnotationsQueryCtrl { export class CloudWatchAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html'; static templateUrl = 'partials/annotations.editor.html';
@ -17,7 +17,7 @@ export class CloudWatchAnnotationsQueryCtrl {
region: 'default', region: 'default',
id: '', id: '',
alias: '', alias: '',
statistics: ['Average'], statistic: 'Average',
matchExact: true, matchExact: true,
prefixMatching: false, prefixMatching: false,
actionPrefix: '', actionPrefix: '',
@ -27,7 +27,7 @@ export class CloudWatchAnnotationsQueryCtrl {
this.onChange = this.onChange.bind(this); this.onChange = this.onChange.bind(this);
} }
onChange(query: AnnotationQuery) { onChange(query: CloudWatchAnnotationQuery) {
Object.assign(this.annotation, query); Object.assign(this.annotation, query);
} }
} }

View File

@ -2,14 +2,14 @@ import React, { ChangeEvent } from 'react';
import { LegacyForms } from '@grafana/ui'; import { LegacyForms } from '@grafana/ui';
const { Switch } = LegacyForms; const { Switch } = LegacyForms;
import { PanelData } from '@grafana/data'; import { PanelData } from '@grafana/data';
import { AnnotationQuery } from '../types'; import { CloudWatchAnnotationQuery } from '../types';
import { CloudWatchDatasource } from '../datasource'; import { CloudWatchDatasource } from '../datasource';
import { QueryField, PanelQueryEditor } from './'; import { QueryField, PanelQueryEditor } from './';
export type Props = { export type Props = {
query: AnnotationQuery; query: CloudWatchAnnotationQuery;
datasource: CloudWatchDatasource; datasource: CloudWatchDatasource;
onChange: (value: AnnotationQuery) => void; onChange: (value: CloudWatchAnnotationQuery) => void;
data?: PanelData; data?: PanelData;
}; };
@ -20,7 +20,7 @@ export function AnnotationQueryEditor(props: React.PropsWithChildren<Props>) {
<> <>
<PanelQueryEditor <PanelQueryEditor
{...props} {...props}
onChange={(editorQuery: AnnotationQuery) => onChange({ ...query, ...editorQuery })} onChange={(editorQuery: CloudWatchAnnotationQuery) => onChange({ ...query, ...editorQuery })}
onRunQuery={() => {}} onRunQuery={() => {}}
history={[]} history={[]}
></PanelQueryEditor> ></PanelQueryEditor>

View File

@ -1,10 +1,9 @@
import React, { PureComponent, ChangeEvent } from 'react'; import React, { PureComponent, ChangeEvent } from 'react';
import { isEmpty } from 'lodash';
import { ExploreQueryFieldProps } from '@grafana/data'; import { ExploreQueryFieldProps, PanelData } from '@grafana/data';
import { LegacyForms, ValidationEvents, EventsWithValidation, Icon } from '@grafana/ui'; import { LegacyForms, ValidationEvents, EventsWithValidation, Icon } from '@grafana/ui';
const { Input, Switch } = LegacyForms; const { Input, Switch } = LegacyForms;
import { CloudWatchQuery, CloudWatchMetricsQuery, CloudWatchJsonData } from '../types'; import { CloudWatchQuery, CloudWatchMetricsQuery, CloudWatchJsonData, ExecutedQueryPreview } from '../types';
import { CloudWatchDatasource } from '../datasource'; import { CloudWatchDatasource } from '../datasource';
import { QueryField, Alias, MetricsQueryFieldsEditor } from './'; import { QueryField, Alias, MetricsQueryFieldsEditor } from './';
@ -31,7 +30,7 @@ export const normalizeQuery = ({
region, region,
id, id,
alias, alias,
statistics, statistic,
period, period,
...rest ...rest
}: CloudWatchMetricsQuery): CloudWatchMetricsQuery => { }: CloudWatchMetricsQuery): CloudWatchMetricsQuery => {
@ -43,7 +42,7 @@ export const normalizeQuery = ({
region: region || 'default', region: region || 'default',
id: id || '', id: id || '',
alias: alias || '', alias: alias || '',
statistics: isEmpty(statistics) ? ['Average'] : statistics, statistic: statistic ?? 'Average',
period: period || '', period: period || '',
...rest, ...rest,
}; };
@ -65,55 +64,65 @@ export class MetricsQueryEditor extends PureComponent<Props, State> {
onRunQuery(); onRunQuery();
} }
getExecutedQueryPreview(data?: PanelData): ExecutedQueryPreview {
if (!(data?.series.length && data?.series[0].meta?.custom)) {
return {
executedQuery: '',
period: '',
id: '',
};
}
return {
executedQuery: data?.series[0].meta.executedQueryString ?? '',
period: data.series[0].meta.custom['period'],
id: data.series[0].meta.custom['id'],
};
}
render() { render() {
const { data, onRunQuery } = this.props; const { data, onRunQuery } = this.props;
const metricsQuery = this.props.query as CloudWatchMetricsQuery; const metricsQuery = this.props.query as CloudWatchMetricsQuery;
const { showMeta } = this.state; const { showMeta } = this.state;
const query = normalizeQuery(metricsQuery); const query = normalizeQuery(metricsQuery);
const executedQueries = const executedQueryPreview = this.getExecutedQueryPreview(data);
data && data.series.length && data.series[0].meta && data.state === 'Done'
? data.series[0].meta.executedQueryString
: null;
return ( return (
<> <>
<MetricsQueryFieldsEditor {...{ ...this.props, query }}></MetricsQueryFieldsEditor> <MetricsQueryFieldsEditor {...{ ...this.props, query }}></MetricsQueryFieldsEditor>
{query.statistics.length <= 1 && ( <div className="gf-form-inline">
<div className="gf-form-inline"> <div className="gf-form">
<div className="gf-form"> <QueryField
<QueryField label="Id"
label="Id" tooltip="Id can include numbers, letters, and underscore, and must start with a lowercase letter."
tooltip="Id can include numbers, letters, and underscore, and must start with a lowercase letter." >
> <Input
<Input className="gf-form-input width-8"
className="gf-form-input width-8" onBlur={onRunQuery}
onBlur={onRunQuery} onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onChange({ ...metricsQuery, id: event.target.value })
this.onChange({ ...metricsQuery, id: event.target.value }) }
} validationEvents={idValidationEvents}
validationEvents={idValidationEvents} value={query.id}
value={query.id} />
/> </QueryField>
</QueryField>
</div>
<div className="gf-form gf-form--grow">
<QueryField
className="gf-form--grow"
label="Expression"
tooltip="Optionally you can add an expression here. Please note that if a math expression that is referencing other queries is being used, it will not be possible to create an alert rule based on this query"
>
<Input
className="gf-form-input"
onBlur={onRunQuery}
value={query.expression || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, expression: event.target.value })
}
/>
</QueryField>
</div>
</div> </div>
)} <div className="gf-form gf-form--grow">
<QueryField
className="gf-form--grow"
label="Expression"
tooltip="Optionally you can add an expression here. Please note that if a math expression that is referencing other queries is being used, it will not be possible to create an alert rule based on this query"
>
<Input
className="gf-form-input"
onBlur={onRunQuery}
value={query.expression || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, expression: event.target.value })
}
/>
</QueryField>
</div>
</div>
<div className="gf-form-inline"> <div className="gf-form-inline">
<div className="gf-form"> <div className="gf-form">
<QueryField label="Period" tooltip="Minimum interval between points in seconds"> <QueryField label="Period" tooltip="Minimum interval between points in seconds">
@ -153,21 +162,20 @@ export class MetricsQueryEditor extends PureComponent<Props, State> {
<label className="gf-form-label"> <label className="gf-form-label">
<a <a
onClick={() => onClick={() =>
executedQueries && executedQueryPreview &&
this.setState({ this.setState({
showMeta: !showMeta, showMeta: !showMeta,
}) })
} }
> >
<Icon name={showMeta && executedQueries ? 'angle-down' : 'angle-right'} />{' '} <Icon name={showMeta ? 'angle-down' : 'angle-right'} /> {showMeta ? 'Hide' : 'Show'} Query Preview
{showMeta && executedQueries ? 'Hide' : 'Show'} Query Preview
</a> </a>
</label> </label>
</div> </div>
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" /> <div className="gf-form-label gf-form-label--grow" />
</div> </div>
{showMeta && executedQueries && ( {showMeta && (
<table className="filter-table form-inline"> <table className="filter-table form-inline">
<thead> <thead>
<tr> <tr>
@ -178,13 +186,11 @@ export class MetricsQueryEditor extends PureComponent<Props, State> {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{JSON.parse(executedQueries).map(({ ID, Expression, Period }: any) => ( <tr>
<tr key={ID}> <td>{executedQueryPreview.id}</td>
<td>{ID}</td> <td>{executedQueryPreview.executedQuery}</td>
<td>{Expression}</td> <td>{executedQueryPreview.period}</td>
<td>{Period}</td> </tr>
</tr>
))}
</tbody> </tbody>
</table> </table>
)} )}

View File

@ -3,7 +3,7 @@ import { SelectableValue } from '@grafana/data';
import { Segment, SegmentAsync } from '@grafana/ui'; import { Segment, SegmentAsync } from '@grafana/ui';
import { CloudWatchMetricsQuery, SelectableStrings } from '../types'; import { CloudWatchMetricsQuery, SelectableStrings } from '../types';
import { CloudWatchDatasource } from '../datasource'; import { CloudWatchDatasource } from '../datasource';
import { Dimensions, QueryInlineField, Stats } from '.'; import { Dimensions, QueryInlineField } from '.';
export type Props = { export type Props = {
query: CloudWatchMetricsQuery; query: CloudWatchMetricsQuery;
@ -120,12 +120,25 @@ export function MetricsQueryFieldsEditor({
/> />
</QueryInlineField> </QueryInlineField>
<QueryInlineField label="Stats"> <QueryInlineField label="Statistic">
<Stats <Segment
stats={datasource.standardStatistics.map(toOption)} allowCustomValue
values={metricsQuery.statistics} value={query.statistic}
onChange={(statistics) => onQueryChange({ ...metricsQuery, statistics })} options={[
variableOptionGroup={variableOptionGroup} ...datasource.standardStatistics.filter((s) => s !== query.statistic).map(toOption),
variableOptionGroup,
]}
onChange={({ value: statistic }) => {
if (
!datasource.standardStatistics.includes(statistic) &&
!/^p\d{2}(?:\.\d{1,2})?$/.test(statistic) &&
!statistic.startsWith('$')
) {
return;
}
onQueryChange({ ...metricsQuery, statistic });
}}
/> />
</QueryInlineField> </QueryInlineField>

View File

@ -1,21 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Stats } from './Stats';
const toOption = (value: any) => ({ label: value, value });
describe('Stats', () => {
it('should render component', () => {
render(
<Stats
data-testid="stats"
values={['Average', 'Minimum']}
variableOptionGroup={{ label: 'templateVar', value: 'templateVar' }}
onChange={() => {}}
stats={['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'].map(toOption)}
/>
);
expect(screen.getByText('Average')).toBeInTheDocument();
expect(screen.getByText('Minimum')).toBeInTheDocument();
});
});

View File

@ -1,45 +0,0 @@
import React, { FunctionComponent } from 'react';
import { SelectableStrings } from '../types';
import { SelectableValue } from '@grafana/data';
import { Segment, Icon } from '@grafana/ui';
export interface Props {
values: string[];
onChange: (values: string[]) => void;
variableOptionGroup: SelectableValue<string>;
stats: SelectableStrings;
}
const removeText = '-- remove stat --';
const removeOption: SelectableValue<string> = { label: removeText, value: removeText };
export const Stats: FunctionComponent<Props> = ({ stats, values, onChange, variableOptionGroup }) => (
<>
{values &&
values.map((value, index) => (
<Segment
allowCustomValue
key={value + index}
value={value}
options={[removeOption, ...stats, variableOptionGroup]}
onChange={({ value }) =>
onChange(
value === removeText
? values.filter((_, i) => i !== index)
: values.map((v, i) => (i === index ? value! : v))
)
}
/>
))}
<Segment
Component={
<a className="gf-form-label query-part">
<Icon name="plus" />
</a>
}
allowCustomValue
onChange={({ value }) => onChange([...values, value!])}
options={[...stats.filter(({ value }) => !values.includes(value!)), variableOptionGroup]}
/>
</>
);

View File

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`QueryEditor should render component 1`] = `null`;

View File

@ -1,4 +1,3 @@
export { Stats } from './Stats';
export { Dimensions } from './Dimensions'; export { Dimensions } from './Dimensions';
export { QueryInlineField, QueryField } from './Forms'; export { QueryInlineField, QueryField } from './Forms';
export { Alias } from './Alias'; export { Alias } from './Alias';

View File

@ -263,8 +263,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
const validMetricsQueries = metricQueries const validMetricsQueries = metricQueries
.filter( .filter(
(item) => (item) =>
(!!item.region && !!item.namespace && !!item.metricName && !isEmpty(item.statistics)) || (!!item.region && !!item.namespace && !!item.metricName && !!item.statistic) || item.expression?.length > 0
item.expression?.length > 0
) )
.map( .map(
(item: CloudWatchMetricsQuery): MetricQuery => { (item: CloudWatchMetricsQuery): MetricQuery => {
@ -272,25 +271,11 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace'); item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace');
item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name'); item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name');
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
item.statistics = item.statistics.map((stat) => this.replace(stat, options.scopedVars, true, 'statistics')); item.statistic = this.templateSrv.replace(item.statistic, options.scopedVars);
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars); item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars); item.expression = this.templateSrv.replace(item.expression, options.scopedVars);
// valid ExtendedStatistics is like p90.00, check the pattern
const hasInvalidStatistics = item.statistics.some((s) => {
if (s.indexOf('p') === 0) {
const matches = /^p\d{2}(?:\.\d{1,2})?$/.exec(s);
return !matches || matches[0] !== s;
}
return false;
});
if (hasInvalidStatistics) {
throw { message: 'Invalid extended statistics' };
}
return { return {
intervalMs: options.intervalMs, intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints, maxDataPoints: options.maxDataPoints,
@ -558,22 +543,32 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
}; };
}), }),
catchError((err) => { catchError((err) => {
if (/^Throttling:.*/.test(err.data.message)) { const isFrameError = err.data.results;
// Error is not frame specific
if (!isFrameError && err.data && err.data.message === 'Metric request error' && err.data.error) {
err.message = err.data.error;
return throwError(() => err);
}
// The error is either for a specific frame or for all the frames
const results: Array<{ error?: string }> = Object.values(err.data.results);
const firstErrorResult = results.find((r) => r.error);
if (firstErrorResult) {
err.message = firstErrorResult.error;
}
if (results.some((r) => r.error && /^Throttling:.*/.test(r.error))) {
const failedRedIds = Object.keys(err.data.results); const failedRedIds = Object.keys(err.data.results);
const regionsAffected = Object.values(request.queries).reduce( const regionsAffected = Object.values(request.queries).reduce(
(res: string[], { refId, region }) => (res: string[], { refId, region }) =>
(refId && !failedRedIds.includes(refId)) || res.includes(region) ? res : [...res, region], (refId && !failedRedIds.includes(refId)) || res.includes(region) ? res : [...res, region],
[] []
) as string[]; ) as string[];
regionsAffected.forEach((region) => this.debouncedAlert(this.datasourceName, this.getActualRegion(region))); regionsAffected.forEach((region) => this.debouncedAlert(this.datasourceName, this.getActualRegion(region)));
} }
if (err.data && err.data.message === 'Metric request error' && err.data.error) { return throwError(() => err);
err.data.message = err.data.error;
}
return throwError(err);
}) })
); );
} }
@ -827,7 +822,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
annotationQuery(options: any) { annotationQuery(options: any) {
const annotation = options.annotation; const annotation = options.annotation;
const statistics = annotation.statistics.map((s: any) => this.templateSrv.replace(s)); const statistic = this.templateSrv.replace(annotation.statistic);
const defaultPeriod = annotation.prefixMatching ? '' : '300'; const defaultPeriod = annotation.prefixMatching ? '' : '300';
let period = annotation.period || defaultPeriod; let period = annotation.period || defaultPeriod;
period = parseInt(period, 10); period = parseInt(period, 10);
@ -837,7 +832,7 @@ export class CloudWatchDatasource extends DataSourceWithBackend<CloudWatchQuery,
namespace: this.templateSrv.replace(annotation.namespace), namespace: this.templateSrv.replace(annotation.namespace),
metricName: this.templateSrv.replace(annotation.metricName), metricName: this.templateSrv.replace(annotation.metricName),
dimensions: this.convertDimensionFormat(annotation.dimensions, {}), dimensions: this.convertDimensionFormat(annotation.dimensions, {}),
statistics: statistics, statistic: statistic,
period: period, period: period,
actionPrefix: annotation.actionPrefix || '', actionPrefix: annotation.actionPrefix || '',
alarmNamePrefix: annotation.alarmNamePrefix || '', alarmNamePrefix: annotation.alarmNamePrefix || '',

View File

@ -0,0 +1,118 @@
import { DataQuery } from '@grafana/data';
import { migrateMultipleStatsAnnotationQuery, migrateMultipleStatsMetricsQuery } from './migrations';
import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery } from './types';
describe('migration', () => {
describe('migrateMultipleStatsMetricsQuery', () => {
const queryToMigrate = {
statistics: ['Average', 'Sum', 'Maximum'],
refId: 'A',
};
const panelQueries: DataQuery[] = [
{ ...queryToMigrate },
{
refId: 'B',
},
];
const newQueries = migrateMultipleStatsMetricsQuery(queryToMigrate as CloudWatchMetricsQuery, panelQueries);
const newMetricQueries = newQueries as CloudWatchMetricsQuery[];
it('should create one new query for each stat', () => {
expect(newQueries.length).toBe(2);
});
it('should assign new queries the right stats', () => {
expect(newMetricQueries[0].statistic).toBe('Sum');
expect(newMetricQueries[1].statistic).toBe('Maximum');
});
it('should assign new queries the right ref id', () => {
expect(newQueries[0].refId).toBe('C');
expect(newQueries[1].refId).toBe('D');
});
it('should not have statistics prop anymore', () => {
expect(queryToMigrate).not.toHaveProperty('statistics');
expect(newQueries[0]).not.toHaveProperty('statistics');
expect(newQueries[1]).not.toHaveProperty('statistics');
});
});
describe('migrateMultipleStatsMetricsQuery with only one stat', () => {
const queryToMigrate = {
statistics: ['Average'],
refId: 'A',
} as CloudWatchMetricsQuery;
const panelQueries: DataQuery[] = [
{ ...queryToMigrate },
{
refId: 'B',
},
];
const newQueries = migrateMultipleStatsMetricsQuery(queryToMigrate as CloudWatchMetricsQuery, panelQueries);
it('should not create any new queries', () => {
expect(newQueries.length).toBe(0);
});
it('should have the right stats', () => {
expect(queryToMigrate.statistic).toBe('Average');
});
it('should not have statistics prop anymore', () => {
expect(queryToMigrate).not.toHaveProperty('statistics');
});
});
describe('migrateMultipleStatsAnnotationQuery', () => {
const annotationToMigrate = {
statistics: ['p23.23', 'SampleCount'],
name: 'Test annotation',
};
const newAnnotations = migrateMultipleStatsAnnotationQuery(annotationToMigrate as CloudWatchAnnotationQuery);
const newCloudWatchAnnotations = newAnnotations as CloudWatchAnnotationQuery[];
it('should create one new annotation for each stat', () => {
expect(newAnnotations.length).toBe(1);
});
it('should assign new queries the right stats', () => {
expect(newCloudWatchAnnotations[0].statistic).toBe('SampleCount');
});
it('should assign new queries the right ref id', () => {
expect(newAnnotations[0].name).toBe('Test annotation - SampleCount');
});
it('should not have statistics prop anymore', () => {
expect(newCloudWatchAnnotations[0]).not.toHaveProperty('statistics');
});
it('should migrate original query correctly', () => {
expect(annotationToMigrate).not.toHaveProperty('statistics');
expect(annotationToMigrate.name).toBe('Test annotation - p23.23');
});
describe('migrateMultipleStatsAnnotationQuery with only with stat', () => {
const annotationToMigrate = {
statistics: ['p23.23'],
name: 'Test annotation',
} as CloudWatchAnnotationQuery;
const newAnnotations = migrateMultipleStatsAnnotationQuery(annotationToMigrate as CloudWatchAnnotationQuery);
it('should not create new annotations', () => {
expect(newAnnotations.length).toBe(0);
});
it('should not change the name', () => {
expect(annotationToMigrate.name).toBe('Test annotation');
});
it('should use statistics prop and remove statistics prop', () => {
expect(annotationToMigrate.statistic).toEqual('p23.23');
expect(annotationToMigrate).not.toHaveProperty('statistics');
});
});
});
});

View File

@ -0,0 +1,44 @@
import { AnnotationQuery, DataQuery } from '@grafana/data';
import { getNextRefIdChar } from 'app/core/utils/query';
import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery } from './types';
export function migrateMultipleStatsMetricsQuery(
query: CloudWatchMetricsQuery,
panelQueries: DataQuery[]
): DataQuery[] {
const newQueries = [];
if (query?.statistics && query?.statistics.length) {
query.statistic = query.statistics[0];
for (const stat of query.statistics.splice(1)) {
newQueries.push({ ...query, statistic: stat });
}
}
for (const newTarget of newQueries) {
newTarget.refId = getNextRefIdChar(panelQueries);
delete newTarget.statistics;
panelQueries.push(newTarget);
}
delete query.statistics;
return newQueries;
}
export function migrateMultipleStatsAnnotationQuery(
annotationQuery: CloudWatchAnnotationQuery
): Array<AnnotationQuery<DataQuery>> {
const newAnnotations: CloudWatchAnnotationQuery[] = [];
if (annotationQuery?.statistics && annotationQuery?.statistics.length) {
for (const stat of annotationQuery.statistics.splice(1)) {
const { statistics, name, ...newAnnotation } = annotationQuery;
newAnnotations.push({ ...newAnnotation, statistic: stat, name: `${name} - ${stat}` });
}
annotationQuery.statistic = annotationQuery.statistics[0];
// Only change the name of the original if new annotations have been created
if (newAnnotations.length !== 0) {
annotationQuery.name = `${annotationQuery.name} - ${annotationQuery.statistic}`;
}
delete annotationQuery.statistics;
}
return newAnnotations as Array<AnnotationQuery<DataQuery>>;
}

View File

@ -1,4 +1,3 @@
import './query_parameter_ctrl';
import { DataSourcePlugin } from '@grafana/data'; import { DataSourcePlugin } from '@grafana/data';
import { ConfigEditor } from './components/ConfigEditor'; import { ConfigEditor } from './components/ConfigEditor';
import { CloudWatchDatasource } from './datasource'; import { CloudWatchDatasource } from './datasource';

View File

@ -1,241 +0,0 @@
import angular from 'angular';
import coreModule from 'app/core/core_module';
import { each, flatten, isEmpty, map, reduce } from 'lodash';
import { TemplateSrv } from '@grafana/runtime';
export class CloudWatchQueryParameterCtrl {
/** @ngInject */
constructor($scope: any, templateSrv: TemplateSrv, uiSegmentSrv: any) {
$scope.init = () => {
const target = $scope.target;
target.namespace = target.namespace || '';
target.metricName = target.metricName || '';
target.statistics = target.statistics || ['Average'];
target.dimensions = target.dimensions || {};
target.period = target.period || '';
target.region = target.region || 'default';
target.id = target.id || '';
target.expression = target.expression || '';
$scope.regionSegment = uiSegmentSrv.getSegmentForValue($scope.target.region, 'select region');
$scope.namespaceSegment = uiSegmentSrv.getSegmentForValue($scope.target.namespace, 'select namespace');
$scope.metricSegment = uiSegmentSrv.getSegmentForValue($scope.target.metricName, 'select metric');
$scope.dimSegments = reduce(
$scope.target.dimensions,
(memo, value, key) => {
memo.push(uiSegmentSrv.newKey(key));
memo.push(uiSegmentSrv.newOperator('='));
memo.push(uiSegmentSrv.newKeyValue(value));
return memo;
},
[] as any
);
$scope.statSegments = map($scope.target.statistics, (stat) => {
return uiSegmentSrv.getSegmentForValue(stat);
});
$scope.ensurePlusButton($scope.statSegments);
$scope.ensurePlusButton($scope.dimSegments);
$scope.removeDimSegment = uiSegmentSrv.newSegment({
fake: true,
value: '-- remove dimension --',
});
$scope.removeStatSegment = uiSegmentSrv.newSegment({
fake: true,
value: '-- remove stat --',
});
if (isEmpty($scope.target.region)) {
$scope.target.region = 'default';
}
if (!$scope.onChange) {
$scope.onChange = () => {};
}
};
$scope.getStatSegments = () => {
return Promise.resolve(
flatten([
angular.copy($scope.removeStatSegment),
map($scope.datasource.standardStatistics, (s) => {
return uiSegmentSrv.getSegmentForValue(s);
}),
uiSegmentSrv.getSegmentForValue('pNN.NN'),
])
);
};
$scope.statSegmentChanged = (segment: any, index: number) => {
if (segment.value === $scope.removeStatSegment.value) {
$scope.statSegments.splice(index, 1);
} else {
segment.type = 'value';
}
$scope.target.statistics = reduce(
$scope.statSegments,
(memo, seg) => {
if (!seg.fake) {
memo.push(seg.value);
}
return memo;
},
[] as any
);
$scope.ensurePlusButton($scope.statSegments);
$scope.onChange();
};
$scope.ensurePlusButton = (segments: any) => {
const count = segments.length;
const lastSegment = segments[Math.max(count - 1, 0)];
if (!lastSegment || lastSegment.type !== 'plus-button') {
segments.push(uiSegmentSrv.newPlusButton());
}
};
$scope.getDimSegments = (segment: any, $index: number) => {
if (segment.type === 'operator') {
return Promise.resolve([]);
}
const target = $scope.target;
let query = Promise.resolve([] as any[]);
if (segment.type === 'key' || segment.type === 'plus-button') {
query = $scope.datasource.getDimensionKeys($scope.target.namespace, $scope.target.region);
} else if (segment.type === 'value') {
const dimensionKey = $scope.dimSegments[$index - 2].value;
delete target.dimensions[dimensionKey];
query = $scope.datasource.getDimensionValues(
target.region,
target.namespace,
target.metricName,
dimensionKey,
target.dimensions
);
}
return query.then($scope.transformToSegments(true)).then((results) => {
if (segment.type === 'key') {
results.splice(0, 0, angular.copy($scope.removeDimSegment));
}
return results;
});
};
$scope.dimSegmentChanged = (segment: any, index: number) => {
$scope.dimSegments[index] = segment;
if (segment.value === $scope.removeDimSegment.value) {
$scope.dimSegments.splice(index, 3);
} else if (segment.type === 'plus-button') {
$scope.dimSegments.push(uiSegmentSrv.newOperator('='));
$scope.dimSegments.push(uiSegmentSrv.newFake('select dimension value', 'value', 'query-segment-value'));
segment.type = 'key';
segment.cssClass = 'query-segment-key';
}
$scope.syncDimSegmentsWithModel();
$scope.ensurePlusButton($scope.dimSegments);
$scope.onChange();
};
$scope.syncDimSegmentsWithModel = () => {
const dims: any = {};
const length = $scope.dimSegments.length;
for (let i = 0; i < length - 2; i += 3) {
const keySegment = $scope.dimSegments[i];
const valueSegment = $scope.dimSegments[i + 2];
if (!valueSegment.fake) {
dims[keySegment.value] = valueSegment.value;
}
}
$scope.target.dimensions = dims;
};
$scope.getRegions = () => {
return $scope.datasource
.metricFindQuery('regions()')
.then((results: any) => {
results.unshift({ text: 'default' });
return results;
})
.then($scope.transformToSegments(true));
};
$scope.getNamespaces = () => {
return $scope.datasource.metricFindQuery('namespaces()').then($scope.transformToSegments(true));
};
$scope.getMetrics = () => {
return $scope.datasource
.metricFindQuery('metrics(' + $scope.target.namespace + ',' + $scope.target.region + ')')
.then($scope.transformToSegments(true));
};
$scope.regionChanged = () => {
$scope.target.region = $scope.regionSegment.value;
$scope.onChange();
};
$scope.namespaceChanged = () => {
$scope.target.namespace = $scope.namespaceSegment.value;
$scope.onChange();
};
$scope.metricChanged = () => {
$scope.target.metricName = $scope.metricSegment.value;
$scope.onChange();
};
$scope.transformToSegments = (addTemplateVars: any) => {
return (results: any) => {
const segments = map(results, (segment) => {
return uiSegmentSrv.newSegment({
value: segment.text,
expandable: segment.expandable,
});
});
if (addTemplateVars) {
each(templateSrv.getVariables(), (variable) => {
segments.unshift(
uiSegmentSrv.newSegment({
type: 'template',
value: '$' + variable.name,
expandable: true,
})
);
});
}
return segments;
};
};
$scope.init();
}
}
export function cloudWatchQueryParameter() {
return {
templateUrl: 'public/app/plugins/datasource/cloudwatch/partials/query.parameter.html',
controller: CloudWatchQueryParameterCtrl,
restrict: 'E',
scope: {
target: '=',
datasource: '=',
onChange: '&',
},
};
}
coreModule.directive('cloudwatchQueryParameter', cloudWatchQueryParameter);

View File

@ -2,7 +2,6 @@ import { interval, lastValueFrom, of, throwError } from 'rxjs';
import { import {
DataFrame, DataFrame,
DataQueryErrorType, DataQueryErrorType,
DataQueryRequest,
DataQueryResponse, DataQueryResponse,
DataSourceInstanceSettings, DataSourceInstanceSettings,
dateMath, dateMath,
@ -17,7 +16,6 @@ import {
CloudWatchLogsQuery, CloudWatchLogsQuery,
CloudWatchLogsQueryStatus, CloudWatchLogsQueryStatus,
CloudWatchMetricsQuery, CloudWatchMetricsQuery,
CloudWatchQuery,
LogAction, LogAction,
} from '../types'; } from '../types';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
@ -378,7 +376,7 @@ describe('CloudWatchDatasource', () => {
dimensions: { dimensions: {
InstanceId: 'i-12345678', InstanceId: 'i-12345678',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300', period: '300',
}, },
], ],
@ -419,7 +417,7 @@ describe('CloudWatchDatasource', () => {
namespace: query.targets[0].namespace, namespace: query.targets[0].namespace,
metricName: query.targets[0].metricName, metricName: query.targets[0].metricName,
dimensions: { InstanceId: ['i-12345678'] }, dimensions: { InstanceId: ['i-12345678'] },
statistics: query.targets[0].statistics, statistic: query.targets[0].statistic,
period: query.targets[0].period, period: query.targets[0].period,
}), }),
]) ])
@ -457,7 +455,7 @@ describe('CloudWatchDatasource', () => {
dimensions: { dimensions: {
InstanceId: 'i-12345678', InstanceId: 'i-12345678',
}, },
statistics: ['Average'], statistic: 'Average',
period: '[[period]]', period: '[[period]]',
}, },
], ],
@ -470,30 +468,6 @@ describe('CloudWatchDatasource', () => {
}); });
}); });
it.each(['pNN.NN', 'p9', 'p99.', 'p99.999'])('should cancel query for invalid extended statistics (%s)', (stat) => {
const { ds } = getTestContext({ response });
const query: DataQueryRequest<CloudWatchQuery> = ({
range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'AWS/EC2',
metricName: 'CPUUtilization',
dimensions: {
InstanceId: 'i-12345678',
},
statistics: [stat],
period: '60s',
},
],
} as unknown) as DataQueryRequest<CloudWatchQuery>;
expect(ds.query.bind(ds, query)).toThrow(/Invalid extended statistics/);
});
it('should return series list', async () => { it('should return series list', async () => {
const { ds } = getTestContext({ response }); const { ds } = getTestContext({ response });
@ -512,7 +486,7 @@ describe('CloudWatchDatasource', () => {
dimensions: { dimensions: {
InstanceId: 'i-12345678', InstanceId: 'i-12345678',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300', period: '300',
expression: '', expression: '',
}; };
@ -645,7 +619,7 @@ describe('CloudWatchDatasource', () => {
dimensions: { dimensions: {
InstanceId: 'i-12345678', InstanceId: 'i-12345678',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300s', period: '300s',
}, },
], ],
@ -704,7 +678,7 @@ describe('CloudWatchDatasource', () => {
[`$${variableName}`]: `$${variableName}`, [`$${variableName}`]: `$${variableName}`,
}, },
matchExact: false, matchExact: false,
statistics: [], statistic: '',
}; };
ds.interpolateVariablesInQueries([logQuery], {}); ds.interpolateVariablesInQueries([logQuery], {});
@ -715,7 +689,7 @@ describe('CloudWatchDatasource', () => {
}); });
}); });
describe('When performing CloudWatch query for extended statistics', () => { describe('When performing CloudWatch query for extended statistic', () => {
const query: any = { const query: any = {
range: defaultTimeRange, range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 }, rangeRaw: { from: 1483228800, to: 1483232400 },
@ -730,7 +704,7 @@ describe('CloudWatchDatasource', () => {
LoadBalancer: 'lb', LoadBalancer: 'lb',
TargetGroup: 'tg', TargetGroup: 'tg',
}, },
statistics: ['p90.00'], statistic: 'p90.00',
period: '300s', period: '300s',
}, },
], ],
@ -856,7 +830,7 @@ describe('CloudWatchDatasource', () => {
dimensions: { dimensions: {
dim2: '[[var2]]', dim2: '[[var2]]',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300s', period: '300s',
}, },
], ],
@ -884,7 +858,7 @@ describe('CloudWatchDatasource', () => {
dim2: '[[var2]]', dim2: '[[var2]]',
dim3: '[[var3]]', dim3: '[[var3]]',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300s', period: '300s',
}, },
], ],
@ -918,7 +892,7 @@ describe('CloudWatchDatasource', () => {
dim3: '[[var3]]', dim3: '[[var3]]',
dim4: '[[var4]]', dim4: '[[var4]]',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300s', period: '300s',
}, },
], ],
@ -948,7 +922,7 @@ describe('CloudWatchDatasource', () => {
dim2: '[[var2]]', dim2: '[[var2]]',
dim3: '[[var3]]', dim3: '[[var3]]',
}, },
statistics: ['Average'], statistic: 'Average',
period: '300', period: '300',
}, },
], ],

View File

@ -11,7 +11,11 @@ export interface CloudWatchMetricsQuery extends DataQuery {
metricName: string; metricName: string;
dimensions: { [key: string]: string | string[] }; dimensions: { [key: string]: string | string[] };
statistics: string[]; statistic: string;
/**
* @deprecated use statistic
*/
statistics?: string[];
period: string; period: string;
alias: string; alias: string;
matchExact: boolean; matchExact: boolean;
@ -49,7 +53,10 @@ export type CloudWatchQuery = CloudWatchMetricsQuery | CloudWatchLogsQuery;
export const isCloudWatchLogsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwatchQuery is CloudWatchLogsQuery => export const isCloudWatchLogsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwatchQuery is CloudWatchLogsQuery =>
(cloudwatchQuery as CloudWatchLogsQuery).queryMode === 'Logs'; (cloudwatchQuery as CloudWatchLogsQuery).queryMode === 'Logs';
export interface AnnotationQuery extends CloudWatchMetricsQuery { export interface CloudWatchAnnotationQuery extends CloudWatchMetricsQuery {
enable: boolean;
name: string;
iconColor: string;
prefixMatching: boolean; prefixMatching: boolean;
actionPrefix: string; actionPrefix: string;
alarmNamePrefix: string; alarmNamePrefix: string;
@ -320,17 +327,8 @@ export interface MetricQuery {
// IntervalMs int64 // IntervalMs int64
// } // }
export interface CloudWatchMetricsAnnotation { export interface ExecutedQueryPreview {
namespace: string;
metricName: string;
expression: string;
dimensions: {};
region: string;
id: string; id: string;
alias: string; executedQuery: string;
statistics: string[]; period: string;
matchExact: true;
prefixMatching: false;
actionPrefix: string;
alarmNamePrefix: string;
} }