Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/config/config_stdin.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ type StdinOpenTelemetryConfig struct {
// Endpoint is the OTLP/HTTP collector URL. MUST be HTTPS. Supports ${VAR} expansion.
Endpoint string `json:"endpoint"`

// Headers are HTTP headers for export requests (e.g. auth tokens). Values support ${VAR}.
Headers map[string]string `json:"headers,omitempty"`
// Headers is a comma-separated list of key=value HTTP headers for export requests
// (e.g. "Authorization=Bearer ${OTEL_TOKEN},X-Custom=value"). Supports ${VAR} expansion.
Headers string `json:"headers,omitempty"`

// TraceID is the parent trace ID (32-char lowercase hex, W3C format). Supports ${VAR}.
TraceID string `json:"traceId,omitempty"`
Expand Down
11 changes: 5 additions & 6 deletions internal/config/config_tracing.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,17 @@ const DefaultTracingServiceName = "mcp-gateway"
// service_name = "mcp-gateway"
// trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
// span_id = "00f067aa0ba902b7"
//
// [gateway.opentelemetry.headers]
// Authorization = "Bearer ${OTEL_TOKEN}"
// headers = "Authorization=Bearer ${OTEL_TOKEN}"
type TracingConfig struct {
// Endpoint is the OTLP HTTP endpoint to export traces to.
// When using the opentelemetry section (spec §4.1.3.6), this MUST be an HTTPS URL.
// If empty, tracing is disabled and a noop tracer is used.
Endpoint string `toml:"endpoint" json:"endpoint,omitempty"`

// Headers are HTTP headers sent with every OTLP export request (e.g. auth tokens).
// Header values support ${VAR} variable expansion (expanded at config load time).
Headers map[string]string `toml:"headers" json:"headers,omitempty"`
// Headers is a comma-separated list of key=value HTTP headers sent with every OTLP
// export request (e.g. "Authorization=Bearer ${OTEL_TOKEN},X-Custom=value").
// Supports ${VAR} variable expansion (expanded at config load time).
Headers string `toml:"headers" json:"headers,omitempty"`

// TraceID is an optional W3C trace ID (32-char lowercase hex) used to construct the
// parent traceparent header, linking gateway spans into a pre-existing trace.
Expand Down
13 changes: 5 additions & 8 deletions internal/config/config_tracing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestOTEL004_NonHTTPSEndpoint_Error(t *testing.T) {

// T-OTEL-005: TracingConfig struct carries all required spec §4.1.3.6 fields.
func TestOTEL005_TracingConfigFields(t *testing.T) {
headers := map[string]string{"Authorization": "Bearer token"}
headers := "Authorization=Bearer token"
cfg := &TracingConfig{
Endpoint: "https://otel-collector.example.com",
Headers: headers,
Expand All @@ -80,10 +80,7 @@ func TestOTEL005_TracingConfigFields(t *testing.T) {

// T-OTEL-006: Headers are preserved in TracingConfig when configured.
func TestOTEL006_HeadersPreserved(t *testing.T) {
headers := map[string]string{
"Authorization": "Bearer my-token",
"X-Custom": "value",
}
headers := "Authorization=Bearer my-token,X-Custom=value"
cfg := &TracingConfig{
Endpoint: "https://otel-collector.example.com",
Headers: headers,
Expand Down Expand Up @@ -206,7 +203,7 @@ func TestExpandTracingVariables(t *testing.T) {
Endpoint: "${TEST_OTEL_ENDPOINT}",
TraceID: "${TEST_TRACE_ID}",
SpanID: "${TEST_SPAN_ID}",
Headers: map[string]string{"Authorization": "${TEST_AUTH_TOKEN}"},
Headers: "Authorization=${TEST_AUTH_TOKEN}",
}

err := expandTracingVariables(cfg)
Expand All @@ -215,7 +212,7 @@ func TestExpandTracingVariables(t *testing.T) {
assert.Equal(t, "https://otel.example.com", cfg.Endpoint)
assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", cfg.TraceID)
assert.Equal(t, "00f067aa0ba902b7", cfg.SpanID)
assert.Equal(t, "Bearer secret-token", cfg.Headers["Authorization"])
assert.Equal(t, "Authorization=Bearer secret-token", cfg.Headers)

// After expansion, validation should pass
err = validateOpenTelemetryConfig(cfg, true)
Expand Down Expand Up @@ -262,7 +259,7 @@ func TestGetSampleRate_NewFields(t *testing.T) {
rate := 0.5
cfg := &TracingConfig{
Endpoint: "https://otel-collector.example.com",
Headers: map[string]string{"Authorization": "Bearer tok"},
Headers: "Authorization=Bearer tok",
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
SpanID: "00f067aa0ba902b7",
ServiceName: "my-service",
Expand Down
788 changes: 393 additions & 395 deletions internal/config/schema/mcp-gateway-config.schema.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,12 @@ func expandTracingVariables(cfg *TracingConfig) error {
cfg.SpanID = expanded
}

for key, value := range cfg.Headers {
expanded, err := expandVariables(value, fmt.Sprintf("gateway.opentelemetry.headers.%s", key))
if cfg.Headers != "" {
expanded, err := expandVariables(cfg.Headers, "gateway.opentelemetry.headers")
if err != nil {
return err
}
cfg.Headers[key] = expanded
cfg.Headers = expanded
}

return nil
Expand Down
89 changes: 89 additions & 0 deletions internal/tracing/parse_headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package tracing

import (
"testing"

"github.com/stretchr/testify/assert"
)

// TestParseOTLPHeaders covers the parseOTLPHeaders helper with a range of inputs.
func TestParseOTLPHeaders(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]string
}{
{
name: "empty string",
input: "",
expected: map[string]string{},
},
{
name: "single well-formed pair",
input: "Authorization=Bearer test-token",
expected: map[string]string{
"Authorization": "Bearer test-token",
},
},
{
name: "multiple well-formed pairs",
input: "Authorization=Bearer test-token,X-Request-ID=req-123",
expected: map[string]string{
"Authorization": "Bearer test-token",
"X-Request-ID": "req-123",
},
},
{
name: "whitespace is trimmed around keys and values",
input: " Authorization = Bearer test-token , X-Request-ID = req-123 ",
expected: map[string]string{
"Authorization": "Bearer test-token",
"X-Request-ID": "req-123",
},
},
{
name: "value containing '=' is preserved",
input: "Authorization=Basic dXNlcjpwYXNz==",
expected: map[string]string{
"Authorization": "Basic dXNlcjpwYXNz==",
},
},
{
name: "malformed pair without '=' is skipped",
input: "Authorization=Bearer test-token,malformed,X-Trace-ID=trace-123",
expected: map[string]string{
"Authorization": "Bearer test-token",
"X-Trace-ID": "trace-123",
},
},
{
name: "pair with empty key is skipped",
input: "Authorization=Bearer test-token,=empty-key,X-Trace-ID=trace-123",
expected: map[string]string{
"Authorization": "Bearer test-token",
"X-Trace-ID": "trace-123",
},
},
{
name: "pair with whitespace-only key is skipped",
input: "Authorization=Bearer test-token, =whitespace-key",
expected: map[string]string{
"Authorization": "Bearer test-token",
},
},
{
name: "empty trailing comma is skipped",
input: "Authorization=Bearer test-token,",
expected: map[string]string{
"Authorization": "Bearer test-token",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseOTLPHeaders(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
35 changes: 31 additions & 4 deletions internal/tracing/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"

"go.opentelemetry.io/otel"
Expand Down Expand Up @@ -93,12 +94,38 @@ func resolveSampleRate(cfg *config.TracingConfig) float64 {
return config.DefaultTracingSampleRate
}

// resolveHeaders returns the configured OTLP export headers (or nil).
// parseOTLPHeaders parses a comma-separated "key=value" string into a map.
// Empty pairs, pairs without "=", and pairs with an empty key are logged as
// warnings and skipped to avoid invalid HTTP header field names.
// Leading/trailing whitespace around keys and values is trimmed.
func parseOTLPHeaders(raw string) map[string]string {
headers := make(map[string]string)
for _, pair := range strings.Split(raw, ",") {
trimmed := strings.TrimSpace(pair)
if trimmed == "" {
continue
}
k, v, ok := strings.Cut(trimmed, "=")
if !ok {
logTracing.Printf("Warning: skipping malformed OTLP header pair (missing '=')")
continue
}
key := strings.TrimSpace(k)
if key == "" {
logTracing.Printf("Warning: skipping OTLP header pair with empty key")
continue
}
headers[key] = strings.TrimSpace(v)
}
Comment on lines +101 to +119
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseOTLPHeaders can produce an entry with an empty header name when the input contains pairs like "=value" or " = value". Passing a map with an empty key to the OTLP HTTP exporter can cause request creation to fail due to an invalid HTTP header field name. After trimming, validate that the header key is non-empty (and ideally a valid HTTP token) before adding it; otherwise log a warning and skip the pair.

Copilot uses AI. Check for mistakes.
return headers
}

// resolveHeaders parses the configured OTLP export headers string (or returns nil).
func resolveHeaders(cfg *config.TracingConfig) map[string]string {
if cfg != nil && len(cfg.Headers) > 0 {
return cfg.Headers
if cfg == nil || cfg.Headers == "" {
return nil
}
return nil
return parseOTLPHeaders(cfg.Headers)
}

// resolveParentContext builds a context carrying the W3C remote parent span context
Expand Down
118 changes: 81 additions & 37 deletions internal/tracing/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,48 +314,92 @@ func TestWrapHTTPHandler_GeneratesRootSpan(t *testing.T) {
}

// TestInitProvider_WithHeaders verifies that OTLP export headers are forwarded
// to the collector. A channel synchronises with the test HTTP server so the
// assertion is deterministic rather than timing-dependent.
// to the collector. Table-driven sub-tests cover single headers, multiple
// headers with whitespace trimming, and malformed/empty-key cases that must be
// skipped. A channel synchronises with the test HTTP server so assertions are
// deterministic rather than timing-dependent.
func TestInitProvider_WithHeaders(t *testing.T) {
ctx := context.Background()

// Channel signals when the test server receives an export request.
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case received <- r.Header.Get("Authorization"):
default:
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()

cfg := &config.TracingConfig{
Endpoint: ts.URL,
Headers: map[string]string{"Authorization": "Bearer test-token"},
tests := []struct {
name string
headers string
expectedValues map[string]string // canonical HTTP header name → expected value
notExpectedSet []string // canonical HTTP header names that must NOT be present
}{
{
name: "single well-formed header",
headers: "Authorization=Bearer test-token",
expectedValues: map[string]string{
"Authorization": "Bearer test-token",
},
},
{
name: "multiple headers with whitespace trimmed",
headers: " Authorization = Bearer test-token , X-Request-Id = req-123 ",
expectedValues: map[string]string{
"Authorization": "Bearer test-token",
"X-Request-Id": "req-123",
},
},
{
name: "malformed and empty-key headers are skipped",
headers: "Authorization=Bearer test-token, malformed, =empty-key, X-Trace-Id=trace-123",
expectedValues: map[string]string{
"Authorization": "Bearer test-token",
"X-Trace-Id": "trace-123",
},
notExpectedSet: []string{"Malformed"},
},
}

provider, err := tracing.InitProvider(ctx, cfg)
require.NoError(t, err)
require.NotNil(t, provider)

// Create and end a span to trigger an export attempt.
tr := provider.Tracer()
_, span := tr.Start(ctx, "header-test-span")
span.End()

// Shutdown flushes the batch processor, ensuring the export is sent.
shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_ = provider.Shutdown(shutdownCtx)

// Wait for the export request with a timeout.
select {
case auth := <-received:
assert.Equal(t, "Bearer test-token", auth,
"Authorization header must be forwarded to the OTLP collector")
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for OTLP export request — headers test is non-deterministic")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Channel signals when the test server receives an export request.
received := make(chan http.Header, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case received <- r.Header.Clone():
default:
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()

cfg := &config.TracingConfig{
Endpoint: ts.URL,
Headers: tt.headers,
}

provider, err := tracing.InitProvider(ctx, cfg)
require.NoError(t, err)
require.NotNil(t, provider)

// Create and end a span to trigger an export attempt.
tr := provider.Tracer()
_, span := tr.Start(ctx, "header-test-span")
span.End()

// Shutdown flushes the batch processor, ensuring the export is sent.
shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_ = provider.Shutdown(shutdownCtx)

// Wait for the export request with a timeout.
select {
case headers := <-received:
for key, expectedValue := range tt.expectedValues {
assert.Equal(t, expectedValue, headers.Get(key),
fmt.Sprintf("%s header must be forwarded to the OTLP collector", key))
}
for _, key := range tt.notExpectedSet {
assert.Empty(t, headers.Get(key),
fmt.Sprintf("%s header must not be sent when pair is malformed", key))
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for OTLP export request — headers test is non-deterministic")
}
})
}
}

Expand Down
Loading