diff --git a/pkg/tsdb/cloudwatch/cloudwatch_query.go b/pkg/tsdb/cloudwatch/cloudwatch_query.go index 064f54a4926..a5c7176dc96 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_query.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_query.go @@ -9,23 +9,24 @@ import ( ) type cloudWatchQuery struct { - RefId string - Region string - Id string - Namespace string - MetricName string - Statistic string - Expression string - SqlExpression string - ReturnData bool - Dimensions map[string][]string - Period int - Alias string - Label string - MatchExact bool - UsedExpression string - MetricQueryType metricQueryType - MetricEditorMode metricEditorMode + RefId string + Region string + Id string + Namespace string + MetricName string + Statistic string + Expression string + SqlExpression string + ReturnData bool + Dimensions map[string][]string + Period int + Alias string + Label string + MatchExact bool + UsedExpression string + TimezoneUTCOffset string + MetricQueryType metricQueryType + MetricEditorMode metricEditorMode } func (q *cloudWatchQuery) getGMDAPIMode() gmdApiMode { diff --git a/pkg/tsdb/cloudwatch/metric_data_input_builder.go b/pkg/tsdb/cloudwatch/metric_data_input_builder.go index 231d56155d7..a18a036f969 100644 --- a/pkg/tsdb/cloudwatch/metric_data_input_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_input_builder.go @@ -5,6 +5,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func (e *cloudWatchExecutor) buildMetricDataInput(startTime time.Time, endTime time.Time, @@ -14,6 +15,15 @@ func (e *cloudWatchExecutor) buildMetricDataInput(startTime time.Time, endTime t EndTime: aws.Time(endTime), ScanBy: aws.String("TimestampAscending"), } + + shouldSetLabelOptions := e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels) && len(queries) > 0 && len(queries[0].TimezoneUTCOffset) > 0 + + if shouldSetLabelOptions { + metricDataInput.LabelOptions = &cloudwatch.LabelOptions{ + Timezone: aws.String(queries[0].TimezoneUTCOffset), + } + } + for _, query := range queries { metricDataQuery, err := e.buildMetricDataQuery(query) if err != nil { diff --git a/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go new file mode 100644 index 00000000000..2fa1aaf4359 --- /dev/null +++ b/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go @@ -0,0 +1,44 @@ +package cloudwatch + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetricDataInputBuilder(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + timezoneUTCOffset string + expectedLabelOptions *cloudwatch.LabelOptions + featureEnabled bool + }{ + {name: "when timezoneUTCOffset is provided and feature is enabled", timezoneUTCOffset: "+1234", expectedLabelOptions: &cloudwatch.LabelOptions{Timezone: aws.String("+1234")}, featureEnabled: true}, + {name: "when timezoneUTCOffset is not provided and feature is enabled", timezoneUTCOffset: "", expectedLabelOptions: nil, featureEnabled: true}, + {name: "when timezoneUTCOffset is provided and feature is disabled", timezoneUTCOffset: "+1234", expectedLabelOptions: nil, featureEnabled: false}, + {name: "when timezoneUTCOffset is not provided and feature is disabled", timezoneUTCOffset: "", expectedLabelOptions: nil, featureEnabled: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels, tc.featureEnabled)) + query := getBaseQuery() + query.TimezoneUTCOffset = tc.timezoneUTCOffset + + from := now.Add(time.Hour * -2) + to := now.Add(time.Hour * -1) + mdi, err := executor.buildMetricDataInput(from, to, []*cloudWatchQuery{query}) + + assert.NoError(t, err) + require.NotNil(t, mdi) + assert.Equal(t, tc.expectedLabelOptions, mdi.LabelOptions) + }) + } +} diff --git a/pkg/tsdb/cloudwatch/request_parser.go b/pkg/tsdb/cloudwatch/request_parser.go index e31ad779738..6ec134a92da 100644 --- a/pkg/tsdb/cloudwatch/request_parser.go +++ b/pkg/tsdb/cloudwatch/request_parser.go @@ -201,6 +201,8 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time label := model.Get("label").MustString() returnData := !model.Get("hide").MustBool(false) queryType := model.Get("type").MustString() + timezoneUTCOffset := model.Get("timezoneUTCOffset").MustString("") + if queryType == "" { // If no type is provided we assume we are called by alerting service, which requires to return data! // Note, this is sort of a hack, but the official Grafana interfaces do not carry the information @@ -221,23 +223,24 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time } return &cloudWatchQuery{ - RefId: refId, - Region: region, - Id: id, - Namespace: namespace, - MetricName: metricName, - Statistic: statistic, - Expression: expression, - ReturnData: returnData, - Dimensions: dimensions, - Period: period, - Alias: alias, - Label: label, - MatchExact: matchExact, - UsedExpression: "", - MetricQueryType: metricQueryType, - MetricEditorMode: metricEditorModeValue, - SqlExpression: sqlExpression, + RefId: refId, + Region: region, + Id: id, + Namespace: namespace, + MetricName: metricName, + Statistic: statistic, + Expression: expression, + ReturnData: returnData, + Dimensions: dimensions, + Period: period, + Alias: alias, + Label: label, + MatchExact: matchExact, + UsedExpression: "", + MetricQueryType: metricQueryType, + MetricEditorMode: metricEditorModeValue, + SqlExpression: sqlExpression, + TimezoneUTCOffset: timezoneUTCOffset, }, nil } diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index e8f8619fd95..3a906e69bbb 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -399,6 +399,50 @@ describe('datasource', () => { }); }); + describe('timezoneUTCOffset', () => { + const testQuery = { + id: '', + refId: 'a', + region: 'us-east-2', + namespace: '', + period: '', + label: '${MAX_TIME_RELATIVE}', + metricName: '', + dimensions: {}, + matchExact: true, + statistic: '', + expression: '', + metricQueryType: MetricQueryType.Query, + metricEditorMode: MetricEditorMode.Code, + sqlExpression: 'SELECT SUM($metric) FROM "$namespace" GROUP BY ${labels:raw} LIMIT $limit', + }; + const testTable = [ + ['Europe/Stockholm', '+0200'], + ['America/New_York', '-0400'], + ['Asia/Tokyo', '+0900'], + ['UTC', '+0000'], + ]; + describe.each(testTable)('should use the right time zone offset', (ianaTimezone, expectedOffset) => { + const { datasource, fetchMock } = setupMockedDataSource(); + datasource.handleMetricQueries([testQuery], { + range: { from: dateTime(), to: dateTime() }, + timezone: ianaTimezone, + } as any); + + expect(fetchMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + queries: expect.arrayContaining([ + expect.objectContaining({ + timezoneUTCOffset: expectedOffset, + }), + ]), + }), + }) + ); + }); + }); + describe('convertMultiFiltersFormat', () => { const ds = setupMockedDataSource({ variables: [labelsVariable, dimensionVariable], mockGetVariableName: false }); it('converts keys and values correctly', () => { diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 064ec3d7f5c..15761c0d7de 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -12,6 +12,7 @@ import { DataSourceInstanceSettings, DataSourceWithLogsContextSupport, dateMath, + dateTimeFormat, FieldType, LoadingState, LogRowModel, @@ -22,6 +23,7 @@ import { import { DataSourceWithBackend, FetchError, getBackendSrv, toDataQueryResponse } from '@grafana/runtime'; import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import { notifyApp } from 'app/core/actions'; +import { config } from 'app/core/config'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; @@ -29,8 +31,6 @@ import { VariableWithMultiSupport } from 'app/features/variables/types'; import { store } from 'app/store/store'; import { AppNotificationTimeout } from 'app/types'; -import config from '../../../core/config'; - import { CloudWatchAnnotationSupport } from './annotationSupport'; import { SQLCompletionItemProvider } from './cloudwatch-sql/completion/CompletionItemProvider'; import { ThrottlingErrorMessage } from './components/ThrottlingErrorMessage'; @@ -290,11 +290,17 @@ export class CloudWatchDatasource metricQueries: CloudWatchMetricsQuery[], options: DataQueryRequest ): Observable => { + const timezoneUTCOffset = dateTimeFormat(Date.now(), { + timeZone: options.timezone, + format: 'Z', + }).replace(':', ''); + const validMetricsQueries = metricQueries.filter(this.filterQuery).map((q: CloudWatchMetricsQuery): MetricQuery => { const migratedQuery = migrateMetricQuery(q); const migratedAndIterpolatedQuery = this.replaceMetricQueryVars(migratedQuery, options); return { + timezoneUTCOffset, intervalMs: options.intervalMs, maxDataPoints: options.maxDataPoints, ...migratedAndIterpolatedQuery,