Repository: grafana/grafana-plugin-sdk-go (go)

Issue:
Tracing: Support multiple OTEL propagators
<!-- Please only use this template for submitting enhancement requests -->

**What would you like to be added**:

Corresponding work for https://github.com/grafana/grafana/pull/61199 in the plugins sdk

**Why is this needed**:

Match behaviour of core grafana

Pull request description:
Tracing: Support multiple OTel propagators
<!--

Thank you for sending a pull request! Here are some tips:

1. If this is your first time, please read our contribution guide at https://github.com/grafana/grafana-plugin-sdk-go/blob/master/CONTRIBUTING.md

2. Ensure you include and run the appropriate tests as part of your Pull Request.

3. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.

4. If you can not merge your Pull Request due to a merge conflict, Rebase it. This gets it in sync with the master branch.

-->

**What this PR does / why we need it**:

Allows plugins to propagate context with multiple open telemetry propagators at the same time.

Matches the behaviour added by this PR from core: https://github.com/grafana/grafana/pull/61199

**Which issue(s) this PR fixes**:

<!--

* Automatically closes linked issue when the Pull Request is merged.

Usage: "Fixes #<issue number>", or "Fixes (paste link of issue)"

-->

Fixes #657 

**Special notes for your reviewer**:


Patch:
diff --git a/backend/setup.go b/backend/setup.go
index d4b5b80..f21d787 100644
--- a/backend/setup.go
+++ b/backend/setup.go
@@ -104,7 +104,11 @@ func SetupTracer(pluginID string, tracingOpts tracing.Opts) error {
 		if err != nil {
 			return fmt.Errorf("new trace provider: %w", err)
 		}
-		tracerprovider.InitGlobalTracerProvider(tp, tracerprovider.NewTextMapPropagator(tracingCfg.Propagation))
+		pf, err := tracerprovider.NewTextMapPropagator(tracingCfg.Propagation)
+		if err != nil {
+			return fmt.Errorf("new propagator format: %w", err)
+		}
+		tracerprovider.InitGlobalTracerProvider(tp, pf)
 
 		// Initialize global tracer for plugin developer usage
 		tracing.InitDefaultTracer(otel.Tracer(pluginID))
@@ -116,7 +120,7 @@ func SetupTracer(pluginID string, tracingOpts tracing.Opts) error {
 // tracingConfig contains the configuration for OTEL tracing.
 type tracingConfig struct {
 	Address     string
-	Propagation tracerprovider.PropagatorFormat
+	Propagation string
 }
 
 // IsEnabled returns true if OTEL tracing is enabled.
@@ -133,6 +137,6 @@ func getTracingConfig() tracingConfig {
 	}
 	return tracingConfig{
 		Address:     otelAddr,
-		Propagation: tracerprovider.PropagatorFormat(otelPropagation),
+		Propagation: otelPropagation,
 	}
 }
diff --git a/data/contract_docs/logs.md b/data/contract_docs/logs.md
index 4a36e7a..d35fe51 100644
--- a/data/contract_docs/logs.md
+++ b/data/contract_docs/logs.md
@@ -17,10 +17,12 @@ Version: 0.0
   - There must be at least one non nullable time field
   - If there are multiple time fields present, following will decide the priority
     - First matching time field with name `timestamp`
+      - If there are multiple time fields, and none of them is named `timestamp`, it is considered an error.
 - **Message field** - _required_
   - There must be at lease one non nullable string field must present
   - If more than one string fields found, the following will decide the priority
     - First matching string field with name `body`
+      - If there are multiple string fields, and none of them is named `body`, it is considered an error.
 - **Severity field** - _optional_
   - This is optional field
   - Level/Severity of the log line can be represented with this field.
@@ -48,7 +50,7 @@ Version: 0.0
   - Field type must be nullable number and field name must be `tsNs`
   - when this field detected, clients use this as an additional timeField property to calculate the nano second precision.
 
-If any other fields (remainder fields) found, they will be treated as items of the attributes field.
+Any other field is ignored.
 
 ## Example
 
diff --git a/data/field_config.go b/data/field_config.go
index f325244..0a9dd2e 100644
--- a/data/field_config.go
+++ b/data/field_config.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"math"
 	"strconv"
+	"time"
 )
 
 // FieldConfig represents the display properties for a Field.
