stackdriver: better error handling and show query metadata

If the Stackdriver returns an error, show that error in the query
editor. Also, allow the user to see the raw querystring that was sent
to google (for troubleshooting).
This commit is contained in:
Daniel Lee 2018-09-11 22:41:24 +02:00
parent 2683699ab4
commit 0b5783563e
7 changed files with 218 additions and 117 deletions

View File

@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/api/pluginproxy"
"github.com/grafana/grafana/pkg/components/null"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
@ -28,7 +29,8 @@ var slog log.Logger
// StackdriverExecutor executes queries for the Stackdriver datasource
type StackdriverExecutor struct {
HTTPClient *http.Client
httpClient *http.Client
dsInfo *models.DataSource
}
// NewStackdriverExecutor initializes a http client
@ -39,7 +41,8 @@ func NewStackdriverExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint,
}
return &StackdriverExecutor{
HTTPClient: httpClient,
httpClient: httpClient,
dsInfo: dsInfo,
}, nil
}
@ -62,44 +65,7 @@ func (e *StackdriverExecutor) Query(ctx context.Context, dsInfo *models.DataSour
}
for _, query := range queries {
req, err := e.createRequest(ctx, dsInfo)
if err != nil {
return nil, err
}
req.URL.RawQuery = query.Params.Encode()
slog.Info("tsdbQuery", "req.URL.RawQuery", req.URL.RawQuery)
httpClient, err := dsInfo.GetHttpClient()
if err != nil {
return nil, err
}
span, ctx := opentracing.StartSpanFromContext(ctx, "stackdriver query")
span.SetTag("target", query.Target)
span.SetTag("from", tsdbQuery.TimeRange.From)
span.SetTag("until", tsdbQuery.TimeRange.To)
span.SetTag("datasource_id", dsInfo.Id)
span.SetTag("org_id", dsInfo.OrgId)
defer span.Finish()
opentracing.GlobalTracer().Inject(
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header))
res, err := ctxhttp.Do(ctx, httpClient, req)
if err != nil {
return nil, err
}
data, err := e.unmarshalResponse(res)
if err != nil {
return nil, err
}
queryRes, err := e.parseResponse(data, query.RefID)
queryRes, err := e.executeQuery(ctx, query, tsdbQuery)
if err != nil {
return nil, err
}
@ -153,6 +119,53 @@ func (e *StackdriverExecutor) parseQueries(tsdbQuery *tsdb.TsdbQuery) ([]*Stackd
return stackdriverQueries, nil
}
func (e *StackdriverExecutor) executeQuery(ctx context.Context, query *StackdriverQuery, tsdbQuery *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID}
req, err := e.createRequest(ctx, e.dsInfo)
if err != nil {
queryResult.Error = err
return queryResult, nil
}
req.URL.RawQuery = query.Params.Encode()
queryResult.Meta.Set("rawQuery", req.URL.RawQuery)
span, ctx := opentracing.StartSpanFromContext(ctx, "stackdriver query")
span.SetTag("target", query.Target)
span.SetTag("from", tsdbQuery.TimeRange.From)
span.SetTag("until", tsdbQuery.TimeRange.To)
span.SetTag("datasource_id", e.dsInfo.Id)
span.SetTag("org_id", e.dsInfo.OrgId)
defer span.Finish()
opentracing.GlobalTracer().Inject(
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header))
res, err := ctxhttp.Do(ctx, e.httpClient, req)
if err != nil {
queryResult.Error = err
return queryResult, nil
}
data, err := e.unmarshalResponse(res)
if err != nil {
queryResult.Error = err
return queryResult, nil
}
err = e.parseResponse(queryResult, data)
if err != nil {
queryResult.Error = err
return queryResult, nil
}
return queryResult, nil
}
func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (StackDriverResponse, error) {
body, err := ioutil.ReadAll(res.Body)
defer res.Body.Close()
@ -161,24 +174,21 @@ func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (StackDriver
}
if res.StatusCode/100 != 2 {
slog.Info("Request failed", "status", res.Status, "body", string(body))
return StackDriverResponse{}, fmt.Errorf("Request failed status: %v", res.Status)
slog.Error("Request failed", "status", res.Status, "body", string(body))
return StackDriverResponse{}, fmt.Errorf(string(body))
}
var data StackDriverResponse
err = json.Unmarshal(body, &data)
if err != nil {
slog.Info("Failed to unmarshal Stackdriver response", "error", err, "status", res.Status, "body", string(body))
slog.Error("Failed to unmarshal Stackdriver response", "error", err, "status", res.Status, "body", string(body))
return StackDriverResponse{}, err
}
return data, nil
}
func (e *StackdriverExecutor) parseResponse(data StackDriverResponse, queryRefID string) (*tsdb.QueryResult, error) {
queryRes := tsdb.NewQueryResult()
queryRes.RefId = queryRefID
func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data StackDriverResponse) error {
for _, series := range data.TimeSeries {
points := make([]tsdb.TimePoint, 0)
for _, point := range series.Points {
@ -195,7 +205,7 @@ func (e *StackdriverExecutor) parseResponse(data StackDriverResponse, queryRefID
})
}
return queryRes, nil
return nil
}
func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models.DataSource) (*http.Request, error) {
@ -227,7 +237,7 @@ func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models.
pluginproxy.ApplyRoute(ctx, req, proxyPass, stackdriverRoute, dsInfo)
return req, err
return req, nil
}
func fixIntervalFormat(target string) string {

View File

@ -28,7 +28,7 @@ func TestStackdriver(t *testing.T) {
{
Model: simplejson.NewFromAny(map[string]interface{}{
"target": "target",
"metricType": "time_series",
"metricType": "a/metric/type",
}),
RefId: "A",
},
@ -44,52 +44,56 @@ func TestStackdriver(t *testing.T) {
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_NONE")
So(queries[0].Params["filter"][0], ShouldEqual, "time_series")
So(queries[0].Params["filter"][0], ShouldEqual, "a/metric/type")
})
Convey("Parse stackdriver response for data aggregated to one time series", func() {
var data StackDriverResponse
Convey("Parse stackdriver response in the time series format", func() {
Convey("when data from query aggregated to one time series", func() {
var data StackDriverResponse
jsonBody, err := ioutil.ReadFile("./test-data/1-series-response-agg-one-metric.json")
So(err, ShouldBeNil)
err = json.Unmarshal(jsonBody, &data)
So(err, ShouldBeNil)
So(len(data.TimeSeries), ShouldEqual, 1)
jsonBody, err := ioutil.ReadFile("./test-data/1-series-response-agg-one-metric.json")
So(err, ShouldBeNil)
err = json.Unmarshal(jsonBody, &data)
So(err, ShouldBeNil)
So(len(data.TimeSeries), ShouldEqual, 1)
res, err := executor.parseResponse(data, "A")
So(err, ShouldBeNil)
So(len(res.Series), ShouldEqual, 1)
So(res.Series[0].Name, ShouldEqual, "serviceruntime.googleapis.com/api/request_count")
So(len(res.Series[0].Points), ShouldEqual, 3)
So(res.Series[0].Points[0][0].Float64, ShouldEqual, 1.0666666666667)
So(res.Series[0].Points[1][0].Float64, ShouldEqual, 1.05)
So(res.Series[0].Points[2][0].Float64, ShouldEqual, 0.05)
})
Convey("Parse stackdriver response for data with no aggregation", func() {
var data StackDriverResponse
jsonBody, err := ioutil.ReadFile("./test-data/2-series-response-no-agg.json")
So(err, ShouldBeNil)
err = json.Unmarshal(jsonBody, &data)
So(err, ShouldBeNil)
So(len(data.TimeSeries), ShouldEqual, 3)
res, err := executor.parseResponse(data, "A")
So(err, ShouldBeNil)
Convey("Should add labels to metric name", func() {
So(len(res.Series), ShouldEqual, 3)
So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-asia-east-1")
So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-europe-west-1")
So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-us-east-1")
res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
err = executor.parseResponse(res, data)
So(err, ShouldBeNil)
So(len(res.Series), ShouldEqual, 1)
So(res.Series[0].Name, ShouldEqual, "serviceruntime.googleapis.com/api/request_count")
So(len(res.Series[0].Points), ShouldEqual, 3)
So(res.Series[0].Points[0][0].Float64, ShouldEqual, 9.7730520330369)
So(res.Series[0].Points[1][0].Float64, ShouldEqual, 9.7323568146676)
So(res.Series[0].Points[2][0].Float64, ShouldEqual, 9.8566497180145)
So(res.Series[0].Points[0][0].Float64, ShouldEqual, 1.0666666666667)
So(res.Series[0].Points[1][0].Float64, ShouldEqual, 1.05)
So(res.Series[0].Points[2][0].Float64, ShouldEqual, 0.05)
})
Convey("when data from query with no aggregation", func() {
var data StackDriverResponse
jsonBody, err := ioutil.ReadFile("./test-data/2-series-response-no-agg.json")
So(err, ShouldBeNil)
err = json.Unmarshal(jsonBody, &data)
So(err, ShouldBeNil)
So(len(data.TimeSeries), ShouldEqual, 3)
res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
err = executor.parseResponse(res, data)
So(err, ShouldBeNil)
Convey("Should add labels to metric name", func() {
So(len(res.Series), ShouldEqual, 3)
So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-asia-east-1")
So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-europe-west-1")
So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-us-east-1")
So(len(res.Series[0].Points), ShouldEqual, 3)
So(res.Series[0].Points[0][0].Float64, ShouldEqual, 9.7730520330369)
So(res.Series[0].Points[1][0].Float64, ShouldEqual, 9.7323568146676)
So(res.Series[0].Points[2][0].Float64, ShouldEqual, 9.8566497180145)
})
})
})
})

View File

@ -20,26 +20,27 @@ export default class StackdriverDatasource {
const result = [];
try {
const { data } = await this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries,
},
});
const { data } = await this.backendSrv.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries,
},
});
if (data.results) {
Object['values'](data.results).forEach(queryRes => {
queryRes.series.forEach(series => {
result.push({ target: series.name, datapoints: series.points });
if (data.results) {
Object['values'](data.results).forEach(queryRes => {
queryRes.series.forEach(series => {
result.push({
target: series.name,
datapoints: series.points,
refId: queryRes.refId,
meta: queryRes.meta,
});
});
}
} catch (error) {
console.log(error);
});
}
return { data: result };

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -2,15 +2,49 @@
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Project</span>
<input class="gf-form-input" disabled type="text" ng-model='ctrl.target.project.name' get-options="ctrl.getProjects()" css-class="min-width-12"
/>
<input class="gf-form-input" disabled type="text" ng-model='ctrl.target.project.name' get-options="ctrl.getProjects()"
css-class="min-width-12" />
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Metric Type</span>
<gf-form-dropdown model="ctrl.target.metricType" get-options="ctrl.getMetricTypes($query)" class="gf-form-input" disabled
type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.refresh()"></gf-form-dropdown>
<gf-form-dropdown model="ctrl.target.metricType" get-options="ctrl.getMetricTypes($query)" class="min-width-20"
disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.refresh()"></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</query-editor-row>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuery = !ctrl.showLastQuery">
Raw Query
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuery"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuery"></i>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuery">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.rawQueryString}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info">
Help text for aliasing
</pre>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>

View File

@ -3,15 +3,23 @@
"type": "datasource",
"id": "stackdriver",
"metrics": true,
"alerting": false,
"alerting": true,
"annotations": false,
"queryOptions": {
"maxDataPoints": true,
"cacheTimeout": true
},
"info": {
"description": "Data Source for Stackdriver",
"version": "1.0.0"
"description": "Google Stackdriver Datasource for Grafana",
"version": "1.0.0",
"logos": {
"small": "img/stackdriver_logo.png",
"large": "img/stackdriver_logo.png"
},
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
}
},
"routes": [
{

View File

@ -2,6 +2,10 @@ import _ from 'lodash';
import { QueryCtrl } from 'app/plugins/sdk';
import appEvents from 'app/core/app_events';
export interface QueryMeta {
rawQuery: string;
rawQueryString: string;
}
export class StackdriverQueryCtrl extends QueryCtrl {
static templateUrl = 'partials/query.editor.html';
target: {
@ -10,21 +14,31 @@ export class StackdriverQueryCtrl extends QueryCtrl {
name: string;
};
metricType: string;
refId: string;
};
defaultDropdownValue = 'select';
defaultDropdownValue = 'Select metric';
defaults = {
project: {
id: 'default',
name: 'loading project...',
},
metricType: this.defaultDropdownValue,
// metricType: this.defaultDropdownValue,
};
showHelp: boolean;
showLastQuery: boolean;
lastQueryMeta: QueryMeta;
lastQueryError?: string;
/** @ngInject */
constructor($scope, $injector) {
super($scope, $injector);
_.defaultsDeep(this.target, this.defaults);
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
this.getCurrentProject().then(this.getMetricTypes.bind(this));
}
@ -67,4 +81,34 @@ export class StackdriverQueryCtrl extends QueryCtrl {
return [];
}
}
onDataReceived(dataList) {
this.lastQueryError = null;
this.lastQueryMeta = null;
const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId });
if (anySeriesFromQuery) {
this.lastQueryMeta = anySeriesFromQuery.meta;
this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
}
}
onDataError(err) {
if (err.data && err.data.results) {
const queryRes = err.data.results[this.target.refId];
if (queryRes) {
this.lastQueryMeta = queryRes.meta;
this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
let jsonBody;
try {
jsonBody = JSON.parse(queryRes.error);
} catch {
this.lastQueryError = queryRes.error;
}
this.lastQueryError = jsonBody.error.message;
}
}
}
}