mirror of https://github.com/grafana/grafana.git
				
				
				
			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:
		
							parent
							
								
									c7b6822a5e
								
							
						
					
					
						commit
						e5519161f2
					
				
							
								
								
									
										2
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										2
									
								
								go.mod
								
								
								
								
							|  | @ -476,6 +476,8 @@ require github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-ac | ||||||
| 
 | 
 | ||||||
| require github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs | require github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs | ||||||
| 
 | 
 | ||||||
|  | require github.com/openzipkin/zipkin-go v0.4.3 // @grafana/oss-big-tent | ||||||
|  | 
 | ||||||
| require ( | require ( | ||||||
| 	cloud.google.com/go/longrunning v0.6.0 // indirect | 	cloud.google.com/go/longrunning v0.6.0 // indirect | ||||||
| 	github.com/at-wat/mqtt-go v0.19.4 // indirect | 	github.com/at-wat/mqtt-go v0.19.4 // indirect | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										2
									
								
								go.sum
								
								
								
								
							|  | @ -2903,6 +2903,8 @@ github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJ | ||||||
| github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= | github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= | ||||||
| github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= | github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= | ||||||
| github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= | github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= | ||||||
|  | github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= | ||||||
|  | github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= | ||||||
| github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= | ||||||
| github.com/ovh/go-ovh v1.4.3 h1:Gs3V823zwTFpzgGLZNI6ILS4rmxZgJwJCz54Er9LwD0= | github.com/ovh/go-ovh v1.4.3 h1:Gs3V823zwTFpzgGLZNI6ILS4rmxZgJwJCz54Er9LwD0= | ||||||
| github.com/ovh/go-ovh v1.4.3/go.mod h1:AkPXVtgwB6xlKblMjRKJJmjRp+ogrE7fz2lVgcQY8SY= | github.com/ovh/go-ovh v1.4.3/go.mod h1:AkPXVtgwB6xlKblMjRKJJmjRp+ogrE7fz2lVgcQY8SY= | ||||||
|  |  | ||||||
|  | @ -2,12 +2,14 @@ package zipkin | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
|  | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/backend" | 	"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/log" | ||||||
|  | 	"github.com/openzipkin/zipkin-go/model" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type ZipkinClient struct { | type ZipkinClient struct { | ||||||
|  | @ -48,3 +50,119 @@ func (z *ZipkinClient) Services() ([]string, error) { | ||||||
| 	} | 	} | ||||||
| 	return services, err | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  | 			} | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -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) | ||||||
|  | } | ||||||
|  | @ -8,6 +8,7 @@ import ( | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/backend" | 	"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/datasource" | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" | 	"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" | 	"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", | 		Message: "Data source is working", | ||||||
| 	}, nil | 	}, 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) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -29,7 +29,7 @@ describe('useServices', () => { | ||||||
|   it('returns services from datasource', async () => { |   it('returns services from datasource', async () => { | ||||||
|     const ds = { |     const ds = { | ||||||
|       async metadataRequest(url) { |       async metadataRequest(url) { | ||||||
|         if (url === '/api/v2/services') { |         if (url === 'services') { | ||||||
|           return Promise.resolve(['service1', 'service2']); |           return Promise.resolve(['service1', 'service2']); | ||||||
|         } |         } | ||||||
|         return undefined; |         return undefined; | ||||||
|  | @ -50,11 +50,11 @@ describe('useLoadOptions', () => { | ||||||
|   it('loads spans and traces', async () => { |   it('loads spans and traces', async () => { | ||||||
|     const ds = { |     const ds = { | ||||||
|       async metadataRequest(url, params) { |       async metadataRequest(url, params) { | ||||||
|         if (url === '/api/v2/spans' && params?.serviceName === 'service1') { |         if (url === 'spans' && params?.serviceName === 'service1') { | ||||||
|           return Promise.resolve(['span1', 'span2']); |           return Promise.resolve(['span1', 'span2']); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (url === '/api/v2/traces' && params?.serviceName === 'service1' && params?.spanName === 'span1') { |         if (url === 'traces' && params?.serviceName === 'service1' && params?.spanName === 'span1') { | ||||||
|           return Promise.resolve([[{ name: 'trace1', duration: 10_000, traceId: 'traceId1' }]]); |           return Promise.resolve([[{ name: 'trace1', duration: 10_000, traceId: 'traceId1' }]]); | ||||||
|         } |         } | ||||||
|         return undefined; |         return undefined; | ||||||
|  |  | ||||||
|  | @ -21,7 +21,6 @@ import { | ||||||
|   Button, |   Button, | ||||||
| } from '@grafana/ui'; | } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { apiPrefix } from './constants'; |  | ||||||
| import { ZipkinDatasource } from './datasource'; | import { ZipkinDatasource } from './datasource'; | ||||||
| import { ZipkinQuery, ZipkinQueryType, ZipkinSpan } from './types'; | import { ZipkinQuery, ZipkinQueryType, ZipkinSpan } from './types'; | ||||||
| 
 | 
 | ||||||
|  | @ -147,11 +146,9 @@ export function useServices( | ||||||
|   datasource: ZipkinDatasource, |   datasource: ZipkinDatasource, | ||||||
|   setErrorText: (text: string) => void |   setErrorText: (text: string) => void | ||||||
| ): AsyncState<CascaderOption[]> { | ): AsyncState<CascaderOption[]> { | ||||||
|   const url = `${apiPrefix}/services`; |  | ||||||
| 
 |  | ||||||
|   const [servicesOptions, fetch] = useAsyncFn(async (): Promise<CascaderOption[]> => { |   const [servicesOptions, fetch] = useAsyncFn(async (): Promise<CascaderOption[]> => { | ||||||
|     try { |     try { | ||||||
|       const services: string[] | null = await datasource.metadataRequest(url); |       const services: string[] | null = await datasource.metadataRequest('services'); | ||||||
|       if (services) { |       if (services) { | ||||||
|         return services.sort().map((service) => ({ |         return services.sort().map((service) => ({ | ||||||
|           label: service, |           label: service, | ||||||
|  | @ -191,12 +188,11 @@ export function useLoadOptions(datasource: ZipkinDatasource, setErrorText: (text | ||||||
| 
 | 
 | ||||||
|   const [, fetchSpans] = useAsyncFn( |   const [, fetchSpans] = useAsyncFn( | ||||||
|     async function findSpans(service: string): Promise<void> { |     async function findSpans(service: string): Promise<void> { | ||||||
|       const url = `${apiPrefix}/spans`; |  | ||||||
|       try { |       try { | ||||||
|         // The response of this should have been full ZipkinSpan objects based on API docs but is just list
 |         // The response of this should have been full ZipkinSpan objects based on API docs but is just list
 | ||||||
|         // of span names.
 |         // of span names.
 | ||||||
|         // TODO: check if this is some issue of version used or something else
 |         // TODO: check if this is some issue of version used or something else
 | ||||||
|         const response: string[] = await datasource.metadataRequest(url, { serviceName: service }); |         const response: string[] = await datasource.metadataRequest('spans', { serviceName: service }); | ||||||
|         if (isMounted()) { |         if (isMounted()) { | ||||||
|           setAllOptions((state) => { |           setAllOptions((state) => { | ||||||
|             const spanOptions = fromPairs(response.map((span: string) => [span, undefined])); |             const spanOptions = fromPairs(response.map((span: string) => [span, undefined])); | ||||||
|  | @ -218,7 +214,6 @@ export function useLoadOptions(datasource: ZipkinDatasource, setErrorText: (text | ||||||
| 
 | 
 | ||||||
|   const [, fetchTraces] = useAsyncFn( |   const [, fetchTraces] = useAsyncFn( | ||||||
|     async function findTraces(serviceName: string, spanName: string): Promise<void> { |     async function findTraces(serviceName: string, spanName: string): Promise<void> { | ||||||
|       const url = `${apiPrefix}/traces`; |  | ||||||
|       const search = { |       const search = { | ||||||
|         serviceName, |         serviceName, | ||||||
|         spanName, |         spanName, | ||||||
|  | @ -226,7 +221,7 @@ export function useLoadOptions(datasource: ZipkinDatasource, setErrorText: (text | ||||||
|       }; |       }; | ||||||
|       try { |       try { | ||||||
|         // This should return just root traces as there isn't any nesting
 |         // This should return just root traces as there isn't any nesting
 | ||||||
|         const traces: ZipkinSpan[][] = await datasource.metadataRequest(url, search); |         const traces: ZipkinSpan[][] = await datasource.metadataRequest('traces', search); | ||||||
|         if (isMounted()) { |         if (isMounted()) { | ||||||
|           const newTraces = traces.length |           const newTraces = traces.length | ||||||
|             ? fromPairs( |             ? fromPairs( | ||||||
|  |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| export const apiPrefix = '/api/v2'; |  | ||||||
|  | @ -76,7 +76,7 @@ describe('ZipkinDatasource', () => { | ||||||
|     it('runs query', async () => { |     it('runs query', async () => { | ||||||
|       setupBackendSrv(['service 1', 'service 2'] as unknown as ZipkinSpan[]); |       setupBackendSrv(['service 1', 'service 2'] as unknown as ZipkinSpan[]); | ||||||
|       const ds = new ZipkinDatasource(defaultSettings); |       const ds = new ZipkinDatasource(defaultSettings); | ||||||
|       const response = await ds.metadataRequest('/api/v2/services'); |       const response = await ds.metadataRequest('services'); | ||||||
|       expect(response).toEqual(['service 1', 'service 2']); |       expect(response).toEqual(['service 1', 'service 2']); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  | @ -22,11 +22,12 @@ import { | ||||||
|   TemplateSrv, |   TemplateSrv, | ||||||
| } from '@grafana/runtime'; | } from '@grafana/runtime'; | ||||||
| 
 | 
 | ||||||
| import { apiPrefix } from './constants'; |  | ||||||
| import { ZipkinQuery, ZipkinSpan } from './types'; | import { ZipkinQuery, ZipkinSpan } from './types'; | ||||||
| import { createGraphFrames } from './utils/graphTransform'; | import { createGraphFrames } from './utils/graphTransform'; | ||||||
| import { transformResponse } from './utils/transforms'; | import { transformResponse } from './utils/transforms'; | ||||||
| 
 | 
 | ||||||
|  | const apiPrefix = '/api/v2'; | ||||||
|  | 
 | ||||||
| export interface ZipkinJsonData extends DataSourceJsonData { | export interface ZipkinJsonData extends DataSourceJsonData { | ||||||
|   nodeGraph?: NodeGraphOptions; |   nodeGraph?: NodeGraphOptions; | ||||||
| } | } | ||||||
|  | @ -68,7 +69,11 @@ export class ZipkinDatasource extends DataSourceWithBackend<ZipkinQuery, ZipkinJ | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async metadataRequest(url: string, params?: Record<string, unknown>) { |   async metadataRequest(url: string, params?: Record<string, unknown>) { | ||||||
|     const res = await lastValueFrom(this.request(url, params, { hideFromInspector: true })); |     if (config.featureToggles.zipkinBackendMigration) { | ||||||
|  |       return await this.getResource(url, params); | ||||||
|  |     } | ||||||
|  |     const urlWithPrefix = `${apiPrefix}/${url}`; | ||||||
|  |     const res = await lastValueFrom(this.request(urlWithPrefix, params, { hideFromInspector: true })); | ||||||
|     return res.data; |     return res.data; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -77,7 +82,7 @@ export class ZipkinDatasource extends DataSourceWithBackend<ZipkinQuery, ZipkinJ | ||||||
|       return await super.testDatasource(); |       return await super.testDatasource(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     await this.metadataRequest(`${apiPrefix}/services`); |     await this.metadataRequest('services'); | ||||||
|     return { status: 'success', message: 'Data source is working' }; |     return { status: 'success', message: 'Data source is working' }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue