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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user