@@ -158,11 +159,45 @@ func (fc *FieldConfig) SetFilterable(b bool) *FieldConfig {
 
 // DataLink define what
 type DataLink struct { //revive:disable-line
-	Title       string `json:"title,omitempty"`
-	TargetBlank bool   `json:"targetBlank,omitempty"`
-	URL         string `json:"url,omitempty"`
+	Title       string            `json:"title,omitempty"`
+	TargetBlank bool              `json:"targetBlank,omitempty"`
+	URL         string            `json:"url,omitempty"`
+	Internal    *InternalDataLink `json:"internal,omitempty"`
 }
 
+// InternalDataLink definition to allow Explore links to be constructed in the backend
+type InternalDataLink struct {
+	Query              any                         `json:"query,omitempty"`
+	DatasourceUID      string                      `json:"datasourceUid,omitempty"`
+	DatasourceName     string                      `json:"datasourceName,omitempty"`
+	ExplorePanelsState *ExplorePanelsState         `json:"panelsState,omitempty"`
+	Transformations    *[]LinkTransformationConfig `json:"transformations,omitempty"`
+	Range              *TimeRange                  `json:"timeRange,omitempty"`
+}
+
+// This is an object constructed with the keys as the values of the enum VisType and the value being a bag of properties
+type ExplorePanelsState any
+
+// Redefining this to avoid an import cycle
+type TimeRange struct {
+	From time.Time `json:"from,omitempty"`
+	To   time.Time `json:"to,omitempty"`
+}
+
+type LinkTransformationConfig struct {
+	Type       SupportedTransformationTypes `json:"type,omitempty"`
+	Field      string                       `json:"field,omitempty"`
+	Expression string                       `json:"expression,omitempty"`
+	MapValue   string                       `json:"mapValue,omitempty"`
+}
+
+type SupportedTransformationTypes string
+
+const (
+	Regex  SupportedTransformationTypes = "regex"
+	Logfmt SupportedTransformationTypes = "logfmt"
+)
+
 // ThresholdsConfig setup thresholds
 type ThresholdsConfig struct {
 	Mode ThresholdsMode `json:"mode"`
diff --git a/internal/tracerprovider/tracerprovider.go b/internal/tracerprovider/tracerprovider.go
index 0e4bb64..69cacce 100644
--- a/internal/tracerprovider/tracerprovider.go
+++ b/internal/tracerprovider/tracerprovider.go
@@ -2,6 +2,8 @@ package tracerprovider
 
 import (
 	"context"
+	"fmt"
+	"strings"
 
 	jaegerpropagator "go.opentelemetry.io/contrib/propagators/jaeger"
 	"go.opentelemetry.io/otel"
@@ -11,6 +13,7 @@ import (
 	"go.opentelemetry.io/otel/propagation"
 	"go.opentelemetry.io/otel/sdk/resource"
 	tracesdk "go.opentelemetry.io/otel/sdk/trace"
+	"go.opentelemetry.io/otel/sdk/trace/tracetest"
 	"go.opentelemetry.io/otel/trace"
 
 	"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
@@ -49,6 +52,10 @@ func newOpentelemetryTracerProvider(address string, customAttributes ...attribut
 		return nil, err
 	}
 
+	return initTracerProvider(exp, customAttributes...)
+}
+
+func initTracerProvider(exp tracesdk.SpanExporter, customAttributes ...attribute.KeyValue) (*tracesdk.TracerProvider, error) {
 	res, err := resource.New(
 		context.Background(),
 		resource.WithAttributes(customAttributes...),
@@ -79,14 +86,29 @@ func NewTracerProvider(address string, opts tracing.Opts) (TracerProvider, error
 }
 
 // NewTextMapPropagator takes a string-like value and returns the corresponding propagation.TextMapPropagator.
-func NewTextMapPropagator(pf PropagatorFormat) propagation.TextMapPropagator {
-	switch pf {
-	case PropagatorFormatJaeger:
-		return propagation.TraceContext{}
-	case PropagatorFormatW3C:
-		return jaegerpropagator.Jaeger{}
+func NewTextMapPropagator(pf string) (propagation.TextMapPropagator, error) {
+	var propagators []propagation.TextMapPropagator
+	for _, propagatorString := range strings.Split(pf, ",") {
+		var propagator propagation.TextMapPropagator
+		switch PropagatorFormat(propagatorString) {
+		case PropagatorFormatW3C:
+			propagator = propagation.TraceContext{}
+		case PropagatorFormatJaeger:
+			propagator = jaegerpropagator.Jaeger{}
+		case "":
+			continue
+		default:
+			return nil, fmt.Errorf("unsupported OpenTelemetry propagator: %q", propagator)
+		}
+		propagators = append(propagators, propagator)
+	}
+	switch len(propagators) {
+	case 0:
+		return propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}), nil
+	case 1:
+		return propagators[0], nil
 	default:
-		return propagation.TraceContext{}
+		return propagation.NewCompositeTextMapPropagator(propagators...), nil
 	}
 }
 
@@ -96,3 +118,20 @@ func InitGlobalTracerProvider(tp TracerProvider, propagator propagation.TextMapP
 	otel.SetTracerProvider(tp)
 	otel.SetTextMapPropagator(propagator)
 }
+
+func InitializeForTestsWithPropagatorFormat(propagatorFormat string) (trace.Tracer, error) {
+	exp := tracetest.NewInMemoryExporter()
+	tp, _ := initTracerProvider(exp)
+	otel.SetTracerProvider(tp)
+
+	propagator, err := NewTextMapPropagator(propagatorFormat)
+	if err != nil {
+		return nil, err
+	}
+	otel.SetTextMapPropagator(propagator)
+	return tp.Tracer("test"), nil
+}
+
+func InitializeForTests() (trace.Tracer, error) {
+	return InitializeForTestsWithPropagatorFormat("jaeger,w3c")
+}


Test patch:
diff --git a/backend/httpclient/tracing_middleware_test.go b/backend/httpclient/tracing_middleware_test.go
index b9b308a..1c8c2c6 100644
--- a/backend/httpclient/tracing_middleware_test.go
+++ b/backend/httpclient/tracing_middleware_test.go
@@ -9,9 +9,11 @@ import (
 	"github.com/stretchr/testify/require"
 	"go.opentelemetry.io/otel/attribute"
 	"go.opentelemetry.io/otel/codes"
+	"go.opentelemetry.io/otel/sdk/trace/tracetest"
 	"go.opentelemetry.io/otel/trace"
 
 	"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
+	"github.com/grafana/grafana-plugin-sdk-go/internal/tracerprovider"
 )
 
 type mockTracerProvider struct{}
@@ -251,4 +253,81 @@ func TestTracingMiddleware(t *testing.T) {
 			"http.status_code": attribute.Int64Value(200),
 		}, attrMap)
 	})
+
+	t.Run("propagation", func(t *testing.T) {
+		traceExporter := tracetest.NewInMemoryExporter()
+		t.Cleanup(func() {
+			require.NoError(t, traceExporter.Shutdown(context.Background()))
+		})
+
+		t.Run("single", func(t *testing.T) {
+			tracer, err := tracerprovider.InitializeForTestsWithPropagatorFormat("w3c")
+			require.NoError(t, err)
+
+			ctx, span := tracer.Start(context.Background(), "testspan")
+			defer span.End()
+
+			expectedTraceID := trace.SpanContextFromContext(ctx).TraceID()
+			require.NotEmpty(t, expectedTraceID)
+
+			mw := httpclient.TracingMiddleware(tracer)
+			rt := mw.CreateMiddleware(httpclient.Options{}, httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+				// Only w3c header should be present
+				require.NotEmpty(t, req.Header.Get("Traceparent"))
+				require.Empty(t, req.Header.Get("Uber-Trace-Id"))
+
+				// child span should have the same trace ID as the parent span
+				ctx, span := tracer.Start(req.Context(), "inner")
+				defer span.End()
+
+				require.Equal(t, expectedTraceID, trace.SpanContextFromContext(ctx).TraceID())
+
+				return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
+			}))
+
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://test.com/query", nil)
+			require.NoError(t, err)
+			res, err := rt.RoundTrip(req)
+			require.NoError(t, err)
+			require.NotNil(t, req)
+			if res.Body != nil {
+				require.NoError(t, res.Body.Close())
+			}
+		})
+
+		t.Run("composite", func(t *testing.T) {
+			tracer, err := tracerprovider.InitializeForTests()
+			require.NoError(t, err)
+
+			ctx, span := tracer.Start(context.Background(), "testspan")
+			defer span.End()
+
+			expectedTraceID := trace.SpanContextFromContext(ctx).TraceID()
+			require.NotEmpty(t, expectedTraceID)
+
+			mw := httpclient.TracingMiddleware(tracer)
+			rt := mw.CreateMiddleware(httpclient.Options{}, httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+				// both Jaeger and w3c headers should be set
+				require.NotEmpty(t, req.Header.Get("Uber-Trace-Id"))
+				require.NotEmpty(t, req.Header.Get("Traceparent"))
+
+				// child span should have the same trace ID as the parent span
+				ctx, span := tracer.Start(req.Context(), "inner")
+				defer span.End()
+
+				require.Equal(t, expectedTraceID, trace.SpanContextFromContext(ctx).TraceID())
+
+				return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
+			}))
+
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://test.com/query", nil)
+			require.NoError(t, err)
+			res, err := rt.RoundTrip(req)
+			require.NoError(t, err)
+			require.NotNil(t, req)
+			if res.Body != nil {
+				require.NoError(t, res.Body.Close())
+			}
+		})
+	})
 }
