From 3e0f57a407383521d6b15d9161a4d608af127b10 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Fri, 12 Sep 2025 23:35:10 +0200 Subject: [PATCH] Graphite: Backend metrics expand endpoint (#110678) * Add lint rules * Backend decoupling - Add standalone files - Add graphite query type - Add logger to Service - Create logger in the ProvideService method - Use a pointer for the HTTP client provider - Update logger usage everywhere - Update tracer type - Replace simplejson with json - Add dummy CallResource and CheckHealth methods - Update tests * Update ConfigEditor imports * Update types imports * Update datasource - Switch to using semver package - Update imports * Update store imports * Update helper imports and notification creation * Update context import * Update version numbers and logic * Copy array_move from core * Test updates * Add required files and update plugin.json * Update core references and packages * Remove commented code * Update wire * Lint * Fix import * Copy null type * More lint * Update snapshot * Refactor backend - Split query logic into separate file - Move utils to separate file * Add health-check logic - Support backend healthcheck if the FF is enabled * Remove query import support as unneeded * Add test * Add util function for decoding responses * Add events types * Add resource handler * Add events handler and generic resource req handler * Tests * Update frontend - Add types - Update events function to support backend requests * Lint and typing * Lint * Add metrics find endpoint - Add types - Add generic response parser - Add endpoint - Tests * Update FE functoin to use backend endpoint * Lint * Simplify request * Update test * Metrics expand type * Extract shared logic and add metric expand endpoint * Update tests * Call metric expand from backend * Add tests * Review * Review * Fix packages * Format * Fix merge issues * Review * Fix undefined values * Extract request creation - Add method for create requests generically with tests - Replace usage in query method - Update usages in resource handlers - Update tests - Update types --- pkg/tsdb/graphite/graphite.go | 38 ++ pkg/tsdb/graphite/graphite_test.go | 241 +++++++ pkg/tsdb/graphite/query.go | 25 +- pkg/tsdb/graphite/resource_handler.go | 227 ++++--- pkg/tsdb/graphite/resource_handler_test.go | 605 +++++++++++++++--- pkg/tsdb/graphite/types.go | 14 + .../plugins/datasource/graphite/datasource.ts | 18 +- 7 files changed, 959 insertions(+), 209 deletions(-) create mode 100644 pkg/tsdb/graphite/graphite_test.go diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 406ba10f106..62b932763af 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "net/url" + "path" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" @@ -94,3 +96,39 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { return s.resourceHandler.CallResource(ctx, req, sender) } + +func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, params URLParams) (*http.Request, error) { + u, err := url.Parse(dsInfo.URL) + if err != nil { + return nil, err + } + + if params.SubPath != "" { + u.Path = path.Join(u.Path, params.SubPath) + } + + if params.QueryParams != nil { + queryValues := u.Query() + for k, v := range params.QueryParams { + queryValues.Set(k, v) + } + u.RawQuery = queryValues.Encode() + } + + method := params.Method + if method == "" { + method = http.MethodGet + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), params.Body) + if err != nil { + s.logger.Info("Failed to create request", "error", err) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range params.Headers { + req.Header.Add(k, v) + } + + return req, err +} diff --git a/pkg/tsdb/graphite/graphite_test.go b/pkg/tsdb/graphite/graphite_test.go new file mode 100644 index 00000000000..bff49068f30 --- /dev/null +++ b/pkg/tsdb/graphite/graphite_test.go @@ -0,0 +1,241 @@ +package graphite + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CreateRequest(t *testing.T) { + ctx := context.Background() + + service := &Service{} + dsInfo := &datasourceInfo{ + URL: "http://graphite.example.com", + } + + tests := []struct { + name string + dsInfo *datasourceInfo + params URLParams + expectedURL string + expectedMethod string + expectedError string + checkHeaders map[string]string + checkQuery map[string]string + }{ + { + name: "basic request with default GET method", + dsInfo: dsInfo, + params: URLParams{}, + expectedURL: "http://graphite.example.com", + expectedMethod: "GET", + }, + { + name: "request with subpath", + dsInfo: dsInfo, + params: URLParams{ + SubPath: "/metrics/find", + }, + expectedURL: "http://graphite.example.com/metrics/find", + expectedMethod: "GET", + }, + { + name: "request with custom method", + dsInfo: dsInfo, + params: URLParams{ + Method: "POST", + }, + expectedURL: "http://graphite.example.com", + expectedMethod: "POST", + }, + { + name: "request with query parameters", + dsInfo: dsInfo, + params: URLParams{ + QueryParams: map[string]string{ + "query": "stats.counters.*", + "format": "json", + }, + }, + expectedURL: "http://graphite.example.com", + expectedMethod: "GET", + checkQuery: map[string]string{ + "query": "stats.counters.*", + "format": "json", + }, + }, + { + name: "request with headers", + dsInfo: dsInfo, + params: URLParams{ + Headers: map[string]string{ + "Content-Type": "application/json", + }, + }, + expectedURL: "http://graphite.example.com", + expectedMethod: "GET", + checkHeaders: map[string]string{ + "Content-Type": "application/json", + }, + }, + { + name: "request with body", + dsInfo: dsInfo, + params: URLParams{ + Method: "POST", + Body: strings.NewReader(`{"test": "data"}`), + }, + expectedURL: "http://graphite.example.com", + expectedMethod: "POST", + }, + { + name: "complex request with all parameters", + dsInfo: dsInfo, + params: URLParams{ + SubPath: "/metrics/expand", + Method: "POST", + QueryParams: map[string]string{ + "groupByExpr": "true", + "leavesOnly": "false", + }, + Headers: map[string]string{ + "X-Custom-Header": "test-value", + }, + Body: strings.NewReader(`{"query": "stats.*"}`), + }, + expectedURL: "http://graphite.example.com/metrics/expand", + expectedMethod: "POST", + checkQuery: map[string]string{ + "groupByExpr": "true", + "leavesOnly": "false", + }, + checkHeaders: map[string]string{ + "X-Custom-Header": "test-value", + }, + }, + { + name: "invalid URL in datasource", + dsInfo: &datasourceInfo{ + URL: "://invalid-url", + }, + params: URLParams{}, + expectedError: "missing protocol scheme", + }, + { + name: "empty query parameter values", + dsInfo: dsInfo, + params: URLParams{ + QueryParams: map[string]string{ + "empty": "", + "valid": "value", + }, + }, + expectedURL: "http://graphite.example.com", + expectedMethod: "GET", + checkQuery: map[string]string{ + "empty": "", + "valid": "value", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := service.createRequest(ctx, tt.dsInfo, tt.params) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + return + } + + require.NoError(t, err) + require.NotNil(t, req) + + // Check URL (base URL without query parameters) + baseURL := req.URL.Scheme + "://" + req.URL.Host + req.URL.Path + assert.Equal(t, tt.expectedURL, baseURL) + assert.Equal(t, tt.expectedMethod, req.Method) + + if tt.checkQuery != nil { + for key, expectedValue := range tt.checkQuery { + actualValue := req.URL.Query().Get(key) + assert.Equal(t, expectedValue, actualValue, "Query parameter %s", key) + } + } + + if tt.checkHeaders != nil { + for key, expectedValue := range tt.checkHeaders { + actualValue := req.Header.Get(key) + assert.Equal(t, expectedValue, actualValue, "Header %s", key) + } + } + + if tt.params.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + + expectedContent := "" + switch tt.name { + case "request with body": + expectedContent = `{"test": "data"}` + case "complex request with all parameters": + expectedContent = `{"query": "stats.*"}` + } + assert.Equal(t, expectedContent, string(bodyBytes)) + } + }) + } +} + +func Test_CreateRequest_Body(t *testing.T) { + ctx := context.Background() + service := &Service{} + dsInfo := &datasourceInfo{URL: "http://graphite.example.com"} + + t.Run("string reader body", func(t *testing.T) { + bodyContent := `{"query": "stats.*", "format": "json"}` + params := URLParams{ + Method: "POST", + Body: strings.NewReader(bodyContent), + } + + req, err := service.createRequest(ctx, dsInfo, params) + require.NoError(t, err) + + // Read the body to verify content + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, bodyContent, string(bodyBytes)) + }) + + t.Run("nil body", func(t *testing.T) { + params := URLParams{ + Method: "GET", + Body: nil, + } + + req, err := service.createRequest(ctx, dsInfo, params) + require.NoError(t, err) + assert.Nil(t, req.Body) + }) + + t.Run("empty body reader", func(t *testing.T) { + params := URLParams{ + Method: "POST", + Body: strings.NewReader(""), + } + + req, err := service.createRequest(ctx, dsInfo, params) + require.NoError(t, err) + + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Empty(t, string(bodyBytes)) + }) +} diff --git a/pkg/tsdb/graphite/query.go b/pkg/tsdb/graphite/query.go index 4889328aeee..331dbc1af34 100644 --- a/pkg/tsdb/graphite/query.go +++ b/pkg/tsdb/graphite/query.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "path" "regexp" "strconv" "strings" @@ -173,7 +172,12 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ s.logger.Debug("Graphite request", "params", formData) - graphiteReq, err := s.createRequest(ctx, dsInfo, formData) + graphiteReq, err := s.createRequest(ctx, dsInfo, URLParams{ + SubPath: "render", + Method: http.MethodPost, + Body: strings.NewReader(formData.Encode()), + Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }) if err != nil { return nil, formData, nil, err } @@ -181,23 +185,6 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ return graphiteReq, formData, emptyQuery, nil } -func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, data url.Values) (*http.Request, error) { - u, err := url.Parse(dsInfo.URL) - if err != nil { - return nil, err - } - u.Path = path.Join(u.Path, "render") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(data.Encode())) - if err != nil { - s.logger.Info("Failed to create request", "error", err) - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req, err -} - func (s *Service) toDataFrames(response *http.Response, refId string) (frames data.Frames, error error) { responseData, err := s.parseResponse(response) if err != nil { diff --git a/pkg/tsdb/graphite/resource_handler.go b/pkg/tsdb/graphite/resource_handler.go index 53130cbee6b..6988aafaad3 100644 --- a/pkg/tsdb/graphite/resource_handler.go +++ b/pkg/tsdb/graphite/resource_handler.go @@ -10,21 +10,23 @@ import ( "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" ) -type resourceHandler func(context.Context, *datasourceInfo, []byte) ([]byte, int, error) +type resourceHandler[T any] func(context.Context, *datasourceInfo, T) ([]byte, int, error) func (s *Service) newResourceMux() *http.ServeMux { mux := http.NewServeMux() - mux.HandleFunc("/events", s.handleResourceReq(s.handleEvents)) - mux.HandleFunc("/metrics/find", s.handleResourceReq(s.handleMetricsFind)) + mux.HandleFunc("/events", handleResourceReq[GraphiteEventsRequest](s.handleEvents, s)) + mux.HandleFunc("/metrics/find", handleResourceReq[GraphiteMetricsFindRequest](s.handleMetricsFind, s)) + mux.HandleFunc("/metrics/expand", handleResourceReq[GraphiteMetricsFindRequest](s.handleMetricsExpand, s)) return mux } -func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.ResponseWriter, req *http.Request) { +func handleResourceReq[T any](handlerFn resourceHandler[T], s *Service) func(rw http.ResponseWriter, req *http.Request) { return func(rw http.ResponseWriter, req *http.Request) { s.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) @@ -55,7 +57,13 @@ func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.Resp return } - response, statusCode, err := handlerFn(ctx, dsInfo, requestBody) + parsedBody, err := parseRequestBody[T](requestBody, s.logger) + if err != nil { + writeErrorResponse(rw, http.StatusBadRequest, fmt.Sprintf("failed to parse request body: %v", err)) + return + } + + response, statusCode, err := handlerFn(ctx, dsInfo, *parsedBody) if err != nil { writeErrorResponse(rw, statusCode, fmt.Sprintf("failed to handle resource request: %v", err)) return @@ -70,59 +78,27 @@ func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.Resp } } -func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requestBody []byte) ([]byte, int, error) { - eventsRequestJson := GraphiteEventsRequest{} - err := json.Unmarshal(requestBody, &eventsRequestJson) - if err != nil { - s.logger.Error("Failed to unmarshal events request body to JSON", "error", err) - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err) +func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, eventsRequestJson GraphiteEventsRequest) ([]byte, int, error) { + queryParams := map[string]string{ + "from": eventsRequestJson.From, + "until": eventsRequestJson.Until, } - - eventsUrl, err := url.Parse(fmt.Sprintf("%s/events/get_data", dsInfo.URL)) - if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err) - } - - queryValues := eventsUrl.Query() - queryValues.Set("from", eventsRequestJson.From) - queryValues.Set("until", eventsRequestJson.Until) if eventsRequestJson.Tags != "" { - queryValues.Set("tags", eventsRequestJson.Tags) + queryParams["tags"] = eventsRequestJson.Tags } - eventsUrl.RawQuery = queryValues.Encode() - - graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodGet, eventsUrl.String(), nil) + req, err := s.createRequest(ctx, dsInfo, URLParams{ + SubPath: "events/get_data", + Method: http.MethodGet, + QueryParams: queryParams, + }) if err != nil { - s.logger.Info("Failed to create events request", "error", err) - return nil, http.StatusInternalServerError, fmt.Errorf("failed to create events request: %v", err) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create events request %v", err) } - _, span := tracing.DefaultTracer().Start(ctx, "graphite events") - defer span.End() - span.SetAttributes( - attribute.Int64("datasource_id", dsInfo.Id), - ) - res, err := dsInfo.HTTPClient.Do(graphiteReq) - if res != nil { - span.SetAttributes(attribute.Int("graphite.response.code", res.StatusCode)) - } + events, statusCode, err := doGraphiteRequest[[]GraphiteEventsResponse](ctx, dsInfo, s.logger, req) if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - return nil, http.StatusInternalServerError, fmt.Errorf("failed to complete events request: %v", err) - } - - defer func() { - err := res.Body.Close() - if err != nil { - s.logger.Warn("Failed to close response body", "error", err) - } - }() - - events, err := parseResponse[[]GraphiteEventsResponse](res) - if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse events response: %v", err) + return nil, statusCode, fmt.Errorf("events request failed: %v", err) } // We construct this struct to avoid frontend changes. @@ -133,68 +109,39 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal events response: %s", err) } - return graphiteEventsResponse, res.StatusCode, nil + return graphiteEventsResponse, statusCode, nil } -func (s *Service) handleMetricsFind(ctx context.Context, dsInfo *datasourceInfo, requestBody []byte) ([]byte, int, error) { - metricsFindRequestJson := GraphiteMetricsFindRequest{} - err := json.Unmarshal(requestBody, &metricsFindRequestJson) - if err != nil { - s.logger.Error("Failed to unmarshal metrics find request body to JSON", "error", err) - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err) - } - +func (s *Service) handleMetricsFind(ctx context.Context, dsInfo *datasourceInfo, metricsFindRequestJson GraphiteMetricsFindRequest) ([]byte, int, error) { if metricsFindRequestJson.Query == "" { return nil, http.StatusBadRequest, fmt.Errorf("query is required") } - metricsFindUrl, err := url.Parse(fmt.Sprintf("%s/metrics/find", dsInfo.URL)) - if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err) - } - - queryValues := metricsFindUrl.Query() - if metricsFindRequestJson.From != "" { - queryValues.Set("from", metricsFindRequestJson.From) - } - if metricsFindRequestJson.Until != "" { - queryValues.Set("until", metricsFindRequestJson.Until) - } - data := url.Values{} data.Set("query", metricsFindRequestJson.Query) - graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodPost, metricsFindUrl.String(), strings.NewReader(data.Encode())) - if err != nil { - s.logger.Info("Failed to create metrics find request", "error", err) - return nil, http.StatusInternalServerError, fmt.Errorf("failed to create metrics find request: %v", err) + queryParams := map[string]string{} + if metricsFindRequestJson.From != "" { + queryParams["from"] = metricsFindRequestJson.From + } + if metricsFindRequestJson.Until != "" { + queryParams["until"] = metricsFindRequestJson.Until } - graphiteReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") - _, span := tracing.DefaultTracer().Start(ctx, "graphite metrics find") - defer span.End() - span.SetAttributes( - attribute.Int64("datasource_id", dsInfo.Id), - ) - res, err := dsInfo.HTTPClient.Do(graphiteReq) - if res != nil { - span.SetAttributes(attribute.Int("graphite.response.code", res.StatusCode)) - } + req, err := s.createRequest(ctx, dsInfo, URLParams{ + SubPath: "metrics/find", + Method: http.MethodPost, + QueryParams: queryParams, + Body: strings.NewReader(data.Encode()), + Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }) if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - return nil, http.StatusInternalServerError, fmt.Errorf("failed to complete metrics find request: %v", err) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create metrics find request %v", err) } - defer func() { - err := res.Body.Close() - if err != nil { - s.logger.Warn("Failed to close response body", "error", err) - } - }() - metrics, err := parseResponse[[]GraphiteMetricsFindResponse](res) + metrics, statusCode, err := doGraphiteRequest[[]GraphiteMetricsFindResponse](ctx, dsInfo, s.logger, req) if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse metrics find response: %v", err) + return nil, statusCode, fmt.Errorf("metrics find request failed: %v", err) } metricsFindResponse, err := json.Marshal(*metrics) @@ -202,7 +149,91 @@ func (s *Service) handleMetricsFind(ctx context.Context, dsInfo *datasourceInfo, return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal metrics find response: %s", err) } - return metricsFindResponse, res.StatusCode, nil + return metricsFindResponse, statusCode, nil +} + +func (s *Service) handleMetricsExpand(ctx context.Context, dsInfo *datasourceInfo, metricsExpandRequestJson GraphiteMetricsFindRequest) ([]byte, int, error) { + if metricsExpandRequestJson.Query == "" { + return nil, http.StatusBadRequest, fmt.Errorf("query is required") + } + + queryParams := map[string]string{ + "query": metricsExpandRequestJson.Query, + } + if metricsExpandRequestJson.From != "" { + queryParams["from"] = metricsExpandRequestJson.From + } + if metricsExpandRequestJson.Until != "" { + queryParams["until"] = metricsExpandRequestJson.Until + } + + req, err := s.createRequest(ctx, dsInfo, URLParams{ + SubPath: "metrics/expand", + Method: http.MethodGet, + QueryParams: queryParams, + }) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create metrics expand request %v", err) + } + + metrics, statusCode, err := doGraphiteRequest[GraphiteMetricsExpandResponse](ctx, dsInfo, s.logger, req) + if err != nil { + return nil, statusCode, fmt.Errorf("metrics expand request failed: %v", err) + } + + metricsResponse := make([]GraphiteMetricsFindResponse, 0, len(metrics.Results)) + for _, metric := range metrics.Results { + metricsResponse = append(metricsResponse, GraphiteMetricsFindResponse{ + Text: metric, + }) + } + + metricsExpandResponse, err := json.Marshal(metricsResponse) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal metrics expand response: %s", err) + } + + return metricsExpandResponse, statusCode, nil +} + +func doGraphiteRequest[T any](ctx context.Context, dsInfo *datasourceInfo, logger log.Logger, req *http.Request) (*T, int, error) { + _, span := tracing.DefaultTracer().Start(ctx, "graphite request") + defer span.End() + span.SetAttributes( + attribute.Int64("datasource_id", dsInfo.Id), + ) + res, err := dsInfo.HTTPClient.Do(req) + if res != nil { + span.SetAttributes(attribute.Int("graphite.response.code", res.StatusCode)) + } + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to complete request: %v", err) + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Warn("Failed to close response body", "err", err) + } + }() + + parsedResponse, err := parseResponse[T](res) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse response: %v", err) + } + + return parsedResponse, res.StatusCode, nil +} + +func parseRequestBody[V any](requestBody []byte, logger log.Logger) (*V, error) { + requestJson := new(V) + err := json.Unmarshal(requestBody, &requestJson) + if err != nil { + logger.Error("Failed to unmarshal request body to JSON", "error", err) + return nil, fmt.Errorf("unexpected error %v", err) + } + return requestJson, nil } func parseResponse[V any](res *http.Response) (*V, error) { diff --git a/pkg/tsdb/graphite/resource_handler_test.go b/pkg/tsdb/graphite/resource_handler_test.go index 5857af1e146..51d5a0fb290 100644 --- a/pkg/tsdb/graphite/resource_handler_test.go +++ b/pkg/tsdb/graphite/resource_handler_test.go @@ -58,7 +58,7 @@ func TestHandleEvents(t *testing.T) { tests := []struct { name string dsInfo *datasourceInfo - requestBody []byte + request GraphiteEventsRequest expectedStatus int expectError bool errorContains string @@ -71,11 +71,7 @@ func TestHandleEvents(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, - requestBody: func() []byte { - request := GraphiteEventsRequest{From: "now-1h", Until: "now", Tags: "foo"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteEventsRequest{From: "now-1h", Until: "now", Tags: "foo"}, expectedStatus: 200, expectError: false, expectedEvents: mockEvents, @@ -87,37 +83,21 @@ func TestHandleEvents(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, - requestBody: func() []byte { - request := GraphiteEventsRequest{From: "now-1h", Until: "now"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: 200, expectError: false, expectedEvents: mockEvents, }, - { - name: "Invalid request body", - dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, - requestBody: []byte(`{"invalid": json}`), - expectedStatus: http.StatusInternalServerError, - expectError: true, - errorContains: "unexpected error", - }, { name: "Invalid URL", dsInfo: &datasourceInfo{ Id: 1, URL: "ht tp://invalid url", // Invalid URL }, - requestBody: func() []byte { - request := GraphiteEventsRequest{From: "now-1h", Until: "now"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "unexpected error", + errorContains: "failed to create events request", }, { name: "HTTP client error", @@ -126,14 +106,10 @@ func TestHandleEvents(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, }, - requestBody: func() []byte { - request := GraphiteEventsRequest{From: "now-1h", Until: "now"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "failed to complete events request", + errorContains: "events request failed", }, { name: "Invalid response JSON", @@ -142,14 +118,10 @@ func TestHandleEvents(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}}, }, - requestBody: func() []byte { - request := GraphiteEventsRequest{From: "now-1h", Until: "now"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "failed to parse events response", + errorContains: "events request failed", }, } @@ -157,7 +129,7 @@ func TestHandleEvents(t *testing.T) { t.Run(tt.name, func(t *testing.T) { svc := &Service{logger: log.NewNullLogger()} - respBody, status, err := svc.handleEvents(context.Background(), tt.dsInfo, tt.requestBody) + respBody, status, err := svc.handleEvents(context.Background(), tt.dsInfo, tt.request) assert.Equal(t, tt.expectedStatus, status) @@ -191,7 +163,7 @@ func TestHandleMetricsFind(t *testing.T) { tests := []struct { name string dsInfo *datasourceInfo - requestBody []byte + request GraphiteMetricsFindRequest expectedStatus int expectError bool errorContains string @@ -204,11 +176,7 @@ func TestHandleMetricsFind(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, - requestBody: func() []byte { - request := GraphiteMetricsFindRequest{Query: "app.grafana.*"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: 200, expectError: false, expectedMetrics: mockMetrics, @@ -220,35 +188,19 @@ func TestHandleMetricsFind(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, - requestBody: func() []byte { - request := GraphiteMetricsFindRequest{ - Query: "app.grafana.*", - From: "now-1h", - Until: "now", - } - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteMetricsFindRequest{ + Query: "app.grafana.*", + From: "now-1h", + Until: "now", + }, expectedStatus: 200, expectError: false, expectedMetrics: mockMetrics, }, { - name: "Invalid request body", + name: "Empty query", dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, - requestBody: []byte(`{"invalid": json}`), - expectedStatus: http.StatusInternalServerError, - expectError: true, - errorContains: "unexpected error", - }, - { - name: "Empty query", - dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, - requestBody: func() []byte { - request := GraphiteMetricsFindRequest{Query: ""} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteMetricsFindRequest{Query: ""}, expectedStatus: http.StatusBadRequest, expectError: true, errorContains: "query is required", @@ -259,14 +211,10 @@ func TestHandleMetricsFind(t *testing.T) { Id: 1, URL: "ht tp://invalid url", // Invalid URL }, - requestBody: func() []byte { - request := GraphiteMetricsFindRequest{Query: "app.grafana.*"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "unexpected error", + errorContains: "failed to create metrics find request", }, { name: "HTTP client error", @@ -275,14 +223,120 @@ func TestHandleMetricsFind(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, }, - requestBody: func() []byte { - request := GraphiteMetricsFindRequest{Query: "app.grafana.*"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "failed to complete metrics find request", + errorContains: "metrics find request failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &Service{logger: log.NewNullLogger()} + + respBody, status, err := svc.handleMetricsFind(context.Background(), tt.dsInfo, tt.request) + + assert.Equal(t, tt.expectedStatus, status) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, respBody) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.NotNil(t, respBody) + + if tt.expectedMetrics != nil { + var result []GraphiteMetricsFindResponse + require.NoError(t, json.Unmarshal(respBody, &result)) + assert.Equal(t, tt.expectedMetrics, result) + } + } + }) + } +} + +func TestHandleMetricsExpand(t *testing.T) { + mockExpandResponse := GraphiteMetricsExpandResponse{ + Results: []string{"app.grafana.metric1", "app.grafana.metric2", "app.grafana.metric3"}, + } + mockResp, _ := json.Marshal(mockExpandResponse) + + expectedMetrics := []GraphiteMetricsFindResponse{ + {Text: "app.grafana.metric1"}, + {Text: "app.grafana.metric2"}, + {Text: "app.grafana.metric3"}, + } + + tests := []struct { + name string + dsInfo *datasourceInfo + request GraphiteMetricsFindRequest + expectedStatus int + expectError bool + errorContains string + expectedMetrics []GraphiteMetricsFindResponse + }{ + { + name: "Success with query", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, + }, + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, + expectedStatus: 200, + expectError: false, + expectedMetrics: expectedMetrics, + }, + { + name: "Success with query and time range", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, + }, + request: GraphiteMetricsFindRequest{ + Query: "app.grafana.*", + From: "now-1h", + Until: "now", + }, + expectedStatus: 200, + expectError: false, + expectedMetrics: expectedMetrics, + }, + { + name: "Empty query", + dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, + request: GraphiteMetricsFindRequest{Query: ""}, + expectedStatus: http.StatusBadRequest, + expectError: true, + errorContains: "query is required", + }, + { + name: "Invalid URL", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "ht tp://invalid url", // Invalid URL + }, + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, + expectedStatus: http.StatusInternalServerError, + expectError: true, + errorContains: "failed to create metrics expand request", + }, + { + name: "HTTP client error", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, + }, + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, + expectedStatus: http.StatusInternalServerError, + expectError: true, + errorContains: "metrics expand request failed", }, { name: "Invalid response JSON", @@ -291,14 +345,22 @@ func TestHandleMetricsFind(t *testing.T) { URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}}, }, - requestBody: func() []byte { - request := GraphiteMetricsFindRequest{Query: "app.grafana.*"} - body, _ := json.Marshal(request) - return body - }(), + request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "failed to parse metrics find response", + errorContains: "metrics expand request failed", + }, + { + name: "Empty results", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte(`{"results":[]}`), status: 200}}, + }, + request: GraphiteMetricsFindRequest{Query: "nonexistent.*"}, + expectedStatus: 200, + expectError: false, + expectedMetrics: []GraphiteMetricsFindResponse{}, }, } @@ -306,7 +368,7 @@ func TestHandleMetricsFind(t *testing.T) { t.Run(tt.name, func(t *testing.T) { svc := &Service{logger: log.NewNullLogger()} - respBody, status, err := svc.handleMetricsFind(context.Background(), tt.dsInfo, tt.requestBody) + respBody, status, err := svc.handleMetricsExpand(context.Background(), tt.dsInfo, tt.request) assert.Equal(t, tt.expectedStatus, status) @@ -352,7 +414,7 @@ func TestHandleResourceReq_Success(t *testing.T) { req = req.WithContext(backend.WithPluginContext(context.Background(), backend.PluginContext{})) rr := httptest.NewRecorder() - handler := svc.handleResourceReq(svc.handleEvents) + handler := handleResourceReq(svc.handleEvents, svc) handler(rr, req) assert.Equal(t, http.StatusOK, rr.Code) @@ -372,7 +434,7 @@ func TestHandleResourceReq_GetDSInfoError(t *testing.T) { req = req.WithContext(backend.WithPluginContext(context.Background(), backend.PluginContext{})) rr := httptest.NewRecorder() - handler := svc.handleResourceReq(svc.handleEvents) + handler := handleResourceReq(svc.handleEvents, svc) handler(rr, req) assert.Equal(t, http.StatusInternalServerError, rr.Code) @@ -394,7 +456,7 @@ func TestHandleResourceReq_NilHandler(t *testing.T) { req = req.WithContext(backend.WithPluginContext(context.Background(), backend.PluginContext{})) rr := httptest.NewRecorder() - handler := svc.handleResourceReq(nil) + handler := handleResourceReq[any](nil, svc) handler(rr, req) assert.Equal(t, http.StatusInternalServerError, rr.Code) @@ -414,3 +476,368 @@ func TestWriteErrorResponse(t *testing.T) { require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errorResp)) assert.Equal(t, "test error message", errorResp["error"]) } + +func TestDoGraphiteRequest(t *testing.T) { + mockResponse := []GraphiteEventsResponse{ + {When: 1234567890, What: "event1", Tags: []string{"tag1"}, Data: "data1"}, + } + mockResp, _ := json.Marshal(mockResponse) + + tests := []struct { + name string + endpoint string + dsInfo *datasourceInfo + method string + body io.Reader + headers map[string]string + expectedStatus int + expectError bool + errorContains string + expectedData []GraphiteEventsResponse + }{ + { + name: "Success GET request", + endpoint: "events", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, + }, + method: "GET", + headers: map[string]string{"Content-Type": "application/json"}, + expectedStatus: 200, + expectError: false, + expectedData: mockResponse, + }, + { + name: "Success POST request with body", + endpoint: "events", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, + }, + method: "POST", + body: bytes.NewReader([]byte("query=test")), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + expectedStatus: 200, + expectError: false, + expectedData: mockResponse, + }, + { + name: "HTTP client error", + endpoint: "events", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, + }, + method: "GET", + headers: map[string]string{}, + expectError: true, + errorContains: "failed to complete request", + }, + { + name: "Invalid response JSON", + endpoint: "events", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}}, + }, + method: "GET", + headers: map[string]string{}, + expectError: true, + errorContains: "failed to parse response", + }, + { + name: "Non-200 status code with valid JSON", + endpoint: "events", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("[]"), status: 500}}, + }, + method: "GET", + headers: map[string]string{}, + expectedStatus: 500, + expectError: false, + expectedData: []GraphiteEventsResponse{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Create a service instance for the test + svc := &Service{logger: log.NewNullLogger()} + + // Create the HTTP request using the createRequest method + req, err := svc.createRequest(ctx, tt.dsInfo, URLParams{ + SubPath: tt.endpoint, + Method: tt.method, + Body: tt.body, + Headers: tt.headers, + }) + + if tt.expectError { + // For cases where we expect errors in request creation + if err != nil { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + return + } + } else { + assert.NoError(t, err) + } + + result, status, err := doGraphiteRequest[[]GraphiteEventsResponse](ctx, tt.dsInfo, svc.logger, req) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + if tt.expectedStatus != 0 { + assert.Equal(t, tt.expectedStatus, status) + } + if tt.expectedData != nil { + assert.Equal(t, tt.expectedData, *result) + } + } + }) + } +} + +func TestDoGraphiteRequestGenericTypes(t *testing.T) { + // Test with GraphiteMetricsFindResponse + mockMetrics := []GraphiteMetricsFindResponse{ + {Text: "metric1", Id: "metric1.id", AllowChildren: 1, Expandable: 1, Leaf: 0}, + } + mockMetricsResp, _ := json.Marshal(mockMetrics) + + // Test with GraphiteMetricsExpandResponse + mockExpand := GraphiteMetricsExpandResponse{ + Results: []string{"app.grafana.metric1", "app.grafana.metric2"}, + } + mockExpandResp, _ := json.Marshal(mockExpand) + + tests := []struct { + name string + testFunc func(t *testing.T) + }{ + { + name: "Success with GraphiteMetricsFindResponse type", + testFunc: func(t *testing.T) { + dsInfo := &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockMetricsResp, status: 200}}, + } + ctx := context.Background() + + // Create a service instance for the test + svc := &Service{logger: log.NewNullLogger()} + + // Create the HTTP request using the createRequest method + req, err := svc.createRequest(ctx, dsInfo, URLParams{ + SubPath: "test", + Method: "GET", + }) + assert.NoError(t, err) + + result, status, err := doGraphiteRequest[[]GraphiteMetricsFindResponse](ctx, dsInfo, svc.logger, req) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 200, status) + assert.Equal(t, mockMetrics, *result) + }, + }, + { + name: "Success with GraphiteMetricsExpandResponse type", + testFunc: func(t *testing.T) { + dsInfo := &datasourceInfo{ + Id: 1, + URL: "http://graphite.grafana", + HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockExpandResp, status: 200}}, + } + ctx := context.Background() + + // Create a service instance for the test + svc := &Service{logger: log.NewNullLogger()} + + // Create the HTTP request using the createRequest method + req, err := svc.createRequest(ctx, dsInfo, URLParams{ + SubPath: "test", + Method: "GET", + }) + assert.NoError(t, err) + + result, status, err := doGraphiteRequest[GraphiteMetricsExpandResponse](ctx, dsInfo, svc.logger, req) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 200, status) + assert.Equal(t, mockExpand, *result) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, tt.testFunc) + } +} + +func TestParseRequestBody(t *testing.T) { + tests := []struct { + name string + requestBody []byte + expectError bool + errorContains string + expectedData GraphiteEventsRequest + }{ + { + name: "Valid JSON request", + requestBody: []byte(`{"from": "now-1h", "until": "now", "tags": "app.grafana"}`), + expectError: false, + expectedData: GraphiteEventsRequest{From: "now-1h", Until: "now", Tags: "app.grafana"}, + }, + { + name: "Empty JSON object", + requestBody: []byte(`{}`), + expectError: false, + expectedData: GraphiteEventsRequest{}, + }, + { + name: "Invalid JSON", + requestBody: []byte(`{"invalid": json}`), + expectError: true, + errorContains: "unexpected error", + }, + { + name: "Empty request body", + requestBody: []byte(``), + expectError: true, + errorContains: "unexpected error", + }, + { + name: "Malformed JSON", + requestBody: []byte(`{"from": "now-1h", "until": }`), + expectError: true, + errorContains: "unexpected error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := log.NewNullLogger() + + result, err := parseRequestBody[GraphiteEventsRequest](tt.requestBody, logger) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.expectedData, *result) + } + }) + } +} + +func TestParseResponse(t *testing.T) { + mockEvents := []GraphiteEventsResponse{ + {When: 1234567890, What: "event1", Tags: []string{"tag1"}, Data: "data1"}, + {When: 1234567891, What: "event2", Tags: []string{"tag2"}, Data: "data2"}, + } + mockResp, _ := json.Marshal(mockEvents) + + tests := []struct { + name string + response *http.Response + expectError bool + errorContains string + expectedData []GraphiteEventsResponse + }{ + { + name: "Valid JSON response", + response: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(mockResp)), + Header: make(http.Header), + }, + expectError: false, + expectedData: mockEvents, + }, + { + name: "Empty JSON array", + response: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte("[]"))), + Header: make(http.Header), + }, + expectError: false, + expectedData: []GraphiteEventsResponse{}, + }, + { + name: "Invalid JSON response", + response: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte("invalid json"))), + Header: make(http.Header), + }, + expectError: true, + errorContains: "failed to unmarshal response", + }, + { + name: "Empty response body", + response: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte(""))), + Header: make(http.Header), + }, + expectError: true, + errorContains: "failed to unmarshal response", + }, + { + name: "Malformed JSON response", + response: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer([]byte(`[{"when": 123, "what": }]`))), + Header: make(http.Header), + }, + expectError: true, + errorContains: "failed to unmarshal response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseResponse[[]GraphiteEventsResponse](tt.response) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.expectedData, *result) + } + }) + } +} diff --git a/pkg/tsdb/graphite/types.go b/pkg/tsdb/graphite/types.go index 8bc46177cf0..30d872f375a 100644 --- a/pkg/tsdb/graphite/types.go +++ b/pkg/tsdb/graphite/types.go @@ -1,5 +1,7 @@ package graphite +import "io" + type TargetResponseDTO struct { Target string `json:"target"` DataPoints DataTimeSeriesPoints `json:"datapoints"` @@ -10,6 +12,14 @@ type TargetResponseDTO struct { type DataTimePoint [2]Float type DataTimeSeriesPoints []DataTimePoint +type URLParams struct { + SubPath string + Method string + Body io.Reader + QueryParams map[string]string + Headers map[string]string +} + type GraphiteQuery struct { QueryType string `json:"queryType"` TextEditor *bool `json:"textEditor,omitempty"` @@ -45,3 +55,7 @@ type GraphiteMetricsFindResponse struct { Expandable int `json:"expandable"` Leaf int `json:"leaf"` } + +type GraphiteMetricsExpandResponse struct { + Results []string `json:"results"` +} diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index 2a51d0107d6..7f04fa713af 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -704,8 +704,8 @@ export class GraphiteDatasource if (config.featureToggles.graphiteBackendMode) { return await this.postResource('metrics/find', { - from: typeof params.from === 'string' ? params.from : `${params.from}`, - until: typeof params.until === 'string' ? params.until : `${params.until}`, + from: params.from ? (typeof params.from === 'string' ? params.from : `${params.from}`) : undefined, + until: params.until ? (typeof params.until === 'string' ? params.until : `${params.until}`) : undefined, query, }); } @@ -741,7 +741,7 @@ export class GraphiteDatasource * The result will contain all metrics (with full name) matching provided query. * It's a more flexible version of /metrics/find endpoint (@see requestMetricFind) */ - private requestMetricExpand( + private async requestMetricExpand( query: string, requestId: string, range?: { from: string | number; until: string | number } @@ -752,6 +752,18 @@ export class GraphiteDatasource params.until = range.until; } + if (config.featureToggles.graphiteBackendMode) { + const metrics = await this.postResource('metrics/expand', { + from: params.from ? (typeof params.from === 'string' ? params.from : `${params.from}`) : undefined, + until: params.until ? (typeof params.until === 'string' ? params.until : `${params.until}`) : undefined, + query, + }); + return metrics.map((metric) => ({ + text: metric.text, + expandable: false, + })); + } + const httpOptions: BackendSrvRequest = { method: 'GET', url: '/metrics/expand',