AzureMonitor: Move Application Insights and Insight Analytics to a deprecated package (#45834)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user