AzureMonitor: Move Application Insights and Insight Analytics to a deprecated package (#45834)

This commit is contained in:
Andres Martinez Gotor
2022-03-02 15:41:07 +01:00
committed by GitHub
parent 3427ae463d
commit 700f6863f2
33 changed files with 523 additions and 387 deletions
@@ -0,0 +1,319 @@
package loganalytics
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"path"
"regexp"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/util/errutil"
"go.opentelemetry.io/otel/attribute"
"golang.org/x/net/context/ctxhttp"
)
// AzureLogAnalyticsDatasource calls the Azure Log Analytics API's
type AzureLogAnalyticsDatasource struct {
Proxy types.ServiceProxy
}
// AzureLogAnalyticsQuery is the query request that is built from the saved values for
// from the UI
type AzureLogAnalyticsQuery struct {
RefID string
ResultFormat string
URL string
JSON json.RawMessage
Params url.Values
Target string
TimeRange backend.TimeRange
}
func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) {
e.Proxy.Do(rw, req, cli)
}
// executeTimeSeriesQuery does the following:
// 1. build the AzureMonitor url and querystring for each query
// 2. executes each query by calling the Azure Monitor API
// 3. parses the responses for each query into data frames
func (e *AzureLogAnalyticsDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client,
url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) {
result := backend.NewQueryDataResponse()
queries, err := e.buildQueries(originalQueries, dsInfo)
if err != nil {
return nil, err
}
for _, query := range queries {
result.Responses[query.RefID] = e.executeQuery(ctx, query, dsInfo, client, url, tracer)
}
return result, nil
}
func getApiURL(queryJSONModel types.LogJSONQuery) string {
// Legacy queries only specify a Workspace GUID, which we need to use the old workspace-centric
// API URL for, and newer queries specifying a resource URI should use resource-centric API.
// However, legacy workspace queries using a `workspaces()` template variable will be resolved
// to a resource URI, so they should use the new resource-centric.
azureLogAnalyticsTarget := queryJSONModel.AzureLogAnalytics
var resourceOrWorkspace string
if azureLogAnalyticsTarget.Resource != "" {
resourceOrWorkspace = azureLogAnalyticsTarget.Resource
} else {
resourceOrWorkspace = azureLogAnalyticsTarget.Workspace
}
matchesResourceURI, _ := regexp.MatchString("^/subscriptions/", resourceOrWorkspace)
if matchesResourceURI {
return fmt.Sprintf("v1%s/query", resourceOrWorkspace)
} else {
return fmt.Sprintf("v1/workspaces/%s/query", resourceOrWorkspace)
}
}
func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureLogAnalyticsQuery, error) {
azureLogAnalyticsQueries := []*AzureLogAnalyticsQuery{}
for _, query := range queries {
queryJSONModel := types.LogJSONQuery{}
err := json.Unmarshal(query.JSON, &queryJSONModel)
if err != nil {
return nil, fmt.Errorf("failed to decode the Azure Log Analytics query object from JSON: %w", err)
}
azureLogAnalyticsTarget := queryJSONModel.AzureLogAnalytics
azlog.Debug("AzureLogAnalytics", "target", azureLogAnalyticsTarget)
resultFormat := azureLogAnalyticsTarget.ResultFormat
if resultFormat == "" {
resultFormat = types.TimeSeries
}
apiURL := getApiURL(queryJSONModel)
params := url.Values{}
rawQuery, err := macros.KqlInterpolate(query, dsInfo, azureLogAnalyticsTarget.Query, "TimeGenerated")
if err != nil {
return nil, err
}
params.Add("query", rawQuery)
azureLogAnalyticsQueries = append(azureLogAnalyticsQueries, &AzureLogAnalyticsQuery{
RefID: query.RefID,
ResultFormat: resultFormat,
URL: apiURL,
JSON: query.JSON,
Params: params,
Target: params.Encode(),
TimeRange: query.TimeRange,
})
}
return azureLogAnalyticsQueries, nil
}
func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *AzureLogAnalyticsQuery, dsInfo types.DatasourceInfo, client *http.Client,
url string, tracer tracing.Tracer) backend.DataResponse {
dataResponse := backend.DataResponse{}
dataResponseErrorWithExecuted := func(err error) backend.DataResponse {
dataResponse.Error = err
dataResponse.Frames = data.Frames{
&data.Frame{
RefID: query.RefID,
Meta: &data.FrameMeta{
ExecutedQueryString: query.Params.Get("query"),
},
},
}
return dataResponse
}
// If azureLogAnalyticsSameAs is defined and set to false, return an error
if sameAs, ok := dsInfo.JSONData["azureLogAnalyticsSameAs"]; ok && !sameAs.(bool) {
return dataResponseErrorWithExecuted(fmt.Errorf("Log Analytics credentials are no longer supported. Go to the data source configuration to update Azure Monitor credentials")) //nolint:golint,stylecheck
}
req, err := e.createRequest(ctx, dsInfo, url)
if err != nil {
dataResponse.Error = err
return dataResponse
}
req.URL.Path = path.Join(req.URL.Path, query.URL)
req.URL.RawQuery = query.Params.Encode()
ctx, span := tracer.Start(ctx, "azure log analytics query")
span.SetAttributes("target", query.Target, attribute.Key("target").String(query.Target))
span.SetAttributes("from", query.TimeRange.From.UnixNano()/int64(time.Millisecond), attribute.Key("from").Int64(query.TimeRange.From.UnixNano()/int64(time.Millisecond)))
span.SetAttributes("until", query.TimeRange.To.UnixNano()/int64(time.Millisecond), attribute.Key("until").Int64(query.TimeRange.To.UnixNano()/int64(time.Millisecond)))
span.SetAttributes("datasource_id", dsInfo.DatasourceID, attribute.Key("datasource_id").Int64(dsInfo.DatasourceID))
span.SetAttributes("org_id", dsInfo.OrgID, attribute.Key("org_id").Int64(dsInfo.OrgID))
defer span.End()
tracer.Inject(ctx, req.Header, span)
azlog.Debug("AzureLogAnalytics", "Request ApiURL", req.URL.String())
res, err := ctxhttp.Do(ctx, client, req)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
logResponse, err := e.unmarshalResponse(res)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
t, err := logResponse.GetPrimaryResultTable()
if err != nil {
return dataResponseErrorWithExecuted(err)
}
frame, err := ResponseTableToFrame(t)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
model, err := simplejson.NewJson(query.JSON)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
err = setAdditionalFrameMeta(frame,
query.Params.Get("query"),
model.Get("subscriptionId").MustString(),
model.Get("azureLogAnalytics").Get("workspace").MustString())
if err != nil {
frame.AppendNotices(data.Notice{Severity: data.NoticeSeverityWarning, Text: "could not add custom metadata: " + err.Error()})
azlog.Warn("failed to add custom metadata to azure log analytics response", err)
}
if query.ResultFormat == types.TimeSeries {
tsSchema := frame.TimeSeriesSchema()
if tsSchema.Type == data.TimeSeriesTypeLong {
wideFrame, err := data.LongToWide(frame, nil)
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()})
}
}
}
dataResponse.Frames = data.Frames{frame}
return dataResponse
}
func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("failed to create request", err)
}
req.URL.Path = "/"
req.Header.Set("Content-Type", "application/json")
return req, nil
}
// AzureLogAnalyticsResponse is the json response object from the Azure Log Analytics API.
type AzureLogAnalyticsResponse struct {
Tables []types.AzureResponseTable `json:"tables"`
}
// 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() (*types.AzureResponseTable, 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 response")
}
func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (AzureLogAnalyticsResponse, error) {
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return AzureLogAnalyticsResponse{}, err
}
defer func() {
if err := res.Body.Close(); err != nil {
azlog.Warn("Failed to close response body", "err", err)
}
}()
if res.StatusCode/100 != 2 {
azlog.Debug("Request failed", "status", res.Status, "body", string(body))
return AzureLogAnalyticsResponse{}, fmt.Errorf("request failed, status: %s, body: %s", res.Status, string(body))
}
var data AzureLogAnalyticsResponse
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
}
return data, nil
}
// LogAnalyticsMeta is a type for the a Frame's Meta's Custom property.
type LogAnalyticsMeta struct {
ColumnTypes []string `json:"azureColumnTypes"`
Subscription string `json:"subscription"`
Workspace string `json:"workspace"`
EncodedQuery []byte `json:"encodedQuery"` // EncodedQuery is used for deep links.
}
func setAdditionalFrameMeta(frame *data.Frame, query, subscriptionID, workspace string) error {
frame.Meta.ExecutedQueryString = query
la, ok := frame.Meta.Custom.(*LogAnalyticsMeta)
if !ok {
return fmt.Errorf("unexpected type found for frame's custom metadata")
}
la.Subscription = subscriptionID
la.Workspace = workspace
encodedQuery, err := encodeQuery(query)
if err == nil {
la.EncodedQuery = encodedQuery
return nil
}
return fmt.Errorf("failed to encode the query into the encodedQuery property")
}
// 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 nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return b.Bytes(), nil
}
@@ -0,0 +1,242 @@
package loganalytics
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/stretchr/testify/require"
)
func TestBuildingAzureLogAnalyticsQueries(t *testing.T) {
datasource := &AzureLogAnalyticsDatasource{}
fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
timeRange := backend.TimeRange{From: fromStart, To: fromStart.Add(34 * time.Minute)}
tests := []struct {
name string
queryModel []backend.DataQuery
azureLogAnalyticsQueries []*AzureLogAnalyticsQuery
Err require.ErrorAssertionFunc
}{
{
name: "Query with macros should be interpolated",
queryModel: []backend.DataQuery{
{
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"resource": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
RefID: "A",
TimeRange: timeRange,
},
},
azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{
{
RefID: "A",
ResultFormat: types.TimeSeries,
URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query",
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"resource": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
Params: url.Values{"query": {"query=Perf | where ['TimeGenerated'] >= datetime('2018-03-15T13:00:00Z') and ['TimeGenerated'] <= datetime('2018-03-15T13:34:00Z') | where ['Computer'] in ('comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, 34000ms), Computer"}},
Target: "query=query%3DPerf+%7C+where+%5B%27TimeGenerated%27%5D+%3E%3D+datetime%28%272018-03-15T13%3A00%3A00Z%27%29+and+%5B%27TimeGenerated%27%5D+%3C%3D+datetime%28%272018-03-15T13%3A34%3A00Z%27%29+%7C+where+%5B%27Computer%27%5D+in+%28%27comp1%27%2C%27comp2%27%29+%7C+summarize+avg%28CounterValue%29+by+bin%28TimeGenerated%2C+34000ms%29%2C+Computer",
TimeRange: timeRange,
},
},
Err: require.NoError,
},
{
name: "Legacy queries with a workspace GUID should use workspace-centric url",
queryModel: []backend.DataQuery{
{
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"workspace": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"query": "query=Perf",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
RefID: "A",
},
},
azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{
{
RefID: "A",
ResultFormat: types.TimeSeries,
URL: "v1/workspaces/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/query",
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"workspace": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"query": "query=Perf",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
Params: url.Values{"query": {"query=Perf"}},
Target: "query=query%3DPerf",
},
},
Err: require.NoError,
},
{
name: "Legacy workspace queries with a resource URI (from a template variable) should use resource-centric url",
queryModel: []backend.DataQuery{
{
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"workspace": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
RefID: "A",
},
},
azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{
{
RefID: "A",
ResultFormat: types.TimeSeries,
URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query",
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"workspace": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
Params: url.Values{"query": {"query=Perf"}},
Target: "query=query%3DPerf",
},
},
Err: require.NoError,
},
{
name: "Queries with a Resource should use resource-centric url",
queryModel: []backend.DataQuery{
{
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"resource": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
RefID: "A",
},
},
azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{
{
RefID: "A",
ResultFormat: types.TimeSeries,
URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query",
JSON: []byte(fmt.Sprintf(`{
"queryType": "Azure Log Analytics",
"azureLogAnalytics": {
"resource": "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace",
"query": "query=Perf",
"resultFormat": "%s"
}
}`, types.TimeSeries)),
Params: url.Values{"query": {"query=Perf"}},
Target: "query=query%3DPerf",
},
},
Err: require.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
queries, err := datasource.buildQueries(tt.queryModel, types.DatasourceInfo{})
tt.Err(t, err)
if diff := cmp.Diff(tt.azureLogAnalyticsQueries[0], queries[0]); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestLogAnalyticsCreateRequest(t *testing.T) {
ctx := context.Background()
url := "http://ds"
dsInfo := types.DatasourceInfo{}
tests := []struct {
name string
expectedURL string
expectedHeaders http.Header
Err require.ErrorAssertionFunc
}{
{
name: "creates a request",
expectedURL: "http://ds/",
expectedHeaders: http.Header{"Content-Type": []string{"application/json"}},
Err: require.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ds := AzureLogAnalyticsDatasource{}
req, err := ds.createRequest(ctx, dsInfo, url)
tt.Err(t, err)
if req.URL.String() != tt.expectedURL {
t.Errorf("Expecting %s, got %s", tt.expectedURL, req.URL.String())
}
if !cmp.Equal(req.Header, tt.expectedHeaders) {
t.Errorf("Unexpected HTTP headers: %v", cmp.Diff(req.Header, tt.expectedHeaders))
}
})
}
}
func Test_executeQueryErrorWithDifferentLogAnalyticsCreds(t *testing.T) {
ds := AzureLogAnalyticsDatasource{}
dsInfo := types.DatasourceInfo{
Services: map[string]types.DatasourceService{
"Azure Log Analytics": {URL: "http://ds"},
},
JSONData: map[string]interface{}{
"azureLogAnalyticsSameAs": false,
},
}
ctx := context.Background()
query := &AzureLogAnalyticsQuery{
Params: url.Values{},
TimeRange: backend.TimeRange{},
}
tracer, err := tracing.InitializeTracerForTest()
require.NoError(t, err)
res := ds.executeQuery(ctx, query, dsInfo, &http.Client{}, dsInfo.Services["Azure Log Analytics"].URL, tracer)
if res.Error == nil {
t.Fatal("expecting an error")
}
if !strings.Contains(res.Error.Error(), "Log Analytics credentials are no longer supported") {
t.Error("expecting the error to inform of bad credentials")
}
}
@@ -0,0 +1,259 @@
package loganalytics
import (
"encoding/json"
"fmt"
"math"
"strconv"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
)
// ResponseTableToFrame converts an AzureResponseTable to a data.Frame.
func ResponseTableToFrame(table *types.AzureResponseTable) (*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 *types.AzureResponseTable) (*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: &LogAnalyticsMeta{ColumnTypes: colTypes},
}
return fic, nil
}
var converterMap = map[string]data.FieldConverter{
"string": stringConverter,
"guid": stringConverter,
"timespan": stringConverter,
"dynamic": stringConverter,
"object": objectToStringConverter,
"datetime": timeConverter,
"int": intConverter,
"long": longConverter,
"real": realConverter,
"bool": boolConverter,
"decimal": decimalConverter,
"integer": intConverter,
"number": decimalConverter,
}
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 objectToStringConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableString,
Converter: func(kustoValue interface{}) (interface{}, error) {
var output *string
if kustoValue == nil {
return output, nil
}
data, err := json.Marshal(kustoValue)
if err != nil {
fmt.Printf("failed to marshal column value: %s", err)
}
asString := string(data)
output = &asString
return output, 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 {
s, sOk := v.(string)
if sOk {
switch s {
case "Infinity":
f := math.Inf(0)
return &f, nil
case "-Infinity":
f := math.Inf(-1)
return &f, nil
case "NaN":
f := math.NaN()
return &f, nil
}
}
return nil, fmt.Errorf("unexpected type, expected json.Number but got type %T for value %v", v, 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
},
}
// decimalConverter converts the Kusto 128-bit type number to
// a float64. We do not have 128 bit numbers in our dataframe
// model yet (and even if we did, not sure how javascript would handle them).
// In the future, we may want to revisit storing this will proper precision,
// but for now it solves the case of people getting an error response.
// If we were to keep it a string, it would not work correctly with calls
// to functions like sdk's data.LongToWide.
var decimalConverter = data.FieldConverter{
OutputFieldType: data.FieldTypeNullableFloat64,
Converter: func(v interface{}) (interface{}, error) {
var af *float64
if v == nil {
return af, nil
}
jS, sOk := v.(string)
if sOk {
out, err := strconv.ParseFloat(jS, 64)
if err != nil {
return nil, err
}
return &out, err
}
// As far as I can tell this always comes in a string, but this is in the
// ADX code, so leaving this in case values do sometimes become a number somehow.
jN, nOk := v.(json.Number)
if !nOk {
return nil, fmt.Errorf("unexpected type, expected json.Number or string but got type %T with a value of %v", v, v)
}
out, err := jN.Float64() // Float64 calls strconv.ParseFloat64
if err != nil {
return nil, err
}
return &out, nil
},
}
@@ -0,0 +1,175 @@
package loganalytics
import (
"encoding/json"
"math"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/assert"
"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: &LogAnalyticsMeta{ColumnTypes: []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: &LogAnalyticsMeta{ColumnTypes: []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")}),
data.NewField("XDecimal", nil, []*float64{pointer.Float64(79228162514264337593543950335)}),
data.NewField("XObject", nil, []*string{pointer.String(`"{\"person\": \"Daniel\", \"cats\": 23, \"diagnosis\": \"cat problem\"}"`)}),
data.NewField("XNumber", nil, []*float64{pointer.Float64(79228162514264337593543950335)}),
)
frame.Meta = &data.FrameMeta{
Custom: &LogAnalyticsMeta{ColumnTypes: []string{"bool", "string", "datetime",
"dynamic", "guid", "int", "long", "real", "timespan", "decimal", "object", "number"}},
}
return frame
},
},
{
name: "nan and infinity in real response",
testFile: "loganalytics/8-log-analytics-response-nan-inf.json",
expectedFrame: func() *data.Frame {
frame := data.NewFrame("",
data.NewField("XInf", nil, []*float64{pointer.Float64(math.Inf(0))}),
data.NewField("XInfNeg", nil, []*float64{pointer.Float64(math.Inf(-2))}),
data.NewField("XNan", nil, []*float64{pointer.Float64(math.NaN())}),
)
frame.Meta = &data.FrameMeta{
Custom: &LogAnalyticsMeta{ColumnTypes: []string{"real", "real", "real"}},
}
return frame
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := loadLogAnalyticsTestFileWithNumber(t, tt.testFile)
frame, err := ResponseTableToFrame(&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(t *testing.T, name string) AzureLogAnalyticsResponse {
t.Helper()
path := filepath.Join("../testdata", name)
// Ignore gosec warning G304 since it's a test
// nolint:gosec
f, err := os.Open(path)
require.NoError(t, err)
defer func() {
err := f.Close()
assert.NoError(t, err)
}()
d := json.NewDecoder(f)
d.UseNumber()
var data AzureLogAnalyticsResponse
err = d.Decode(&data)
require.NoError(t, err)
return data
}