Zipkin: Run resource calls through backend with feature toggle enabled (#96139)

* Zipkin: Run resource calls througgh backend with feature toggle enabled

* Update

* Don't return early in createZipkinURL and add tests

* Update pkg/tsdb/zipkin/client.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Update pkg/tsdb/zipkin/client.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Update pkg/tsdb/zipkin/client.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Update pkg/tsdb/zipkin/client_test.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Update pkg/tsdb/zipkin/client_test.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Update pkg/tsdb/zipkin/client_test.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Update pkg/tsdb/zipkin/client_test.go

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>

* Fix lint

* Fix tests

---------

Co-authored-by: Sriram <153843+yesoreyeram@users.noreply.github.com>
This commit is contained in:
Ivana Huckova
2024-11-11 13:04:22 +01:00
committed by GitHub
parent c7b6822a5e
commit e5519161f2
11 changed files with 588 additions and 16 deletions
+118
View File
@@ -2,12 +2,14 @@ package zipkin
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/openzipkin/zipkin-go/model"
)
type ZipkinClient struct {
@@ -48,3 +50,119 @@ func (z *ZipkinClient) Services() ([]string, error) {
}
return services, err
}
// Spans returns list of spans for the given service
// https://zipkin.io/zipkin-api/#/default/get_spans
func (z *ZipkinClient) Spans(serviceName string) ([]string, error) {
spans := []string{}
if serviceName == "" {
return spans, errors.New("invalid/empty serviceName")
}
spansUrl, err := createZipkinURL(z.url, "/api/v2/spans", map[string]string{"serviceName": serviceName})
if err != nil {
return spans, backend.DownstreamError(fmt.Errorf("failed to compose url: %w", err))
}
res, err := z.httpClient.Get(spansUrl)
defer func() {
if res != nil {
if err = res.Body.Close(); err != nil {
z.logger.Error("Failed to close response body", "error", err)
}
}
}()
if err != nil {
return spans, err
}
if err := json.NewDecoder(res.Body).Decode(&spans); err != nil {
return spans, err
}
return spans, err
}
// Traces returns list of traces for the given service and span
// https://zipkin.io/zipkin-api/#/default/get_traces
func (z *ZipkinClient) Traces(serviceName string, spanName string) ([][]model.SpanModel, error) {
traces := [][]model.SpanModel{}
if serviceName == "" {
return traces, errors.New("invalid/empty serviceName")
}
if spanName == "" {
return traces, errors.New("invalid/empty spanName")
}
tracesUrl, err := createZipkinURL(z.url, "/api/v2/traces", map[string]string{"serviceName": serviceName, "spanName": spanName})
if err != nil {
return traces, backend.DownstreamError(fmt.Errorf("failed to compose url: %w", err))
}
res, err := z.httpClient.Get(tracesUrl)
defer func() {
if res != nil {
if err = res.Body.Close(); err != nil {
z.logger.Error("Failed to close response body", "error", err)
}
}
}()
if err != nil {
return traces, err
}
if err := json.NewDecoder(res.Body).Decode(&traces); err != nil {
return traces, err
}
return traces, err
}
// Trace returns trace for the given traceId
// https://zipkin.io/zipkin-api/#/default/get_trace__traceId_
func (z *ZipkinClient) Trace(traceId string) ([]model.SpanModel, error) {
trace := []model.SpanModel{}
if traceId == "" {
return trace, errors.New("invalid/empty traceId")
}
traceUrl, err := url.JoinPath(z.url, "/api/v2/trace", url.QueryEscape(traceId))
if err != nil {
return trace, backend.DownstreamError(fmt.Errorf("failed to join url: %w", err))
}
res, err := z.httpClient.Get(traceUrl)
defer func() {
if res != nil {
if err = res.Body.Close(); err != nil {
z.logger.Error("Failed to close response body", "error", err)
}
}
}()
if err != nil {
return trace, err
}
if err := json.NewDecoder(res.Body).Decode(&trace); err != nil {
return trace, err
}
return trace, err
}
func createZipkinURL(baseURL string, path string, params map[string]string) (string, error) {
// Parse the base URL
finalUrl, err := url.Parse(baseURL)
if err != nil {
return "", err
}
// Add the path
urlPath, err := url.JoinPath(finalUrl.Path, path)
if err != nil {
return "", err
}
finalUrl.Path = urlPath
// If there are query parameters, add them
if len(params) > 0 {
queryParams := finalUrl.Query()
for k, v := range params {
queryParams.Set(k, v)
}
finalUrl.RawQuery = queryParams.Encode()
}
// Return the composed URL as a string
return finalUrl.String(), nil
}
+357
View File
@@ -0,0 +1,357 @@
package zipkin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/openzipkin/zipkin-go/model"
"github.com/stretchr/testify/assert"
)
func TestZipkinClient_Services(t *testing.T) {
tests := []struct {
name string
mockResponse string
mockStatusCode int
expectedResult []string
expectError bool
}{
{
name: "Successful response",
mockResponse: `["service1", "service2"]`,
mockStatusCode: http.StatusOK,
expectedResult: []string{"service1", "service2"},
expectError: false,
},
{
name: "Non-200 response",
mockResponse: "",
mockStatusCode: http.StatusInternalServerError,
expectedResult: []string{},
expectError: true,
},
{
name: "Invalid JSON response",
mockResponse: `{invalid json`,
mockStatusCode: http.StatusOK,
expectedResult: []string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/services", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
client, _ := New(server.URL, server.Client(), log.New())
services, err := client.Services()
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectedResult, services)
})
}
}
func TestZipkinClient_Spans(t *testing.T) {
tests := []struct {
name string
serviceName string
mockResponse string
mockStatusCode int
expectedResult []string
expectError bool
}{
{
name: "Successful response",
serviceName: "service1",
mockResponse: `["span1", "span2"]`,
mockStatusCode: http.StatusOK,
expectedResult: []string{"span1", "span2"},
expectError: false,
},
{
name: "Non-200 response",
serviceName: "service1",
mockResponse: "",
mockStatusCode: http.StatusNotFound,
expectedResult: []string{},
expectError: true,
},
{
name: "Invalid JSON response",
serviceName: "service1",
mockResponse: `{invalid json`,
mockStatusCode: http.StatusOK,
expectedResult: []string{},
expectError: true,
},
{
name: "Empty serviceName",
serviceName: "",
mockResponse: "",
mockStatusCode: http.StatusOK,
expectedResult: []string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/spans", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
client, _ := New(server.URL, server.Client(), log.New())
spans, err := client.Spans(tt.serviceName)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectedResult, spans)
})
}
}
func TestZipkinClient_Traces(t *testing.T) {
tests := []struct {
name string
serviceName string
spanName string
mockResponse interface{}
mockStatusCode int
expectedResult [][]model.SpanModel
expectError bool
expectedError string
}{
{
name: "Successful response",
serviceName: "service1",
spanName: "span1",
mockResponse: [][]model.SpanModel{{{SpanContext: model.SpanContext{TraceID: model.TraceID{Low: 1234}, ID: 1}, Name: "operation1", Tags: map[string]string{"key1": "value1"}}}},
mockStatusCode: http.StatusOK,
expectedResult: [][]model.SpanModel{{{SpanContext: model.SpanContext{TraceID: model.TraceID{Low: 1234}, ID: 1}, Name: "operation1", Tags: map[string]string{"key1": "value1"}}}},
expectError: false,
expectedError: "",
},
{
name: "Non-200 response",
serviceName: "service1",
spanName: "span1",
mockResponse: nil,
mockStatusCode: http.StatusForbidden,
expectedResult: [][]model.SpanModel{},
expectError: true,
expectedError: "EOF",
},
{
name: "Empty serviceName",
serviceName: "",
spanName: "span1",
mockResponse: nil,
mockStatusCode: http.StatusOK,
expectedResult: [][]model.SpanModel{},
expectError: true,
expectedError: "invalid/empty serviceName",
},
{
name: "Empty spanName",
serviceName: "service1",
spanName: "",
mockResponse: nil,
mockStatusCode: http.StatusOK,
expectedResult: [][]model.SpanModel{},
expectError: true,
expectedError: "invalid/empty spanName",
},
{
name: "Valid response with empty trace list",
serviceName: "service1",
spanName: "span1",
mockResponse: [][]model.SpanModel{},
mockStatusCode: http.StatusOK,
expectedResult: [][]model.SpanModel{},
expectError: false,
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var response []byte
if mockData, ok := tt.mockResponse.([][]model.SpanModel); ok {
response, _ = json.Marshal(mockData)
} else if str, ok := tt.mockResponse.(string); ok {
response = []byte(str)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/traces", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write(response)
}))
defer server.Close()
client, _ := New(server.URL, server.Client(), log.New())
traces, err := client.Traces(tt.serviceName, tt.spanName)
if tt.expectError {
assert.Error(t, err)
assert.Equal(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectedResult, traces)
})
}
}
func TestZipkinClient_Trace(t *testing.T) {
tests := []struct {
name string
traceID string
mockResponse string
mockStatusCode int
expectedResult []model.SpanModel
expectError bool
expectedError string
}{
{
name: "Successful response",
traceID: "trace-id",
mockResponse: `[{"traceId":"00000000000004d2","id":"0000000000000001","name":"operation1","tags":{"key1":"value1"}}]`,
mockStatusCode: http.StatusOK,
expectedResult: []model.SpanModel{
{
SpanContext: model.SpanContext{
TraceID: model.TraceID{Low: 1234},
ID: model.ID(1),
},
Name: "operation1",
Tags: map[string]string{"key1": "value1"},
},
},
expectError: false,
},
{
name: "Invalid traceID",
traceID: "",
mockResponse: "",
mockStatusCode: http.StatusOK,
expectedResult: []model.SpanModel{},
expectError: true,
expectedError: "invalid/empty traceId",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var client ZipkinClient
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/trace/"+tt.traceID, r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
client, _ = New(server.URL, server.Client(), log.New())
trace, err := client.Trace(tt.traceID)
if tt.expectError {
assert.Error(t, err)
assert.Empty(t, trace)
assert.Equal(t, tt.expectedError, err.Error())
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, trace)
}
})
}
}
func TestCreateZipkinURL(t *testing.T) {
tests := []struct {
name string
baseURL string
path string
params map[string]string
expected string
shouldErr bool
}{
{
name: "WithPathAndParams",
baseURL: "http://example.com",
path: "api/v1/trace",
params: map[string]string{"key1": "value1", "key2": "value2"},
expected: "http://example.com/api/v1/trace?key1=value1&key2=value2",
},
{
name: "OnlyParams",
baseURL: "http://example.com",
path: "",
params: map[string]string{"key1": "value1"},
expected: "http://example.com?key1=value1",
},
{
name: "NoParams",
baseURL: "http://example.com",
path: "api/v1/trace",
params: map[string]string{},
expected: "http://example.com/api/v1/trace",
},
{
name: "InvalidBaseURL",
baseURL: "http://example .com",
path: "api/v1/trace",
params: map[string]string{},
shouldErr: true,
},
{
name: "BaseURLWithPath",
baseURL: "http://example.com/base",
path: "api/v1/trace",
params: map[string]string{"key1": "value1"},
expected: "http://example.com/base/api/v1/trace?key1=value1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := createZipkinURL(tc.baseURL, tc.path, tc.params)
if tc.shouldErr {
if err == nil {
t.Fatalf("Expected error, but got nil")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if result != tc.expected {
t.Errorf("Expected %s, got %s", tc.expected, result)
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package zipkin
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func (s *Service) registerResourceRoutes() *http.ServeMux {
router := http.NewServeMux()
router.HandleFunc("GET /services", s.withDatasourceHandlerFunc(getServicesHandler))
router.HandleFunc("GET /spans", s.withDatasourceHandlerFunc(getSpansHandler))
router.HandleFunc("GET /traces", s.withDatasourceHandlerFunc(getTracesHandler))
router.HandleFunc("GET /trace/{traceId}", s.withDatasourceHandlerFunc(getTraceHandler))
return router
}
func (s *Service) withDatasourceHandlerFunc(getHandler func(d *datasourceInfo) http.HandlerFunc) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
client, err := s.getDSInfo(r.Context(), backend.PluginConfigFromContext(r.Context()))
if err != nil {
writeResponse(nil, errors.New("error getting data source information from context"), rw, client.ZipkinClient.logger)
return
}
h := getHandler(client)
h.ServeHTTP(rw, r)
}
}
func getServicesHandler(ds *datasourceInfo) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
services, err := ds.ZipkinClient.Services()
writeResponse(services, err, rw, ds.ZipkinClient.logger)
}
}
func getSpansHandler(ds *datasourceInfo) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
serviceName := strings.TrimSpace(r.URL.Query().Get("serviceName"))
spans, err := ds.ZipkinClient.Spans(serviceName)
writeResponse(spans, err, rw, ds.ZipkinClient.logger)
}
}
func getTracesHandler(ds *datasourceInfo) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
serviceName := strings.TrimSpace(r.URL.Query().Get("serviceName"))
spanName := strings.TrimSpace(r.URL.Query().Get("spanName"))
traces, err := ds.ZipkinClient.Traces(serviceName, spanName)
writeResponse(traces, err, rw, ds.ZipkinClient.logger)
}
}
func getTraceHandler(ds *datasourceInfo) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
traceId := strings.TrimSpace(r.PathValue("traceId"))
trace, err := ds.ZipkinClient.Trace(traceId)
writeResponse(trace, err, rw, ds.ZipkinClient.logger)
}
}
func writeResponse(res interface{}, err error, rw http.ResponseWriter, logger log.Logger) {
if err != nil {
// This is used for resource calls, we don't need to add actual error message, but we should log it
logger.Warn("An error occurred while doing a resource call", "error", err)
http.Error(rw, "An error occurred within the plugin", http.StatusInternalServerError)
return
}
// Response should not be string, but just in case, handle it
if str, ok := res.(string); ok {
rw.Header().Set("Content-Type", "text/plain")
_, _ = rw.Write([]byte(str))
return
}
b, err := json.Marshal(res)
if err != nil {
// This is used for resource calls, we don't need to add actual error message, but we should log it
logger.Warn("An error occurred while processing response from resource call", "error", err)
http.Error(rw, "An error occurred within the plugin", http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", "application/json")
_, _ = rw.Write(b)
}
+6
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana/pkg/infra/httpclient"
)
@@ -81,3 +82,8 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
Message: "Data source is working",
}, nil
}
func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
handler := httpadapter.New(s.registerResourceRoutes())
return handler.CallResource(ctx, req, sender)
}