Graphite: Backend metrics find endpoint (#110610)
* 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 * Add tests * Review * Review * Fix packages * Format * Fix merge issues
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
@@ -19,6 +20,7 @@ type resourceHandler func(context.Context, *datasourceInfo, []byte) ([]byte, int
|
||||
func (s *Service) newResourceMux() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/events", s.handleResourceReq(s.handleEvents))
|
||||
mux.HandleFunc("/metrics/find", s.handleResourceReq(s.handleMetricsFind))
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.Resp
|
||||
}()
|
||||
requestBody, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to read events request body", "error", err)
|
||||
s.logger.Error("Failed to read request body", "error", err)
|
||||
writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("unexpected error %v", err))
|
||||
return
|
||||
}
|
||||
@@ -62,7 +64,7 @@ func (s *Service) handleResourceReq(handlerFn resourceHandler) func(rw http.Resp
|
||||
rw.WriteHeader(statusCode)
|
||||
_, err = rw.Write(response)
|
||||
if err != nil {
|
||||
writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("failed to write events response: %v", err))
|
||||
writeErrorResponse(rw, http.StatusInternalServerError, fmt.Sprintf("failed to write response: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -90,11 +92,10 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ
|
||||
|
||||
eventsUrl.RawQuery = queryValues.Encode()
|
||||
|
||||
p := eventsUrl.String()
|
||||
graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil)
|
||||
graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodGet, eventsUrl.String(), nil)
|
||||
if err != nil {
|
||||
s.logger.Info("Failed to create request", "error", err)
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create request: %v", err)
|
||||
s.logger.Info("Failed to create events request", "error", err)
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create events request: %v", err)
|
||||
}
|
||||
|
||||
_, span := tracing.DefaultTracer().Start(ctx, "graphite events")
|
||||
@@ -119,21 +120,14 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ
|
||||
}
|
||||
}()
|
||||
|
||||
encoding := res.Header.Get("Content-Encoding")
|
||||
body, err := decode(encoding, res.Body)
|
||||
events, err := parseResponse[[]GraphiteEventsResponse](res)
|
||||
if err != nil {
|
||||
return nil, res.StatusCode, fmt.Errorf("failed to read events response: %v", err)
|
||||
}
|
||||
|
||||
events := []GraphiteEventsResponse{}
|
||||
err = json.Unmarshal(body, &events)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal events response: %v", err)
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse events response: %v", err)
|
||||
}
|
||||
|
||||
// We construct this struct to avoid frontend changes.
|
||||
graphiteEventsResponse, err := json.Marshal(map[string][]GraphiteEventsResponse{
|
||||
"data": events,
|
||||
"data": *events,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal events response: %s", err)
|
||||
@@ -142,6 +136,90 @@ func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, requ
|
||||
return graphiteEventsResponse, res.StatusCode, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleMetricsFind(ctx context.Context, dsInfo *datasourceInfo, requestBody []byte) ([]byte, int, error) {
|
||||
metricsFindRequestJson := GraphiteMetricsFindRequest{}
|
||||
err := json.Unmarshal(requestBody, &metricsFindRequestJson)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to unmarshal metrics find request body to JSON", "error", err)
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
if metricsFindRequestJson.Query == "" {
|
||||
return nil, http.StatusBadRequest, fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
metricsFindUrl, err := url.Parse(fmt.Sprintf("%s/metrics/find", dsInfo.URL))
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
queryValues := metricsFindUrl.Query()
|
||||
if metricsFindRequestJson.From != "" {
|
||||
queryValues.Set("from", metricsFindRequestJson.From)
|
||||
}
|
||||
if metricsFindRequestJson.Until != "" {
|
||||
queryValues.Set("until", metricsFindRequestJson.Until)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("query", metricsFindRequestJson.Query)
|
||||
|
||||
graphiteReq, err := http.NewRequestWithContext(ctx, http.MethodPost, metricsFindUrl.String(), strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
s.logger.Info("Failed to create metrics find request", "error", err)
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create metrics find request: %v", err)
|
||||
}
|
||||
graphiteReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
_, span := tracing.DefaultTracer().Start(ctx, "graphite metrics find")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.Int64("datasource_id", dsInfo.Id),
|
||||
)
|
||||
res, err := dsInfo.HTTPClient.Do(graphiteReq)
|
||||
if res != nil {
|
||||
span.SetAttributes(attribute.Int("graphite.response.code", res.StatusCode))
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to complete metrics find request: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
err := res.Body.Close()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
metrics, err := parseResponse[[]GraphiteMetricsFindResponse](res)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to parse metrics find response: %v", err)
|
||||
}
|
||||
|
||||
metricsFindResponse, err := json.Marshal(*metrics)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal metrics find response: %s", err)
|
||||
}
|
||||
|
||||
return metricsFindResponse, res.StatusCode, nil
|
||||
}
|
||||
|
||||
func parseResponse[V any](res *http.Response) (*V, error) {
|
||||
encoding := res.Header.Get("Content-Encoding")
|
||||
body, err := decode(encoding, res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
data := new(V)
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func writeErrorResponse(rw http.ResponseWriter, code int, msg string) {
|
||||
rw.WriteHeader(code)
|
||||
errorBody := map[string]string{
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestHandleEvents(t *testing.T) {
|
||||
name: "Success with tags",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://example.com",
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
@@ -84,7 +84,7 @@ func TestHandleEvents(t *testing.T) {
|
||||
name: "Success without tags",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://example.com",
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
@@ -98,7 +98,7 @@ func TestHandleEvents(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Invalid request body",
|
||||
dsInfo: &datasourceInfo{Id: 1, URL: "http://example.com"},
|
||||
dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"},
|
||||
requestBody: []byte(`{"invalid": json}`),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
@@ -123,7 +123,7 @@ func TestHandleEvents(t *testing.T) {
|
||||
name: "HTTP client error",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://example.com",
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
@@ -139,7 +139,7 @@ func TestHandleEvents(t *testing.T) {
|
||||
name: "Invalid response JSON",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://example.com",
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
@@ -149,7 +149,7 @@ func TestHandleEvents(t *testing.T) {
|
||||
}(),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
errorContains: "failed to unmarshal events response",
|
||||
errorContains: "failed to parse events response",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -181,13 +181,162 @@ func TestHandleEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetricsFind(t *testing.T) {
|
||||
mockMetrics := []GraphiteMetricsFindResponse{
|
||||
{Text: "metric1", Id: "metric1.id", AllowChildren: 1, Expandable: 1, Leaf: 0},
|
||||
{Text: "metric2", Id: "metric2.id", AllowChildren: 0, Expandable: 0, Leaf: 1},
|
||||
}
|
||||
mockResp, _ := json.Marshal(mockMetrics)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsInfo *datasourceInfo
|
||||
requestBody []byte
|
||||
expectedStatus int
|
||||
expectError bool
|
||||
errorContains string
|
||||
expectedMetrics []GraphiteMetricsFindResponse
|
||||
}{
|
||||
{
|
||||
name: "Success with query",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
request := GraphiteMetricsFindRequest{Query: "app.grafana.*"}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}(),
|
||||
expectedStatus: 200,
|
||||
expectError: false,
|
||||
expectedMetrics: mockMetrics,
|
||||
},
|
||||
{
|
||||
name: "Success with query and time range",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
request := GraphiteMetricsFindRequest{
|
||||
Query: "app.grafana.*",
|
||||
From: "now-1h",
|
||||
Until: "now",
|
||||
}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}(),
|
||||
expectedStatus: 200,
|
||||
expectError: false,
|
||||
expectedMetrics: mockMetrics,
|
||||
},
|
||||
{
|
||||
name: "Invalid request body",
|
||||
dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"},
|
||||
requestBody: []byte(`{"invalid": json}`),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
errorContains: "unexpected error",
|
||||
},
|
||||
{
|
||||
name: "Empty query",
|
||||
dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"},
|
||||
requestBody: func() []byte {
|
||||
request := GraphiteMetricsFindRequest{Query: ""}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}(),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
errorContains: "query is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid URL",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "ht tp://invalid url", // Invalid URL
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
request := GraphiteMetricsFindRequest{Query: "app.grafana.*"}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}(),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
errorContains: "unexpected error",
|
||||
},
|
||||
{
|
||||
name: "HTTP client error",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
request := GraphiteMetricsFindRequest{Query: "app.grafana.*"}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}(),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
errorContains: "failed to complete metrics find request",
|
||||
},
|
||||
{
|
||||
name: "Invalid response JSON",
|
||||
dsInfo: &datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}},
|
||||
},
|
||||
requestBody: func() []byte {
|
||||
request := GraphiteMetricsFindRequest{Query: "app.grafana.*"}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}(),
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
errorContains: "failed to parse metrics find response",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &Service{logger: log.NewNullLogger()}
|
||||
|
||||
respBody, status, err := svc.handleMetricsFind(context.Background(), tt.dsInfo, tt.requestBody)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, status)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, respBody)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, respBody)
|
||||
|
||||
if tt.expectedMetrics != nil {
|
||||
var result []GraphiteMetricsFindResponse
|
||||
require.NoError(t, json.Unmarshal(respBody, &result))
|
||||
assert.Equal(t, tt.expectedMetrics, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResourceReq_Success(t *testing.T) {
|
||||
mockEvents := []GraphiteEventsResponse{{When: 1234567890, What: "event1"}}
|
||||
mockResp, _ := json.Marshal(mockEvents)
|
||||
|
||||
dsInfo := datasourceInfo{
|
||||
Id: 1,
|
||||
URL: "http://example.com",
|
||||
URL: "http://graphite.grafana",
|
||||
HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}},
|
||||
}
|
||||
|
||||
@@ -234,7 +383,7 @@ func TestHandleResourceReq_GetDSInfoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleResourceReq_NilHandler(t *testing.T) {
|
||||
dsInfo := datasourceInfo{Id: 1, URL: "http://example.com"}
|
||||
dsInfo := datasourceInfo{Id: 1, URL: "http://graphite.grafana"}
|
||||
|
||||
svc := &Service{
|
||||
logger: log.NewNullLogger(),
|
||||
|
||||
@@ -31,3 +31,17 @@ type GraphiteEventsResponse struct {
|
||||
Tags []string `json:"tags"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type GraphiteMetricsFindRequest struct {
|
||||
From string `json:"from"`
|
||||
Until string `json:"until"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type GraphiteMetricsFindResponse struct {
|
||||
Text string `json:"text"`
|
||||
Id string `json:"id"`
|
||||
AllowChildren int `json:"allowChildren"`
|
||||
Expandable int `json:"expandable"`
|
||||
Leaf int `json:"leaf"`
|
||||
}
|
||||
|
||||
@@ -690,7 +690,7 @@ export class GraphiteDatasource
|
||||
*
|
||||
* For more complex searches use requestMetricExpand
|
||||
*/
|
||||
private requestMetricFind(
|
||||
private async requestMetricFind(
|
||||
query: string,
|
||||
requestId: string,
|
||||
range?: { from: string | number; until: string | number }
|
||||
@@ -702,6 +702,14 @@ export class GraphiteDatasource
|
||||
params.until = range.until;
|
||||
}
|
||||
|
||||
if (config.featureToggles.graphiteBackendMode) {
|
||||
return await this.postResource<MetricFindValue[]>('metrics/find', {
|
||||
from: typeof params.from === 'string' ? params.from : `${params.from}`,
|
||||
until: typeof params.until === 'string' ? params.until : `${params.until}`,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
const httpOptions: BackendSrvRequest = {
|
||||
method: 'POST',
|
||||
url: '/metrics/find',
|
||||
|
||||
Reference in New Issue
Block a user