Repository: open-telemetry/opentelemetry-go
Pull request #6392: Stop percent-encoding the header environment variables in otlplog exporters
Author: login: tongoss
Created: 2025-03-02T06:39:28Z | Merged: 2025-03-12T05:32:19Z

Bugfixes for #5623 

Based on the conversation https://github.com/open-telemetry/opentelemetry-go/issues/5623#issuecomment-2331089315, only OTLP log exporters need bugfixes.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cbf0123ce8..8b80fe0ec96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 
 - Drop support for [Go 1.22]. (#6381, #6418)
 
+### Fixes
+
+- Stop percent encoding header environment variables in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc` and `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. (#6392)
+
 <!-- Released section -->
 <!-- Don't change this section unless doing release -->
 
diff --git a/exporters/otlp/otlplog/otlploggrpc/config.go b/exporters/otlp/otlplog/otlploggrpc/config.go
index cd33a168271..65c3eb750ea 100644
--- a/exporters/otlp/otlplog/otlploggrpc/config.go
+++ b/exporters/otlp/otlplog/otlploggrpc/config.go
@@ -13,6 +13,7 @@ import (
 	"strconv"
 	"strings"
 	"time"
+	"unicode"
 
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/credentials"
@@ -442,13 +443,15 @@ func convHeaders(s string) (map[string]string, error) {
 			continue
 		}
 
-		escKey, e := url.PathUnescape(rawKey)
-		if e != nil {
+		key := strings.TrimSpace(rawKey)
+
+		// Validate the key.
+		if !isValidHeaderKey(key) {
 			err = errors.Join(err, fmt.Errorf("invalid header key: %s", rawKey))
 			continue
 		}
-		key := strings.TrimSpace(escKey)
 
+		// Only decode the value.
 		escVal, e := url.PathUnescape(rawVal)
 		if e != nil {
 			err = errors.Join(err, fmt.Errorf("invalid header value: %s", rawVal))
@@ -651,3 +654,22 @@ func fallback[T any](val T) resolver[T] {
 		return s
 	}
 }
+
+func isValidHeaderKey(key string) bool {
+	if key == "" {
+		return false
+	}
+	for _, c := range key {
+		if !isTokenChar(c) {
+			return false
+		}
+	}
+	return true
+}
+
+func isTokenChar(c rune) bool {
+	return c <= unicode.MaxASCII && (unicode.IsLetter(c) ||
+		unicode.IsDigit(c) ||
+		c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' ||
+		c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~')
+}
diff --git a/exporters/otlp/otlplog/otlploggrpc/config_test.go b/exporters/otlp/otlplog/otlploggrpc/config_test.go
index cb21786b2c4..e7a0cf1c9b4 100644
--- a/exporters/otlp/otlplog/otlploggrpc/config_test.go
+++ b/exporters/otlp/otlplog/otlploggrpc/config_test.go
@@ -338,7 +338,7 @@ func TestNewConfig(t *testing.T) {
 			name: "InvalidEnvironmentVariables",
 			envars: map[string]string{
 				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "%invalid",
-				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "a,%ZZ=valid,key=%ZZ",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "invalid key=value",
 				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "xz",
 				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "100 seconds",
 				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "invalid_cert",
@@ -355,10 +355,7 @@ func TestNewConfig(t *testing.T) {
 				`failed to load TLS:`,
 				`certificate not added`,
 				`tls: failed to find any PEM data in certificate input`,
-				`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value a,%ZZ=valid,key=%ZZ:`,
-				`invalid header: a`,
-				`invalid header key: %ZZ`,
-				`invalid header value: %ZZ`,
+				`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value invalid key=value: invalid header key: invalid key`,
 				`invalid OTEL_EXPORTER_OTLP_LOGS_COMPRESSION value xz: unknown compression: xz`,
 				`invalid OTEL_EXPORTER_OTLP_LOGS_TIMEOUT value 100 seconds: strconv.Atoi: parsing "100 seconds": invalid syntax`,
 			},
@@ -440,6 +437,47 @@ func TestNewConfig(t *testing.T) {
 				timeout:  newSetting(defaultTimeout),
 			},
 		},
+		{
+			name: "with percent-encoded headers",
+			envars: map[string]string{
+				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "https://env.endpoint:8080/prefix",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "user%2Did=42,user%20name=alice%20smith",
+				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "gzip",
+				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "15000",
+				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY":         "key_path",
+			},
+			want: config{
+				endpoint:    newSetting("env.endpoint:8080"),
+				insecure:    newSetting(false),
+				tlsCfg:      newSetting(tlsCfg),
+				headers:     newSetting(map[string]string{"user%2Did": "42", "user%20name": "alice smith"}),
+				compression: newSetting(GzipCompression),
+				timeout:     newSetting(15 * time.Second),
+				retryCfg:    newSetting(defaultRetryCfg),
+			},
+		},
+		{
+			name: "with invalid header key",
+			envars: map[string]string{
+				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "https://env.endpoint:8080/prefix",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "valid-key=value,invalid key=value",
+				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "gzip",
+				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "15000",
+				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY":         "key_path",
+			},
+			want: config{
+				endpoint:    newSetting("env.endpoint:8080"),
+				insecure:    newSetting(false),
+				tlsCfg:      newSetting(tlsCfg),
+				compression: newSetting(GzipCompression),
+				timeout:     newSetting(15 * time.Second),
+				retryCfg:    newSetting(defaultRetryCfg),
+			},
+		},
 	}
 
 	for _, tc := range testcases {
@@ -494,3 +532,88 @@ func assertTLSConfig(t *testing.T, want, got setting[*tls.Config]) {
 	}
 	assert.Equal(t, want.Value.Certificates, got.Value.Certificates, "Certificates")
 }
+
+func TestConvHeaders(t *testing.T) {
+	tests := []struct {
+		name    string
+		value   string
+		want    map[string]string
+		wantErr bool
+	}{
+		{
+			name:    "simple test",
+			value:   "userId=alice",
+			want:    map[string]string{"userId": "alice"},
+			wantErr: false,
+		},
+		{
+			name:    "simple test with spaces",
+			value:   " userId = alice  ",
+			want:    map[string]string{"userId": "alice"},
+			wantErr: false,
+		},
+		{
+			name:    "simple header conforms to RFC 3986 spec",
+			value:   " userId = alice+test ",
+			want:    map[string]string{"userId": "alice+test"},
+			wantErr: false,
+		},
+		{
+			name:  "multiple headers encoded",
+			value: "userId=alice,serverNode=DF%3A28,isProduction=false",
+			want: map[string]string{
+				"userId":       "alice",
+				"serverNode":   "DF:28",
+				"isProduction": "false",
+			},
+			wantErr: false,
+		},
+		{
+			name:  "multiple headers encoded per RFC 3986 spec",
+			value: "userId=alice+test,serverNode=DF%3A28,isProduction=false,namespace=localhost/test",
+			want: map[string]string{
+				"userId":       "alice+test",
+				"serverNode":   "DF:28",
+				"isProduction": "false",
+				"namespace":    "localhost/test",
+			},
+			wantErr: false,
+		},
+		{
+			name:    "invalid headers format",
+			value:   "userId:alice",
+			want:    map[string]string{},
+			wantErr: true,
+		},
+		{
+			name:  "invalid key",
+			value: "%XX=missing,userId=alice",
+			want: map[string]string{
+				"%XX":    "missing",
+				"userId": "alice",
+			},
+			wantErr: false,
+		},
+		{
+			name:  "invalid value",
+			value: "missing=%XX,userId=alice",
+			want: map[string]string{
+				"userId": "alice",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			keyValues, err := convHeaders(tt.value)
+			assert.Equal(t, tt.want, keyValues)
+
+			if tt.wantErr {
+				assert.Error(t, err, "expected an error but got nil")
+			} else {
+				assert.NoError(t, err, "expected no error but got one")
+			}
+		})
+	}
+}
diff --git a/exporters/otlp/otlplog/otlploghttp/config.go b/exporters/otlp/otlplog/otlploghttp/config.go
index bfe768091e3..b8952272c13 100644
--- a/exporters/otlp/otlplog/otlploghttp/config.go
+++ b/exporters/otlp/otlplog/otlploghttp/config.go
@@ -14,6 +14,7 @@ import (
 	"strconv"
 	"strings"
 	"time"
+	"unicode"
 
 	"go.opentelemetry.io/otel"
 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal/retry"
@@ -544,13 +545,15 @@ func convHeaders(s string) (map[string]string, error) {
 			continue
 		}
 
-		escKey, e := url.PathUnescape(rawKey)
-		if e != nil {
+		key := strings.TrimSpace(rawKey)
+
+		// Validate the key.
+		if !isValidHeaderKey(key) {
 			err = errors.Join(err, fmt.Errorf("invalid header key: %s", rawKey))
 			continue
 		}
-		key := strings.TrimSpace(escKey)
 
+		// Only decode the value.
 		escVal, e := url.PathUnescape(rawVal)
 		if e != nil {
 			err = errors.Join(err, fmt.Errorf("invalid header value: %s", rawVal))
@@ -600,3 +603,22 @@ func fallback[T any](val T) resolver[T] {
 		return s
 	}
 }
+
+func isValidHeaderKey(key string) bool {
+	if key == "" {
+		return false
+	}
+	for _, c := range key {
+		if !isTokenChar(c) {
+			return false
+		}
+	}
+	return true
+}
+
+func isTokenChar(c rune) bool {
+	return c <= unicode.MaxASCII && (unicode.IsLetter(c) ||
+		unicode.IsDigit(c) ||
+		c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' ||
+		c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~')
+}
diff --git a/exporters/otlp/otlplog/otlploghttp/config_test.go b/exporters/otlp/otlplog/otlploghttp/config_test.go
index 1a7568921db..a6863e3805a 100644
--- a/exporters/otlp/otlplog/otlploghttp/config_test.go
+++ b/exporters/otlp/otlplog/otlploghttp/config_test.go
@@ -344,7 +344,7 @@ func TestNewConfig(t *testing.T) {
 			name: "InvalidEnvironmentVariables",
 			envars: map[string]string{
 				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "%invalid",
-				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "a,%ZZ=valid,key=%ZZ",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "invalid key=value",
 				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "xz",
 				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "100 seconds",
 				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "invalid_cert",
@@ -362,14 +362,54 @@ func TestNewConfig(t *testing.T) {
 				`failed to load TLS:`,
 				`certificate not added`,
 				`tls: failed to find any PEM data in certificate input`,
-				`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value a,%ZZ=valid,key=%ZZ:`,
-				`invalid header: a`,
-				`invalid header key: %ZZ`,
-				`invalid header value: %ZZ`,
+				`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value invalid key=value: invalid header key: invalid key`,
 				`invalid OTEL_EXPORTER_OTLP_LOGS_COMPRESSION value xz: unknown compression: xz`,
 				`invalid OTEL_EXPORTER_OTLP_LOGS_TIMEOUT value 100 seconds: strconv.Atoi: parsing "100 seconds": invalid syntax`,
 			},
 		},
+		{
+			name: "with percent-encoded headers",
+			envars: map[string]string{
+				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "https://env.endpoint:8080/prefix",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "user%2Did=42,user%20name=alice%20smith",
+				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "gzip",
+				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "15000",
+				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY":         "key_path",
+			},
+			want: config{
+				endpoint:    newSetting("env.endpoint:8080"),
+				path:        newSetting("/prefix"),
+				insecure:    newSetting(false),
+				tlsCfg:      newSetting(tlsCfg),
+				headers:     newSetting(map[string]string{"user%2Did": "42", "user%20name": "alice smith"}),
+				compression: newSetting(GzipCompression),
+				timeout:     newSetting(15 * time.Second),
+				retryCfg:    newSetting(defaultRetryCfg),
+			},
+		},
+		{
+			name: "with invalid header key",
+			envars: map[string]string{
+				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "https://env.endpoint:8080/prefix",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "valid-key=value,invalid key=value",
+				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "gzip",
+				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "15000",
+				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY":         "key_path",
+			},
+			want: config{
+				endpoint:    newSetting("env.endpoint:8080"),
+				path:        newSetting("/prefix"),
+				insecure:    newSetting(false),
+				tlsCfg:      newSetting(tlsCfg),
+				compression: newSetting(GzipCompression),
+				timeout:     newSetting(15 * time.Second),
+				retryCfg:    newSetting(defaultRetryCfg),
+			},
+		},
 	}
 
 	for _, tc := range testcases {
@@ -436,3 +476,88 @@ func TestWithProxy(t *testing.T) {
 	assert.True(t, c.proxy.Set)
 	assert.NotNil(t, c.proxy.Value)
 }
+
+func TestConvHeaders(t *testing.T) {
+	tests := []struct {
+		name    string
+		value   string
+		want    map[string]string
+		wantErr bool
+	}{
+		{
+			name:    "simple test",
+			value:   "userId=alice",
+			want:    map[string]string{"userId": "alice"},
+			wantErr: false,
+		},
+		{
+			name:    "simple test with spaces",
+			value:   " userId = alice  ",
+			want:    map[string]string{"userId": "alice"},
+			wantErr: false,
+		},
+		{
+			name:    "simple header conforms to RFC 3986 spec",
+			value:   " userId = alice+test ",
+			want:    map[string]string{"userId": "alice+test"},
+			wantErr: false,
+		},
+		{
+			name:  "multiple headers encoded",
+			value: "userId=alice,serverNode=DF%3A28,isProduction=false",
+			want: map[string]string{
+				"userId":       "alice",
+				"serverNode":   "DF:28",
+				"isProduction": "false",
+			},
+			wantErr: false,
+		},
+		{
+			name:  "multiple headers encoded per RFC 3986 spec",
+			value: "userId=alice+test,serverNode=DF%3A28,isProduction=false,namespace=localhost/test",
+			want: map[string]string{
+				"userId":       "alice+test",
+				"serverNode":   "DF:28",
+				"isProduction": "false",
+				"namespace":    "localhost/test",
+			},
+			wantErr: false,
+		},
+		{
+			name:    "invalid headers format",
+			value:   "userId:alice",
+			want:    map[string]string{},
+			wantErr: true,
+		},
+		{
+			name:  "invalid key",
+			value: "%XX=missing,userId=alice",
+			want: map[string]string{
+				"%XX":    "missing",
+				"userId": "alice",
+			},
+			wantErr: false,
+		},
+		{
+			name:  "invalid value",
+			value: "missing=%XX,userId=alice",
+			want: map[string]string{
+				"userId": "alice",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			keyValues, err := convHeaders(tt.value)
+			assert.Equal(t, tt.want, keyValues)
+
+			if tt.wantErr {
+				assert.Error(t, err, "expected an error but got nil")
+			} else {
+				assert.NoError(t, err, "expected no error but got one")
+			}
+		})
+	}
+}


Reviews:
user: login: dmathieu
body: This needs to be tested.
I'd recommend looking at https://github.com/open-telemetry/opentelemetry-go/pull/5705/files for inspiration on how to write those tests.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-03T08:28:09Z
user: login: dmathieu
body: When errors are returned (and therefore handled), we shouldn't be logging them.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T13:23:13Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:37:53Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:47:10Z
user: login: dmathieu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T14:18:20Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T17:47:36Z
user: login: dmathieu
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-05T09:06:36Z
user: login: pellared
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T12:56:14Z
user: login: dmathieu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T15:41:43Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T19:13:53Z
user: login: pellared
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T14:06:56Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T17:24:03Z
user: login: pellared
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T18:30:34Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T22:05:21Z
user: login: pellared
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T22:08:30Z
user: login: tongoss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T23:07:29Z
user: login: pellared
body: Good overall :+1:  Just two comments related to unit tests.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T17:52:50Z
user: login: pellared
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T22:22:58Z
user: login: dmathieu
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T08:20:02Z
user: login: XSAM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-12T04:39:51Z

Review comments:
diff_hunk: @@ -43,6 +43,7 @@ The next release will require at least [Go 1.23].
 
 - Eliminate goroutine leak for the processor returned by `NewSimpleSpanProcessor` when `Shutdown` is called and the passed `ctx` is canceled and `SpanExporter.Shutdown` has not returned. (#6368)
 - Eliminate goroutine leak for the processor returned by `NewBatchSpanProcessor` when `ForceFlush` is called and the passed `ctx` is canceled and `SpanExporter.Export` has not returned. (#6369)
+- Eliminate percent decoding for the header keys when parsing OTEL_EXPORTER_OTLP_LOGS_HEADERS env vars. (#6392)
path: CHANGELOG.md
user: login: dmathieu
body: ```suggestion
- Stop percent encoding header environment variables in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc` and `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. (#6392)
```
created_at: 2025-03-03T08:26:53Z
author_association: MEMBER
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: dmathieu
body: ```suggestion
```
created_at: 2025-03-04T13:21:55Z
author_association: MEMBER
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
 			err = errors.Join(err, fmt.Errorf("invalid header: %s", header))
 			continue
 		}
 
-		escKey, e := url.PathUnescape(rawKey)
-		if e != nil {
+		key := strings.TrimSpace(rawKey)
+
+		// Validate the key.
+		if !isValidHeaderKey(key) {
+			global.Error(errors.New("invalid header key"), "parse headers", "key", key)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: dmathieu
body: ```suggestion
```
created_at: 2025-03-04T13:22:01Z
author_association: MEMBER
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
 			err = errors.Join(err, fmt.Errorf("invalid header: %s", header))
 			continue
 		}
 
-		escKey, e := url.PathUnescape(rawKey)
-		if e != nil {
+		key := strings.TrimSpace(rawKey)
+
+		// Validate the key.
+		if !isValidHeaderKey(key) {
+			global.Error(errors.New("invalid header key"), "parse headers", "key", key)
 			err = errors.Join(err, fmt.Errorf("invalid header key: %s", rawKey))
 			continue
 		}
-		key := strings.TrimSpace(escKey)
 
+		// Only decode the value.
 		escVal, e := url.PathUnescape(rawVal)
 		if e != nil {
+			global.Error(e, "escape header value", "value", rawVal)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: dmathieu
body: ```suggestion
```
created_at: 2025-03-04T13:22:09Z
author_association: MEMBER
diff_hunk: @@ -540,26 +541,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
path: exporters/otlp/otlplog/otlploghttp/config.go
user: login: dmathieu
body: ```suggestion
```
created_at: 2025-03-04T13:22:29Z
author_association: MEMBER
diff_hunk: @@ -540,26 +541,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
 			err = errors.Join(err, fmt.Errorf("invalid header: %s", header))
 			continue
 		}
 
-		escKey, e := url.PathUnescape(rawKey)
-		if e != nil {
+		key := strings.TrimSpace(rawKey)
+
+		// Validate the key.
+		if !isValidHeaderKey(key) {
+			global.Error(errors.New("invalid header key"), "parse headers", "key", key)
path: exporters/otlp/otlplog/otlploghttp/config.go
user: login: dmathieu
body: ```suggestion
```
created_at: 2025-03-04T13:22:36Z
author_association: MEMBER
diff_hunk: @@ -540,26 +541,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
 			err = errors.Join(err, fmt.Errorf("invalid header: %s", header))
 			continue
 		}
 
-		escKey, e := url.PathUnescape(rawKey)
-		if e != nil {
+		key := strings.TrimSpace(rawKey)
+
+		// Validate the key.
+		if !isValidHeaderKey(key) {
+			global.Error(errors.New("invalid header key"), "parse headers", "key", key)
 			err = errors.Join(err, fmt.Errorf("invalid header key: %s", rawKey))
 			continue
 		}
-		key := strings.TrimSpace(escKey)
 
+		// Only decode the value.
 		escVal, e := url.PathUnescape(rawVal)
 		if e != nil {
+			global.Error(e, "escape header value", "value", rawVal)
path: exporters/otlp/otlplog/otlploghttp/config.go
user: login: dmathieu
body: ```suggestion
```
created_at: 2025-03-04T13:22:43Z
author_association: MEMBER
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: @dmathieu , the reason why add the logging here is that if there is partial success on headers, `err` is reset to nil before return. Therefore, partial failure messages could be lost.
created_at: 2025-03-04T13:37:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: @dmathieu , I can remove these logging but just want to clarify before I do so.
created_at: 2025-03-04T13:47:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: dmathieu
body: Maybe adding them makes sense. But not in the context of fixing percent-encoding, which is what this PR does.
Therefore, that should be in another PR.
created_at: 2025-03-04T14:18:19Z
author_association: MEMBER
diff_hunk: @@ -438,26 +439,35 @@ func convHeaders(s string) (map[string]string, error) {
 	for _, header := range strings.Split(s, ",") {
 		rawKey, rawVal, found := strings.Cut(header, "=")
 		if !found {
+			global.Error(errors.New("missing '="), "parse headers", "input", header)
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: > Maybe adding them makes sense. But not in the context of fixing percent-encoding, which is what this PR does. Therefore, that should be in another PR.

@dmathieu , Thanks for your comment. Removing these logging now.
created_at: 2025-03-04T17:47:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,6 +461,10 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: pellared
body: How would we know if there was some problem with decoding other keys or values?

Shouldn't we call `global.Warn`?

This handling is also not consistent with the logic from other OTLP exporters: https://github.com/open-telemetry/opentelemetry-go/blob/main/exporters/otlp/otlptrace/otlptracehttp/internal/envconfig/envconfig.go#L157-L188

Do we have a plan to unify it?

CC @dmathieu as you seemed to comment on the error handling (however, I did not read the comments carefully)

created_at: 2025-03-05T12:56:13Z
author_association: MEMBER
diff_hunk: @@ -458,6 +461,10 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: dmathieu
body: My suggestion was indeed to remove the logs, as contrarily to other exporters, we do return errors in here. So any error that is not nulled would be returned.

When we nullify the error, logging would definitely make sense though.
created_at: 2025-03-05T15:41:43Z
author_association: MEMBER
diff_hunk: @@ -458,6 +461,10 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: @pellared , @dmathieu , totally make sense to log the warning here and it is a great idea. I have pushed the new commit to address this comment.

Regarding the plan, I haven't put too much thought on it yet but I am happy to contribute and learn. 
created_at: 2025-03-05T19:13:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,6 +461,11 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		global.Warn("Some headers were invalid but at least one valid header exists: %v", err)
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: pellared
body: I looked at the code again with more care and I propose to remove these lines

```suggestion
```

Otherwise, this PR would also change the error handling of env var parsing.

I think it would be better to have a separate PR that refines/unifies the error handling of  `_HEADERS` env vars parsing. Then we can have this PR focused only on the change that is described in the changelog and PR title.

> Regarding the plan, I haven't put too much thought on it yet but I am happy to contribute and learn.

This would also give more room to have more thought on the unified behavior. We welcome contributions.

@dmathieu, does it make sense?

@tongoss, sorry for any inconvenience :cry: 
created_at: 2025-03-06T14:06:56Z
author_association: MEMBER
diff_hunk: @@ -458,6 +461,11 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		global.Warn("Some headers were invalid but at least one valid header exists: %v", err)
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: @pellared , @dmathieu , no worries at all. I like all these discussion. 

If removing these, it will not resolve one requirement - "Moreover, only valid header keys should be parsed." from #5623 , because the current other implementation components from `config.go` can only handle either parsing all valid headers or parsing non if one invalid headers exist. 

This can be reflected from unit tests. It will fail on similar unit tests from metric and trace. 
created_at: 2025-03-06T17:24:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,6 +461,11 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		global.Warn("Some headers were invalid but at least one valid header exists: %v", err)
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: pellared
body: > If removing these, it will not resolve one requirement - "Moreover, only valid header keys should be parsed." from #5623 , because the current other implementation components from config.go can only handle either parsing all valid headers or parsing non if one invalid headers exist.

This is not why had in mind. It was about validating the header key to follow

> A token which identifies a value in the baggage. token is defined in [RFC7230, Section 3.2.6](https://tools.ietf.org/html/rfc7230#section-3.2.6).

We can create a separate issue for error handling. 

Notice that the error handling pattern in this PR is even different from the one in OTLP trace and metrics exporters which logs an error for each invalid input:

```go
func stringToHeader(value string) map[string]string {
	headersPairs := strings.Split(value, ",")
	headers := make(map[string]string)

	for _, header := range headersPairs {
		n, v, found := strings.Cut(header, "=")
		if !found {
			global.Error(errors.New("missing '="), "parse headers", "input", header)
			continue
		}

		trimmedName := strings.TrimSpace(n)

		// Validate the key.
		if !isValidHeaderKey(trimmedName) {
			global.Error(errors.New("invalid header key"), "parse headers", "key", trimmedName)
			continue
		}

		// Only decode the value.
		value, err := url.PathUnescape(v)
		if err != nil {
			global.Error(err, "escape header value", "value", v)
			continue
		}
		trimmedValue := strings.TrimSpace(value)

		headers[trimmedName] = trimmedValue
	}

	return headers
}
```

In this PR we are now logging using `global.Warn` and `otel.Handle` when an error is returned.

But if the value provided by env var has errors. Then I think that NOT accepting the whole env var is a good thing to do. I think that the error handling in this exporter is superior to the one proposed in this PR as well as OTLP trace and metrics exporters.

> @pellared , @dmathieu , no worries at all. I like all these discussion.

Thank you for your attitude. It is a "grey area" as it seems very tempting to fix two thing at once, but I think this would not be a good thing in this scenario. Especially that I prefer the error handling in OTLP log exporters :wink:  
created_at: 2025-03-06T18:30:34Z
author_association: MEMBER
diff_hunk: @@ -458,6 +461,11 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		global.Warn("Some headers were invalid but at least one valid header exists: %v", err)
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: @pellared , the implementation in `config.go` in log is quite different from metric and trace. Even function signature is quite different together with other components. Back to your original question about unifying them, I could anticipate fair amount of refactoring work.

Regarding this PR, just want to recap and clarify:

1. If both valid and invalid headers are included, should we return only valid ones (my original thought but could be wrong) or none?
2. Depend on the answer for 1st question:
    a) if return valid headers only, the current fix can return valid one but the error handling part is different from trace and metric. Might need another PR to unify error handling implementation.

    b) if return none, then we have to remove nullifying error and update unit tests as well. This will cause the difference from metric and trace. My understanding is that for metric and trace, they are following a) scenario. So we might need to raise another PR to resolve metric and trace handling part.

Please feel free to let me know if there is any guidance on this. I will update PR accordingly.  :-)
created_at: 2025-03-06T22:05:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,6 +461,11 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		global.Warn("Some headers were invalid but at least one valid header exists: %v", err)
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: pellared
body: Let's do b) for now.
created_at: 2025-03-06T22:08:30Z
author_association: MEMBER
diff_hunk: @@ -458,6 +461,11 @@ func convHeaders(s string) (map[string]string, error) {
 
 		out[key] = val
 	}
+	// If at least one valid header exists, reset err to nil
+	if len(out) > 0 {
+		global.Warn("Some headers were invalid but at least one valid header exists: %v", err)
+		err = nil
+	}
path: exporters/otlp/otlplog/otlploggrpc/config.go
user: login: tongoss
body: > Let's do b) for now.

@pellared , Thanks very much for the guidance. Just push the new changes to address the comment. I agree with you that `return none` in this scenario makes more sense.

If there is any issue raised against metric and trace later on, I will be happy to contribute and learn. :-)
created_at: 2025-03-06T23:07:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -362,14 +363,54 @@ func TestNewConfig(t *testing.T) {
 				`failed to load TLS:`,
 				`certificate not added`,
 				`tls: failed to find any PEM data in certificate input`,
-				`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value a,%ZZ=valid,key=%ZZ:`,
-				`invalid header: a`,
-				`invalid header key: %ZZ`,
-				`invalid header value: %ZZ`,
+				`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value invalid key=value: invalid header key: invalid key`,
 				`invalid OTEL_EXPORTER_OTLP_LOGS_COMPRESSION value xz: unknown compression: xz`,
 				`invalid OTEL_EXPORTER_OTLP_LOGS_TIMEOUT value 100 seconds: strconv.Atoi: parsing "100 seconds": invalid syntax`,
 			},
 		},
+		{
+			name: "with percent-encoded headers",
+			envars: map[string]string{
+				"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT":           "https://env.endpoint:8080/prefix",
+				"OTEL_EXPORTER_OTLP_LOGS_HEADERS":            "user%2Did=42,user%20name=alice%20smith",
+				"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION":        "gzip",
+				"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT":            "15000",
+				"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE":        "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
+				"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY":         "key_path",
+			},
+			want: config{
+				endpoint:    newSetting("env.endpoint:8080"),
+				path:        newSetting("/prefix"),
+				insecure:    newSetting(false),
+				tlsCfg:      newSetting(tlsCfg),
+				headers:     newSetting(percentDecodedHeaders),
path: exporters/otlp/otlplog/otlploghttp/config_test.go
user: login: pellared
body: I think it would be more readable if you inline `percentDecodedHeaders` here. The defintion is very far away from 

```go
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": "user%2Did=42,user%20name=alice%20smith",
```
created_at: 2025-03-07T17:50:33Z
author_association: MEMBER
diff_hunk: @@ -436,3 +477,73 @@ func TestWithProxy(t *testing.T) {
 	assert.True(t, c.proxy.Set)
 	assert.NotNil(t, c.proxy.Value)
 }
+
+func TestConvHeaders(t *testing.T) {
+	tests := []struct {
+		name  string
+		value string
+		want  map[string]string
+	}{
+		{
+			name:  "simple test",
+			value: "userId=alice",
+			want:  map[string]string{"userId": "alice"},
+		},
+		{
+			name:  "simple test with spaces",
+			value: " userId = alice  ",
+			want:  map[string]string{"userId": "alice"},
+		},
+		{
+			name:  "simple header conforms to RFC 3986 spec",
+			value: " userId = alice+test ",
+			want:  map[string]string{"userId": "alice+test"},
+		},
+		{
+			name:  "multiple headers encoded",
+			value: "userId=alice,serverNode=DF%3A28,isProduction=false",
+			want: map[string]string{
+				"userId":       "alice",
+				"serverNode":   "DF:28",
+				"isProduction": "false",
+			},
+		},
+		{
+			name:  "multiple headers encoded per RFC 3986 spec",
+			value: "userId=alice+test,serverNode=DF%3A28,isProduction=false,namespace=localhost/test",
+			want: map[string]string{
+				"userId":       "alice+test",
+				"serverNode":   "DF:28",
+				"isProduction": "false",
+				"namespace":    "localhost/test",
+			},
+		},
+		{
+			name:  "invalid headers format",
+			value: "userId:alice",
+			want:  map[string]string{},
+		},
+		{
+			name:  "invalid key",
+			value: "%XX=missing,userId=alice",
+			want: map[string]string{
+				"%XX":    "missing",
+				"userId": "alice",
+			},
+		},
+		{
+			name:  "invalid value",
+			value: "missing=%XX,userId=alice",
+			want: map[string]string{
+				"userId": "alice",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			keyValues, _ := convHeaders(tt.value)
path: exporters/otlp/otlplog/otlploghttp/config_test.go
user: login: pellared
body: Can we also assert if it returns an `nil` or `non-nil` error? We do not need to assert for exact "error message".
created_at: 2025-03-07T17:51:59Z
author_association: MEMBER

PR comments:
user: login: linux-foundation-easycla[bot]
created_at: 2025-03-02T06:39:31Z
body: <a href="https://easycla.lfx.linuxfoundation.org/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-signed.svg" alt="CLA Signed" align="left" height="28" width="328" ></a><br/><br />The committers listed above are authorized under a signed CLA.<ul><li>:white_check_mark: login: tongoss / name: Robert Wu  (337a320b8f6e4b0f8aad1e2f34d1208e2c3a2fc5, bcf5fecbc739841f2280f6173325ab74ac62ff00, 84dccd6dd894042feb0a68ab0ee83b5b64f80a90, 9cbacc6bdb102cd32a4da6394cce6218325cda97, 28b03f4db550c295aa1537ad3edb471bbff2c326, 0a50e3ef1b5a56ae404305ad58dcc8897361c619, 52d82725f3a3685cd85838035db312626cee45fd, 0d956e55c1bd32121ea6dfc5be96dba5f14b0672, c6f0fa374eed690595298561a0acd8873b678e56, a4e2d2e3285e314d948155d724161eace6a07447, bfbb5aafc46707bc330d3e3e8c00f30e62c8533b, edcfc4376247ad4565c1aed332d3b7513ed841bb, 2d57f4c87e43d78bc74b2ee9cd325c8b360a9407, fe68066109f6a24d9560ac4096321d316167e1bd, fb22d991ae1073f4a713ae0016be59cbc9886df7, 6889ded10fd3c16542f246b4e2e52544d4d5da70)</li><li>:white_check_mark: login: dmathieu / name: Damien Mathieu  (532fb77eb15048bf0efbe8428a6284cd0498d3fa)</li><li>:white_check_mark: login: pellared / name: Robert Pająk  (3816fc5d2a5dd5358ab2162a0ae7872a677e4c08)</li></ul><!-- Date Modified: 2025-03-11 21:13:14.359050 -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-03T22:55:07Z
body: ## [Codecov](https://app.codecov.io/gh/open-telemetry/opentelemetry-go/pull/6392?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 81.8%. Comparing base [(`fb89a38`)](https://app.codecov.io/gh/open-telemetry/opentelemetry-go/commit/fb89a382e67c710ab2bdea0dfd4ec3d385f3fcf9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) to head [(`bfbb5aa`)](https://app.codecov.io/gh/open-telemetry/opentelemetry-go/commit/bfbb5aafc46707bc330d3e3e8c00f30e62c8533b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/open-telemetry/opentelemetry-go/pull/6392/graphs/tree.svg?width=650&height=150&src=pr&token=8efTmh4kvf&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry)](https://app.codecov.io/gh/open-telemetry/opentelemetry-go/pull/6392?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry)

```diff
@@           Coverage Diff           @@
##            main   #6392     +/-   ##
=======================================
- Coverage   81.8%   81.8%   -0.1%     
=======================================
  Files        283     283             
  Lines      24899   24927     +28     
=======================================
+ Hits       20379   20400     +21     
- Misses      4117    4122      +5     
- Partials     403     405      +2     
```

[see 3 files with indirect coverage changes](https://app.codecov.io/gh/open-telemetry/opentelemetry-go/pull/6392/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry)

</details>
<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: tongoss
created_at: 2025-03-03T23:00:15Z
body: > This needs to be tested. I'd recommend looking at https://github.com/open-telemetry/opentelemetry-go/pull/5705/files for inspiration on how to write those tests.

@dmathieu , the required test cases have been added.
author_association: CONTRIBUTOR
user: login: pellared
created_at: 2025-03-04T05:45:33Z
body: @tongoss, why have your closed the PR?
author_association: MEMBER
user: login: tongoss
created_at: 2025-03-04T12:14:15Z
body: > @tongoss, why have your closed the PR?

@pellared , Thanks so much for reminding me. I happened to click the wrong button...
author_association: CONTRIBUTOR
user: login: tongoss
created_at: 2025-03-07T22:45:02Z
body: > Good overall 👍 Just two comments related to unit tests.

@pellared , Thanks very much for the comments. They are all very helpful. I have pushed the new changes.
author_association: CONTRIBUTOR
user: login: pellared
created_at: 2025-03-11T08:22:07Z
body: @open-telemetry/go-maintainers, planning to merge tomorrow.
author_association: MEMBER