Repository: open-telemetry/opentelemetry-go-contrib (go)

Issue:
otlpHTTPLogExporter, otlpGRPCLogExporter, etc do not support fields like OTLP.Certificate
### Description

The OTLP configuration type has fields Certificate, ClientCertificate, ClientKey, HeadersList and Insecure:

https://github.com/open-telemetry/opentelemetry-go-contrib/blob/6e79f7ca22d58345e2ccfe3f9e8bb4ad71633ab3/config/generated_config.go#L258-L264
 

These fields are not read/supported by constructors like [otlpGRPCLogExporter](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/6e79f7ca22d58345e2ccfe3f9e8bb4ad71633ab3/config/log.go#L163-L199), otlpHTTPLogExporter, otlpHTTPMetricExporter, otlpGRPCMetricExporter, otlpGRPCSpanExporter and otlpHTTPSpanExporter. 

### Environment

- OS: N/A
- Architecture: N/A
- Go Version: N/A
- `config` version: [6e79f7c](https://github.com/open-telemetry/opentelemetry-go-contrib/commit/6e79f7ca22d58345e2ccfe3f9e8bb4ad71633ab3), 1.32.0

### Steps To Reproduce
An example of how to trigger this is to call NewSDK specifying a custom certificate. This certificate will not be used when connecting to the trace endpoint specified.

### Expected behavior
These fields should be respected when creating log, trace and metric exporters.

Pull request:
config: support HeadersList
Fixes #6351

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a3a2c28e..844c4e1db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ The next release will require at least [Go 1.23].
 - Support for the `OTEL_HTTP_CLIENT_COMPATIBILITY_MODE=http/dup` environment variable in `instrumentation/github.com/emicklei/go-restful/otelrestful` to emit attributes for both the v1.20.0 and v1.26.0 semantic conventions. (#6710)
 - Added metrics support, and emit all stable metrics from the [Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-metrics.md) in `go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin`. (#6747)
 - Support [Go 1.24]. (#6765)
+- Add support for configuring `HeadersList` field for OTLP exporters in `go.opentelemetry.io/contrib/config`. (#6657)
 
 ### Changed
 
diff --git a/config/v0.3.0/config.go b/config/v0.3.0/config.go
index 1dd458d5a..6a3b2d3d0 100644
--- a/config/v0.3.0/config.go
+++ b/config/v0.3.0/config.go
@@ -13,6 +13,7 @@ import (
 
 	"gopkg.in/yaml.v3"
 
+	"go.opentelemetry.io/otel/baggage"
 	"go.opentelemetry.io/otel/log"
 	nooplog "go.opentelemetry.io/otel/log/noop"
 	"go.opentelemetry.io/otel/metric"
@@ -152,16 +153,6 @@ func ParseYAML(file []byte) (*OpenTelemetryConfiguration, error) {
 	return &cfg, nil
 }
 
-func toStringMap(pairs []NameStringValuePair) map[string]string {
-	output := make(map[string]string)
-	for _, v := range pairs {
-		if v.Value != nil && len(v.Name) > 0 {
-			output[v.Name] = *v.Value
-		}
-	}
-	return output
-}
-
 // createTLSConfig creates a tls.Config from certificate files.
 func createTLSConfig(caCertFile *string, clientCertFile *string, clientKeyFile *string) (*tls.Config, error) {
 	tlsConfig := &tls.Config{}
@@ -188,3 +179,27 @@ func createTLSConfig(caCertFile *string, clientCertFile *string, clientKeyFile *
 	}
 	return tlsConfig, nil
 }
+
+// createHeadersConfig combines the two header config fields. Headers take precedence over headersList.
+func createHeadersConfig(headers []NameStringValuePair, headersList *string) (map[string]string, error) {
+	result := make(map[string]string)
+	if headersList != nil {
+		// Parsing follows https://github.com/open-telemetry/opentelemetry-configuration/blob/568e5080816d40d75792eb754fc96bde09654159/schema/type_descriptions.yaml#L584.
+		headerslist, err := baggage.Parse(*headersList)
+		if err != nil {
+			return nil, fmt.Errorf("invalid headers list: %w", err)
+		}
+		for _, kv := range headerslist.Members() {
+			result[kv.Key()] = kv.Value()
+		}
+	}
+	// Headers take precedence over HeadersList, so this has to be after HeadersList is processed
+	if len(headers) > 0 {
+		for _, kv := range headers {
+			if kv.Value != nil {
+				result[kv.Name] = *kv.Value
+			}
+		}
+	}
+	return result, nil
+}
diff --git a/config/v0.3.0/log.go b/config/v0.3.0/log.go
index 7bb527cc0..714ed4fae 100644
--- a/config/v0.3.0/log.go
+++ b/config/v0.3.0/log.go
@@ -152,8 +152,12 @@ func otlpHTTPLogExporter(ctx context.Context, otlpConfig *OTLP) (sdklog.Exporter
 	if otlpConfig.Timeout != nil && *otlpConfig.Timeout > 0 {
 		opts = append(opts, otlploghttp.WithTimeout(time.Millisecond*time.Duration(*otlpConfig.Timeout)))
 	}
-	if len(otlpConfig.Headers) > 0 {
-		opts = append(opts, otlploghttp.WithHeaders(toStringMap(otlpConfig.Headers)))
+	headersConfig, err := createHeadersConfig(otlpConfig.Headers, otlpConfig.HeadersList)
+	if err != nil {
+		return nil, err
+	}
+	if len(headersConfig) > 0 {
+		opts = append(opts, otlploghttp.WithHeaders(headersConfig))
 	}
 
 	tlsConfig, err := createTLSConfig(otlpConfig.Certificate, otlpConfig.ClientCertificate, otlpConfig.ClientKey)
@@ -200,8 +204,12 @@ func otlpGRPCLogExporter(ctx context.Context, otlpConfig *OTLP) (sdklog.Exporter
 	if otlpConfig.Timeout != nil && *otlpConfig.Timeout > 0 {
 		opts = append(opts, otlploggrpc.WithTimeout(time.Millisecond*time.Duration(*otlpConfig.Timeout)))
 	}
-	if len(otlpConfig.Headers) > 0 {
-		opts = append(opts, otlploggrpc.WithHeaders(toStringMap(otlpConfig.Headers)))
+	headersConfig, err := createHeadersConfig(otlpConfig.Headers, otlpConfig.HeadersList)
+	if err != nil {
+		return nil, err
+	}
+	if len(headersConfig) > 0 {
+		opts = append(opts, otlploggrpc.WithHeaders(headersConfig))
 	}
 
 	tlsConfig, err := createTLSConfig(otlpConfig.Certificate, otlpConfig.ClientCertificate, otlpConfig.ClientKey)
diff --git a/config/v0.3.0/metric.go b/config/v0.3.0/metric.go
index 039eff232..eb43a5311 100644
--- a/config/v0.3.0/metric.go
+++ b/config/v0.3.0/metric.go
@@ -166,8 +166,12 @@ func otlpHTTPMetricExporter(ctx context.Context, otlpConfig *OTLPMetric) (sdkmet
 	if otlpConfig.Timeout != nil {
 		opts = append(opts, otlpmetrichttp.WithTimeout(time.Millisecond*time.Duration(*otlpConfig.Timeout)))
 	}
-	if len(otlpConfig.Headers) > 0 {
-		opts = append(opts, otlpmetrichttp.WithHeaders(toStringMap(otlpConfig.Headers)))
+	headersConfig, err := createHeadersConfig(otlpConfig.Headers, otlpConfig.HeadersList)
+	if err != nil {
+		return nil, err
+	}
+	if len(headersConfig) > 0 {
+		opts = append(opts, otlpmetrichttp.WithHeaders(headersConfig))
 	}
 	if otlpConfig.TemporalityPreference != nil {
 		switch *otlpConfig.TemporalityPreference {
@@ -227,8 +231,12 @@ func otlpGRPCMetricExporter(ctx context.Context, otlpConfig *OTLPMetric) (sdkmet
 	if otlpConfig.Timeout != nil && *otlpConfig.Timeout > 0 {
 		opts = append(opts, otlpmetricgrpc.WithTimeout(time.Millisecond*time.Duration(*otlpConfig.Timeout)))
 	}
-	if len(otlpConfig.Headers) > 0 {
-		opts = append(opts, otlpmetricgrpc.WithHeaders(toStringMap(otlpConfig.Headers)))
+	headersConfig, err := createHeadersConfig(otlpConfig.Headers, otlpConfig.HeadersList)
+	if err != nil {
+		return nil, err
+	}
+	if len(headersConfig) > 0 {
+		opts = append(opts, otlpmetricgrpc.WithHeaders(headersConfig))
 	}
 	if otlpConfig.TemporalityPreference != nil {
 		switch *otlpConfig.TemporalityPreference {
diff --git a/config/v0.3.0/trace.go b/config/v0.3.0/trace.go
index 5c5bc0123..b977348a6 100644
--- a/config/v0.3.0/trace.go
+++ b/config/v0.3.0/trace.go
@@ -125,8 +125,12 @@ func otlpGRPCSpanExporter(ctx context.Context, otlpConfig *OTLP) (sdktrace.SpanE
 	if otlpConfig.Timeout != nil && *otlpConfig.Timeout > 0 {
 		opts = append(opts, otlptracegrpc.WithTimeout(time.Millisecond*time.Duration(*otlpConfig.Timeout)))
 	}
-	if len(otlpConfig.Headers) > 0 {
-		opts = append(opts, otlptracegrpc.WithHeaders(toStringMap(otlpConfig.Headers)))
+	headersConfig, err := createHeadersConfig(otlpConfig.Headers, otlpConfig.HeadersList)
+	if err != nil {
+		return nil, err
+	}
+	if len(headersConfig) > 0 {
+		opts = append(opts, otlptracegrpc.WithHeaders(headersConfig))
 	}
 
 	tlsConfig, err := createTLSConfig(otlpConfig.Certificate, otlpConfig.ClientCertificate, otlpConfig.ClientKey)
@@ -168,8 +172,12 @@ func otlpHTTPSpanExporter(ctx context.Context, otlpConfig *OTLP) (sdktrace.SpanE
 	if otlpConfig.Timeout != nil && *otlpConfig.Timeout > 0 {
 		opts = append(opts, otlptracehttp.WithTimeout(time.Millisecond*time.Duration(*otlpConfig.Timeout)))
 	}
-	if len(otlpConfig.Headers) > 0 {
-		opts = append(opts, otlptracehttp.WithHeaders(toStringMap(otlpConfig.Headers)))
+	headersConfig, err := createHeadersConfig(otlpConfig.Headers, otlpConfig.HeadersList)
+	if err != nil {
+		return nil, err
+	}
+	if len(headersConfig) > 0 {
+		opts = append(opts, otlptracehttp.WithHeaders(headersConfig))
 	}
 
 	tlsConfig, err := createTLSConfig(otlpConfig.Certificate, otlpConfig.ClientCertificate, otlpConfig.ClientKey)


Test patch:
diff --git a/config/v0.3.0/config_test.go b/config/v0.3.0/config_test.go
index a25cfc9ff..4ab519c84 100644
--- a/config/v0.3.0/config_test.go
+++ b/config/v0.3.0/config_test.go
@@ -568,10 +568,97 @@ func TestCreateTLSConfig(t *testing.T) {
 	}
 }
 
-func TestToStringMap(t *testing.T) {
-	require.Equal(t, map[string]string{}, toStringMap([]NameStringValuePair{}))
-	require.Equal(t, map[string]string{}, toStringMap([]NameStringValuePair{{Name: "test"}}))
-	require.Equal(t, map[string]string{}, toStringMap([]NameStringValuePair{{Value: ptr("test")}}))
+func TestCreateHeadersConfig(t *testing.T) {
+	tests := []struct {
+		name        string
+		headers     []NameStringValuePair
+		headersList *string
+		wantHeaders map[string]string
+		wantErr     string
+	}{
+		{
+			name:        "no headers",
+			headers:     []NameStringValuePair{},
+			headersList: nil,
+			wantHeaders: map[string]string{},
+		},
+		{
+			name:        "headerslist only",
+			headers:     []NameStringValuePair{},
+			headersList: ptr("a=b,c=d"),
+			wantHeaders: map[string]string{
+				"a": "b",
+				"c": "d",
+			},
+		},
+		{
+			name: "headers only",
+			headers: []NameStringValuePair{
+				{
+					Name:  "a",
+					Value: ptr("b"),
+				},
+				{
+					Name:  "c",
+					Value: ptr("d"),
+				},
+			},
+			headersList: nil,
+			wantHeaders: map[string]string{
+				"a": "b",
+				"c": "d",
+			},
+		},
+		{
+			name: "both headers and headerslist",
+			headers: []NameStringValuePair{
+				{
+					Name:  "a",
+					Value: ptr("b"),
+				},
+			},
+			headersList: ptr("c=d"),
+			wantHeaders: map[string]string{
+				"a": "b",
+				"c": "d",
+			},
+		},
+		{
+			name: "headers supersedes headerslist",
+			headers: []NameStringValuePair{
+				{
+					Name:  "a",
+					Value: ptr("b"),
+				},
+				{
+					Name:  "c",
+					Value: ptr("override"),
+				},
+			},
+			headersList: ptr("c=d"),
+			wantHeaders: map[string]string{
+				"a": "b",
+				"c": "override",
+			},
+		},
+		{
+			name:        "invalid headerslist",
+			headersList: ptr("==="),
+			wantErr:     "invalid headers list: invalid key: \"\"",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			headersMap, err := createHeadersConfig(tt.headers, tt.headersList)
+			if tt.wantErr != "" {
+				require.Error(t, err)
+				require.Equal(t, tt.wantErr, err.Error())
+			} else {
+				require.NoError(t, err)
+			}
+			require.Equal(t, tt.wantHeaders, headersMap)
+		})
+	}
 }
 
 func ptr[T any](v T) *T {
diff --git a/config/v0.3.0/log_test.go b/config/v0.3.0/log_test.go
index 2c2e2d221..4f73c289e 100644
--- a/config/v0.3.0/log_test.go
+++ b/config/v0.3.0/log_test.go
@@ -6,8 +6,6 @@ package config
 import (
 	"context"
 	"errors"
-	"fmt"
-	"net/url"
 	"path/filepath"
 	"reflect"
 	"testing"
@@ -79,12 +77,12 @@ func TestLogProcessor(t *testing.T) {
 		name          string
 		processor     LogRecordProcessor
 		args          any
-		wantErr       error
+		wantErr       string
 		wantProcessor sdklog.Processor
 	}{
 		{
 			name:    "no processor",
-			wantErr: errors.New("unsupported log processor type, must be one of simple or batch"),
+			wantErr: "unsupported log processor type, must be one of simple or batch",
 		},
 		{
 			name: "multiple processor types",
@@ -94,7 +92,7 @@ func TestLogProcessor(t *testing.T) {
 				},
 				Simple: &SimpleLogRecordProcessor{},
 			},
-			wantErr: errors.New("must not specify multiple log processor type"),
+			wantErr: "must not specify multiple log processor type",
 		},
 		{
 			name: "batch processor invalid batch size otlphttp exporter",
@@ -109,7 +107,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid batch size -1"),
+			wantErr: "invalid batch size -1",
 		},
 		{
 			name: "batch processor invalid export timeout otlphttp exporter",
@@ -123,7 +121,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid export timeout -2"),
+			wantErr: "invalid export timeout -2",
 		},
 		{
 			name: "batch processor invalid queue size otlphttp exporter",
@@ -138,7 +136,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid queue size -3"),
+			wantErr: "invalid queue size -3",
 		},
 		{
 			name: "batch processor invalid schedule delay console exporter",
@@ -152,7 +150,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid schedule delay -4"),
+			wantErr: "invalid schedule delay -4",
 		},
 		{
 			name: "batch processor invalid exporter",
@@ -161,7 +159,7 @@ func TestLogProcessor(t *testing.T) {
 					Exporter: LogRecordExporter{},
 				},
 			},
-			wantErr: errors.New("no valid log exporter"),
+			wantErr: "no valid log exporter",
 		},
 		{
 			name: "batch/console",
@@ -278,7 +276,24 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not create certificate authority chain from certificate"),
+			wantErr: "could not create certificate authority chain from certificate",
+		},
+		{
+			name: "batch/otlp-grpc-bad-headerslist",
+			processor: LogRecordProcessor{
+				Batch: &BatchLogRecordProcessor{
+					Exporter: LogRecordExporter{
+						OTLP: &OTLP{
+							Protocol:    ptr("grpc"),
+							Endpoint:    ptr("localhost:4317"),
+							Compression: ptr("gzip"),
+							Timeout:     ptr(1000),
+							HeadersList: ptr("==="),
+						},
+					},
+				},
+			},
+			wantErr: "invalid headers list: invalid key: \"\"",
 		},
 		{
 			name: "batch/otlp-grpc-bad-client-certificate",
@@ -296,7 +311,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not use client certificate: %w", errors.New("tls: failed to find any PEM data in certificate input")),
+			wantErr: "could not use client certificate: tls: failed to find any PEM data in certificate input",
 		},
 		{
 			name: "batch/otlp-grpc-exporter-no-scheme",
@@ -342,7 +357,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: &url.Error{Op: "parse", URL: " ", Err: errors.New("invalid URI for request")},
+			wantErr: "parse \" \": invalid URI for request",
 		},
 		{
 			name: "batch/otlp-grpc-invalid-compression",
@@ -365,7 +380,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported compression \"invalid\""),
+			wantErr: "unsupported compression \"invalid\"",
 		},
 		{
 			name: "batch/otlp-http-exporter",
@@ -422,7 +437,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not create certificate authority chain from certificate"),
+			wantErr: "could not create certificate authority chain from certificate",
 		},
 		{
 			name: "batch/otlp-http-bad-client-certificate",
@@ -440,7 +455,24 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not use client certificate: %w", errors.New("tls: failed to find any PEM data in certificate input")),
+			wantErr: "could not use client certificate: tls: failed to find any PEM data in certificate input",
+		},
+		{
+			name: "batch/otlp-http-bad-headerslist",
+			processor: LogRecordProcessor{
+				Batch: &BatchLogRecordProcessor{
+					Exporter: LogRecordExporter{
+						OTLP: &OTLP{
+							Protocol:    ptr("http/protobuf"),
+							Endpoint:    ptr("localhost:4317"),
+							Compression: ptr("gzip"),
+							Timeout:     ptr(1000),
+							HeadersList: ptr("==="),
+						},
+					},
+				},
+			},
+			wantErr: "invalid headers list: invalid key: \"\"",
 		},
 		{
 			name: "batch/otlp-http-exporter-with-path",
@@ -531,7 +563,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported protocol \"invalid\""),
+			wantErr: "unsupported protocol \"invalid\"",
 		},
 		{
 			name: "batch/otlp-http-invalid-endpoint",
@@ -554,7 +586,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: &url.Error{Op: "parse", URL: " ", Err: errors.New("invalid URI for request")},
+			wantErr: "parse \" \": invalid URI for request",
 		},
 		{
 			name: "batch/otlp-http-none-compression",
@@ -600,7 +632,7 @@ func TestLogProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported compression \"invalid\""),
+			wantErr: "unsupported compression \"invalid\"",
 		},
 		{
 			name: "simple/no-exporter",
@@ -609,7 +641,7 @@ func TestLogProcessor(t *testing.T) {
 					Exporter: LogRecordExporter{},
 				},
 			},
-			wantErr: errors.New("no valid log exporter"),
+			wantErr: "no valid log exporter",
 		},
 		{
 			name: "simple/console",
@@ -645,7 +677,12 @@ func TestLogProcessor(t *testing.T) {
 	for _, tt := range testCases {
 		t.Run(tt.name, func(t *testing.T) {
 			got, err := logProcessor(context.Background(), tt.processor)
-			require.Equal(t, tt.wantErr, err)
+			if tt.wantErr != "" {
+				require.Error(t, err)
+				require.Equal(t, tt.wantErr, err.Error())
+			} else {
+				require.NoError(t, err)
+			}
 			if tt.wantProcessor == nil {
 				require.Nil(t, got)
 			} else {
diff --git a/config/v0.3.0/metric_test.go b/config/v0.3.0/metric_test.go
index 0b2c17a2a..4f3c05976 100644
--- a/config/v0.3.0/metric_test.go
+++ b/config/v0.3.0/metric_test.go
@@ -7,7 +7,6 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"net/url"
 	"path/filepath"
 	"reflect"
 	"testing"
@@ -106,19 +105,19 @@ func TestReader(t *testing.T) {
 		name       string
 		reader     MetricReader
 		args       any
-		wantErr    error
+		wantErr    string
 		wantReader sdkmetric.Reader
 	}{
 		{
 			name:    "no reader",
-			wantErr: errors.New("no valid metric reader"),
+			wantErr: "no valid metric reader",
 		},
 		{
 			name: "pull/no-exporter",
 			reader: MetricReader{
 				Pull: &PullMetricReader{},
 			},
-			wantErr: errors.New("no valid metric exporter"),
+			wantErr: "no valid metric exporter",
 		},
 		{
 			name: "pull/prometheus-no-host",
@@ -129,7 +128,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("host must be specified"),
+			wantErr: "host must be specified",
 		},
 		{
 			name: "pull/prometheus-no-port",
@@ -142,7 +141,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("port must be specified"),
+			wantErr: "port must be specified",
 		},
 		{
 			name: "pull/prometheus",
@@ -176,7 +175,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported protocol \"http/invalid\""),
+			wantErr: "unsupported protocol \"http/invalid\"",
 		},
 		{
 			name: "periodic/otlp-grpc-exporter",
@@ -248,7 +247,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("could not create certificate authority chain from certificate"),
+			wantErr: "could not create certificate authority chain from certificate",
 		},
 		{
 			name: "periodic/otlp-grpc-bad-client-certificate",
@@ -266,7 +265,24 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not use client certificate: %w", errors.New("tls: failed to find any PEM data in certificate input")),
+			wantErr: "could not use client certificate: tls: failed to find any PEM data in certificate input",
+		},
+		{
+			name: "periodic/otlp-grpc-bad-headerslist",
+			reader: MetricReader{
+				Periodic: &PeriodicMetricReader{
+					Exporter: PushMetricExporter{
+						OTLP: &OTLPMetric{
+							Protocol:    ptr("grpc"),
+							Endpoint:    ptr("localhost:4317"),
+							Compression: ptr("gzip"),
+							Timeout:     ptr(1000),
+							HeadersList: ptr("==="),
+						},
+					},
+				},
+			},
+			wantErr: "invalid headers list: invalid key: \"\"",
 		},
 		{
 			name: "periodic/otlp-grpc-exporter-no-endpoint",
@@ -341,7 +357,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: &url.Error{Op: "parse", URL: " ", Err: errors.New("invalid URI for request")},
+			wantErr: "parse \" \": invalid URI for request",
 		},
 		{
 			name: "periodic/otlp-grpc-none-compression",
@@ -440,7 +456,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported temporality preference \"invalid\""),
+			wantErr: "unsupported temporality preference \"invalid\"",
 		},
 		{
 			name: "periodic/otlp-grpc-invalid-compression",
@@ -459,7 +475,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported compression \"invalid\""),
+			wantErr: "unsupported compression \"invalid\"",
 		},
 		{
 			name: "periodic/otlp-http-exporter",
@@ -512,7 +528,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("could not create certificate authority chain from certificate"),
+			wantErr: "could not create certificate authority chain from certificate",
 		},
 		{
 			name: "periodic/otlp-http-bad-client-certificate",
@@ -530,7 +546,24 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not use client certificate: %w", errors.New("tls: failed to find any PEM data in certificate input")),
+			wantErr: "could not use client certificate: tls: failed to find any PEM data in certificate input",
+		},
+		{
+			name: "periodic/otlp-http-bad-headerslist",
+			reader: MetricReader{
+				Periodic: &PeriodicMetricReader{
+					Exporter: PushMetricExporter{
+						OTLP: &OTLPMetric{
+							Protocol:    ptr("http/protobuf"),
+							Endpoint:    ptr("localhost:4317"),
+							Compression: ptr("gzip"),
+							Timeout:     ptr(1000),
+							HeadersList: ptr("==="),
+						},
+					},
+				},
+			},
+			wantErr: "invalid headers list: invalid key: \"\"",
 		},
 		{
 			name: "periodic/otlp-http-exporter-with-path",
@@ -605,7 +638,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: &url.Error{Op: "parse", URL: " ", Err: errors.New("invalid URI for request")},
+			wantErr: "parse \" \": invalid URI for request",
 		},
 		{
 			name: "periodic/otlp-http-none-compression",
@@ -704,7 +737,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported temporality preference \"invalid\""),
+			wantErr: "unsupported temporality preference \"invalid\"",
 		},
 		{
 			name: "periodic/otlp-http-invalid-compression",
@@ -723,7 +756,7 @@ func TestReader(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported compression \"invalid\""),
+			wantErr: "unsupported compression \"invalid\"",
 		},
 		{
 			name: "periodic/no-exporter",
@@ -732,7 +765,7 @@ func TestReader(t *testing.T) {
 					Exporter: PushMetricExporter{},
 				},
 			},
-			wantErr: errors.New("no valid metric exporter"),
+			wantErr: "no valid metric exporter",
 		},
 		{
 			name: "periodic/console-exporter",
@@ -766,7 +799,12 @@ func TestReader(t *testing.T) {
 	for _, tt := range testCases {
 		t.Run(tt.name, func(t *testing.T) {
 			got, err := metricReader(context.Background(), tt.reader)
-			require.Equal(t, tt.wantErr, err)
+			if tt.wantErr != "" {
+				require.Error(t, err)
+				require.Equal(t, tt.wantErr, err.Error())
+			} else {
+				require.NoError(t, err)
+			}
 			if tt.wantReader == nil {
 				require.Nil(t, got)
 			} else {
diff --git a/config/v0.3.0/trace_test.go b/config/v0.3.0/trace_test.go
index 2fe078cf9..a9e3b2709 100644
--- a/config/v0.3.0/trace_test.go
+++ b/config/v0.3.0/trace_test.go
@@ -6,8 +6,6 @@ package config
 import (
 	"context"
 	"errors"
-	"fmt"
-	"net/url"
 	"path/filepath"
 	"reflect"
 	"testing"
@@ -100,12 +98,12 @@ func TestSpanProcessor(t *testing.T) {
 		name          string
 		processor     SpanProcessor
 		args          any
-		wantErr       error
+		wantErr       string
 		wantProcessor sdktrace.SpanProcessor
 	}{
 		{
 			name:    "no processor",
-			wantErr: errors.New("unsupported span processor type, must be one of simple or batch"),
+			wantErr: "unsupported span processor type, must be one of simple or batch",
 		},
 		{
 			name: "multiple processor types",
@@ -115,7 +113,7 @@ func TestSpanProcessor(t *testing.T) {
 				},
 				Simple: &SimpleSpanProcessor{},
 			},
-			wantErr: errors.New("must not specify multiple span processor type"),
+			wantErr: "must not specify multiple span processor type",
 		},
 		{
 			name: "batch processor invalid exporter",
@@ -124,7 +122,7 @@ func TestSpanProcessor(t *testing.T) {
 					Exporter: SpanExporter{},
 				},
 			},
-			wantErr: errors.New("no valid span exporter"),
+			wantErr: "no valid span exporter",
 		},
 		{
 			name: "batch processor invalid batch size console exporter",
@@ -136,7 +134,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid batch size -1"),
+			wantErr: "invalid batch size -1",
 		},
 		{
 			name: "batch processor invalid export timeout console exporter",
@@ -148,7 +146,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid export timeout -2"),
+			wantErr: "invalid export timeout -2",
 		},
 		{
 			name: "batch processor invalid queue size console exporter",
@@ -160,7 +158,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid queue size -3"),
+			wantErr: "invalid queue size -3",
 		},
 		{
 			name: "batch processor invalid schedule delay console exporter",
@@ -172,7 +170,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("invalid schedule delay -4"),
+			wantErr: "invalid schedule delay -4",
 		},
 		{
 			name: "batch processor with multiple exporters",
@@ -184,7 +182,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("must not specify multiple exporters"),
+			wantErr: "must not specify multiple exporters",
 		},
 		{
 			name: "batch processor console exporter",
@@ -216,7 +214,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported protocol \"http/invalid\""),
+			wantErr: "unsupported protocol \"http/invalid\"",
 		},
 		{
 			name: "batch/otlp-grpc-exporter-no-endpoint",
@@ -318,7 +316,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("could not create certificate authority chain from certificate"),
+			wantErr: "could not create certificate authority chain from certificate",
 		},
 		{
 			name: "batch/otlp-grpc-bad-client-certificate",
@@ -336,7 +334,24 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not use client certificate: %w", errors.New("tls: failed to find any PEM data in certificate input")),
+			wantErr: "could not use client certificate: tls: failed to find any PEM data in certificate input",
+		},
+		{
+			name: "batch/otlp-grpc-bad-headerslist",
+			processor: SpanProcessor{
+				Batch: &BatchSpanProcessor{
+					Exporter: SpanExporter{
+						OTLP: &OTLP{
+							Protocol:    ptr("grpc"),
+							Endpoint:    ptr("localhost:4317"),
+							Compression: ptr("gzip"),
+							Timeout:     ptr(1000),
+							HeadersList: ptr("==="),
+						},
+					},
+				},
+			},
+			wantErr: "invalid headers list: invalid key: \"\"",
 		},
 		{
 			name: "batch/otlp-grpc-exporter-no-scheme",
@@ -382,7 +397,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: &url.Error{Op: "parse", URL: " ", Err: errors.New("invalid URI for request")},
+			wantErr: "parse \" \": invalid URI for request",
 		},
 		{
 			name: "batch/otlp-grpc-invalid-compression",
@@ -405,7 +420,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported compression \"invalid\""),
+			wantErr: "unsupported compression \"invalid\"",
 		},
 		{
 			name: "batch/otlp-http-exporter",
@@ -462,7 +477,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("could not create certificate authority chain from certificate"),
+			wantErr: "could not create certificate authority chain from certificate",
 		},
 		{
 			name: "batch/otlp-http-bad-client-certificate",
@@ -480,7 +495,24 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: fmt.Errorf("could not use client certificate: %w", errors.New("tls: failed to find any PEM data in certificate input")),
+			wantErr: "could not use client certificate: tls: failed to find any PEM data in certificate input",
+		},
+		{
+			name: "batch/otlp-http-bad-headerslist",
+			processor: SpanProcessor{
+				Batch: &BatchSpanProcessor{
+					Exporter: SpanExporter{
+						OTLP: &OTLP{
+							Protocol:    ptr("http/protobuf"),
+							Endpoint:    ptr("localhost:4317"),
+							Compression: ptr("gzip"),
+							Timeout:     ptr(1000),
+							HeadersList: ptr("==="),
+						},
+					},
+				},
+			},
+			wantErr: "invalid headers list: invalid key: \"\"",
 		},
 		{
 			name: "batch/otlp-http-exporter-with-path",
@@ -571,7 +603,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: &url.Error{Op: "parse", URL: " ", Err: errors.New("invalid URI for request")},
+			wantErr: "parse \" \": invalid URI for request",
 		},
 		{
 			name: "batch/otlp-http-none-compression",
@@ -617,7 +649,7 @@ func TestSpanProcessor(t *testing.T) {
 					},
 				},
 			},
-			wantErr: errors.New("unsupported compression \"invalid\""),
+			wantErr: "unsupported compression \"invalid\"",
 		},
 		{
 			name: "simple/no-exporter",
@@ -626,7 +658,7 @@ func TestSpanProcessor(t *testing.T) {
 					Exporter: SpanExporter{},
 				},
 			},
-			wantErr: errors.New("no valid span exporter"),
+			wantErr: "no valid span exporter",
 		},
 		{
 			name: "simple/console-exporter",
@@ -643,7 +675,12 @@ func TestSpanProcessor(t *testing.T) {
 	for _, tt := range testCases {
 		t.Run(tt.name, func(t *testing.T) {
 			got, err := spanProcessor(context.Background(), tt.processor)
-			require.Equal(t, tt.wantErr, err)
+			if tt.wantErr != "" {
+				require.Error(t, err)
+				require.Equal(t, tt.wantErr, err.Error())
+			} else {
+				require.NoError(t, err)
+			}
 			if tt.wantProcessor == nil {
 				require.Nil(t, got)
 			} else {