diff --git a/pkg/tsdb/zipkin/client.go b/pkg/tsdb/zipkin/client.go index ff60decb326..4002d89ee91 100644 --- a/pkg/tsdb/zipkin/client.go +++ b/pkg/tsdb/zipkin/client.go @@ -127,6 +127,10 @@ func (z *ZipkinClient) Trace(traceId string) ([]model.SpanModel, error) { } res, err := z.httpClient.Get(traceUrl) + if err != nil { + return trace, err + } + defer func() { if res != nil { if err = res.Body.Close(); err != nil { @@ -134,9 +138,6 @@ func (z *ZipkinClient) Trace(traceId string) ([]model.SpanModel, error) { } } }() - if err != nil { - return trace, err - } if err := json.NewDecoder(res.Body).Decode(&trace); err != nil { return trace, err } diff --git a/pkg/tsdb/zipkin/client_test.go b/pkg/tsdb/zipkin/client_test.go index 0e690194349..c62d10b8d97 100644 --- a/pkg/tsdb/zipkin/client_test.go +++ b/pkg/tsdb/zipkin/client_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend/log" @@ -250,7 +251,8 @@ func TestZipkinClient_Trace(t *testing.T) { Tags: map[string]string{"key1": "value1"}, }, }, - expectError: false, + expectError: false, + expectedError: "", }, { name: "Invalid traceID", @@ -261,13 +263,31 @@ func TestZipkinClient_Trace(t *testing.T) { expectError: true, expectedError: "invalid/empty traceId", }, + { + name: "Special characters traceID", + traceID: "a/b", + 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, + expectedError: "", + }, } 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) + assert.Contains(t, r.URL.String(), "/api/v2/trace/"+url.QueryEscape(tt.traceID)) w.WriteHeader(tt.mockStatusCode) _, _ = w.Write([]byte(tt.mockResponse)) })) diff --git a/pkg/tsdb/zipkin/handler_querydata.go b/pkg/tsdb/zipkin/handler_querydata.go new file mode 100644 index 00000000000..3e08ad9e585 --- /dev/null +++ b/pkg/tsdb/zipkin/handler_querydata.go @@ -0,0 +1,260 @@ +package zipkin + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/openzipkin/zipkin-go/model" +) + +func queryData(ctx context.Context, dsInfo *datasourceInfo, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + response := backend.NewQueryDataResponse() + logger := dsInfo.ZipkinClient.logger.FromContext(ctx) + + for _, q := range req.Queries { + query, err := loadQuery(q) + if err != nil { + es := backend.ErrorSourcePlugin + if backend.IsDownstreamError(err) { + es = backend.ErrorSourceDownstream + } + response.Responses[q.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: es, + } + continue + } + + switch query.QueryType { + case zipkinQueryTypeUpload: + logger.Debug("upload query type is not supported in backend mode") + response.Responses[q.RefID] = backend.DataResponse{ + Error: fmt.Errorf("unsupported query type %s. only available in frontend mode", query.QueryType), + ErrorSource: backend.ErrorSourcePlugin, + } + default: + traces, err := dsInfo.ZipkinClient.Trace(query.Query) + if err != nil { + es := backend.ErrorSourcePlugin + if backend.IsDownstreamHTTPError(err) { + es = backend.ErrorSourceDownstream + } + response.Responses[q.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: es, + } + continue + } + + frame := transformResponse(traces, q.RefID) + response.Responses[q.RefID] = backend.DataResponse{ + Frames: []*data.Frame{frame}, + } + } + } + return response, nil +} + +type zipkinQueryType string + +const ( + zipkinQueryTypeTraceId zipkinQueryType = "traceID" + zipkinQueryTypeUpload zipkinQueryType = "upload" +) + +type zipkinQuery struct { + Query string `json:"query,omitempty"` + QueryType zipkinQueryType `json:"queryType,omitempty"` +} + +func loadQuery(backendQuery backend.DataQuery) (zipkinQuery, error) { + var query zipkinQuery + err := json.Unmarshal(backendQuery.JSON, &query) + if err != nil { + return query, backend.DownstreamError(fmt.Errorf("error while parsing the query json. %w", err)) + } + return query, err +} + +type TraceKeyValuePair struct { + Key string `json:"key"` + Value interface{} `json:"value"` + Type string `json:"type,omitempty"` +} + +type TraceLog struct { + Timestamp int64 + Fields []TraceKeyValuePair +} + +func transformResponse(zipkinSpans []model.SpanModel, refId string) *data.Frame { + newFrame := data.NewFrame(refId, + data.NewField("traceID", nil, []string{}), + data.NewField("spanID", nil, []string{}), + data.NewField("parentSpanID", nil, []*string{}), + data.NewField("operationName", nil, []string{}), + data.NewField("serviceName", nil, []string{}), + data.NewField("serviceTags", nil, []json.RawMessage{}), + data.NewField("startTime", nil, []float64{}), + data.NewField("duration", nil, []float64{}), + data.NewField("logs", nil, []json.RawMessage{}), + data.NewField("tags", nil, []json.RawMessage{}), + ) + + newFrame.Meta = &data.FrameMeta{ + PreferredVisualization: "trace", + Custom: map[string]interface{}{ + "traceFormat": "zipkin", + }, + } + + // go through each span and add to the frame + for _, span := range zipkinSpans { + var parentSpanIdString *string + if span.ParentID != nil { + s := span.ParentID.String() + parentSpanIdString = &s + } + var serviceTags json.RawMessage + serviceTagsMarshaled, err := json.Marshal(getServiceTags(span)) + if err == nil { + serviceTags = json.RawMessage(serviceTagsMarshaled) + } + + var logs json.RawMessage + logsMarshaled, err := json.Marshal(transformAnnotationsToTraceLogs(span.Annotations)) + if err == nil { + logs = json.RawMessage(logsMarshaled) + } + + var tags json.RawMessage + tagsMarshaled, err := json.Marshal(transformTags(span)) + if err == nil { + tags = json.RawMessage(tagsMarshaled) + } + newFrame.AppendRow( + span.TraceID.String(), + span.ID.String(), + parentSpanIdString, + span.Name, + getServiceName(span), + serviceTags, + float64(span.Timestamp.UnixMicro())/1000, + float64(span.Duration.Microseconds())/1000, + logs, + tags, + ) + } + return newFrame +} + +func getServiceName(span model.SpanModel) string { + if span.LocalEndpoint != nil && span.LocalEndpoint.ServiceName != "" { + return span.LocalEndpoint.ServiceName + } else if span.RemoteEndpoint != nil && span.RemoteEndpoint.ServiceName != "" { + return span.RemoteEndpoint.ServiceName + } + return "unknown" +} + +func getServiceTags(span model.SpanModel) []TraceKeyValuePair { + tags := make([]TraceKeyValuePair, 0, 4) + endpoint := span.LocalEndpoint + endpointType := "local" + + if endpoint == nil { + endpoint = span.RemoteEndpoint + endpointType = "remote" + } + + if endpoint == nil { + return tags + } + + if endpoint.IPv4 != nil { + tag := valueToTag("ipv4", endpoint.IPv4.String()) + tags = append(tags, tag) + } + + if endpoint.IPv6 != nil { + tag := valueToTag("ipv6", endpoint.IPv6.String()) + tags = append(tags, tag) + } + + if endpoint.Port != 0 { + tag := valueToTag("port", endpoint.Port) + tags = append(tags, tag) + } + + if endpointType != "" { + tag := valueToTag("endpointType", endpointType) + tags = append(tags, tag) + } + return tags +} + +func valueToTag(key string, value interface{}) TraceKeyValuePair { + return TraceKeyValuePair{ + Key: key, + Value: value, + } +} + +func transformAnnotationsToTraceLogs(annotations []model.Annotation) []TraceLog { + transformed := make([]TraceLog, 0, len(annotations)) + if len(annotations) == 0 { + return transformed + } + + for _, annotation := range annotations { + transformedAnnotation := TraceLog{ + Timestamp: annotation.Timestamp.UnixMicro(), + Fields: []TraceKeyValuePair{ + { + Key: "annotation", + Value: annotation.Value, + }, + }, + } + transformed = append(transformed, transformedAnnotation) + } + return transformed +} + +func transformTags(span model.SpanModel) []TraceKeyValuePair { + tags := make([]TraceKeyValuePair, 0, len(span.Tags)+2) + + for key, value := range span.Tags { + if key == "error" { + // Remap error tag to show error icon and include error details + tags = append(tags, TraceKeyValuePair{ + Key: "error", + Value: true, + }) + tags = append(tags, TraceKeyValuePair{ + Key: "errorValue", + Value: value, + }) + } else { + tags = append(tags, TraceKeyValuePair{ + Key: key, + Value: value, + }) + } + } + + // Prepend kind if present + if span.Kind != "" { + tags = append([]TraceKeyValuePair{{Key: "kind", Value: span.Kind}}, tags...) + } + + // Prepend shared if present + if span.Shared { + tags = append([]TraceKeyValuePair{{Key: "shared", Value: span.Shared}}, tags...) + } + + return tags +} diff --git a/pkg/tsdb/zipkin/handler_querydata.test.go b/pkg/tsdb/zipkin/handler_querydata.test.go new file mode 100644 index 00000000000..2ec92927730 --- /dev/null +++ b/pkg/tsdb/zipkin/handler_querydata.test.go @@ -0,0 +1,83 @@ +package zipkin + +import ( + "net" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/experimental" + "github.com/openzipkin/zipkin-go/model" +) + +func TestTransformResponse(t *testing.T) { + t.Run("simple_trace", func(t *testing.T) { + span1 := model.SpanModel{ + SpanContext: model.SpanContext{ + TraceID: model.TraceID{ + High: 123, + Low: 456, + }, + ID: 1, + }, + Name: "span 1", + Kind: "CLIENT", + Timestamp: time.Unix(0, 1*int64(time.Microsecond)), + Duration: 10 * time.Microsecond, + LocalEndpoint: &model.Endpoint{ + ServiceName: "service 1", + IPv4: net.IPv4(1, 0, 0, 1), + Port: 42, + }, + Annotations: []model.Annotation{ + {Timestamp: time.Unix(0, 2*int64(time.Microsecond)), Value: "annotation text"}, + {Timestamp: time.Unix(0, 6*int64(time.Microsecond)), Value: "annotation text 3"}, + }, + Tags: map[string]string{ + "tag1": "val1", + "tag2": "val2", + }, + } + span2 := model.SpanModel{ + SpanContext: model.SpanContext{ + TraceID: model.TraceID{ + High: 123, + Low: 456, + }, + ID: 2, + ParentID: &span1.ID, + }, + Name: "span 2", + Timestamp: time.Unix(0, 4*int64(time.Microsecond)), + Duration: 5 * time.Microsecond, + LocalEndpoint: &model.Endpoint{ + ServiceName: "service 2", + IPv4: net.IPv4(1, 0, 0, 1), + }, + Tags: map[string]string{ + "error": "404", + }, + } + + span3 := model.SpanModel{ + SpanContext: model.SpanContext{ + TraceID: model.TraceID{ + High: 123, + Low: 456, + }, + ID: 3, + ParentID: &span1.ID, + }, + Name: "span 3", + Timestamp: time.Unix(0, 6*int64(time.Microsecond)), + Duration: 7 * time.Microsecond, + RemoteEndpoint: &model.Endpoint{ + ServiceName: "spanstore-jdbc", + IPv6: net.ParseIP("::1"), + }, + } + + spans := []model.SpanModel{span1, span2, span3} + frames := transformResponse(spans, "test") + experimental.CheckGoldenJSONFrame(t, "./testdata", "simple_trace.golden", frames, false) + }) +} diff --git a/pkg/tsdb/zipkin/testdata/simple_trace.golden.jsonc b/pkg/tsdb/zipkin/testdata/simple_trace.golden.jsonc new file mode 100644 index 00000000000..420457c3330 --- /dev/null +++ b/pkg/tsdb/zipkin/testdata/simple_trace.golden.jsonc @@ -0,0 +1,247 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "custom": { +// "traceFormat": "zipkin" +// }, +// "preferredVisualisationType": "trace" +// } +// Name: test +// Dimensions: 10 Fields by 3 Rows +// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+ +// | Name: traceID | Name: spanID | Name: parentSpanID | Name: operationName | Name: serviceName | Name: serviceTags | Name: startTime | Name: duration | Name: logs | Name: tags | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []string | Type: []string | Type: []*string | Type: []string | Type: []string | Type: []json.RawMessage | Type: []float64 | Type: []float64 | Type: []json.RawMessage | Type: []json.RawMessage | +// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+ +// | 000000000000007b00000000000001c8 | 0000000000000001 | null | span 1 | service 1 | [{"key":"ipv4","value":"1.0.0.1"},{"key":"port","value":42},{"key":"endpointType","value":"local"}] | 0.001 | 0.01 | [{"Timestamp":2,"Fields":[{"key":"annotation","value":"annotation text"}],"Name":""},{"Timestamp":6,"Fields":[{"key":"annotation","value":"annotation text 3"}],"Name":""}] | [{"key":"kind","value":"CLIENT"},{"key":"tag2","value":"val2"},{"key":"tag1","value":"val1"}] | +// | 000000000000007b00000000000001c8 | 0000000000000002 | 0000000000000001 | span 2 | service 2 | [{"key":"ipv4","value":"1.0.0.1"},{"key":"endpointType","value":"local"}] | 0.004 | 0.005 | [] | [{"key":"error","value":true},{"key":"errorValue","value":"404"}] | +// | 000000000000007b00000000000001c8 | 0000000000000003 | 0000000000000001 | span 3 | spanstore-jdbc | [{"key":"ipv6","value":"::1"},{"key":"endpointType","value":"remote"}] | 0.006 | 0.007 | [] | [] | +// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "name": "test", + "meta": { + "typeVersion": [ + 0, + 0 + ], + "custom": { + "traceFormat": "zipkin" + }, + "preferredVisualisationType": "trace" + }, + "fields": [ + { + "name": "traceID", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "spanID", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "parentSpanID", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "operationName", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "serviceName", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "serviceTags", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + }, + { + "name": "startTime", + "type": "number", + "typeInfo": { + "frame": "float64" + } + }, + { + "name": "duration", + "type": "number", + "typeInfo": { + "frame": "float64" + } + }, + { + "name": "logs", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + }, + { + "name": "tags", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage" + } + } + ] + }, + "data": { + "values": [ + [ + "000000000000007b00000000000001c8", + "000000000000007b00000000000001c8", + "000000000000007b00000000000001c8" + ], + [ + "0000000000000001", + "0000000000000002", + "0000000000000003" + ], + [ + null, + "0000000000000001", + "0000000000000001" + ], + [ + "span 1", + "span 2", + "span 3" + ], + [ + "service 1", + "service 2", + "spanstore-jdbc" + ], + [ + [ + { + "key": "ipv4", + "value": "1.0.0.1" + }, + { + "key": "port", + "value": 42 + }, + { + "key": "endpointType", + "value": "local" + } + ], + [ + { + "key": "ipv4", + "value": "1.0.0.1" + }, + { + "key": "endpointType", + "value": "local" + } + ], + [ + { + "key": "ipv6", + "value": "::1" + }, + { + "key": "endpointType", + "value": "remote" + } + ] + ], + [ + 0.001, + 0.004, + 0.006 + ], + [ + 0.01, + 0.005, + 0.007 + ], + [ + [ + { + "Timestamp": 2, + "Fields": [ + { + "key": "annotation", + "value": "annotation text" + } + ], + "Name": "" + }, + { + "Timestamp": 6, + "Fields": [ + { + "key": "annotation", + "value": "annotation text 3" + } + ], + "Name": "" + } + ], + [], + [] + ], + [ + [ + { + "key": "kind", + "value": "CLIENT" + }, + { + "key": "tag2", + "value": "val2" + }, + { + "key": "tag1", + "value": "val1" + } + ], + [ + { + "key": "error", + "value": true + }, + { + "key": "errorValue", + "value": "404" + } + ], + [] + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/zipkin/zipkin.go b/pkg/tsdb/zipkin/zipkin.go index fc8576f266f..e6d77a82caf 100644 --- a/pkg/tsdb/zipkin/zipkin.go +++ b/pkg/tsdb/zipkin/zipkin.go @@ -58,7 +58,7 @@ func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext } instance, ok := i.(*datasourceInfo) if !ok { - return nil, errors.New("failed to cast datasource info") + return nil, backend.DownstreamError(errors.New("failed to cast datasource info")) } return instance, nil } @@ -87,3 +87,11 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq handler := httpadapter.New(s.registerResourceRoutes()) return handler.CallResource(ctx, req, sender) } + +func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + dsInfo, err := s.getDSInfo(ctx, req.PluginContext) + if err != nil { + return nil, err + } + return queryData(ctx, dsInfo, req) +} diff --git a/public/app/plugins/datasource/zipkin/datasource.ts b/public/app/plugins/datasource/zipkin/datasource.ts index ceef9d14465..a6c58314ef0 100644 --- a/public/app/plugins/datasource/zipkin/datasource.ts +++ b/public/app/plugins/datasource/zipkin/datasource.ts @@ -60,7 +60,10 @@ export class ZipkinDatasource extends DataSourceWithBackend(`${apiPrefix}/trace/${encodeURIComponent(query.query)}`).pipe( map((res) => responseToDataQueryResponse(res, this.nodeGraph?.enabled)) ); @@ -99,12 +102,12 @@ export class ZipkinDatasource extends DataSourceWithBackend