Azure Monitor: Log Analytics response to data frames (#25297)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Kyle Brandt
2020-06-05 12:32:10 -04:00
committed by GitHub
co-authored by Ryan McKinley
parent c3549f845e
commit ef61a64c46
13 changed files with 594 additions and 673 deletions
@@ -4,7 +4,6 @@ import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -12,10 +11,9 @@ import (
"net/http"
"net/url"
"path"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"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/models"
"github.com/grafana/grafana/pkg/plugins"
@@ -58,11 +56,7 @@ func (e *AzureLogAnalyticsDatasource) executeTimeSeriesQuery(ctx context.Context
}
for _, query := range queries {
queryRes, err := e.executeQuery(ctx, query, originalQueries, timeRange)
if err != nil {
queryRes.Error = err
}
result.Results[query.RefID] = queryRes
result.Results[query.RefID] = e.executeQuery(ctx, query, originalQueries, timeRange)
}
return result, nil
@@ -115,13 +109,17 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []*tsdb.Query, timeRa
return azureLogAnalyticsQueries, nil
}
func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *AzureLogAnalyticsQuery, queries []*tsdb.Query, timeRange *tsdb.TimeRange) (*tsdb.QueryResult, error) {
func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *AzureLogAnalyticsQuery, queries []*tsdb.Query, timeRange *tsdb.TimeRange) *tsdb.QueryResult {
queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID}
queryResultError := func(err error) *tsdb.QueryResult {
queryResult.Error = err
return queryResult
}
req, err := e.createRequest(ctx, e.dsInfo)
if err != nil {
queryResult.Error = err
return queryResult, nil
return queryResultError(err)
}
req.URL.Path = path.Join(req.URL.Path, query.URL)
@@ -140,38 +138,52 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header)); err != nil {
queryResult.Error = err
return queryResult, nil
return queryResultError(err)
}
azlog.Debug("AzureLogAnalytics", "Request ApiURL", req.URL.String())
res, err := ctxhttp.Do(ctx, e.httpClient, req)
if err != nil {
queryResult.Error = err
return queryResult, nil
return queryResultError(err)
}
data, err := e.unmarshalResponse(res)
logResponse, err := e.unmarshalResponse(res)
if err != nil {
queryResult.Error = err
return queryResult, nil
return queryResultError(err)
}
azlog.Debug("AzureLogsAnalytics", "Response", queryResult)
if query.ResultFormat == "table" {
queryResult.Tables, queryResult.Meta, err = e.parseToTables(data, query.Model, query.Params)
if err != nil {
return nil, err
}
} else {
queryResult.Series, queryResult.Meta, err = e.parseToTimeSeries(data, query.Model, query.Params)
if err != nil {
return nil, err
}
t, err := logResponse.GetPrimaryResultTable()
if err != nil {
return queryResultError(err)
}
return queryResult, nil
frame, err := LogTableToFrame(t)
if err != nil {
return queryResultError(err)
}
setAdditionalFrameMeta(frame,
query.Params.Get("query"),
query.Model.Get("subscriptionId").MustString(),
query.Model.Get("azureLogAnalytics").Get("workspace").MustString())
if query.ResultFormat == "time_series" {
tsSchema := frame.TimeSeriesSchema()
if tsSchema.Type == data.TimeSeriesTypeLong {
wideFrame, err := data.LongToWide(frame, &data.FillMissing{})
if err == nil {
frame = wideFrame
} else {
frame.AppendNotices(data.Notice{Severity: data.NoticeSeverityWarning, Text: "could not convert frame to time series, returning raw table: " + err.Error()})
}
}
}
frames := data.Frames{frame}
queryResult.Dataframes, err = frames.MarshalArrow()
if err != nil {
return queryResultError(err)
}
return queryResult
}
func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo *models.DataSource) (*http.Request, error) {
@@ -225,6 +237,17 @@ func (e *AzureLogAnalyticsDatasource) getPluginRoute(plugin *plugins.DataSourceP
return logAnalyticsRoute, pluginRouteName, nil
}
// GetPrimaryResultTable returns the first table in the response named "PrimaryResult", or an
// error if there is no table by that name.
func (ar *AzureLogAnalyticsResponse) GetPrimaryResultTable() (*AzureLogAnalyticsTable, error) {
for _, t := range ar.Tables {
if t.Name == "PrimaryResult" {
return &t, nil
}
}
return nil, fmt.Errorf("no data as PrimaryResult table is missing from the the response")
}
func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (AzureLogAnalyticsResponse, error) {
body, err := ioutil.ReadAll(res.Body)
defer res.Body.Close()
@@ -239,7 +262,9 @@ func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (Azu
}
var data AzureLogAnalyticsResponse
err = json.Unmarshal(body, &data)
d := json.NewDecoder(bytes.NewReader(body))
d.UseNumber()
err = d.Decode(&data)
if err != nil {
azlog.Debug("Failed to unmarshal Azure Log Analytics response", "error", err, "status", res.Status, "body", string(body))
return AzureLogAnalyticsResponse{}, err
@@ -248,153 +273,29 @@ func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (Azu
return data, nil
}
func (e *AzureLogAnalyticsDatasource) parseToTables(data AzureLogAnalyticsResponse, model *simplejson.Json, params url.Values) ([]*tsdb.Table, *simplejson.Json, error) {
meta, err := createMetadata(model, params)
if err != nil {
return nil, simplejson.NewFromAny(meta), err
func setAdditionalFrameMeta(frame *data.Frame, query, subscriptionID, workspace string) {
frame.Meta.ExecutedQueryString = query
frame.Meta.Custom["subscription"] = subscriptionID
frame.Meta.Custom["workspace"] = workspace
encodedQuery, err := encodeQuery(query)
if err == nil {
frame.Meta.Custom["encodedQuery"] = encodedQuery
return
}
tables := make([]*tsdb.Table, 0)
for _, t := range data.Tables {
if t.Name == "PrimaryResult" {
table := tsdb.Table{
Columns: make([]tsdb.TableColumn, 0),
Rows: make([]tsdb.RowValues, 0),
}
meta.Columns = make([]column, 0)
for _, v := range t.Columns {
meta.Columns = append(meta.Columns, column{Name: v.Name, Type: v.Type})
table.Columns = append(table.Columns, tsdb.TableColumn{Text: v.Name})
}
for _, r := range t.Rows {
values := make([]interface{}, len(table.Columns))
for i := 0; i < len(table.Columns); i++ {
values[i] = r[i]
}
table.Rows = append(table.Rows, values)
}
tables = append(tables, &table)
return tables, simplejson.NewFromAny(meta), nil
}
}
return nil, nil, errors.New("no data as no PrimaryResult table was returned in the response")
azlog.Error("failed to encode the query into the encodedQuery property")
}
func (e *AzureLogAnalyticsDatasource) parseToTimeSeries(data AzureLogAnalyticsResponse, model *simplejson.Json, params url.Values) (tsdb.TimeSeriesSlice, *simplejson.Json, error) {
meta, err := createMetadata(model, params)
if err != nil {
return nil, simplejson.NewFromAny(meta), err
}
for _, t := range data.Tables {
if t.Name == "PrimaryResult" {
timeIndex, metricIndex, valueIndex := -1, -1, -1
meta.Columns = make([]column, 0)
for i, v := range t.Columns {
meta.Columns = append(meta.Columns, column{Name: v.Name, Type: v.Type})
if timeIndex == -1 && v.Type == "datetime" {
timeIndex = i
}
if metricIndex == -1 && v.Type == "string" {
metricIndex = i
}
if valueIndex == -1 && (v.Type == "int" || v.Type == "long" || v.Type == "real" || v.Type == "double") {
valueIndex = i
}
}
if timeIndex == -1 {
azlog.Info("No time column specified. Returning existing columns, no data")
return nil, simplejson.NewFromAny(meta), nil
}
if valueIndex == -1 {
azlog.Info("No value column specified. Returning existing columns, no data")
return nil, simplejson.NewFromAny(meta), nil
}
slice := tsdb.TimeSeriesSlice{}
buckets := map[string]*tsdb.TimeSeriesPoints{}
getSeriesBucket := func(metricName string) *tsdb.TimeSeriesPoints {
if points, ok := buckets[metricName]; ok {
return points
}
series := tsdb.NewTimeSeries(metricName, []tsdb.TimePoint{})
slice = append(slice, series)
buckets[metricName] = &series.Points
return &series.Points
}
for _, r := range t.Rows {
timeStr, ok := r[timeIndex].(string)
if !ok {
return nil, simplejson.NewFromAny(meta), errors.New("invalid time value")
}
timeValue, err := time.Parse(time.RFC3339Nano, timeStr)
if err != nil {
return nil, simplejson.NewFromAny(meta), err
}
var value float64
if value, err = getFloat(r[valueIndex]); err != nil {
return nil, simplejson.NewFromAny(meta), err
}
var metricName string
if metricIndex == -1 {
metricName = t.Columns[valueIndex].Name
} else {
metricName, ok = r[metricIndex].(string)
if !ok {
return nil, simplejson.NewFromAny(meta), err
}
}
points := getSeriesBucket(metricName)
*points = append(*points, tsdb.NewTimePoint(null.FloatFrom(value), float64(timeValue.Unix()*1000)))
}
return slice, simplejson.NewFromAny(meta), nil
}
}
return nil, nil, errors.New("no data as no PrimaryResult table was returned in the response")
}
func createMetadata(model *simplejson.Json, params url.Values) (metadata, error) {
meta := metadata{
Query: params.Get("query"),
Subscription: model.Get("subscriptionId").MustString(),
Workspace: model.Get("azureLogAnalytics").Get("workspace").MustString(),
}
encQuery, err := encodeQuery(meta.Query)
if err != nil {
return meta, err
}
meta.EncodedQuery = encQuery
return meta, nil
}
func encodeQuery(rawQuery string) (string, error) {
// encodeQuery encodes the query in gzip so the frontend can build links.
func encodeQuery(rawQuery string) ([]byte, error) {
var b bytes.Buffer
gz := gzip.NewWriter(&b)
if _, err := gz.Write([]byte(rawQuery)); err != nil {
return "", err
return nil, err
}
if err := gz.Close(); err != nil {
return "", err
return nil, err
}
return base64.StdEncoding.EncodeToString(b.Bytes()), nil
return b.Bytes(), nil
}
@@ -1,17 +1,13 @@
package azuremonitor
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/grafana/pkg/components/null"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
@@ -83,235 +79,6 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) {
}
}
func TestParsingAzureLogAnalyticsResponses(t *testing.T) {
datasource := &AzureLogAnalyticsDatasource{}
tests := []struct {
name string
testFile string
query string
series tsdb.TimeSeriesSlice
meta string
Err require.ErrorAssertionFunc
}{
{
name: "Response with single series should be parsed into the Grafana time series format",
testFile: "loganalytics/1-log-analytics-response-metrics-single-series.json",
query: "test query",
series: tsdb.TimeSeriesSlice{
&tsdb.TimeSeries{
Name: "grafana-vm",
Points: tsdb.TimeSeriesPoints{
{null.FloatFrom(1.1), null.FloatFrom(1587323766000)},
{null.FloatFrom(2.2), null.FloatFrom(1587323776000)},
{null.FloatFrom(3.3), null.FloatFrom(1587323786000)},
},
},
},
meta: `{"columns":[{"name":"TimeGenerated","type":"datetime"},{"name":"Computer","type":"string"},{"name":"avg_CounterValue","type":"real"}],"subscription":"1234","workspace":"aworkspace","query":"test query","encodedQuery":"H4sIAAAAAAAA/ypJLS5RKCxNLaoEBAAA///0rBfVCgAAAA=="}`,
Err: require.NoError,
},
{
name: "Response with multiple series should be parsed into the Grafana time series format",
testFile: "loganalytics/2-log-analytics-response-metrics-multiple-series.json",
query: "test query",
series: tsdb.TimeSeriesSlice{
&tsdb.TimeSeries{
Name: "Processor",
Points: tsdb.TimeSeriesPoints{
{null.FloatFrom(0.75), null.FloatFrom(1587418800000)},
{null.FloatFrom(1.0055555555555555), null.FloatFrom(1587419100000)},
{null.FloatFrom(0.7407407407407407), null.FloatFrom(1587419400000)},
},
},
&tsdb.TimeSeries{
Name: "Logical Disk",
Points: tsdb.TimeSeriesPoints{
{null.FloatFrom(16090.551851851851), null.FloatFrom(1587418800000)},
{null.FloatFrom(16090.537037037036), null.FloatFrom(1587419100000)},
{null.FloatFrom(16090.586419753086), null.FloatFrom(1587419400000)},
},
},
&tsdb.TimeSeries{
Name: "Memory",
Points: tsdb.TimeSeriesPoints{
{null.FloatFrom(702.0666666666667), null.FloatFrom(1587418800000)},
{null.FloatFrom(700.5888888888888), null.FloatFrom(1587419100000)},
{null.FloatFrom(703.1111111111111), null.FloatFrom(1587419400000)},
},
},
},
meta: `{"columns":[{"name":"TimeGenerated","type":"datetime"},{"name":"ObjectName","type":"string"},{"name":"avg_CounterValue","type":"real"}],"subscription":"1234","workspace":"aworkspace","query":"test query","encodedQuery":"H4sIAAAAAAAA/ypJLS5RKCxNLaoEBAAA///0rBfVCgAAAA=="}`,
Err: require.NoError,
},
{
name: "Response with no metric name column should use the value column name as the series name",
testFile: "loganalytics/3-log-analytics-response-metrics-no-metric-column.json",
query: "test query",
series: tsdb.TimeSeriesSlice{
&tsdb.TimeSeries{
Name: "avg_CounterValue",
Points: tsdb.TimeSeriesPoints{
{null.FloatFrom(1), null.FloatFrom(1587323766000)},
{null.FloatFrom(2), null.FloatFrom(1587323776000)},
{null.FloatFrom(3), null.FloatFrom(1587323786000)},
},
},
},
meta: `{"columns":[{"name":"TimeGenerated","type":"datetime"},{"name":"avg_CounterValue","type":"int"}],"subscription":"1234","workspace":"aworkspace","query":"test query","encodedQuery":"H4sIAAAAAAAA/ypJLS5RKCxNLaoEBAAA///0rBfVCgAAAA=="}`,
Err: require.NoError,
},
{
name: "Response with no time column should return no data",
testFile: "loganalytics/4-log-analytics-response-metrics-no-time-column.json",
query: "test query",
series: nil,
meta: `{"columns":[{"name":"Computer","type":"string"},{"name":"avg_CounterValue","type":"real"}],"subscription":"1234","workspace":"aworkspace","query":"test query","encodedQuery":"H4sIAAAAAAAA/ypJLS5RKCxNLaoEBAAA///0rBfVCgAAAA=="}`,
Err: require.NoError,
},
{
name: "Response with no value column should return no data",
testFile: "loganalytics/5-log-analytics-response-metrics-no-value-column.json",
query: "test query",
series: nil,
meta: `{"columns":[{"name":"TimeGenerated","type":"datetime"},{"name":"Computer","type":"string"}],"subscription":"1234","workspace":"aworkspace","query":"test query","encodedQuery":"H4sIAAAAAAAA/ypJLS5RKCxNLaoEBAAA///0rBfVCgAAAA=="}`,
Err: require.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, _ := loadLogAnalyticsTestFile(tt.testFile)
model := simplejson.NewFromAny(map[string]interface{}{
"subscriptionId": "1234",
"azureLogAnalytics": map[string]interface{}{
"workspace": "aworkspace",
},
})
params := url.Values{}
params.Add("query", tt.query)
series, meta, err := datasource.parseToTimeSeries(data, model, params)
tt.Err(t, err)
if diff := cmp.Diff(tt.series, series, cmpopts.EquateNaNs()); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
json, _ := json.Marshal(meta)
cols := string(json)
if diff := cmp.Diff(tt.meta, cols, cmpopts.EquateNaNs()); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestParsingAzureLogAnalyticsTableResponses(t *testing.T) {
datasource := &AzureLogAnalyticsDatasource{}
tests := []struct {
name string
testFile string
query string
tables []*tsdb.Table
meta string
Err require.ErrorAssertionFunc
}{
{
name: "Table data should be parsed into the table format Response",
testFile: "loganalytics/6-log-analytics-response-table.json",
query: "test query",
tables: []*tsdb.Table{
{
Columns: []tsdb.TableColumn{
{Text: "TenantId"},
{Text: "Computer"},
{Text: "ObjectName"},
{Text: "CounterName"},
{Text: "InstanceName"},
{Text: "Min"},
{Text: "Max"},
{Text: "SampleCount"},
{Text: "CounterValue"},
{Text: "TimeGenerated"},
},
Rows: []tsdb.RowValues{
{
string("a2c1b44e-3e57-4410-b027-6cc0ae6dee67"),
string("grafana-vm"),
string("Memory"),
string("Available MBytes Memory"),
string("Memory"),
nil,
nil,
nil,
float64(2040),
string("2020-04-23T11:46:03.857Z"),
},
{
string("a2c1b44e-3e57-4410-b027-6cc0ae6dee67"),
string("grafana-vm"),
string("Memory"),
string("Available MBytes Memory"),
string("Memory"),
nil,
nil,
nil,
float64(2066),
string("2020-04-23T11:46:13.857Z"),
},
{
string("a2c1b44e-3e57-4410-b027-6cc0ae6dee67"),
string("grafana-vm"),
string("Memory"),
string("Available MBytes Memory"),
string("Memory"),
nil,
nil,
nil,
float64(2066),
string("2020-04-23T11:46:23.857Z"),
},
},
},
},
meta: `{"columns":[{"name":"TenantId","type":"string"},{"name":"Computer","type":"string"},{"name":"ObjectName","type":"string"},{"name":"CounterName","type":"string"},` +
`{"name":"InstanceName","type":"string"},{"name":"Min","type":"real"},{"name":"Max","type":"real"},{"name":"SampleCount","type":"int"},{"name":"CounterValue","type":"real"},` +
`{"name":"TimeGenerated","type":"datetime"}],"subscription":"1234","workspace":"aworkspace","query":"test query","encodedQuery":"H4sIAAAAAAAA/ypJLS5RKCxNLaoEBAAA///0rBfVCgAAAA=="}`,
Err: require.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, _ := loadLogAnalyticsTestFile(tt.testFile)
model := simplejson.NewFromAny(map[string]interface{}{
"subscriptionId": "1234",
"azureLogAnalytics": map[string]interface{}{
"workspace": "aworkspace",
},
})
params := url.Values{}
params.Add("query", tt.query)
tables, meta, err := datasource.parseToTables(data, model, params)
tt.Err(t, err)
if diff := cmp.Diff(tt.tables, tables, cmpopts.EquateNaNs()); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
json, _ := json.Marshal(meta)
cols := string(json)
if diff := cmp.Diff(tt.meta, cols, cmpopts.EquateNaNs()); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestPluginRoutes(t *testing.T) {
datasource := &AzureLogAnalyticsDatasource{}
plugin := &plugins.DataSourcePlugin{
@@ -389,15 +156,3 @@ func TestPluginRoutes(t *testing.T) {
}
}
func loadLogAnalyticsTestFile(name string) (AzureLogAnalyticsResponse, error) {
var data AzureLogAnalyticsResponse
path := filepath.Join("testdata", name)
jsonBody, err := ioutil.ReadFile(path)
if err != nil {
return data, err
}
err = json.Unmarshal(jsonBody, &data)
return data, err
}
@@ -0,0 +1,181 @@
package azuremonitor
import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
// LogTableToFrame converts an AzureLogAnalyticsTable to a data.Frame.
func LogTableToFrame(table *AzureLogAnalyticsTable) (*data.Frame, error) {
converterFrame, err := converterFrameForTable(table)
if err != nil {
return nil, err
}
for rowIdx, row := range table.Rows {
for fieldIdx, field := range row {
err = converterFrame.Set(fieldIdx, rowIdx, field)
if err != nil {
return nil, err
}
}
}
return converterFrame.Frame, nil
}
func converterFrameForTable(t *AzureLogAnalyticsTable) (*data.FrameInputConverter, error) {
converters := []data.FieldConverter{}
colNames := make([]string, len(t.Columns))
colTypes := make([]string, len(t.Columns)) // for metadata
for i, col := range t.Columns {
colNames[i] = col.Name
colTypes[i] = col.Type
converter, ok := converterMap[col.Type]
if !ok {
return nil, fmt.Errorf("unsupported analytics column type %v", col.Type)
}
converters = append(converters, converter)
}
fic, err := data.NewFrameInputConverter(converters, len(t.Rows))
if err != nil {
return nil, err
}
err = fic.Frame.SetFieldNames(colNames...)
if err != nil {
return nil, err
}
fic.Frame.Meta = &data.FrameMeta{
Custom: map[string]interface{}{"azureColumnTypes": colTypes},
}
return fic, nil
}
var converterMap = map[string]data.FieldConverter{
"string": stringConverter,
"guid": stringConverter,
"timespan": stringConverter,
"dynamic": stringConverter,
"datetime": timeConverter,
"int": intConverter,
"long": longConverter,
"real": realConverter,
"bool": boolConverter,
}
var stringConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableString,
Converter: func(v interface{}) (interface{}, error) {
var as *string
if v == nil {
return as, nil
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("unexpected type, expected string but got %T", v)
}
as = &s
return as, nil
},
}
var timeConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableTime,
Converter: func(v interface{}) (interface{}, error) {
var at *time.Time
if v == nil {
return at, nil
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("unexpected type, expected string but got %T", v)
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return nil, err
}
return &t, nil
},
}
var realConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableFloat64,
Converter: func(v interface{}) (interface{}, error) {
var af *float64
if v == nil {
return af, nil
}
jN, ok := v.(json.Number)
if !ok {
return nil, fmt.Errorf("unexpected type, expected json.Number but got %T", v)
}
f, err := jN.Float64()
if err != nil {
return nil, err
}
return &f, err
},
}
var boolConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableBool,
Converter: func(v interface{}) (interface{}, error) {
var ab *bool
if v == nil {
return ab, nil
}
b, ok := v.(bool)
if !ok {
return nil, fmt.Errorf("unexpected type, expected bool but got %T", v)
}
return &b, nil
},
}
var intConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableInt32,
Converter: func(v interface{}) (interface{}, error) {
var ai *int32
if v == nil {
return ai, nil
}
jN, ok := v.(json.Number)
if !ok {
return nil, fmt.Errorf("unexpected type, expected json.Number but got %T", v)
}
var err error
iv, err := strconv.ParseInt(jN.String(), 10, 32)
if err != nil {
return nil, err
}
aInt := int32(iv)
return &aInt, nil
},
}
var longConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableInt64,
Converter: func(v interface{}) (interface{}, error) {
var ai *int64
if v == nil {
return ai, nil
}
jN, ok := v.(json.Number)
if !ok {
return nil, fmt.Errorf("unexpected type, expected json.Number but got %T", v)
}
out, err := jN.Int64()
if err != nil {
return nil, err
}
return &out, err
},
}
@@ -0,0 +1,153 @@
package azuremonitor
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/require"
"github.com/xorcare/pointer"
)
func TestLogTableToFrame(t *testing.T) {
tests := []struct {
name string
testFile string
expectedFrame func() *data.Frame
}{
{
name: "single series",
testFile: "loganalytics/1-log-analytics-response-metrics-single-series.json",
expectedFrame: func() *data.Frame {
frame := data.NewFrame("",
data.NewField("TimeGenerated", nil, []*time.Time{
pointer.Time(time.Date(2020, 4, 19, 19, 16, 6, 5e8, time.UTC)),
pointer.Time(time.Date(2020, 4, 19, 19, 16, 16, 5e8, time.UTC)),
pointer.Time(time.Date(2020, 4, 19, 19, 16, 26, 5e8, time.UTC)),
}),
data.NewField("Computer", nil, []*string{
pointer.String("grafana-vm"),
pointer.String("grafana-vm"),
pointer.String("grafana-vm"),
}),
data.NewField("avg_CounterValue", nil, []*float64{
pointer.Float64(1.1),
pointer.Float64(2.2),
pointer.Float64(3.3),
}),
)
frame.Meta = &data.FrameMeta{
Custom: map[string]interface{}{"azureColumnTypes": []string{"datetime", "string", "real"}},
}
return frame
},
},
{
name: "response table",
testFile: "loganalytics/6-log-analytics-response-table.json",
expectedFrame: func() *data.Frame {
frame := data.NewFrame("",
data.NewField("TenantId", nil, []*string{
pointer.String("a2c1b44e-3e57-4410-b027-6cc0ae6dee67"),
pointer.String("a2c1b44e-3e57-4410-b027-6cc0ae6dee67"),
pointer.String("a2c1b44e-3e57-4410-b027-6cc0ae6dee67"),
}),
data.NewField("Computer", nil, []*string{
pointer.String("grafana-vm"),
pointer.String("grafana-vm"),
pointer.String("grafana-vm"),
}),
data.NewField("ObjectName", nil, []*string{
pointer.String("Memory"),
pointer.String("Memory"),
pointer.String("Memory"),
}),
data.NewField("CounterName", nil, []*string{
pointer.String("Available MBytes Memory"),
pointer.String("Available MBytes Memory"),
pointer.String("Available MBytes Memory"),
}),
data.NewField("InstanceName", nil, []*string{
pointer.String("Memory"),
pointer.String("Memory"),
pointer.String("Memory"),
}),
data.NewField("Min", nil, []*float64{nil, nil, nil}),
data.NewField("Max", nil, []*float64{nil, nil, nil}),
data.NewField("SampleCount", nil, []*int32{nil, nil, nil}),
data.NewField("CounterValue", nil, []*float64{
pointer.Float64(2040),
pointer.Float64(2066),
pointer.Float64(2066),
}),
data.NewField("TimeGenerated", nil, []*time.Time{
pointer.Time(time.Date(2020, 4, 23, 11, 46, 3, 857e6, time.UTC)),
pointer.Time(time.Date(2020, 4, 23, 11, 46, 13, 857e6, time.UTC)),
pointer.Time(time.Date(2020, 4, 23, 11, 46, 23, 857e6, time.UTC)),
}),
)
frame.Meta = &data.FrameMeta{
Custom: map[string]interface{}{"azureColumnTypes": []string{"string", "string", "string",
"string", "string", "real", "real", "int", "real", "datetime"}},
}
return frame
},
},
{
name: "all supported field types",
testFile: "loganalytics/7-log-analytics-all-types-table.json",
expectedFrame: func() *data.Frame {
frame := data.NewFrame("",
data.NewField("XBool", nil, []*bool{pointer.Bool(true)}),
data.NewField("XString", nil, []*string{pointer.String("Grafana")}),
data.NewField("XDateTime", nil, []*time.Time{pointer.Time(time.Date(2006, 1, 2, 22, 4, 5, 1*1e8, time.UTC))}),
data.NewField("XDynamic", nil, []*string{pointer.String(`[{"person":"Daniel"},{"cats":23},{"diagnosis":"cat problem"}]`)}),
data.NewField("XGuid", nil, []*string{pointer.String("74be27de-1e4e-49d9-b579-fe0b331d3642")}),
data.NewField("XInt", nil, []*int32{pointer.Int32(2147483647)}),
data.NewField("XLong", nil, []*int64{pointer.Int64(9223372036854775807)}),
data.NewField("XReal", nil, []*float64{pointer.Float64(1.797693134862315708145274237317043567981e+308)}),
data.NewField("XTimeSpan", nil, []*string{pointer.String("00:00:00.0000001")}),
)
frame.Meta = &data.FrameMeta{
Custom: map[string]interface{}{"azureColumnTypes": []string{"bool", "string", "datetime",
"dynamic", "guid", "int", "long", "real", "timespan"}},
}
return frame
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := loadLogAnalyticsTestFileWithNumber(tt.testFile)
require.NoError(t, err)
frame, err := LogTableToFrame(&res.Tables[0])
require.NoError(t, err)
if diff := cmp.Diff(tt.expectedFrame(), frame, data.FrameTestCompareOptions()...); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
})
}
}
func loadLogAnalyticsTestFileWithNumber(name string) (AzureLogAnalyticsResponse, error) {
var data AzureLogAnalyticsResponse
path := filepath.Join("testdata", name)
f, err := os.Open(path)
if err != nil {
return data, err
}
defer f.Close()
d := json.NewDecoder(f)
d.UseNumber()
err = d.Decode(&data)
return data, err
}
@@ -0,0 +1,59 @@
{
"tables": [
{
"name": "PrimaryResult",
"columns": [
{
"name": "XBool",
"type": "bool"
},
{
"name": "XString",
"type": "string"
},
{
"name": "XDateTime",
"type": "datetime"
},
{
"name": "XDynamic",
"type": "dynamic"
},
{
"name": "XGuid",
"type": "guid"
},
{
"name": "XInt",
"type": "int"
},
{
"name": "XLong",
"type": "long"
},
{
"name": "XReal",
"type": "real"
},
{
"name": "XTimeSpan",
"type": "timespan"
}
],
"rows": [
[
true,
"Grafana",
"2006-01-02T22:04:05.1Z",
"[{\"person\":\"Daniel\"},{\"cats\":23},{\"diagnosis\":\"cat problem\"}]",
"74be27de-1e4e-49d9-b579-fe0b331d3642",
2147483647,
9223372036854775807,
1.7976931348623157e+308,
"00:00:00.0000001"
]
]
}
]
}
-13
View File
@@ -78,19 +78,6 @@ type AzureLogAnalyticsTable struct {
Rows [][]interface{} `json:"rows"`
}
type metadata struct {
Columns []column `json:"columns"`
Subscription string `json:"subscription"`
Workspace string `json:"workspace"`
Query string `json:"query"`
EncodedQuery string `json:"encodedQuery"`
}
type column struct {
Name string `json:"name"`
Type string `json:"type"`
}
// azureMonitorJSONQuery is the frontend JSON query model for an Azure Monitor query.
type azureMonitorJSONQuery struct {
AzureMonitor struct {