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
This commit is contained in:
Andreas Christou
2025-09-12 22:35:10 +01:00
committed by GitHub
parent f258d8a417
commit 3e0f57a407
7 changed files with 959 additions and 209 deletions
+38
View File
@@ -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
}
+241
View File
@@ -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))
})
}
+6 -19
View File
@@ -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 {
+129 -98
View File
@@ -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) {
+516 -89
View File
@@ -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)
}
})
}
}
+14
View File
@@ -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"`
}
@@ -704,8 +704,8 @@ export class GraphiteDatasource
if (config.featureToggles.graphiteBackendMode) {
return await this.postResource<MetricFindValue[]>('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<MetricFindValue[]>('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',