diff --git a/pkg/tsdb/graphite/resource_handler.go b/pkg/tsdb/graphite/resource_handler.go index 51eea3775d5..53130cbee6b 100644 --- a/pkg/tsdb/graphite/resource_handler.go +++ b/pkg/tsdb/graphite/resource_handler.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" @@ -19,6 +20,7 @@ type resourceHandler func(context.Context, *datasourceInfo, []byte) ([]byte, int func (s *Service) newResourceMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/events", s.handleResourceReq(s.handleEvents)) + mux.HandleFunc("/metrics/find", s.handleResourceReq(s.handleMetricsFind)) return mux } @@ -43,7 +45,7 @@ func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.Resp }() requestBody, err := io.ReadAll(req.Body) if err != nil { - s.logger.Error("Failed to read events request body", "error", err) + s.logger.Error("Failed to read request body", "error", err) writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("unexpected error %v", err)) return } @@ -62,7 +64,7 @@ func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.Resp rw.WriteHeader(statusCode) _, err = rw.Write(response) if err != nil { - writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("failed to write events response: %v", err)) + writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("failed to write response: %v", err)) return } } @@ -90,11 +92,10 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ eventsUrl.RawQuery = queryValues.Encode() - p := eventsUrl.String() - graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil) + graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodGet, eventsUrl.String(), nil) if err != nil { - s.logger.Info("Failed to create request", "error", err) - return nil, http.StatusInternalServerError, fmt.Errorf("failed to create request: %v", err) + s.logger.Info("Failed to create events request", "error", err) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create events request: %v", err) } _, span := tracing.DefaultTracer().Start(ctx, "graphite events") @@ -119,21 +120,14 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ } }() - encoding := res.Header.Get("Content-Encoding") - body, err := decode(encoding, res.Body) + events, err := parseResponse[[]GraphiteEventsResponse](res) if err != nil { - return nil, res.StatusCode, fmt.Errorf("failed to read events response: %v", err) - } - - events := []GraphiteEventsResponse{} - err = json.Unmarshal(body, &events) - if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal events response: %v", err) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse events response: %v", err) } // We construct this struct to avoid frontend changes. graphiteEventsResponse, err := json.Marshal(map[string][]GraphiteEventsResponse{ - "data": events, + "data": *events, }) if err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal events response: %s", err) @@ -142,6 +136,90 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ return graphiteEventsResponse, res.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) + } + + 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) + } + 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)) + } + 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) + } + defer func() { + err := res.Body.Close() + if err != nil { + s.logger.Warn("Failed to close response body", "error", err) + } + }() + + metrics, err := parseResponse[[]GraphiteMetricsFindResponse](res) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse metrics find response: %v", err) + } + + metricsFindResponse, err := json.Marshal(*metrics) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal metrics find response: %s", err) + } + + return metricsFindResponse, res.StatusCode, nil +} + +func parseResponse[V any](res *http.Response) (*V, error) { + encoding := res.Header.Get("Content-Encoding") + body, err := decode(encoding, res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %v", err) + } + + data := new(V) + err = json.Unmarshal(body, &data) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v", err) + } + return data, nil +} + func writeErrorResponse(rw http.ResponseWriter, code int, msg string) { rw.WriteHeader(code) errorBody := map[string]string{ diff --git a/pkg/tsdb/graphite/resource_handler_test.go b/pkg/tsdb/graphite/resource_handler_test.go index a1aeee3697d..5857af1e146 100644 --- a/pkg/tsdb/graphite/resource_handler_test.go +++ b/pkg/tsdb/graphite/resource_handler_test.go @@ -68,7 +68,7 @@ func TestHandleEvents(t *testing.T) { name: "Success with tags", dsInfo: &datasourceInfo{ Id: 1, - URL: "http://example.com", + URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, requestBody: func() []byte { @@ -84,7 +84,7 @@ func TestHandleEvents(t *testing.T) { name: "Success without tags", dsInfo: &datasourceInfo{ Id: 1, - URL: "http://example.com", + URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, requestBody: func() []byte { @@ -98,7 +98,7 @@ func TestHandleEvents(t *testing.T) { }, { name: "Invalid request body", - dsInfo: &datasourceInfo{Id: 1, URL: "http://example.com"}, + dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, requestBody: []byte(`{"invalid": json}`), expectedStatus: http.StatusInternalServerError, expectError: true, @@ -123,7 +123,7 @@ func TestHandleEvents(t *testing.T) { name: "HTTP client error", dsInfo: &datasourceInfo{ Id: 1, - URL: "http://example.com", + URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, }, requestBody: func() []byte { @@ -139,7 +139,7 @@ func TestHandleEvents(t *testing.T) { name: "Invalid response JSON", dsInfo: &datasourceInfo{ Id: 1, - URL: "http://example.com", + URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}}, }, requestBody: func() []byte { @@ -149,7 +149,7 @@ func TestHandleEvents(t *testing.T) { }(), expectedStatus: http.StatusInternalServerError, expectError: true, - errorContains: "failed to unmarshal events response", + errorContains: "failed to parse events response", }, } @@ -181,13 +181,162 @@ func TestHandleEvents(t *testing.T) { } } +func TestHandleMetricsFind(t *testing.T) { + mockMetrics := []GraphiteMetricsFindResponse{ + {Text: "metric1", Id: "metric1.id", AllowChildren: 1, Expandable: 1, Leaf: 0}, + {Text: "metric2", Id: "metric2.id", AllowChildren: 0, Expandable: 0, Leaf: 1}, + } + mockResp, _ := json.Marshal(mockMetrics) + + tests := []struct { + name string + dsInfo *datasourceInfo + requestBody []byte + 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}}, + }, + requestBody: func() []byte { + request := GraphiteMetricsFindRequest{Query: "app.grafana.*"} + body, _ := json.Marshal(request) + return body + }(), + expectedStatus: 200, + expectError: false, + expectedMetrics: mockMetrics, + }, + { + name: "Success with query and time range", + dsInfo: &datasourceInfo{ + Id: 1, + 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 + }(), + expectedStatus: 200, + expectError: false, + expectedMetrics: mockMetrics, + }, + { + 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: "Empty query", + dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, + requestBody: func() []byte { + request := GraphiteMetricsFindRequest{Query: ""} + body, _ := json.Marshal(request) + return body + }(), + expectedStatus: http.StatusBadRequest, + expectError: true, + errorContains: "query is required", + }, + { + name: "Invalid URL", + dsInfo: &datasourceInfo{ + Id: 1, + URL: "ht tp://invalid url", // Invalid URL + }, + requestBody: func() []byte { + request := GraphiteMetricsFindRequest{Query: "app.grafana.*"} + body, _ := json.Marshal(request) + return body + }(), + expectedStatus: http.StatusInternalServerError, + expectError: true, + errorContains: "unexpected error", + }, + { + name: "HTTP client error", + dsInfo: &datasourceInfo{ + Id: 1, + 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 + }(), + expectedStatus: http.StatusInternalServerError, + expectError: true, + errorContains: "failed to complete metrics find request", + }, + { + name: "Invalid response JSON", + dsInfo: &datasourceInfo{ + Id: 1, + 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 + }(), + expectedStatus: http.StatusInternalServerError, + expectError: true, + errorContains: "failed to parse metrics find response", + }, + } + + 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.requestBody) + + 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 TestHandleResourceReq_Success(t *testing.T) { mockEvents := []GraphiteEventsResponse{{When: 1234567890, What: "event1"}} mockResp, _ := json.Marshal(mockEvents) dsInfo := datasourceInfo{ Id: 1, - URL: "http://example.com", + URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, } @@ -234,7 +383,7 @@ func TestHandleResourceReq_GetDSInfoError(t *testing.T) { } func TestHandleResourceReq_NilHandler(t *testing.T) { - dsInfo := datasourceInfo{Id: 1, URL: "http://example.com"} + dsInfo := datasourceInfo{Id: 1, URL: "http://graphite.grafana"} svc := &Service{ logger: log.NewNullLogger(), diff --git a/pkg/tsdb/graphite/types.go b/pkg/tsdb/graphite/types.go index 9265b6c4855..8bc46177cf0 100644 --- a/pkg/tsdb/graphite/types.go +++ b/pkg/tsdb/graphite/types.go @@ -31,3 +31,17 @@ type GraphiteEventsResponse struct { Tags []string `json:"tags"` Data string `json:"data"` } + +type GraphiteMetricsFindRequest struct { + From string `json:"from"` + Until string `json:"until"` + Query string `json:"query"` +} + +type GraphiteMetricsFindResponse struct { + Text string `json:"text"` + Id string `json:"id"` + AllowChildren int `json:"allowChildren"` + Expandable int `json:"expandable"` + Leaf int `json:"leaf"` +} diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index 8d1df35715d..2a51d0107d6 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -690,7 +690,7 @@ export class GraphiteDatasource * * For more complex searches use requestMetricExpand */ - private requestMetricFind( + private async requestMetricFind( query: string, requestId: string, range?: { from: string | number; until: string | number } @@ -702,6 +702,14 @@ export class GraphiteDatasource params.until = range.until; } + 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}`, + query, + }); + } + const httpOptions: BackendSrvRequest = { method: 'POST', url: '/metrics/find',