diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md index b768ff22adb..51767bc8474 100755 --- a/docs/sources/features/datasources/stackdriver.md +++ b/docs/sources/features/datasources/stackdriver.md @@ -27,11 +27,11 @@ Grafana ships with built-in support for Google Stackdriver. Just add it as a dat > NOTE: If you're not seeing the `Data Sources` link in your side menu it means that your current user does not have the `Admin` role for the current organization. -| Name | Description | -| --------------------- | ----------------------------------------------------------------------------------- | -| _Name_ | The data source name. This is how you refer to the data source in panels and queries. | -| _Default_ | Default data source means that it will be pre-selected for new panels. | -| _Service Account Key_ | Service Account Key File for a GCP Project. Instructions below on how to create it. | +| Name | Description | +| --------------------- | ------------------------------------------------------------------------------------- | +| _Name_ | The data source name. This is how you refer to the data source in panels and queries. | +| _Default_ | Default data source means that it will be pre-selected for new panels. | +| _Service Account Key_ | Service Account Key File for a GCP Project. Instructions below on how to create it. | ## Authentication @@ -45,8 +45,8 @@ To authenticate with the Stackdriver API, you need to create a Google Cloud Plat The following APIs need to be enabled first: -* [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com) -* [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com) +- [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com) +- [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com) Click on the links above and click the `Enable` button: @@ -57,24 +57,24 @@ Click on the links above and click the `Enable` button: 1. Navigate to the [APIs and Services Credentials page](https://console.cloud.google.com/apis/credentials). 2. Click on the `Create credentials` dropdown/button and choose the `Service account key` option. - {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_button.png" class="docs-image--no-shadow" caption="Create service account button" >}} + {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_button.png" class="docs-image--no-shadow" caption="Create service account button" >}} 3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option: - {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_key.png" class="docs-image--no-shadow" caption="Create service account key" >}} + {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_key.png" class="docs-image--no-shadow" caption="Create service account key" >}} 4. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown: - {{< docs-imagebox img="/img/docs/v53/stackdriver_service_account_choose_role.png" class="docs-image--no-shadow" caption="Choose role" >}} + {{< docs-imagebox img="/img/docs/v53/stackdriver_service_account_choose_role.png" class="docs-image--no-shadow" caption="Choose role" >}} 5. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Stackdriver data. 6. Upload it to Grafana on the data source Configuration page. You can either upload the file or paste in the contents of the file. - {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_upload_key.png" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}} + {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_upload_key.png" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}} 7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file! - {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}} + {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}} ### Using GCE Default Service Account @@ -86,58 +86,68 @@ If Grafana is running on a Google Compute Engine (GCE) virtual machine, it is po Read more about creating and enabling service accounts for GCE VM instances [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances). -## Metric Query Editor +## Using the Query Editor -{{< docs-imagebox img="/img/docs/v67/stackriver-query-editor.png" max-width= "400px" class="docs-image--right" >}} +The Stackdriver query editor allows you to build two types of queries - **Metric** and **Service Level Objective (SLO)**. Both types return time series data. -The Stackdriver query editor allows you to select metrics, group/aggregate by labels and by time, and use filters to specify which time series you want in the results. +### Metric Queries -Begin by choosing a `Project`. Then select a `Service` and then a metric from the `Metric` dropdown. Use the plus and minus icons in the filter and group by sections to add/remove filters or group by clauses. +{{< docs-imagebox img="/img/docs/v70/metric-query-builder.png" max-width= "400px" class="docs-image--right" >}} + +The metric query editor allows you to select metrics, group/aggregate by labels and by time, and use filters to specify which time series you want in the results. + +To create a metric query, follow these steps: + +1. Choose the option **Metrics** in the **Query Type** dropdown +2. Choose a project from the **Project** dropdown +3. Choose a Google Cloud Platform service from the **Service** dropdown +4. Choose a metric from the **Metric** dropdown. +5. Use the plus and minus icons in the filter and group by sections to add/remove filters or group by clauses. This step is optional. Stackdriver metrics can be of different kinds (GAUGE, DELTA, CUMULATIVE) and these kinds have support for different aggregation options (reducers and aligners). The Grafana query editor shows the list of available aggregation methods for a selected metric and sets a default reducer and aligner when you select the metric. Units for the Y-axis are also automatically selected by the query editor. -### Filter +#### Filter To add a filter, click the plus icon and choose a field to filter by and enter a filter value e.g. `instance_name = grafana-1`. You can remove the filter by clicking on the filter name and select `--remove filter--`. -#### Simple wildcards +##### Simple wildcards When the operator is set to `=` or `!=` it is possible to add wildcards to the filter value field. E.g `us-*` will capture all values that starts with "us-" and `*central-a` will capture all values that ends with "central-a". `*-central-*` captures all values that has the substring of -central-. Simple wildcards are less expensive than regular expressions. -#### Regular expressions +##### Regular expressions When the operator is set to `=~` or `!=~` it is possible to add regular expressions to the filter value field. E.g `us-central[1-3]-[af]` would match all values that starts with "us-central", is followed by a number in the range of 1 to 3, a dash and then either an "a" or an "f". Leading and trailing slashes are not needed when creating regular expressions. -### Aggregation +#### Aggregation The aggregation field lets you combine time series based on common statistics. Read more about this option [here](https://cloud.google.com/monitoring/charts/metrics-selector#aggregation-options). The `Aligner` field allows you to align multiple time series after the same group by time interval. Read more about how it works [here](https://cloud.google.com/monitoring/charts/metrics-selector#alignment). -#### Alignment Period/Group by Time +##### Alignment Period/Group by Time The `Alignment Period` groups a metric by time if an aggregation is chosen. The default is to use the GCP Stackdriver default groupings (which allows you to compare graphs in Grafana with graphs in the Stackdriver UI). The option is called `Stackdriver auto` and the defaults are: -* 1m for time ranges < 23 hours -* 5m for time ranges >= 23 hours and < 6 days -* 1h for time ranges >= 6 days +- 1m for time ranges < 23 hours +- 5m for time ranges >= 23 hours and < 6 days +- 1h for time ranges >= 6 days The other automatic option is `Grafana auto`. This will automatically set the group by time depending on the time range chosen and the width of the graph panel. Read more about the details [here](http://docs.grafana.org/reference/templating/#the-interval-variable). It is also possible to choose fixed time intervals to group by, like `1h` or `1d`. -### Group By +#### Group By Group by resource or metric labels to reduce the number of time series and to aggregate the results by a group by. E.g. Group by instance_name to see an aggregated metric for a Compute instance. -#### Metadata labels +##### Metadata labels -Resource metadata labels contains information to uniquely identify a resource in Google cloud. Metadata labels are only returned in the time series response if they're part of the **Group By** segment in the time series request. There's no API for retrieving metadata labels, so it's not possible to populate the group by dropdown with the metadata labels that are available for the selected service and metric. However, the **Group By** field dropdown comes with a pre-defined list of common system labels. +Resource metadata labels contain information to uniquely identify a resource in Google Cloud. Metadata labels are only returned in the time series response if they're part of the **Group By** segment in the time series request. There's no API for retrieving metadata labels, so it's not possible to populate the group by dropdown with the metadata labels that are available for the selected service and metric. However, the **Group By** field dropdown comes with a pre-defined list of common system labels. User labels cannot be pre-defined, but it's possible to enter them manually in the **Group By** field. If a metadata label, user label or system label is included in the **Group By** segment, then you can create filters based on it and expand its value on the **Alias** field. -### Alias Patterns +#### Alias patterns The Alias By field allows you to control the format of the legend keys. The default is to show the metric name and labels. This can be long and hard to read. Using the following patterns in the alias field, you can format the legend key the way you want it. @@ -153,10 +163,10 @@ The Alias By field allows you to control the format of the legend keys. The defa In the Group By dropdown, you can see a list of metric and resource labels for a metric. These can be included in the legend key using alias patterns. -| Alias Pattern Format | Description | Alias Pattern Example | Example Result | -| ------------------------ | -------------------------------- | -------------------------------- | ---------------- | -| `{{metric.label.xxx}}` | returns the metric label value | `{{metric.label.instance_name}}` | `grafana-1-prod` | -| `{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}` | `us-east1-b` | +| Alias Pattern Format | Description | Alias Pattern Example | Example Result | +| -------------------------------- | ---------------------------------------- | --------------------------------- | ---------------- | +| `{{metric.label.xxx}}` | returns the metric label value | `{{metric.label.instance_name}}` | `grafana-1-prod` | +| `{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}` | `us-east1-b` | | `{{metadata.system_labels.xxx}}` | returns the meta data system label value | `{{metadata.system_labels.name}}` | `grafana` | | `{{metadata.user_labels.xxx}}` | returns the meta data user label value | `{{metadata.user_labels.tag}}` | `production` | @@ -174,6 +184,45 @@ Example Alias By: `{{resource.type}} - {{metric.type}}` Example Result: `gce_instance - compute.googleapis.com/instance/cpu/usage_time` +### SLO (Service Level Objective) queries + +{{< docs-imagebox img="/img/docs/v70/slo-query-builder.png" max-width= "400px" class="docs-image--right" >}} + +The SLO query builder in the Stackdriver data source allows you to display SLO data in time series format. To get an understanding of the basic concepts in service monitoring, please refer to Google Stackdriver's [official docs](https://cloud.google.com/monitoring/service-monitoring). + +#### How to create an SLO query + +To create an SLO query, follow these steps: + +1. Choose the option **Service Level Objectives (SLO)** in the **Query Type** dropdown. +2. Choose a project from the **Project** dropdown. +3. Choose a [SLO service](https://cloud.google.com/monitoring/api/ref_v3/rest/v3/services) from the **Service** dropdown. +4. Choose a [SLO](https://cloud.google.com/monitoring/api/ref_v3/rest/v3/services.serviceLevelObjectives) from the **SLO** dropdown. +5. Choose a [time series selector](https://cloud.google.com/monitoring/service-monitoring/timeseries-selectors#ts-selector-list) from the **Selector** dropdown. + +The friendly names for the time series selectors are shown in Grafana. Here is the mapping from the friendly name to the system name that is used in the Service Monitoring documentation: + +| Selector dropdown value | Corresponding time series selector used | +| -------------------------- | --------------------------------------- | +| SLI Value | select_slo_health | +| SLO Compliance | select_slo_compliance | +| SLO Error Budget Remaining | select_slo_budget_fraction | + +#### Alias Patterns for SLO queries + +The Alias By field allows you to control the format of the legend keys for SLO queries too. + +| Alias Pattern | Description | Example Result | +| -------------- | ---------------------------- | ------------------- | +| `{{project}}` | returns the GCP project name | `myProject` | +| `{{service}}` | returns the service name | `myService` | +| `{{slo}}` | returns the SLO | `latency-slo` | +| `{{selector}}` | returns the selector | `select_slo_health` | + +#### Alignment Period/Group by Time for SLO queries + +SLO queries use the same [alignment period functionality as metric queries]({{< relref "#metric-queries" >}}). + ## Templating Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place. @@ -185,24 +234,27 @@ types of template variables. ### Query Variable -Variable of the type *Query* allows you to query Stackdriver for various types of data. The Stackdriver data source plugin provides the following `Query Types`. +Variable of the type _Query_ allows you to query Stackdriver for various types of data. The Stackdriver data source plugin provides the following `Query Types`. -| Name | Description | -| ------------------- | ------------------------------------------------------------------------------------------------- | -| *Metric Types* | Returns a list of metric type names that are available for the specified service. | -| *Labels Keys* | Returns a list of keys for `metric label` and `resource label` in the specified metric. | -| *Labels Values* | Returns a list of values for the label in the specified metric. | -| *Resource Types* | Returns a list of resource types for the the specified metric. | -| *Aggregations* | Returns a list of aggregations (cross series reducers) for the the specified metric. | -| *Aligners* | Returns a list of aligners (per series aligners) for the the specified metric. | -| *Alignment periods* | Returns a list of all alignment periods that are available in Stackdriver query editor in Grafana | +| Name | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | +| _Metric Types_ | Returns a list of metric type names that are available for the specified service. | +| _Labels Keys_ | Returns a list of keys for `metric label` and `resource label` in the specified metric. | +| _Labels Values_ | Returns a list of values for the label in the specified metric. | +| _Resource Types_ | Returns a list of resource types for the the specified metric. | +| _Aggregations_ | Returns a list of aggregations (cross series reducers) for the the specified metric. | +| _Aligners_ | Returns a list of aligners (per series aligners) for the the specified metric. | +| _Alignment periods_ | Returns a list of all alignment periods that are available in Stackdriver query editor in Grafana | +| _Selectors_ | Returns a list of selectors that can be used in SLO (Service Level Objectives) queries | +| _SLO Services_ | Returns a list of Service Monitoring services that can be used in SLO queries | +| _Service Level Objectives (SLO)_ | Returns a list of SLO's for the specified SLO service | ### Using variables in queries There are two syntaxes: -* `$` Example: `metric.label.$metric_label` -* `[[varname]]` Example: `metric.label.[[metric_label]]` +- `$` Example: `metric.label.$metric_label` +- `[[varname]]` Example: `metric.label.[[metric_label]]` Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the _Multi-value_ or _Include all value_ options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`. diff --git a/pkg/tsdb/stackdriver/annotation_query.go b/pkg/tsdb/stackdriver/annotation_query.go index db35171ad70..a78501f407b 100644 --- a/pkg/tsdb/stackdriver/annotation_query.go +++ b/pkg/tsdb/stackdriver/annotation_query.go @@ -34,7 +34,7 @@ func (e *StackdriverExecutor) executeAnnotationQuery(ctx context.Context, tsdbQu return result, err } -func (e *StackdriverExecutor) parseToAnnotations(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery, title string, text string, tags string) error { +func (e *StackdriverExecutor) parseToAnnotations(queryRes *tsdb.QueryResult, data stackdriverResponse, query *stackdriverQuery, title string, text string, tags string) error { annotations := make([]map[string]string, 0) for _, series := range data.TimeSeries { diff --git a/pkg/tsdb/stackdriver/annotation_query_test.go b/pkg/tsdb/stackdriver/annotation_query_test.go index 8229470d665..471c487ed44 100644 --- a/pkg/tsdb/stackdriver/annotation_query_test.go +++ b/pkg/tsdb/stackdriver/annotation_query_test.go @@ -18,7 +18,7 @@ func TestStackdriverAnnotationQuery(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 3) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "annotationQuery"} - query := &StackdriverQuery{} + query := &stackdriverQuery{} err = executor.parseToAnnotations(res, data, query, "atitle {{metric.label.instance_name}} {{metric.value}}", "atext {{resource.label.zone}}", "atag") So(err, ShouldBeNil) diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go index 495e62de22c..9fccdc5dc51 100644 --- a/pkg/tsdb/stackdriver/stackdriver.go +++ b/pkg/tsdb/stackdriver/stackdriver.go @@ -43,6 +43,8 @@ var ( const ( gceAuthentication string = "gce" jwtAuthentication string = "jwt" + metricQueryType string = "metrics" + sloQueryType string = "slo" ) // StackdriverExecutor executes queries for the Stackdriver datasource @@ -80,8 +82,6 @@ func (e *StackdriverExecutor) Query(ctx context.Context, dsInfo *models.DataSour switch queryType { case "annotationQuery": result, err = e.executeAnnotationQuery(ctx, tsdbQuery) - case "getProjectsListQuery": - result, err = e.getProjectList(ctx, tsdbQuery) case "getGCEDefaultProject": result, err = e.getGCEDefaultProject(ctx, tsdbQuery) case "timeSeriesQuery": @@ -136,8 +136,8 @@ func (e *StackdriverExecutor) executeTimeSeriesQuery(ctx context.Context, tsdbQu return result, nil } -func (e *StackdriverExecutor) buildQueries(tsdbQuery *tsdb.TsdbQuery) ([]*StackdriverQuery, error) { - stackdriverQueries := []*StackdriverQuery{} +func (e *StackdriverExecutor) buildQueries(tsdbQuery *tsdb.TsdbQuery) ([]*stackdriverQuery, error) { + stackdriverQueries := []*stackdriverQuery{} startTime, err := tsdbQuery.TimeRange.ParseFrom() if err != nil { @@ -152,45 +152,67 @@ func (e *StackdriverExecutor) buildQueries(tsdbQuery *tsdb.TsdbQuery) ([]*Stackd durationSeconds := int(endTime.Sub(startTime).Seconds()) for _, query := range tsdbQuery.Queries { + migrateLegacyQueryModel(query) + q := grafanaQuery{} + model, _ := query.Model.MarshalJSON() + if err := json.Unmarshal(model, &q); err != nil { + return nil, fmt.Errorf("could not unmarshal StackdriverQuery json: %w", err) + } var target string - - metricType := query.Model.Get("metricType").MustString() - filterParts := query.Model.Get("filters").MustArray() - params := url.Values{} params.Add("interval.startTime", startTime.UTC().Format(time.RFC3339)) params.Add("interval.endTime", endTime.UTC().Format(time.RFC3339)) - params.Add("filter", buildFilterString(metricType, filterParts)) - params.Add("view", query.Model.Get("view").MustString("FULL")) - setAggParams(¶ms, query, durationSeconds) + + sq := &stackdriverQuery{ + RefID: query.RefId, + GroupBys: []string{}, + } + + if q.QueryType == metricQueryType { + sq.AliasBy = q.MetricQuery.AliasBy + sq.GroupBys = append(sq.GroupBys, q.MetricQuery.GroupBys...) + sq.ProjectName = q.MetricQuery.ProjectName + if q.MetricQuery.View == "" { + q.MetricQuery.View = "FULL" + } + params.Add("filter", buildFilterString(q.MetricQuery.MetricType, q.MetricQuery.Filters)) + params.Add("view", q.MetricQuery.View) + setMetricAggParams(¶ms, &q.MetricQuery, durationSeconds, query.IntervalMs) + } else if q.QueryType == sloQueryType { + sq.AliasBy = q.SloQuery.AliasBy + sq.ProjectName = q.SloQuery.ProjectName + sq.Selector = q.SloQuery.SelectorName + sq.Service = q.SloQuery.ServiceId + sq.Slo = q.SloQuery.SloId + params.Add("filter", buildSLOFilterExpression(q.SloQuery)) + setSloAggParams(¶ms, &q.SloQuery, durationSeconds, query.IntervalMs) + } target = params.Encode() + sq.Target = target + sq.Params = params if setting.Env == setting.DEV { slog.Debug("Stackdriver request", "params", params) } - groupBys := query.Model.Get("groupBys").MustArray() - groupBysAsStrings := make([]string, 0) - for _, groupBy := range groupBys { - groupBysAsStrings = append(groupBysAsStrings, groupBy.(string)) - } - - aliasBy := query.Model.Get("aliasBy").MustString() - - stackdriverQueries = append(stackdriverQueries, &StackdriverQuery{ - Target: target, - Params: params, - RefID: query.RefId, - GroupBys: groupBysAsStrings, - AliasBy: aliasBy, - ProjectName: query.Model.Get("projectName").MustString(""), - }) + stackdriverQueries = append(stackdriverQueries, sq) } return stackdriverQueries, nil } +func migrateLegacyQueryModel(query *tsdb.Query) { + mq := query.Model.Get("metricQuery").MustMap() + if mq == nil { + migratedModel := simplejson.NewFromAny(map[string]interface{}{ + "queryType": metricQueryType, + "metricQuery": query.Model, + }) + query.Model = migratedModel + } +} + func reverse(s string) string { chars := []rune(s) for i, j := 0, len(chars)-1; i < j; i, j = i+1, j-1 { @@ -222,7 +244,7 @@ func interpolateFilterWildcards(value string) string { return value } -func buildFilterString(metricType string, filterParts []interface{}) string { +func buildFilterString(metricType string, filterParts []string) string { filterString := "" for i, part := range filterParts { mod := i % 4 @@ -233,33 +255,53 @@ func buildFilterString(metricType string, filterParts []interface{}) string { if operator == "=~" || operator == "!=~" { filterString = reverse(strings.Replace(reverse(filterString), "~", "", 1)) filterString += fmt.Sprintf(`monitoring.regex.full_match("%s")`, part) - } else if strings.Contains(part.(string), "*") { - filterString += interpolateFilterWildcards(part.(string)) + } else if strings.Contains(part, "*") { + filterString += interpolateFilterWildcards(part) } else { filterString += fmt.Sprintf(`"%s"`, part) } } else { - filterString += part.(string) + filterString += part } } + return strings.Trim(fmt.Sprintf(`metric.type="%s" %s`, metricType, filterString), " ") } -func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) { - crossSeriesReducer := query.Model.Get("crossSeriesReducer").MustString() - perSeriesAligner := query.Model.Get("perSeriesAligner").MustString() - alignmentPeriod := query.Model.Get("alignmentPeriod").MustString() +func buildSLOFilterExpression(q sloQuery) string { + return fmt.Sprintf(`%s("projects/%s/services/%s/serviceLevelObjectives/%s")`, q.SelectorName, q.ProjectName, q.ServiceId, q.SloId) +} - if crossSeriesReducer == "" { - crossSeriesReducer = "REDUCE_NONE" +func setMetricAggParams(params *url.Values, query *metricQuery, durationSeconds int, intervalMs int64) { + if query.CrossSeriesReducer == "" { + query.CrossSeriesReducer = "REDUCE_NONE" } - if perSeriesAligner == "" { - perSeriesAligner = "ALIGN_MEAN" + if query.PerSeriesAligner == "" { + query.PerSeriesAligner = "ALIGN_MEAN" } + params.Add("aggregation.crossSeriesReducer", query.CrossSeriesReducer) + params.Add("aggregation.perSeriesAligner", query.PerSeriesAligner) + params.Add("aggregation.alignmentPeriod", calculateAlignmentPeriod(query.AlignmentPeriod, intervalMs, durationSeconds)) + + for _, groupBy := range query.GroupBys { + params.Add("aggregation.groupByFields", groupBy) + } +} + +func setSloAggParams(params *url.Values, query *sloQuery, durationSeconds int, intervalMs int64) { + params.Add("aggregation.alignmentPeriod", calculateAlignmentPeriod(query.AlignmentPeriod, intervalMs, durationSeconds)) + if query.SelectorName == "select_slo_health" { + params.Add("aggregation.perSeriesAligner", "ALIGN_MEAN") + } else { + params.Add("aggregation.perSeriesAligner", "ALIGN_NEXT_OLDER") + } +} + +func calculateAlignmentPeriod(alignmentPeriod string, intervalMs int64, durationSeconds int) string { if alignmentPeriod == "grafana-auto" || alignmentPeriod == "" { - alignmentPeriodValue := int(math.Max(float64(query.IntervalMs)/1000, 60.0)) + alignmentPeriodValue := int(math.Max(float64(intervalMs)/1000, 60.0)) alignmentPeriod = "+" + strconv.Itoa(alignmentPeriodValue) + "s" } @@ -274,24 +316,15 @@ func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) { } } - params.Add("aggregation.crossSeriesReducer", crossSeriesReducer) - params.Add("aggregation.perSeriesAligner", perSeriesAligner) - params.Add("aggregation.alignmentPeriod", alignmentPeriod) - - groupBys := query.Model.Get("groupBys").MustArray() - if len(groupBys) > 0 { - for i := 0; i < len(groupBys); i++ { - params.Add("aggregation.groupByFields", groupBys[i].(string)) - } - } + return alignmentPeriod } -func (e *StackdriverExecutor) executeQuery(ctx context.Context, query *StackdriverQuery, tsdbQuery *tsdb.TsdbQuery) (*tsdb.QueryResult, StackdriverResponse, error) { +func (e *StackdriverExecutor) executeQuery(ctx context.Context, query *stackdriverQuery, tsdbQuery *tsdb.TsdbQuery) (*tsdb.QueryResult, stackdriverResponse, error) { queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID} req, err := e.createRequest(ctx, e.dsInfo, query, fmt.Sprintf("stackdriver%s", "v3/projects/"+query.ProjectName+"/timeSeries")) if err != nil { queryResult.Error = err - return queryResult, StackdriverResponse{}, nil + return queryResult, stackdriverResponse{}, nil } req.URL.RawQuery = query.Params.Encode() @@ -319,69 +352,47 @@ func (e *StackdriverExecutor) executeQuery(ctx context.Context, query *Stackdriv opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(req.Header)); err != nil { queryResult.Error = err - return queryResult, StackdriverResponse{}, nil + return queryResult, stackdriverResponse{}, nil } res, err := ctxhttp.Do(ctx, e.httpClient, req) if err != nil { queryResult.Error = err - return queryResult, StackdriverResponse{}, nil + return queryResult, stackdriverResponse{}, nil } data, err := e.unmarshalResponse(res) if err != nil { queryResult.Error = err - return queryResult, StackdriverResponse{}, nil + return queryResult, stackdriverResponse{}, nil } return queryResult, data, nil } -func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (StackdriverResponse, error) { +func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (stackdriverResponse, error) { body, err := ioutil.ReadAll(res.Body) defer res.Body.Close() if err != nil { - return StackdriverResponse{}, err + return stackdriverResponse{}, err } if res.StatusCode/100 != 2 { slog.Error("Request failed", "status", res.Status, "body", string(body)) - return StackdriverResponse{}, fmt.Errorf(string(body)) + return stackdriverResponse{}, fmt.Errorf(string(body)) } - var data StackdriverResponse + var data stackdriverResponse err = json.Unmarshal(body, &data) if err != nil { slog.Error("Failed to unmarshal Stackdriver response", "error", err, "status", res.Status, "body", string(body)) - return StackdriverResponse{}, err + return stackdriverResponse{}, err } return data, nil } -func (e *StackdriverExecutor) unmarshalResourceResponse(res *http.Response) (ResourceManagerProjectList, error) { - body, err := ioutil.ReadAll(res.Body) - defer res.Body.Close() - if err != nil { - return ResourceManagerProjectList{}, err - } - - if res.StatusCode/100 != 2 { - slog.Error("Request failed", "status", res.Status, "body", string(body)) - return ResourceManagerProjectList{}, fmt.Errorf(string(body)) - } - - var data ResourceManagerProjectList - err = json.Unmarshal(body, &data) - if err != nil { - slog.Error("Failed to unmarshal Resource manager response", "error", err, "status", res.Status, "body", string(body)) - return ResourceManagerProjectList{}, err - } - - return data, nil -} - -func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery) error { +func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data stackdriverResponse, query *stackdriverQuery) error { labels := make(map[string]map[string]bool) for _, series := range data.TimeSeries { @@ -389,6 +400,7 @@ func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data Sta seriesLabels := make(map[string]string) defaultMetricName := series.Metric.Type labels["resource.type"] = map[string]bool{series.Resource.Type: true} + seriesLabels["resource.type"] = series.Resource.Type for key, value := range series.Metric.Labels { if _, ok := labels["metric.label."+key]; !ok { @@ -546,7 +558,7 @@ func containsLabel(labels []string, newLabel string) bool { return false } -func formatLegendKeys(metricType string, defaultMetricName string, labels map[string]string, additionalLabels map[string]string, query *StackdriverQuery) string { +func formatLegendKeys(metricType string, defaultMetricName string, labels map[string]string, additionalLabels map[string]string, query *stackdriverQuery) string { if query.AliasBy == "" { return defaultMetricName } @@ -574,6 +586,22 @@ func formatLegendKeys(metricType string, defaultMetricName string, labels map[st return []byte(val) } + if metaPartName == "project" && query.ProjectName != "" { + return []byte(query.ProjectName) + } + + if metaPartName == "service" && query.Service != "" { + return []byte(query.Service) + } + + if metaPartName == "slo" && query.Slo != "" { + return []byte(query.Slo) + } + + if metaPartName == "selector" && query.Selector != "" { + return []byte(query.Selector) + } + return in }) @@ -599,7 +627,7 @@ func replaceWithMetricPart(metaPartName string, metricType string) []byte { return nil } -func calcBucketBound(bucketOptions StackdriverBucketOptions, n int) string { +func calcBucketBound(bucketOptions stackdriverBucketOptions, n int) string { bucketBound := "0" if n == 0 { return bucketBound @@ -615,7 +643,7 @@ func calcBucketBound(bucketOptions StackdriverBucketOptions, n int) string { return bucketBound } -func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models.DataSource, query *StackdriverQuery, proxyPass string) (*http.Request, error) { +func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models.DataSource, query *stackdriverQuery, proxyPass string) (*http.Request, error) { u, _ := url.Parse(dsInfo.Url) u.Path = path.Join(u.Path, "render") @@ -647,39 +675,6 @@ func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models. return req, nil } -func (e *StackdriverExecutor) createRequestResourceManager(ctx context.Context, dsInfo *models.DataSource) (*http.Request, error) { - u, _ := url.Parse(dsInfo.Url) - u.Path = path.Join(u.Path, "render") - - req, err := http.NewRequest(http.MethodGet, "https://cloudresourcemanager.googleapis.com/", nil) - if err != nil { - slog.Error("Failed to create request", "error", err) - return nil, fmt.Errorf("Failed to create request. error: %v", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion)) - - // find plugin - plugin, ok := plugins.DataSources[dsInfo.Type] - if !ok { - return nil, errors.New("Unable to find datasource plugin Stackdriver") - } - - var resourceManagerRoute *plugins.AppPluginRoute - for _, route := range plugin.Routes { - if route.Path == "cloudresourcemanager" { - resourceManagerRoute = route - break - } - } - proxyPass := "v1/projects" - - pluginproxy.ApplyRoute(ctx, req, proxyPass, resourceManagerRoute, dsInfo) - - return req, nil -} - func (e *StackdriverExecutor) getDefaultProject(ctx context.Context) (string, error) { authenticationType := e.dsInfo.JsonData.Get("authenticationType").MustString(jwtAuthentication) if authenticationType == gceAuthentication { @@ -699,55 +694,3 @@ func (e *StackdriverExecutor) getDefaultProject(ctx context.Context) (string, er } return e.dsInfo.JsonData.Get("defaultProject").MustString(), nil } - -func (e *StackdriverExecutor) getProjectList(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { - queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: tsdbQuery.Queries[0].RefId} - result := &tsdb.Response{ - Results: make(map[string]*tsdb.QueryResult), - } - projectsList, err := e.getProjects(ctx) - if err != nil { - return nil, err - } - - queryResult.Meta.Set("projectsList", projectsList) - result.Results[tsdbQuery.Queries[0].RefId] = queryResult - return result, nil -} - -func (e *StackdriverExecutor) getProjects(ctx context.Context) ([]ResourceManagerProjectSelect, error) { - var projects []ResourceManagerProjectSelect - - req, err := e.createRequestResourceManager(ctx, e.dsInfo) - if err != nil { - return nil, err - } - - span, ctx := opentracing.StartSpanFromContext(ctx, "resource manager query") - span.SetTag("datasource_id", e.dsInfo.Id) - span.SetTag("org_id", e.dsInfo.OrgId) - - defer span.Finish() - - if err := opentracing.GlobalTracer().Inject( - span.Context(), - opentracing.HTTPHeaders, - opentracing.HTTPHeadersCarrier(req.Header)); err != nil { - return nil, err - } - - res, err := ctxhttp.Do(ctx, e.httpClient, req) - if err != nil { - return nil, err - } - - data, err := e.unmarshalResourceResponse(res) - if err != nil { - return nil, err - } - - for _, project := range data.Projects { - projects = append(projects, ResourceManagerProjectSelect{Label: project.ProjectID, Value: project.ProjectID}) - } - return projects, nil -} diff --git a/pkg/tsdb/stackdriver/stackdriver_test.go b/pkg/tsdb/stackdriver/stackdriver_test.go index 7ee52db2de9..c03f49eaf02 100644 --- a/pkg/tsdb/stackdriver/stackdriver_test.go +++ b/pkg/tsdb/stackdriver/stackdriver_test.go @@ -19,7 +19,7 @@ func TestStackdriver(t *testing.T) { Convey("Stackdriver", t, func() { executor := &StackdriverExecutor{} - Convey("Parse queries from frontend and build Stackdriver API queries", func() { + Convey("Parse migrated queries from frontend and build Stackdriver API queries", func() { fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) tsdbQuery := &tsdb.TsdbQuery{ TimeRange: &tsdb.TimeRange{ @@ -208,6 +208,99 @@ func TestStackdriver(t *testing.T) { }) + Convey("Parse queries from frontend and build Stackdriver API queries", func() { + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + tsdbQuery := &tsdb.TsdbQuery{ + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000), + }, + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "queryType": metricQueryType, + "metricQuery": map[string]interface{}{ + "metricType": "a/metric/type", + "view": "FULL", + "aliasBy": "testalias", + "type": "timeSeriesQuery", + "groupBys": []interface{}{"metric.label.group1", "metric.label.group2"}, + }, + }), + RefId: "A", + }, + }, + } + + Convey("and query type is metrics", func() { + queries, err := executor.buildQueries(tsdbQuery) + So(err, ShouldBeNil) + + So(len(queries), ShouldEqual, 1) + So(queries[0].RefID, ShouldEqual, "A") + So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL") + So(len(queries[0].Params), ShouldEqual, 8) + So(queries[0].Params["aggregation.groupByFields"][0], ShouldEqual, "metric.label.group1") + So(queries[0].Params["aggregation.groupByFields"][1], ShouldEqual, "metric.label.group2") + So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z") + So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z") + So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN") + So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"") + So(queries[0].Params["view"][0], ShouldEqual, "FULL") + So(queries[0].AliasBy, ShouldEqual, "testalias") + So(queries[0].GroupBys, ShouldResemble, []string{"metric.label.group1", "metric.label.group2"}) + }) + + Convey("and query type is SLOs", func() { + tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{ + "queryType": sloQueryType, + "metricQuery": map[string]interface{}{}, + "sloQuery": map[string]interface{}{ + "projectName": "test-proj", + "alignmentPeriod": "stackdriver-auto", + "perSeriesAligner": "ALIGN_NEXT_OLDER", + "aliasBy": "", + "selectorName": "select_slo_health", + "serviceId": "test-service", + "sloId": "test-slo", + }, + }) + + queries, err := executor.buildQueries(tsdbQuery) + So(err, ShouldBeNil) + + So(len(queries), ShouldEqual, 1) + So(queries[0].RefID, ShouldEqual, "A") + So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z") + So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z") + So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`) + So(queries[0].AliasBy, ShouldEqual, "") + So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN") + So(queries[0].Target, ShouldEqual, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_MEAN&filter=select_slo_health%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`) + So(len(queries[0].Params), ShouldEqual, 5) + + Convey("and perSeriesAligner is inferred by SLO selector", func() { + tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{ + "queryType": sloQueryType, + "metricQuery": map[string]interface{}{}, + "sloQuery": map[string]interface{}{ + "projectName": "test-proj", + "alignmentPeriod": "stackdriver-auto", + "perSeriesAligner": "ALIGN_NEXT_OLDER", + "aliasBy": "", + "selectorName": "select_slo_compliance", + "serviceId": "test-service", + "sloId": "test-slo", + }, + }) + + queries, err := executor.buildQueries(tsdbQuery) + So(err, ShouldBeNil) + So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_NEXT_OLDER") + }) + }) + }) + Convey("Parse stackdriver response in the time series format", func() { Convey("when data from query aggregated to one time series", func() { data, err := loadTestFile("./test-data/1-series-response-agg-one-metric.json") @@ -215,7 +308,7 @@ func TestStackdriver(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 1) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{} + query := &stackdriverQuery{} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -241,7 +334,7 @@ func TestStackdriver(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 3) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{} + query := &stackdriverQuery{} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -283,7 +376,7 @@ func TestStackdriver(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 3) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} + query := &stackdriverQuery{GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -304,7 +397,7 @@ func TestStackdriver(t *testing.T) { Convey("and the alias pattern is for metric type, a metric label and a resource label", func() { - query := &StackdriverQuery{AliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} + query := &stackdriverQuery{AliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -318,7 +411,7 @@ func TestStackdriver(t *testing.T) { Convey("and the alias pattern is for metric name", func() { - query := &StackdriverQuery{AliasBy: "metric {{metric.name}} service {{metric.service}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} + query := &stackdriverQuery{AliasBy: "metric {{metric.name}} service {{metric.service}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -337,7 +430,7 @@ func TestStackdriver(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 1) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{AliasBy: "{{bucket}}"} + query := &stackdriverQuery{AliasBy: "{{bucket}}"} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -384,7 +477,7 @@ func TestStackdriver(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 1) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{AliasBy: "{{bucket}}"} + query := &stackdriverQuery{AliasBy: "{{bucket}}"} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) @@ -424,7 +517,7 @@ func TestStackdriver(t *testing.T) { So(len(data.TimeSeries), ShouldEqual, 3) res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{AliasBy: "{{bucket}}"} + query := &stackdriverQuery{AliasBy: "{{bucket}}"} err = executor.parseResponse(res, data, query) labels := res.Meta.Get("labels").Interface().(map[string][]string) So(err, ShouldBeNil) @@ -463,7 +556,7 @@ func TestStackdriver(t *testing.T) { Convey("and systemlabel contains key with array of string", func() { res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{AliasBy: "{{metadata.system_labels.test}}"} + query := &stackdriverQuery{AliasBy: "{{metadata.system_labels.test}}"} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) So(len(res.Series), ShouldEqual, 3) @@ -475,7 +568,7 @@ func TestStackdriver(t *testing.T) { Convey("and systemlabel contains key with array of string2", func() { res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} - query := &StackdriverQuery{AliasBy: "{{metadata.system_labels.test2}}"} + query := &stackdriverQuery{AliasBy: "{{metadata.system_labels.test2}}"} err = executor.parseResponse(res, data, query) So(err, ShouldBeNil) So(len(res.Series), ShouldEqual, 3) @@ -483,6 +576,45 @@ func TestStackdriver(t *testing.T) { So(res.Series[2].Name, ShouldEqual, "testvalue") }) }) + + Convey("when data from query returns slo and alias by is defined", func() { + data, err := loadTestFile("./test-data/6-series-response-slo.json") + So(err, ShouldBeNil) + So(len(data.TimeSeries), ShouldEqual, 1) + + Convey("and alias by is expanded", func() { + res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} + query := &stackdriverQuery{ + ProjectName: "test-proj", + Selector: "select_slo_compliance", + Service: "test-service", + Slo: "test-slo", + AliasBy: "{{project}} - {{service}} - {{slo}} - {{selector}}", + } + err = executor.parseResponse(res, data, query) + So(err, ShouldBeNil) + So(res.Series[0].Name, ShouldEqual, "test-proj - test-service - test-slo - select_slo_compliance") + }) + }) + + Convey("when data from query returns slo and alias by is not defined", func() { + data, err := loadTestFile("./test-data/6-series-response-slo.json") + So(err, ShouldBeNil) + So(len(data.TimeSeries), ShouldEqual, 1) + + Convey("and alias by is expanded", func() { + res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"} + query := &stackdriverQuery{ + ProjectName: "test-proj", + Selector: "select_slo_compliance", + Service: "test-service", + Slo: "test-slo", + } + err = executor.parseResponse(res, data, query) + So(err, ShouldBeNil) + So(res.Series[0].Name, ShouldEqual, "select_slo_compliance(\"projects/test-proj/services/test-service/serviceLevelObjectives/test-slo\")") + }) + }) }) Convey("when interpolating filter wildcards", func() { @@ -550,20 +682,20 @@ func TestStackdriver(t *testing.T) { Convey("when building filter string", func() { Convey("and theres no regex operator", func() { Convey("and there are wildcards in a filter value", func() { - filterParts := []interface{}{"zone", "=", "*-central1*"} + filterParts := []string{"zone", "=", "*-central1*"} value := buildFilterString("somemetrictype", filterParts) So(value, ShouldEqual, `metric.type="somemetrictype" zone=has_substring("-central1")`) }) Convey("and there are no wildcards in any filter value", func() { - filterParts := []interface{}{"zone", "!=", "us-central1-a"} + filterParts := []string{"zone", "!=", "us-central1-a"} value := buildFilterString("somemetrictype", filterParts) So(value, ShouldEqual, `metric.type="somemetrictype" zone!="us-central1-a"`) }) }) Convey("and there is a regex operator", func() { - filterParts := []interface{}{"zone", "=~", "us-central1-a~"} + filterParts := []string{"zone", "=~", "us-central1-a~"} value := buildFilterString("somemetrictype", filterParts) Convey("it should remove the ~ character from the operator that belongs to the value", func() { So(value, ShouldNotContainSubstring, `=~`) @@ -578,8 +710,8 @@ func TestStackdriver(t *testing.T) { }) } -func loadTestFile(path string) (StackdriverResponse, error) { - var data StackdriverResponse +func loadTestFile(path string) (stackdriverResponse, error) { + var data stackdriverResponse jsonBody, err := ioutil.ReadFile(path) if err != nil { diff --git a/pkg/tsdb/stackdriver/test-data/6-series-response-slo.json b/pkg/tsdb/stackdriver/test-data/6-series-response-slo.json new file mode 100644 index 00000000000..05d272b108c --- /dev/null +++ b/pkg/tsdb/stackdriver/test-data/6-series-response-slo.json @@ -0,0 +1,17 @@ +{ + "timeSeries": [{ + "metric": { + "type": "select_slo_compliance(\"projects/test-proj/services/test-service/serviceLevelObjectives/test-slo\")" + }, + "resource": { + "type": "gce_instance", + "labels": { + "instance_id": "114250375703598695", + "project_id": "test-proj" + } + }, + "metricKind": "DELTA", + "valueType": "INT64" + } + ] +} diff --git a/pkg/tsdb/stackdriver/types.go b/pkg/tsdb/stackdriver/types.go index bb177a0628f..a8b710860ba 100644 --- a/pkg/tsdb/stackdriver/types.go +++ b/pkg/tsdb/stackdriver/types.go @@ -6,17 +6,49 @@ import ( ) type ( - // StackdriverQuery is the query that Grafana sends from the frontend - StackdriverQuery struct { + stackdriverQuery struct { Target string Params url.Values RefID string GroupBys []string AliasBy string ProjectName string + Selector string + Service string + Slo string } - StackdriverBucketOptions struct { + metricQuery struct { + ProjectName string + MetricType string + CrossSeriesReducer string + AlignmentPeriod string + PerSeriesAligner string + GroupBys []string + Filters []string + AliasBy string + View string + } + + sloQuery struct { + ProjectName string + AlignmentPeriod string + PerSeriesAligner string + AliasBy string + SelectorName string + ServiceId string + SloId string + } + + grafanaQuery struct { + DatasourceId int + RefId string + QueryType string + MetricQuery metricQuery + SloQuery sloQuery + } + + stackdriverBucketOptions struct { LinearBuckets *struct { NumFiniteBuckets int64 `json:"numFiniteBuckets"` Width int64 `json:"width"` @@ -32,8 +64,7 @@ type ( } `json:"explicitBuckets"` } - // StackdriverResponse is the data returned from the external Google Stackdriver API - StackdriverResponse struct { + stackdriverResponse struct { TimeSeries []struct { Metric struct { Labels map[string]string `json:"labels"` @@ -64,7 +95,7 @@ type ( Min int `json:"min"` Max int `json:"max"` } `json:"range"` - BucketOptions StackdriverBucketOptions `json:"bucketOptions"` + BucketOptions stackdriverBucketOptions `json:"bucketOptions"` BucketCounts []string `json:"bucketCounts"` Examplars []struct { Value float64 `json:"value"` @@ -76,18 +107,4 @@ type ( } `json:"points"` } `json:"timeSeries"` } - - // ResourceManagerProjectList is the data returned from the external Google Resource Manager API - ResourceManagerProjectList struct { - Projects []ResourceManagerProject `json:"projects"` - } - - ResourceManagerProject struct { - ProjectID string `json:"projectId"` - } - - ResourceManagerProjectSelect struct { - Label string `json:"label"` - Value string `json:"value"` - } ) diff --git a/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts b/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts index e24367e6de3..a76ab7f5b5c 100644 --- a/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts +++ b/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts @@ -1,7 +1,8 @@ import isString from 'lodash/isString'; -import { alignmentPeriods, ValueTypes, MetricKind } from './constants'; +import { alignmentPeriods, ValueTypes, MetricKind, selectors } from './constants'; import StackdriverDatasource from './datasource'; import { MetricFindQueryTypes, VariableQueryData } from './types'; +import { SelectableValue } from '@grafana/data'; import { getMetricTypesByService, getAlignmentOptionsByMetric, @@ -38,6 +39,12 @@ export default class StackdriverMetricFindQuery { return this.handleAlignmentPeriodQuery(); case MetricFindQueryTypes.Aggregations: return this.handleAggregationQuery(query); + case MetricFindQueryTypes.SLOServices: + return this.handleSLOServicesQuery(query); + case MetricFindQueryTypes.SLO: + return this.handleSLOQuery(query); + case MetricFindQueryTypes.Selectors: + return this.handleSelectorQuery(); default: return []; } @@ -49,7 +56,7 @@ export default class StackdriverMetricFindQuery { async handleProjectsQuery() { const projects = await this.datasource.getProjects(); - return projects.map((s: { label: string; value: string }) => ({ + return (projects as SelectableValue).map((s: { label: string; value: string }) => ({ text: s.label, value: s.value, expandable: true, @@ -130,6 +137,20 @@ export default class StackdriverMetricFindQuery { return getAggregationOptionsByMetric(valueType as ValueTypes, metricKind as MetricKind).map(this.toFindQueryResult); } + async handleSLOServicesQuery({ projectName }: VariableQueryData) { + const services = await this.datasource.getSLOServices(projectName); + return services.map(this.toFindQueryResult); + } + + async handleSLOQuery({ selectedSLOService, projectName }: VariableQueryData) { + const slos = await this.datasource.getServiceLevelObjectives(projectName, selectedSLOService); + return slos.map(this.toFindQueryResult); + } + + async handleSelectorQuery() { + return selectors.map(this.toFindQueryResult); + } + handleAlignmentPeriodQuery() { return alignmentPeriods.map(this.toFindQueryResult); } diff --git a/public/app/plugins/datasource/stackdriver/api.test.ts b/public/app/plugins/datasource/stackdriver/api.test.ts new file mode 100644 index 00000000000..0f3c026bcd9 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/api.test.ts @@ -0,0 +1,69 @@ +import Api from './api'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { SelectableValue } from '@grafana/data'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => backendSrv, +})); + +const response = [ + { label: 'test1', value: 'test1' }, + { label: 'test2', value: 'test2' }, +]; + +describe('api', () => { + const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: any) => { + const data = { [options.url.match(/([^\/]*)\/*$/)[1]]: response }; + return Promise.resolve({ data, status: 200 }); + }); + }); + + describe('when resource was cached', () => { + let api: Api; + let res: Array>; + beforeEach(async () => { + api = new Api('/stackdriver/'); + api.cache['some-resource'] = response; + res = await api.get('some-resource'); + }); + + it('should return cached value and not load from source', () => { + expect(res).toEqual(response); + expect(api.cache['some-resource']).toEqual(response); + expect(datasourceRequestMock).not.toHaveBeenCalled(); + }); + }); + + describe('when resource was not cached', () => { + let api: Api; + let res: Array>; + beforeEach(async () => { + api = new Api('/stackdriver/'); + res = await api.get('some-resource'); + }); + + it('should return cached value and not load from source', () => { + expect(res).toEqual(response); + expect(api.cache['some-resource']).toEqual(response); + expect(datasourceRequestMock).toHaveBeenCalled(); + }); + }); + + describe('when cache should be bypassed', () => { + let api: Api; + let res: Array>; + beforeEach(async () => { + api = new Api('/stackdriver/'); + api.cache['some-resource'] = response; + res = await api.get('some-resource', { useCache: false }); + }); + + it('should return cached value and not load from source', () => { + expect(res).toEqual(response); + expect(datasourceRequestMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/public/app/plugins/datasource/stackdriver/api.ts b/public/app/plugins/datasource/stackdriver/api.ts new file mode 100644 index 00000000000..2c4a7515b05 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/api.ts @@ -0,0 +1,72 @@ +import appEvents from 'app/core/app_events'; +import { CoreEvents } from 'app/types'; +import { SelectableValue } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; + +import { formatStackdriverError } from './functions'; +import { MetricDescriptor } from './types'; + +interface Options { + responseMap?: (res: any) => SelectableValue | MetricDescriptor; + baseUrl?: string; + useCache?: boolean; +} + +export default class Api { + cache: { [key: string]: Array> }; + defaultOptions: Options; + + constructor(private baseUrl: string) { + this.cache = {}; + this.defaultOptions = { + useCache: true, + responseMap: (res: any) => res, + baseUrl: this.baseUrl, + }; + } + + async get(path: string, options?: Options): Promise> | MetricDescriptor[]> { + try { + const { useCache, responseMap, baseUrl } = { ...this.defaultOptions, ...options }; + + if (useCache && this.cache[path]) { + return this.cache[path]; + } + + const response = await getBackendSrv().datasourceRequest({ + url: baseUrl + path, + method: 'GET', + }); + + const responsePropName = path.match(/([^\/]*)\/*$/)[1]; + let res = []; + if (response && response.data && response.data[responsePropName]) { + res = response.data[responsePropName].map(responseMap); + } + + if (useCache) { + this.cache[path] = res; + } + + return res; + } catch (error) { + appEvents.emit(CoreEvents.dsRequestError, { error: { data: { error: formatStackdriverError(error) } } }); + return []; + } + } + + async post(data: { [key: string]: any }) { + return getBackendSrv().datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data, + }); + } + + async test(projectName: string) { + return getBackendSrv().datasourceRequest({ + url: `${this.baseUrl}${projectName}/metricDescriptors`, + method: 'GET', + }); + } +} diff --git a/public/app/plugins/datasource/stackdriver/components/Aggregations.tsx b/public/app/plugins/datasource/stackdriver/components/Aggregations.tsx index 4fd99ebdd1f..78024b70e80 100644 --- a/public/app/plugins/datasource/stackdriver/components/Aggregations.tsx +++ b/public/app/plugins/datasource/stackdriver/components/Aggregations.tsx @@ -5,10 +5,9 @@ import { SelectableValue } from '@grafana/data'; import { Segment } from '@grafana/ui'; import { getAggregationOptionsByMetric } from '../functions'; import { ValueTypes, MetricKind } from '../constants'; -import { MetricDescriptor } from '../types'; export interface Props { - onChange: (metricDescriptor: MetricDescriptor[]) => void; + onChange: (metricDescriptor: string) => void; metricDescriptor: { valueType: string; metricKind: string; @@ -92,7 +91,7 @@ export class Aggregations extends React.Component { - {this.props.children(this.state.displayAdvancedOptions)} + {this.props.children && this.props.children(this.state.displayAdvancedOptions)} ); } diff --git a/public/app/plugins/datasource/stackdriver/components/AliasBy.tsx b/public/app/plugins/datasource/stackdriver/components/AliasBy.tsx index 8904e51abe7..9e7caf0d614 100644 --- a/public/app/plugins/datasource/stackdriver/components/AliasBy.tsx +++ b/public/app/plugins/datasource/stackdriver/components/AliasBy.tsx @@ -1,53 +1,25 @@ -import React, { Component } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { debounce } from 'lodash'; -import { Input } from '@grafana/ui'; +import { QueryInlineField } from '.'; export interface Props { - onChange: (alignmentPeriod: string) => void; + onChange: (alias: any) => void; value: string; } -export interface State { - value: string; -} +export const AliasBy: FunctionComponent = ({ value = '', onChange }) => { + const [alias, setAlias] = useState(value); -export class AliasBy extends Component { - propagateOnChange: (value: any) => void; + const propagateOnChange = debounce(onChange, 1000); - constructor(props: Props) { - super(props); - this.propagateOnChange = debounce(this.props.onChange, 500); - this.state = { value: '' }; - } - - componentDidMount() { - this.setState({ value: this.props.value }); - } - - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if (nextProps.value !== this.props.value) { - this.setState({ value: nextProps.value }); - } - } - - onChange = (e: React.ChangeEvent) => { - this.setState({ value: e.target.value }); - this.propagateOnChange(e.target.value); + onChange = (e: any) => { + setAlias(e.target.value); + propagateOnChange(e.target.value); }; - render() { - return ( - <> -
-
- - -
-
-
-
-
- - ); - } -} + return ( + + + + ); +}; diff --git a/public/app/plugins/datasource/stackdriver/components/AlignmentPeriods.tsx b/public/app/plugins/datasource/stackdriver/components/AlignmentPeriods.tsx index db490bf0261..b25e2739925 100644 --- a/public/app/plugins/datasource/stackdriver/components/AlignmentPeriods.tsx +++ b/public/app/plugins/datasource/stackdriver/components/AlignmentPeriods.tsx @@ -36,7 +36,7 @@ export const AlignmentPeriods: FC = ({
onChange(value)} + onChange={({ value }) => onChange(value!)} value={[...options, ...templateVariableOptions].find(s => s.value === alignmentPeriod)} options={[ { diff --git a/public/app/plugins/datasource/stackdriver/components/Alignments.tsx b/public/app/plugins/datasource/stackdriver/components/Alignments.tsx index af15c4c0df1..2f55c52de71 100644 --- a/public/app/plugins/datasource/stackdriver/components/Alignments.tsx +++ b/public/app/plugins/datasource/stackdriver/components/Alignments.tsx @@ -17,7 +17,7 @@ export const Alignments: FC = ({ perSeriesAligner, templateVariableOption
onChange(value)} + onChange={({ value }) => onChange(value!)} value={[...alignOptions, ...templateVariableOptions].find(s => s.value === perSeriesAligner)} options={[ { diff --git a/public/app/plugins/datasource/stackdriver/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/AnnotationQueryEditor.tsx index 188fe7d1f1e..0fdbf49dfa6 100644 --- a/public/app/plugins/datasource/stackdriver/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/stackdriver/components/AnnotationQueryEditor.tsx @@ -5,7 +5,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv'; import { SelectableValue } from '@grafana/data'; import StackdriverDatasource from '../datasource'; -import { Metrics, Filters, AnnotationsHelp, Project } from './'; +import { Metrics, LabelFilter, AnnotationsHelp, Project } from './'; import { toOption } from '../functions'; import { AnnotationTarget, MetricDescriptor } from '../types'; @@ -41,7 +41,9 @@ const DefaultTarget: State = { export class AnnotationQueryEditor extends React.Component { state: State = DefaultTarget; - async componentDidMount() { + async UNSAFE_componentWillMount() { + // Unfortunately, migrations like this need to go componentWillMount. As soon as there's + // migration hook for this module.ts, we can do the migrations there instead. const { target, datasource } = this.props; if (!target.projectName) { target.projectName = datasource.getDefaultProject(); @@ -86,7 +88,7 @@ export class AnnotationQueryEditor extends React.Component { } render() { - const { projectName, metricType, filters, title, text, variableOptionGroup, labels, variableOptions } = this.state; + const { metricType, projectName, filters, title, text, variableOptionGroup, labels, variableOptions } = this.state; const { datasource } = this.props; return ( @@ -107,7 +109,7 @@ export class AnnotationQueryEditor extends React.Component { > {metric => ( <> - this.onChange('filters', value)} diff --git a/public/app/plugins/datasource/stackdriver/components/Fields.tsx b/public/app/plugins/datasource/stackdriver/components/Fields.tsx new file mode 100644 index 00000000000..5a90c32a060 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/Fields.tsx @@ -0,0 +1,28 @@ +import React, { InputHTMLAttributes, FunctionComponent } from 'react'; +import { FormLabel } from '@grafana/ui'; + +export interface Props extends InputHTMLAttributes { + label: string; + tooltip?: string; + children?: React.ReactNode; +} + +export const QueryField: FunctionComponent> = ({ label, tooltip, children }) => ( + <> + + {label} + + {children} + +); + +export const QueryInlineField: FunctionComponent = ({ ...props }) => { + return ( +
+ +
+
+
+
+ ); +}; diff --git a/public/app/plugins/datasource/stackdriver/components/GroupBys.tsx b/public/app/plugins/datasource/stackdriver/components/GroupBys.tsx index bea984b2db5..23406fe489f 100644 --- a/public/app/plugins/datasource/stackdriver/components/GroupBys.tsx +++ b/public/app/plugins/datasource/stackdriver/components/GroupBys.tsx @@ -26,7 +26,7 @@ export const GroupBys: FunctionComponent = ({ groupBys = [], values = [], key={value + index} value={value} options={options} - onChange={({ value }) => + onChange={({ value = '' }) => onChange( value === removeText ? values.filter((_, i) => i !== index) @@ -43,7 +43,7 @@ export const GroupBys: FunctionComponent = ({ groupBys = [], values = [], } allowCustomValue - onChange={({ value }) => onChange([...values, value])} + onChange={({ value = '' }) => onChange([...values, value])} options={[ variableOptionGroup, ...labelsToGroupedOptions([...groupBys.filter(groupBy => !values.includes(groupBy)), ...systemLabels]), diff --git a/public/app/plugins/datasource/stackdriver/components/Help.tsx b/public/app/plugins/datasource/stackdriver/components/Help.tsx index e87efe1851d..af59b102878 100644 --- a/public/app/plugins/datasource/stackdriver/components/Help.tsx +++ b/public/app/plugins/datasource/stackdriver/components/Help.tsx @@ -105,6 +105,18 @@ export class Help extends React.Component { {`${'{{bucket}}'}`} = bucket boundary for distribution metrics when using a heatmap in Grafana +
  • + {`${'{{project}}'}`} = The project name that was specified in the query editor +
  • +
  • + {`${'{{service}}'}`} = The service id that was specified in the SLO query editor +
  • +
  • + {`${'{{slo}}'}`} = The SLO id that was specified in the SLO query editor +
  • +
  • + {`${'{{selector}}'}`} = The Selector function that was specified in the SLO query editor +
  • diff --git a/public/app/plugins/datasource/stackdriver/components/Filters.tsx b/public/app/plugins/datasource/stackdriver/components/LabelFilter.tsx similarity index 82% rename from public/app/plugins/datasource/stackdriver/components/Filters.tsx rename to public/app/plugins/datasource/stackdriver/components/LabelFilter.tsx index 36cc53d4ee0..e185a4e9aa5 100644 --- a/public/app/plugins/datasource/stackdriver/components/Filters.tsx +++ b/public/app/plugins/datasource/stackdriver/components/LabelFilter.tsx @@ -2,7 +2,7 @@ import React, { FunctionComponent, Fragment } from 'react'; import _ from 'lodash'; import { SelectableValue } from '@grafana/data'; import { Segment } from '@grafana/ui'; -import { labelsToGroupedOptions, toOption } from '../functions'; +import { labelsToGroupedOptions, filtersToStringArray, stringArrayToFilters, toOption } from '../functions'; import { Filter } from '../types'; export interface Props { @@ -15,18 +15,8 @@ export interface Props { const removeText = '-- remove filter --'; const removeOption: SelectableValue = { label: removeText, value: removeText, icon: 'fa fa-remove' }; const operators = ['=', '!=', '=~', '!=~']; -const filtersToStringArray = (filters: Filter[]) => - _.flatten(filters.map(({ key, operator, value, condition }) => [key, operator, value, condition])); -const stringArrayToFilters = (filterArray: string[]) => - _.chunk(filterArray, 4).map(([key, operator, value, condition = 'AND']) => ({ - key, - operator, - value, - condition, - })); - -export const Filters: FunctionComponent = ({ +export const LabelFilter: FunctionComponent = ({ labels = {}, filters: filterArray, onChange, @@ -45,7 +35,7 @@ export const Filters: FunctionComponent = ({ allowCustomValue value={key} options={options} - onChange={({ value: key }) => { + onChange={({ value: key = '' }) => { if (key === removeText) { onChange(filtersToStringArray(filters.filter((_, i) => i !== index))); } else { @@ -61,7 +51,7 @@ export const Filters: FunctionComponent = ({ value={operator} className="gf-form-label query-segment-operator" options={operators.map(toOption)} - onChange={({ value: operator }) => + onChange={({ value: operator = '=' }) => onChange(filtersToStringArray(filters.map((f, i) => (i === index ? { ...f, operator } : f)))) } /> @@ -72,7 +62,7 @@ export const Filters: FunctionComponent = ({ options={ labels.hasOwnProperty(key) ? [variableOptionGroup, ...labels[key].map(toOption)] : [variableOptionGroup] } - onChange={({ value }) => + onChange={({ value = '' }) => onChange(filtersToStringArray(filters.map((f, i) => (i === index ? { ...f, value } : f)))) } /> @@ -90,7 +80,7 @@ export const Filters: FunctionComponent = ({ } options={[variableOptionGroup, ...labelsToGroupedOptions(Object.keys(labels))]} - onChange={({ value: key }) => + onChange={({ value: key = '' }) => onChange(filtersToStringArray([...filters, { key, operator: '=', condition: 'AND', value: '' } as Filter])) } /> diff --git a/public/app/plugins/datasource/stackdriver/components/MetricQueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/MetricQueryEditor.tsx new file mode 100644 index 00000000000..efa7032628d --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/MetricQueryEditor.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; +import { Project, Aggregations, Metrics, LabelFilter, GroupBys, Alignments, AlignmentPeriods, AliasBy } from '.'; +import { MetricQuery, MetricDescriptor } from '../types'; +import { getAlignmentPickerData } from '../functions'; +import StackdriverDatasource from '../datasource'; +import { SelectableValue } from '@grafana/data'; + +export interface Props { + refId: string; + usedAlignmentPeriod: string; + variableOptionGroup: SelectableValue; + onChange: (query: MetricQuery) => void; + onRunQuery: () => void; + query: MetricQuery; + datasource: StackdriverDatasource; +} + +interface State { + labels: any; + [key: string]: any; +} + +export const defaultState: State = { + labels: {}, +}; + +export const defaultQuery: MetricQuery = { + projectName: '', + metricType: '', + metricKind: '', + valueType: '', + unit: '', + crossSeriesReducer: 'REDUCE_MEAN', + alignmentPeriod: 'stackdriver-auto', + perSeriesAligner: 'ALIGN_MEAN', + groupBys: [], + filters: [], + aliasBy: '', +}; + +function Editor({ + refId, + query, + datasource, + onChange, + usedAlignmentPeriod, + variableOptionGroup, +}: React.PropsWithChildren) { + const [state, setState] = useState(defaultState); + + useEffect(() => { + if (query && query.projectName && query.metricType) { + datasource + .getLabels(query.metricType, refId, query.projectName, query.groupBys) + .then(labels => setState({ ...state, labels })); + } + }, [query.projectName, query.groupBys, query.metricType]); + + const onMetricTypeChange = async ({ valueType, metricKind, type, unit }: MetricDescriptor) => { + const { perSeriesAligner, alignOptions } = getAlignmentPickerData( + { valueType, metricKind, perSeriesAligner: state.perSeriesAligner }, + datasource.templateSrv + ); + setState({ + ...state, + alignOptions, + }); + onChange({ ...query, perSeriesAligner, metricType: type, unit, valueType, metricKind }); + }; + + const { labels } = state; + const { perSeriesAligner, alignOptions } = getAlignmentPickerData(query, datasource.templateSrv); + + return ( + <> + { + onChange({ ...query, projectName }); + }} + /> + + {metric => ( + <> + onChange({ ...query, filters })} + variableOptionGroup={variableOptionGroup} + /> + onChange({ ...query, groupBys })} + variableOptionGroup={variableOptionGroup} + /> + onChange({ ...query, crossSeriesReducer })} + > + {displayAdvancedOptions => + displayAdvancedOptions && ( + onChange({ ...query, perSeriesAligner })} + /> + ) + } + + onChange({ ...query, alignmentPeriod })} + /> + onChange({ ...query, aliasBy })} /> + + )} + + + ); +} + +export const MetricQueryEditor = React.memo(Editor); diff --git a/public/app/plugins/datasource/stackdriver/components/Metrics.tsx b/public/app/plugins/datasource/stackdriver/components/Metrics.tsx index 686e528d2d6..5c1193e6418 100644 --- a/public/app/plugins/datasource/stackdriver/components/Metrics.tsx +++ b/public/app/plugins/datasource/stackdriver/components/Metrics.tsx @@ -81,8 +81,8 @@ export function Metrics(props: Props) { const { metricType, templateSrv } = props; const metrics = metricDescriptors - .filter(m => m.service === templateSrv.replace(service)) - .map(m => ({ + .filter((m: MetricDescriptor) => m.service === templateSrv.replace(service)) + .map((m: MetricDescriptor) => ({ service: m.service, value: m.type, label: m.displayName, @@ -96,7 +96,7 @@ export function Metrics(props: Props) { } }; - const onMetricTypeChange = ({ value }: any, extra: any = {}) => { + const onMetricTypeChange = ({ value }: SelectableValue, extra: any = {}) => { const metricDescriptor = getSelectedMetricDescriptor(state.metricDescriptors, value); setState({ ...state, metricDescriptor, ...extra }); props.onChange({ ...metricDescriptor, type: value }); diff --git a/public/app/plugins/datasource/stackdriver/components/QueryEditor.test.tsx b/public/app/plugins/datasource/stackdriver/components/QueryEditor.test.tsx deleted file mode 100644 index 6350f9675b7..00000000000 --- a/public/app/plugins/datasource/stackdriver/components/QueryEditor.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import { DefaultTarget, Props, QueryEditor } from './QueryEditor'; -import { TemplateSrv } from 'app/features/templating/template_srv'; - -const props: Props = { - onQueryChange: target => {}, - onExecuteQuery: () => {}, - target: DefaultTarget, - events: { on: () => {} }, - datasource: { - getProjects: () => Promise.resolve([]), - getDefaultProject: () => Promise.resolve('projectName'), - ensureGCEDefaultProject: () => {}, - getMetricTypes: () => Promise.resolve([]), - getLabels: () => Promise.resolve([]), - variables: [], - } as any, - templateSrv: new TemplateSrv(), -}; - -describe('QueryEditor', () => { - it('renders correctly', () => { - const tree = renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx index b751284acb1..9c6842fd745 100644 --- a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx @@ -1,258 +1,113 @@ -import React from 'react'; - -import { TemplateSrv } from 'app/features/templating/template_srv'; - -import { Project, Aggregations, Metrics, Filters, GroupBys, Alignments, AlignmentPeriods, AliasBy, Help } from './'; -import { StackdriverQuery, MetricDescriptor } from '../types'; -import { getAlignmentPickerData, toOption } from '../functions'; +import React, { PureComponent } from 'react'; +import appEvents from 'app/core/app_events'; +import { CoreEvents } from 'app/types'; +import { MetricQueryEditor, QueryTypeSelector, SLOQueryEditor, Help } from './'; +import { StackdriverQuery, MetricQuery, QueryType, SLOQuery } from '../types'; +import { defaultQuery } from './MetricQueryEditor'; +import { defaultQuery as defaultSLOQuery } from './SLOQueryEditor'; +import { toOption, formatStackdriverError } from '../functions'; import StackdriverDatasource from '../datasource'; -import { PanelEvents, SelectableValue, TimeSeries } from '@grafana/data'; +import { ExploreQueryFieldProps } from '@grafana/data'; -export interface Props { - onQueryChange: (target: StackdriverQuery) => void; - onExecuteQuery: () => void; - target: StackdriverQuery; - events: any; - datasource: StackdriverDatasource; - templateSrv: TemplateSrv; -} +export type Props = ExploreQueryFieldProps; -interface State extends StackdriverQuery { - variableOptions: Array>; - variableOptionGroup: SelectableValue; - alignOptions: Array>; - lastQuery: string; +interface State { lastQueryError: string; - labels: any; - [key: string]: any; } -export const DefaultTarget: State = { - projectName: '', - metricType: '', - metricKind: '', - valueType: '', - refId: '', - service: '', - unit: '', - crossSeriesReducer: 'REDUCE_MEAN', - alignmentPeriod: 'stackdriver-auto', - perSeriesAligner: 'ALIGN_MEAN', - groupBys: [], - filters: [], - filter: [], - aliasBy: '', - alignOptions: [], - lastQuery: '', - lastQueryError: '', - usedAlignmentPeriod: '', - labels: {}, - variableOptionGroup: {}, - variableOptions: [], -}; +export class QueryEditor extends PureComponent { + state: State = { lastQueryError: '' }; -export class QueryEditor extends React.Component { - state: State = DefaultTarget; + async UNSAFE_componentWillMount() { + const { datasource, query } = this.props; - async componentDidMount() { - const { events, target, templateSrv, datasource } = this.props; - await datasource.ensureGCEDefaultProject(); - if (!target.projectName) { - target.projectName = datasource.getDefaultProject(); + // Unfortunately, migrations like this need to go componentWillMount. As soon as there's + // migration hook for this module.ts, we can do the migrations there instead. + if (!this.props.query.hasOwnProperty('metricQuery')) { + const { hide, refId, datasource, key, queryType, maxLines, metric, ...metricQuery } = this.props.query as any; + this.props.query.metricQuery = metricQuery; } - events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this)); - events.on(PanelEvents.dataError, this.onDataError.bind(this)); + if (!this.props.query.hasOwnProperty('queryType')) { + this.props.query.queryType = QueryType.METRICS; + } - const { perSeriesAligner, alignOptions } = getAlignmentPickerData(target, templateSrv); + await datasource.ensureGCEDefaultProject(); + if (!query.metricQuery.projectName) { + this.props.query.metricQuery.projectName = datasource.getDefaultProject(); + } + } + + componentDidMount() { + appEvents.on(CoreEvents.dsRequestError, this.onDataError.bind(this)); + appEvents.on(CoreEvents.dsRequestResponse, this.onDataResponse.bind(this)); + } + + componentWillUnmount() { + appEvents.off(CoreEvents.dsRequestResponse, this.onDataResponse.bind(this)); + appEvents.on(CoreEvents.dsRequestError, this.onDataError.bind(this)); + } + + onDataResponse() { + this.setState({ lastQueryError: '' }); + } + + onDataError(error: any) { + this.setState({ lastQueryError: formatStackdriverError(error) }); + } + + onQueryChange(prop: string, value: any) { + this.props.onChange({ ...this.props.query, [prop]: value }); + this.props.onRunQuery(); + } + + render() { + const { datasource, query, onRunQuery, onChange } = this.props; + const metricQuery = { ...defaultQuery, projectName: datasource.getDefaultProject(), ...query.metricQuery }; + const sloQuery = { ...defaultSLOQuery, projectName: datasource.getDefaultProject(), ...query.sloQuery }; + const queryType = query.queryType || QueryType.METRICS; + const meta = this.props.data?.series.length ? this.props.data?.series[0].meta : {}; + const usedAlignmentPeriod = meta?.alignmentPeriod as string; const variableOptionGroup = { label: 'Template Variables', expanded: false, options: datasource.variables.map(toOption), }; - const state: Partial = { - ...this.props.target, - projectName: target.projectName, - alignOptions, - perSeriesAligner, - variableOptionGroup, - variableOptions: variableOptionGroup.options, - }; - - this.setState(state); - - datasource - .getLabels(target.metricType, target.refId, target.projectName, target.groupBys) - .then(labels => this.setState({ labels })); - } - - componentWillUnmount() { - this.props.events.off(PanelEvents.dataReceived, this.onDataReceived); - this.props.events.off(PanelEvents.dataError, this.onDataError); - } - - onDataReceived(dataList: TimeSeries[]) { - const series = dataList.find((item: any) => item.refId === this.props.target.refId); - if (series) { - this.setState({ - lastQuery: decodeURIComponent(series.meta.rawQuery), - lastQueryError: '', - usedAlignmentPeriod: series.meta.alignmentPeriod, - }); - } - } - - onDataError(err: any) { - let lastQuery; - let lastQueryError; - if (err.data && err.data.error) { - lastQueryError = this.props.datasource.formatStackdriverError(err); - } else if (err.data && err.data.results) { - const queryRes = err.data.results[this.props.target.refId]; - lastQuery = decodeURIComponent(queryRes.meta.rawQuery); - if (queryRes && queryRes.error) { - try { - lastQueryError = JSON.parse(queryRes.error).error.message; - } catch { - lastQueryError = queryRes.error; - } - } - } - this.setState({ lastQuery, lastQueryError }); - } - - onMetricTypeChange = async ({ valueType, metricKind, type, unit }: MetricDescriptor) => { - const { templateSrv, onQueryChange, onExecuteQuery, target } = this.props; - const { perSeriesAligner, alignOptions } = getAlignmentPickerData( - { valueType, metricKind, perSeriesAligner: this.state.perSeriesAligner }, - templateSrv - ); - const labels = await this.props.datasource.getLabels(type, target.refId, this.state.projectName, target.groupBys); - this.setState( - { - alignOptions, - perSeriesAligner, - metricType: type, - unit, - valueType, - metricKind, - labels, - }, - () => { - onQueryChange(this.state); - if (this.state.projectName !== null) { - onExecuteQuery(); - } - } - ); - }; - - onGroupBysChange(value: string[]) { - const { target, datasource } = this.props; - this.setState({ groupBys: value }, () => { - this.props.onQueryChange(this.state); - this.props.onExecuteQuery(); - }); - datasource - .getLabels(target.metricType, target.refId, this.state.projectName, value) - .then(labels => this.setState({ labels })); - } - - onPropertyChange(prop: string, value: any) { - this.setState({ [prop]: value }, () => { - this.props.onQueryChange(this.state); - if (this.state.projectName !== null) { - this.props.onExecuteQuery(); - } - }); - } - - render() { - const { - groupBys = [], - filters = [], - usedAlignmentPeriod, - projectName, - metricType, - crossSeriesReducer, - perSeriesAligner, - alignOptions, - alignmentPeriod, - aliasBy, - lastQuery, - lastQueryError, - labels, - variableOptionGroup, - variableOptions, - refId, - } = this.state; - const { datasource, templateSrv } = this.props; - return ( <> - { - this.onPropertyChange('projectName', value); - datasource.getLabels(metricType, refId, value, groupBys).then(labels => this.setState({ labels })); + { + onChange({ ...query, sloQuery, queryType }); + onRunQuery(); }} - /> - - {metric => ( - <> - this.onPropertyChange('filters', value)} - variableOptionGroup={variableOptionGroup} - /> - - this.onPropertyChange('crossSeriesReducer', value)} - > - {displayAdvancedOptions => - displayAdvancedOptions && ( - this.onPropertyChange('perSeriesAligner', value)} - /> - ) - } - - this.onPropertyChange('alignmentPeriod', value)} - /> - this.onPropertyChange('aliasBy', value)} /> - - - )} - + > + + {queryType === QueryType.METRICS && ( + this.onQueryChange('metricQuery', query)} + onRunQuery={onRunQuery} + datasource={datasource} + query={metricQuery} + > + )} + + {queryType === QueryType.SLO && ( + this.onQueryChange('sloQuery', query)} + onRunQuery={onRunQuery} + datasource={datasource} + query={sloQuery} + > + )} + ); } diff --git a/public/app/plugins/datasource/stackdriver/components/QueryType.tsx b/public/app/plugins/datasource/stackdriver/components/QueryType.tsx new file mode 100644 index 00000000000..44b27779e92 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/QueryType.tsx @@ -0,0 +1,34 @@ +import React, { FunctionComponent } from 'react'; +import _ from 'lodash'; +import { SelectableValue } from '@grafana/data'; +import { Segment } from '@grafana/ui'; +import { QueryType, queryTypes } from '../types'; + +export interface Props { + value: QueryType; + onChange: (slo: QueryType) => void; + templateVariableOptions: Array>; +} + +export const QueryTypeSelector: FunctionComponent = ({ onChange, value, templateVariableOptions }) => { + return ( +
    + + qt.value === value)} + options={[ + ...queryTypes, + { + label: 'Template Variables', + options: templateVariableOptions, + }, + ]} + onChange={({ value }: SelectableValue) => onChange(value!)} + /> + +
    + +
    +
    + ); +}; diff --git a/public/app/plugins/datasource/stackdriver/components/SLOQueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/SLOQueryEditor.tsx new file mode 100644 index 00000000000..a624feccf8e --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/SLOQueryEditor.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Segment, SegmentAsync } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { selectors } from '../constants'; +import { Project, AlignmentPeriods, AliasBy, QueryInlineField } from '.'; +import { SLOQuery } from '../types'; +import StackdriverDatasource from '../datasource'; + +export interface Props { + usedAlignmentPeriod: string; + variableOptionGroup: SelectableValue; + onChange: (query: SLOQuery) => void; + onRunQuery: () => void; + query: SLOQuery; + datasource: StackdriverDatasource; +} + +export const defaultQuery: SLOQuery = { + projectName: '', + alignmentPeriod: 'stackdriver-auto', + aliasBy: '', + selectorName: 'select_slo_health', + serviceId: '', + sloId: '', +}; + +export function SLOQueryEditor({ + query, + datasource, + onChange, + variableOptionGroup, + usedAlignmentPeriod, +}: React.PropsWithChildren) { + return ( + <> + onChange({ ...query, projectName })} + /> + + + datasource.getSLOServices(query.projectName).then(services => [ + { + label: 'Template Variables', + options: variableOptionGroup.options, + }, + ...services, + ]) + } + onChange={({ value: serviceId = '' }) => onChange({ ...query, serviceId, sloId: '' })} + /> + + + + + datasource.getServiceLevelObjectives(query.projectName, query.serviceId).then(sloIds => [ + { + label: 'Template Variables', + options: variableOptionGroup.options, + }, + ...sloIds, + ]) + } + onChange={async ({ value: sloId = '' }) => { + const slos = await datasource.getServiceLevelObjectives(query.projectName, query.serviceId); + const slo = slos.find(({ value }) => value === datasource.templateSrv.replace(sloId)); + onChange({ ...query, sloId, goal: slo?.goal }); + }} + /> + + + + s.value === query?.selectorName ?? '')} + options={[ + { + label: 'Template Variables', + options: variableOptionGroup.options, + }, + ...selectors, + ]} + onChange={({ value: selectorName }) => onChange({ ...query, selectorName })} + /> + + + onChange({ ...query, alignmentPeriod })} + /> + onChange({ ...query, aliasBy })} /> + + ); +} diff --git a/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx index a5751380e16..b7c3ef66df8 100644 --- a/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx @@ -16,8 +16,10 @@ const props: VariableQueryProps = { query: {}, datasource: { getDefaultProject: () => '', - getProjects: async (): Promise => [], - getMetricTypes: async (p: any): Promise => [], + getProjects: async () => Promise.resolve([]), + getMetricTypes: async (projectName: string) => Promise.resolve([]), + getSLOServices: async (projectName: string, serviceId: string) => Promise.resolve([]), + getServiceLevelObjectives: (projectName: string, serviceId: string) => Promise.resolve([]), }, templateSrv: { replace: (s: string) => s, getVariables: () => ([] as unknown) as VariableModel[] }, }; diff --git a/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx index 8a914716b16..3cd985e1360 100644 --- a/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx @@ -3,6 +3,7 @@ import { VariableQueryProps } from 'app/types/plugins'; import { SimpleSelect } from './'; import { extractServicesFromMetricDescriptors, getLabelKeys, getMetricTypes } from '../functions'; import { MetricFindQueryTypes, VariableQueryData } from '../types'; +import { getConfig } from 'app/core/config'; export class StackdriverVariableQueryEditor extends PureComponent { queryTypes: Array<{ value: string; name: string }> = [ @@ -15,6 +16,9 @@ export class StackdriverVariableQueryEditor extends PureComponent ({ value, name: label })), ...(await this.getLabels(selectedMetricType, this.state.projectName)), + sloServices: sloServices.map(({ value, label }: any) => ({ value, name: label })), }; this.setState(state); } @@ -80,6 +89,7 @@ export class StackdriverVariableQueryEditor extends PureComponent ({ value, name: label })), + }); } async onServiceChange(service: string) { @@ -124,10 +143,12 @@ export class StackdriverVariableQueryEditor extends PureComponent q.value === this.state.selectedQueryType); - this.props.onChange(queryModel, `Stackdriver - ${query.name}`); + componentDidUpdate(prevProps: Readonly, prevState: Readonly) { + if (!getConfig().featureToggles.newVariables || prevState.selectedQueryType !== this.state.selectedQueryType) { + const { metricDescriptors, labels, metricTypes, services, ...queryModel } = this.state; + const query = this.queryTypes.find(q => q.value === this.state.selectedQueryType); + this.props.onChange(queryModel, `Stackdriver - ${query.name}`); + } } async getLabels(selectedMetricType: string, projectName: string, selectedQueryType = this.state.selectedQueryType) { @@ -220,6 +241,40 @@ export class StackdriverVariableQueryEditor extends PureComponent ); + case MetricFindQueryTypes.SLOServices: + return ( + <> + this.onProjectChange(e.target.value)} + label="Project" + /> + + ); + + case MetricFindQueryTypes.SLO: + return ( + <> + this.onProjectChange(e.target.value)} + label="Project" + /> + { + this.setState({ + ...this.state, + selectedSLOService: e.target.value, + }); + }} + label="SLO Service" + /> + + ); default: return ''; } diff --git a/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap b/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap deleted file mode 100644 index 81fc2058be9..00000000000 --- a/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap +++ /dev/null @@ -1,248 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`QueryEditor renders correctly 1`] = ` -Array [ -
    - - Project - - -
    -
    -
    -
    , -
    - - Service - - -
    -
    -
    -
    , -
    - - Metric - - -
    -
    -
    -
    , -
    - -
    - - - -
    -
    -
    -
    , -
    - -
    -
    -
    , -
    - - - -
    , -
    - - -
    - -
    -
    , -
    -
    - -
    - -
    -
    -
    -
    -
    -
    , -
    -
    - -
    - -
    -
    -
    -
    , - "", - "", -] -`; diff --git a/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap b/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap index cdb8b3e42ae..d15a818b02a 100644 --- a/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap @@ -64,6 +64,21 @@ Array [ > Alignment Periods + + +
    , diff --git a/public/app/plugins/datasource/stackdriver/components/index.ts b/public/app/plugins/datasource/stackdriver/components/index.ts index a372f138aea..216b6e91325 100644 --- a/public/app/plugins/datasource/stackdriver/components/index.ts +++ b/public/app/plugins/datasource/stackdriver/components/index.ts @@ -2,10 +2,14 @@ export { Project } from './Project'; export { Metrics } from './Metrics'; export { Help } from './Help'; export { GroupBys } from './GroupBys'; -export { Filters } from './Filters'; +export { LabelFilter } from './LabelFilter'; export { AnnotationsHelp } from './AnnotationsHelp'; export { Alignments } from './Alignments'; export { AlignmentPeriods } from './AlignmentPeriods'; export { AliasBy } from './AliasBy'; export { Aggregations } from './Aggregations'; export { SimpleSelect } from './SimpleSelect'; +export { MetricQueryEditor } from './MetricQueryEditor'; +export { SLOQueryEditor } from './SLOQueryEditor'; +export { QueryTypeSelector } from './QueryType'; +export { QueryInlineField, QueryField } from './Fields'; diff --git a/public/app/plugins/datasource/stackdriver/constants.ts b/public/app/plugins/datasource/stackdriver/constants.ts index 463091e9ada..0dabcd67d22 100644 --- a/public/app/plugins/datasource/stackdriver/constants.ts +++ b/public/app/plugins/datasource/stackdriver/constants.ts @@ -273,3 +273,9 @@ export const systemLabels = [ 'metadata.system_labels.top_level_controller_name', 'metadata.system_labels.container_image', ]; + +export const selectors = [ + { label: 'SLI Value', value: 'select_slo_health' }, + { label: 'SLO Compliance', value: 'select_slo_compliance' }, + { label: 'SLO Error Budget Remaining', value: 'select_slo_budget_fraction' }, +]; diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index aa546a2be38..4268e03f5a2 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -1,28 +1,24 @@ -import { stackdriverUnitMappings } from './constants'; -import appEvents from 'app/core/app_events'; import _ from 'lodash'; -import StackdriverMetricFindQuery from './StackdriverMetricFindQuery'; -import { Filter, MetricDescriptor, StackdriverOptions, StackdriverQuery, VariableQueryData } from './types'; + import { DataQueryRequest, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings, ScopedVars, + SelectableValue, } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; -import { CoreEvents } from 'app/types'; + +import { StackdriverQuery, MetricDescriptor, StackdriverOptions, Filter, VariableQueryData, QueryType } from './types'; +import { stackdriverUnitMappings } from './constants'; +import API from './api'; +import StackdriverMetricFindQuery from './StackdriverMetricFindQuery'; export default class StackdriverDatasource extends DataSourceApi { - url: string; - baseUrl: string; - projectList: Array<{ label: string; value: string }>; + api: API; authenticationType: string; - queryPromise: Promise; - metricTypesCache: { [key: string]: MetricDescriptor[] }; - gceDefaultProject: string; /** @ngInject */ constructor( @@ -31,121 +27,14 @@ export default class StackdriverDatasource extends DataSourceApi `$${v.name}`); } - async getTimeSeries(options: DataQueryRequest) { - await this.ensureGCEDefaultProject(); - const queries = options.targets - .filter((target: StackdriverQuery) => { - return !target.hide && target.metricType; - }) - .map((t: StackdriverQuery) => { - return { - refId: t.refId, - intervalMs: options.intervalMs, - datasourceId: this.id, - metricType: this.templateSrv.replace(t.metricType, options.scopedVars || {}), - crossSeriesReducer: this.templateSrv.replace(t.crossSeriesReducer || 'REDUCE_MEAN', options.scopedVars || {}), - perSeriesAligner: this.templateSrv.replace(t.perSeriesAligner, options.scopedVars || {}), - alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod!, options.scopedVars || {}), - groupBys: this.interpolateGroupBys(t.groupBys || [], options.scopedVars), - view: t.view || 'FULL', - filters: this.interpolateFilters(t.filters || [], options.scopedVars), - aliasBy: this.templateSrv.replace(t.aliasBy!, options.scopedVars || {}), - type: 'timeSeriesQuery', - projectName: this.templateSrv.replace(t.projectName ? t.projectName : this.getDefaultProject()), - }; - }); - - if (queries.length > 0) { - const { data } = await getBackendSrv().datasourceRequest({ - url: '/api/tsdb/query', - method: 'POST', - data: { - from: options.range.from.valueOf().toString(), - to: options.range.to.valueOf().toString(), - queries, - }, - }); - return data; - } else { - return { results: [] }; - } - } - - interpolateFilters(filters: string[], scopedVars: ScopedVars) { - const completeFilter = _.chunk(filters, 4) - .map(([key, operator, value, condition = 'AND']) => ({ - key, - operator, - value, - condition, - })) - .reduce((res, filter) => (filter.value ? [...res, filter] : res), []); - - const filterArray = _.flatten( - completeFilter.map(({ key, operator, value, condition }: Filter) => [ - this.templateSrv.replace(key, scopedVars || {}), - operator, - this.templateSrv.replace(value, scopedVars || {}, 'regex'), - condition, - ]) - ); - - return filterArray || []; - } - - async getLabels(metricType: string, refId: string, projectName: string, groupBys?: string[]) { - const response = await this.getTimeSeries({ - targets: [ - { - refId: refId, - datasourceId: this.id, - projectName: this.templateSrv.replace(projectName), - metricType: this.templateSrv.replace(metricType), - groupBys: this.interpolateGroupBys(groupBys || [], {}), - crossSeriesReducer: 'REDUCE_NONE', - view: 'HEADERS', - }, - ], - range: this.timeSrv.timeRange(), - } as DataQueryRequest); - const result = response.results[refId]; - return result && result.meta ? result.meta.labels : {}; - } - - interpolateGroupBys(groupBys: string[], scopedVars: {}): string[] { - let interpolatedGroupBys: string[] = []; - (groupBys || []).forEach(gb => { - const interpolated = this.templateSrv.replace(gb, scopedVars || {}, 'csv').split(','); - if (Array.isArray(interpolated)) { - interpolatedGroupBys = interpolatedGroupBys.concat(interpolated); - } else { - interpolatedGroupBys.push(interpolated); - } - }); - return interpolatedGroupBys; - } - - resolvePanelUnitFromTargets(targets: StackdriverQuery[]) { - let unit; - if (targets.length > 0 && targets.every(t => t.unit === targets[0].unit)) { - if (stackdriverUnitMappings.hasOwnProperty(targets[0].unit!)) { - // @ts-ignore - unit = stackdriverUnitMappings[targets[0].unit]; - } - } - return unit; - } - async query(options: DataQueryRequest): Promise { const result: DataQueryResponse[] = []; const data = await this.getTimeSeries(options); @@ -180,31 +69,27 @@ export default class StackdriverDatasource extends DataSourceApi { @@ -226,13 +111,53 @@ export default class StackdriverDatasource extends DataSourceApi) { + await this.ensureGCEDefaultProject(); + const queries = options.targets + .map(this.migrateQuery) + .filter(this.shouldRunQuery) + .map(q => this.prepareTimeSeriesQuery(q, options)); + + if (queries.length > 0) { + const { data } = await this.api.post({ + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries, + }); + return data; + } else { + return { results: [] }; + } + } + + async getLabels(metricType: string, refId: string, projectName: string, groupBys?: string[]) { + const response = await this.getTimeSeries({ + targets: [ + { + refId, + datasourceId: this.id, + queryType: QueryType.METRICS, + metricQuery: { + projectName: this.templateSrv.replace(projectName), + metricType: this.templateSrv.replace(metricType), + groupBys: this.interpolateGroupBys(groupBys || [], {}), + crossSeriesReducer: 'REDUCE_NONE', + view: 'HEADERS', + }, + }, + ], + range: this.timeSrv.timeRange(), + } as DataQueryRequest); + const result = response.results[refId]; + return result && result.meta ? result.meta.labels : {}; + } + async testDatasource() { let status, message; const defaultErrorMessage = 'Cannot connect to Stackdriver API'; try { await this.ensureGCEDefaultProject(); - const path = `v3/projects/${this.getDefaultProject()}/metricDescriptors`; - const response = await this.doRequest(`${this.baseUrl}${path}`); + const response = await this.api.test(this.getDefaultProject()); if (response.status === 200) { status = 'success'; message = 'Successfully queried the Stackdriver API.'; @@ -260,19 +185,15 @@ export default class StackdriverDatasource extends DataSourceApi { return data && data.results && data.results.getGCEDefaultProject && data.results.getGCEDefaultProject.meta @@ -284,44 +205,6 @@ export default class StackdriverDatasource extends DataSourceApi { - try { - if (!projectName) { - return []; - } + if (!projectName) { + return []; + } - const interpolatedProject = this.templateSrv.replace(projectName); - if (this.metricTypesCache[interpolatedProject]) { - return this.metricTypesCache[interpolatedProject]; - } - - const metricsApiPath = `v3/projects/${interpolatedProject}/metricDescriptors`; - const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`); - - this.metricTypesCache[interpolatedProject] = data.metricDescriptors.map((m: any) => { + return this.api.get(`${this.templateSrv.replace(projectName)}/metricDescriptors`, { + responseMap: (m: any) => { const [service] = m.type.split('/'); const [serviceShortName] = service.split('.'); m.service = service; @@ -360,27 +235,146 @@ export default class StackdriverDatasource extends DataSourceApi; } - async doRequest(url: string, maxRetries = 1): Promise { - return getBackendSrv() - .datasourceRequest({ - url: this.url + url, - method: 'GET', - }) - .catch((error: any) => { - if (maxRetries > 0) { - return this.doRequest(url, maxRetries - 1); - } + async getSLOServices(projectName: string): Promise>> { + return this.api.get(`${this.templateSrv.replace(projectName)}/services`, { + responseMap: ({ name }: { name: string }) => ({ + value: name.match(/([^\/]*)\/*$/)[1], + label: name.match(/([^\/]*)\/*$/)[1], + }), + }); + } - throw error; - }); + async getServiceLevelObjectives(projectName: string, serviceId: string): Promise>> { + let { projectName: p, serviceId: s } = this.interpolateProps({ projectName, serviceId }); + return this.api.get(`${p}/services/${s}/serviceLevelObjectives`, { + responseMap: ({ name, displayName, goal }: { name: string; displayName: string; goal: number }) => ({ + value: name.match(/([^\/]*)\/*$/)[1], + label: displayName, + goal, + }), + }); + } + + async getProjects() { + return this.api.get(`projects`, { + responseMap: ({ projectId, name }: { projectId: string; name: string }) => ({ + value: projectId, + label: name, + }), + baseUrl: `${this.instanceSettings.url!}/cloudresourcemanager/v1/`, + }); + } + + migrateQuery(query: StackdriverQuery): StackdriverQuery { + if (!query.hasOwnProperty('metricQuery')) { + const { hide, refId, datasource, key, queryType, maxLines, metric, ...rest } = query as any; + return { + refId, + hide, + queryType: QueryType.METRICS, + metricQuery: { + ...rest, + view: rest.view || 'FULL', + }, + }; + } + return query; + } + + interpolateProps(object: { [key: string]: any } = {}, scopedVars: ScopedVars = {}): { [key: string]: any } { + return Object.entries(object).reduce((acc, [key, value]) => { + return { + ...acc, + [key]: value && _.isString(value) ? this.templateSrv.replace(value, scopedVars) : value, + }; + }, {}); + } + + shouldRunQuery(query: StackdriverQuery): boolean { + if (query.hide) { + return false; + } + + if (query.queryType && query.queryType === QueryType.SLO) { + const { selectorName, serviceId, sloId, projectName } = query.sloQuery; + return !!selectorName && !!serviceId && !!sloId && !!projectName; + } + + const { metricType } = query.metricQuery; + + return !!metricType; + } + + prepareTimeSeriesQuery( + { metricQuery, refId, queryType, sloQuery }: StackdriverQuery, + { scopedVars, intervalMs }: DataQueryRequest + ) { + return { + datasourceId: this.id, + refId, + queryType, + intervalMs: intervalMs, + type: 'timeSeriesQuery', + metricQuery: { + ...this.interpolateProps(metricQuery, scopedVars), + projectName: this.templateSrv.replace( + metricQuery.projectName ? metricQuery.projectName : this.getDefaultProject() + ), + filters: this.interpolateFilters(metricQuery.filters || [], scopedVars), + groupBys: this.interpolateGroupBys(metricQuery.groupBys || [], scopedVars), + view: metricQuery.view || 'FULL', + }, + sloQuery: this.interpolateProps(sloQuery, scopedVars), + }; + } + + interpolateFilters(filters: string[], scopedVars: ScopedVars) { + const completeFilter = _.chunk(filters, 4) + .map(([key, operator, value, condition]) => ({ + key, + operator, + value, + ...(condition && { condition }), + })) + .reduce((res, filter) => (filter.value ? [...res, filter] : res), []); + + const filterArray = _.flatten( + completeFilter.map(({ key, operator, value, condition }: Filter) => [ + this.templateSrv.replace(key, scopedVars || {}), + operator, + this.templateSrv.replace(value, scopedVars || {}, 'regex'), + ...(condition ? [condition] : []), + ]) + ); + + return filterArray || []; + } + + interpolateGroupBys(groupBys: string[], scopedVars: {}): string[] { + let interpolatedGroupBys: string[] = []; + (groupBys || []).forEach(gb => { + const interpolated = this.templateSrv.replace(gb, scopedVars || {}, 'csv').split(','); + if (Array.isArray(interpolated)) { + interpolatedGroupBys = interpolatedGroupBys.concat(interpolated); + } else { + interpolatedGroupBys.push(interpolated); + } + }); + return interpolatedGroupBys; + } + + resolvePanelUnitFromTargets(targets: any) { + let unit; + if (targets.length > 0 && targets.every((t: any) => t.unit === targets[0].unit)) { + if (stackdriverUnitMappings.hasOwnProperty(targets[0].unit!)) { + // @ts-ignore + unit = stackdriverUnitMappings[targets[0].unit]; + } + } + return unit; } } diff --git a/public/app/plugins/datasource/stackdriver/functions.ts b/public/app/plugins/datasource/stackdriver/functions.ts index ddb4b3c6e22..2679cdae408 100644 --- a/public/app/plugins/datasource/stackdriver/functions.ts +++ b/public/app/plugins/datasource/stackdriver/functions.ts @@ -3,7 +3,7 @@ import { alignOptions, aggOptions, ValueTypes, MetricKind, systemLabels } from ' import { SelectableValue } from '@grafana/data'; import StackdriverDatasource from './datasource'; import { TemplateSrv } from 'app/features/templating/template_srv'; -import { StackdriverQuery, MetricDescriptor } from './types'; +import { MetricDescriptor, Filter, MetricQuery } from './types'; export const extractServicesFromMetricDescriptors = (metricDescriptors: MetricDescriptor[]) => _.uniqBy(metricDescriptors, 'service'); @@ -61,7 +61,7 @@ export const getLabelKeys = async ( }; export const getAlignmentPickerData = ( - { valueType, metricKind, perSeriesAligner }: Partial, + { valueType, metricKind, perSeriesAligner }: Partial, templateSrv: TemplateSrv ) => { const alignOptions = getAlignmentOptionsByMetric(valueType!, metricKind!).map(option => ({ @@ -92,4 +92,36 @@ export const labelsToGroupedOptions = (groupBys: string[]) => { return Object.entries(groups).map(([label, options]) => ({ label, options, expanded: true }), []); }; +export const filtersToStringArray = (filters: Filter[]) => { + const strArr = _.flatten(filters.map(({ key, operator, value, condition }) => [key, operator, value, condition])); + return strArr.filter((_, i) => i !== strArr.length - 1); +}; + +export const stringArrayToFilters = (filterArray: string[]) => + _.chunk(filterArray, 4).map(([key, operator, value, condition = 'AND']) => ({ + key, + operator, + value, + condition, + })); + export const toOption = (value: string) => ({ label: value, value } as SelectableValue); + +export const formatStackdriverError = (error: any) => { + let message = error.statusText ?? ''; + if (error.data && error.data.error) { + try { + const res = JSON.parse(error.data.error); + message += res.error.code + '. ' + res.error.message; + } catch (err) { + message += error.data.error; + } + } else if (error.data && error.data.message) { + try { + message = JSON.parse(error.data.message).error.message; + } catch (err) { + error.error; + } + } + return message; +}; diff --git a/public/app/plugins/datasource/stackdriver/module.ts b/public/app/plugins/datasource/stackdriver/module.ts index 1b81d29af73..eccc0bd4800 100644 --- a/public/app/plugins/datasource/stackdriver/module.ts +++ b/public/app/plugins/datasource/stackdriver/module.ts @@ -1,13 +1,13 @@ +import { DataSourcePlugin } from '@grafana/data'; import StackdriverDatasource from './datasource'; -import { StackdriverQueryCtrl } from './query_ctrl'; +import { QueryEditor } from './components/QueryEditor'; import { StackdriverConfigCtrl } from './config_ctrl'; import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl'; import { StackdriverVariableQueryEditor } from './components/VariableQueryEditor'; +import { StackdriverQuery } from './types'; -export { - StackdriverDatasource as Datasource, - StackdriverQueryCtrl as QueryCtrl, - StackdriverConfigCtrl as ConfigCtrl, - StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl, - StackdriverVariableQueryEditor as VariableQueryEditor, -}; +export const plugin = new DataSourcePlugin(StackdriverDatasource) + .setQueryEditor(QueryEditor) + .setConfigCtrl(StackdriverConfigCtrl) + .setAnnotationQueryCtrl(StackdriverAnnotationsQueryCtrl) + .setVariableQueryEditor(StackdriverVariableQueryEditor); diff --git a/public/app/plugins/datasource/stackdriver/partials/query.editor.html b/public/app/plugins/datasource/stackdriver/partials/query.editor.html deleted file mode 100644 index 520c3d20f29..00000000000 --- a/public/app/plugins/datasource/stackdriver/partials/query.editor.html +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/public/app/plugins/datasource/stackdriver/query_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_ctrl.ts deleted file mode 100644 index 10b8098f6ed..00000000000 --- a/public/app/plugins/datasource/stackdriver/query_ctrl.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { QueryCtrl } from 'app/plugins/sdk'; -import { StackdriverQuery } from './types'; -import { TemplateSrv } from 'app/features/templating/template_srv'; -import { auto } from 'angular'; - -export class StackdriverQueryCtrl extends QueryCtrl { - static templateUrl = 'partials/query.editor.html'; - templateSrv: TemplateSrv; - - /** @ngInject */ - constructor($scope: any, $injector: auto.IInjectorService, templateSrv: TemplateSrv) { - super($scope, $injector); - this.templateSrv = templateSrv; - this.onQueryChange = this.onQueryChange.bind(this); - this.onExecuteQuery = this.onExecuteQuery.bind(this); - } - - onQueryChange(target: StackdriverQuery) { - Object.assign(this.target, target); - } - - onExecuteQuery() { - this.$scope.ctrl.refresh(); - } -} diff --git a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts index e827d71ae17..c1bf1124dcf 100644 --- a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts +++ b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts @@ -3,7 +3,7 @@ import { metricDescriptors } from './testData'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { CustomVariable } from 'app/features/templating/all'; import { DataSourceInstanceSettings, toUtc } from '@grafana/data'; -import { StackdriverOptions, StackdriverQuery } from '../types'; +import { StackdriverOptions } from '../types'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -180,7 +180,7 @@ describe('StackdriverDataSource', () => { }); it('should replace the variable with the value', () => { - expect(interpolated.length).toBe(4); + expect(interpolated.length).toBe(3); expect(interpolated[2]).toBe('filtervalue1'); }); }); @@ -193,7 +193,7 @@ describe('StackdriverDataSource', () => { }); it('should replace the variable with the value and not with regex formatting', () => { - expect(interpolated.length).toBe(4); + expect(interpolated.length).toBe(3); expect(interpolated[0]).toBe('resource.label.zone'); }); }); @@ -250,7 +250,7 @@ describe('StackdriverDataSource', () => { describe('when theres only one target', () => { describe('and the stackdriver unit doesnt have a corresponding grafana unit', () => { beforeEach(() => { - res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }] as StackdriverQuery[]); + res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }]); }); it('should return undefined', () => { expect(res).toBeUndefined(); @@ -258,7 +258,7 @@ describe('StackdriverDataSource', () => { }); describe('and the stackdriver unit has a corresponding grafana unit', () => { beforeEach(() => { - res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }] as StackdriverQuery[]); + res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }]); }); it('should return bits', () => { expect(res).toEqual('bits'); @@ -269,7 +269,7 @@ describe('StackdriverDataSource', () => { describe('when theres more than one target', () => { describe('and all target units are the same', () => { beforeEach(() => { - res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'bit' }] as StackdriverQuery[]); + res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'bit' }]); }); it('should return bits', () => { expect(res).toEqual('bits'); @@ -277,10 +277,7 @@ describe('StackdriverDataSource', () => { }); describe('and all target units are the same but doesnt have grafana mappings', () => { beforeEach(() => { - res = ds.resolvePanelUnitFromTargets([ - { unit: 'megaseconds' }, - { unit: 'megaseconds' }, - ] as StackdriverQuery[]); + res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }, { unit: 'megaseconds' }]); }); it('should return the default value of undefined', () => { expect(res).toBeUndefined(); @@ -288,7 +285,7 @@ describe('StackdriverDataSource', () => { }); describe('and all target units are not the same', () => { beforeEach(() => { - res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'min' }] as StackdriverQuery[]); + res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'min' }]); }); it('should return the default value of undefined', () => { expect(res).toBeUndefined(); diff --git a/public/app/plugins/datasource/stackdriver/types.ts b/public/app/plugins/datasource/stackdriver/types.ts index 98837468961..8708476e276 100644 --- a/public/app/plugins/datasource/stackdriver/types.ts +++ b/public/app/plugins/datasource/stackdriver/types.ts @@ -21,6 +21,9 @@ export enum MetricFindQueryTypes { Aggregations = 'aggregations', Aligners = 'aligners', AlignmentPeriods = 'alignmentPeriods', + Selectors = 'selectors', + SLOServices = 'sloServices', + SLO = 'slo', } export interface VariableQueryData { @@ -28,32 +31,60 @@ export interface VariableQueryData { metricDescriptors: MetricDescriptor[]; selectedService: string; selectedMetricType: string; + selectedSLOService: string; labels: string[]; labelKey: string; metricTypes: Array<{ value: string; name: string }>; services: Array<{ value: string; name: string }>; projects: Array<{ value: string; name: string }>; + sloServices: Array<{ value: string; name: string }>; projectName: string; } -export interface StackdriverQuery extends DataQuery { +export enum QueryType { + METRICS = 'metrics', + SLO = 'slo', +} + +export const queryTypes = [ + { label: 'Metrics', value: QueryType.METRICS }, + { label: 'Service Level Objectives (SLO)', value: QueryType.SLO }, +]; + +export interface MetricQuery { projectName: string; unit?: string; metricType: string; - service?: string; - refId: string; crossSeriesReducer: string; alignmentPeriod?: string; - perSeriesAligner: string; + perSeriesAligner?: string; groupBys?: string[]; filters?: string[]; aliasBy?: string; - metricKind: string; - valueType: string; - datasourceId?: number; + metricKind?: string; + valueType?: string; view?: string; } +export interface SLOQuery { + projectName: string; + alignmentPeriod?: string; + perSeriesAligner?: string; + aliasBy?: string; + selectorName: string; + serviceId: string; + sloId: string; + goal?: number; +} + +export interface StackdriverQuery extends DataQuery { + datasourceId?: number; + refId: string; + queryType: QueryType; + metricQuery: MetricQuery; + sloQuery?: SLOQuery; +} + export interface StackdriverOptions extends DataSourceJsonData { defaultProject?: string; gceDefaultProject?: string; @@ -100,5 +131,5 @@ export interface Filter { key: string; operator: string; value: string; - condition: string; + condition?: string; }