diff --git a/internal/tracerprovider/tracerprovider_test.go b/internal/tracerprovider/tracerprovider_test.go
new file mode 100644
index 0000000..d828faa
--- /dev/null
+++ b/internal/tracerprovider/tracerprovider_test.go
@@ -0,0 +1,34 @@
+package tracerprovider
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	jaegerpropagator "go.opentelemetry.io/contrib/propagators/jaeger"
+	"go.opentelemetry.io/otel/propagation"
+)
+
+func TestNewTextMapPropagator(t *testing.T) {
+	for _, tc := range []struct {
+		name  string
+		input string
+		exp   propagation.TextMapPropagator
+	}{
+		{name: "jaeger", input: "jaeger", exp: jaegerpropagator.Jaeger{}},
+		{name: "w3c", input: "w3c", exp: propagation.TraceContext{}},
+		{name: "default", input: "", exp: propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})},
+		{name: "composite", input: "w3c,jaeger", exp: propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, jaegerpropagator.Jaeger{})},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			p, err := NewTextMapPropagator(tc.input)
+			require.NoError(t, err)
+			assert.Equal(t, tc.exp, p)
+		})
+	}
+
+	t.Run("unsupported", func(t *testing.T) {
+		_, err := NewTextMapPropagator("unknown")
+		require.Error(t, err)
+	})
+}