Skip to content

Commit 071d382

Browse files
lpcoxCopilot
andcommitted
fix: address review feedback for OpenTelemetry config
- Add TOML variable expansion for tracing fields (endpoint, traceId, spanId, headers) via expandTracingVariables(), matching stdin JSON path behavior. Comments now accurately reflect expansion support. - Reject all-zero traceId/spanId per W3C Trace Context specification. - Make TestInitProvider_WithHeaders deterministic using a channel instead of conditional assertion on captured auth header. - Remove unused varExprPatternSimple regex. - Add tests: variable expansion, undefined var error, all-zero traceId/spanId rejection, unexpanded var expression rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 48821a5 commit 071d382

5 files changed

Lines changed: 160 additions & 27 deletions

File tree

internal/config/config_core.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,10 @@ func LoadFromFile(path string) (*Config, error) {
360360
if cfg.Gateway.Opentelemetry != nil {
361361
cfg.Gateway.Tracing = cfg.Gateway.Opentelemetry
362362
cfg.Gateway.Opentelemetry = nil
363+
// Expand ${VAR} expressions in tracing fields before validation.
364+
if err := expandTracingVariables(cfg.Gateway.Tracing); err != nil {
365+
return nil, err
366+
}
363367
// Validate HTTPS endpoint requirement for the opentelemetry section
364368
if err := validateOpenTelemetryConfig(cfg.Gateway.Tracing, true); err != nil {
365369
return nil, err

internal/config/config_tracing.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,19 @@ type TracingConfig struct {
3232
Endpoint string `toml:"endpoint" json:"endpoint,omitempty"`
3333

3434
// Headers are HTTP headers sent with every OTLP export request (e.g. auth tokens).
35-
// Values support ${VAR} variable expansion.
35+
// Header values support ${VAR} variable expansion (expanded at config load time).
3636
Headers map[string]string `toml:"headers" json:"headers,omitempty"`
3737

3838
// TraceID is an optional W3C trace ID (32-char lowercase hex) used to construct the
3939
// parent traceparent header, linking gateway spans into a pre-existing trace.
40-
// Supports ${VAR} variable expansion. Must be 32 lowercase hex characters.
40+
// Supports ${VAR} variable expansion (expanded at config load time).
41+
// Must be 32 lowercase hex characters and must not be all zeros.
4142
TraceID string `toml:"trace_id" json:"traceId,omitempty"`
4243

4344
// SpanID is an optional W3C span ID (16-char lowercase hex) paired with TraceID
4445
// to construct the parent traceparent header. Ignored when TraceID is absent.
45-
// Supports ${VAR} variable expansion. Must be 16 lowercase hex characters.
46+
// Supports ${VAR} variable expansion (expanded at config load time).
47+
// Must be 16 lowercase hex characters and must not be all zeros.
4648
SpanID string `toml:"span_id" json:"spanId,omitempty"`
4749

4850
// ServiceName is the service name reported in traces.

internal/config/config_tracing_test.go

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,17 +180,81 @@ func TestOTEL010_ServiceNameDefaults(t *testing.T) {
180180
"T-OTEL-010: default serviceName must be 'mcp-gateway' after applyDefaults")
181181
}
182182

183-
// TestValidateOpenTelemetryConfig_VarExpressions verifies that ${VAR} expressions
184-
// are accepted in traceId/spanId fields (they pass the regex check as whole-string var refs).
185-
func TestValidateOpenTelemetryConfig_VarExpressions(t *testing.T) {
183+
// TestValidateOpenTelemetryConfig_UnexpandedVarExpressions verifies that unexpanded
184+
// ${VAR} expressions are rejected by validation. In practice, expandTracingVariables
185+
// (TOML path) or ExpandRawJSONVariables (stdin JSON path) expand vars before validation,
186+
// so unexpanded expressions should never reach the validator in normal flow.
187+
func TestValidateOpenTelemetryConfig_UnexpandedVarExpressions(t *testing.T) {
186188
cfg := &TracingConfig{
187189
Endpoint: "https://otel-collector.example.com",
188190
TraceID: "${TRACE_ID}",
189-
SpanID: "${SPAN_ID}",
190191
}
191-
// Variable expressions should not be validated as hex (they haven't been expanded yet)
192192
err := validateOpenTelemetryConfig(cfg, true)
193-
require.NoError(t, err, "Variable expressions in traceId/spanId must pass validation before expansion")
193+
require.Error(t, err, "Unexpanded variable expressions must fail hex validation")
194+
assert.Contains(t, err.Error(), "traceId")
195+
}
196+
197+
// TestExpandTracingVariables verifies that ${VAR} expressions in tracing config
198+
// fields are expanded from environment variables.
199+
func TestExpandTracingVariables(t *testing.T) {
200+
t.Setenv("TEST_OTEL_ENDPOINT", "https://otel.example.com")
201+
t.Setenv("TEST_TRACE_ID", "4bf92f3577b34da6a3ce929d0e0e4736")
202+
t.Setenv("TEST_SPAN_ID", "00f067aa0ba902b7")
203+
t.Setenv("TEST_AUTH_TOKEN", "Bearer secret-token")
204+
205+
cfg := &TracingConfig{
206+
Endpoint: "${TEST_OTEL_ENDPOINT}",
207+
TraceID: "${TEST_TRACE_ID}",
208+
SpanID: "${TEST_SPAN_ID}",
209+
Headers: map[string]string{"Authorization": "${TEST_AUTH_TOKEN}"},
210+
}
211+
212+
err := expandTracingVariables(cfg)
213+
require.NoError(t, err)
214+
215+
assert.Equal(t, "https://otel.example.com", cfg.Endpoint)
216+
assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", cfg.TraceID)
217+
assert.Equal(t, "00f067aa0ba902b7", cfg.SpanID)
218+
assert.Equal(t, "Bearer secret-token", cfg.Headers["Authorization"])
219+
220+
// After expansion, validation should pass
221+
err = validateOpenTelemetryConfig(cfg, true)
222+
require.NoError(t, err, "Expanded config should pass validation")
223+
}
224+
225+
// TestExpandTracingVariables_UndefinedVar verifies that an undefined variable
226+
// in tracing config causes an error during expansion.
227+
func TestExpandTracingVariables_UndefinedVar(t *testing.T) {
228+
cfg := &TracingConfig{
229+
Endpoint: "${UNDEFINED_OTEL_ENDPOINT_XYZZY}",
230+
}
231+
err := expandTracingVariables(cfg)
232+
require.Error(t, err, "Undefined variable must cause expansion error")
233+
}
234+
235+
// TestValidateOpenTelemetryConfig_AllZeroTraceID verifies that an all-zero traceId
236+
// is rejected per W3C Trace Context specification.
237+
func TestValidateOpenTelemetryConfig_AllZeroTraceID(t *testing.T) {
238+
cfg := &TracingConfig{
239+
Endpoint: "https://otel-collector.example.com",
240+
TraceID: "00000000000000000000000000000000",
241+
}
242+
err := validateOpenTelemetryConfig(cfg, true)
243+
require.Error(t, err, "All-zero traceId must be rejected per W3C Trace Context")
244+
assert.Contains(t, err.Error(), "all zeros")
245+
}
246+
247+
// TestValidateOpenTelemetryConfig_AllZeroSpanID verifies that an all-zero spanId
248+
// is rejected per W3C Trace Context specification.
249+
func TestValidateOpenTelemetryConfig_AllZeroSpanID(t *testing.T) {
250+
cfg := &TracingConfig{
251+
Endpoint: "https://otel-collector.example.com",
252+
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
253+
SpanID: "0000000000000000",
254+
}
255+
err := validateOpenTelemetryConfig(cfg, true)
256+
require.Error(t, err, "All-zero spanId must be rejected per W3C Trace Context")
257+
assert.Contains(t, err.Error(), "all zeros")
194258
}
195259

196260
// TestGetSampleRate_NewFields verifies that the new fields don't affect GetSampleRate.

internal/config/validation.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ var varExprPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
2222
var (
2323
traceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
2424
spanIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
25-
// varExprPatternSimple matches a whole-string variable expression like ${VAR}
26-
varExprPatternSimple = regexp.MustCompile(`^\$\{[A-Za-z_][A-Za-z0-9_]*\}$`)
25+
// W3C Trace Context forbids all-zero trace/span IDs.
26+
allZeroTraceID = regexp.MustCompile(`^0{32}$`)
27+
allZeroSpanID = regexp.MustCompile(`^0{16}$`)
2728
)
2829

2930
var logValidation = logger.New("config:validation")
@@ -116,6 +117,49 @@ func expandEnvVariables(env map[string]string, serverName string) (map[string]st
116117
return result, nil
117118
}
118119

120+
// expandTracingVariables expands ${VAR} expressions in TracingConfig fields.
121+
// This is called for TOML-loaded configs before validation, mirroring the
122+
// stdin JSON path where ExpandRawJSONVariables handles expansion.
123+
func expandTracingVariables(cfg *TracingConfig) error {
124+
if cfg == nil {
125+
return nil
126+
}
127+
128+
if cfg.Endpoint != "" {
129+
expanded, err := expandVariables(cfg.Endpoint, "gateway.opentelemetry.endpoint")
130+
if err != nil {
131+
return err
132+
}
133+
cfg.Endpoint = expanded
134+
}
135+
136+
if cfg.TraceID != "" {
137+
expanded, err := expandVariables(cfg.TraceID, "gateway.opentelemetry.traceId")
138+
if err != nil {
139+
return err
140+
}
141+
cfg.TraceID = expanded
142+
}
143+
144+
if cfg.SpanID != "" {
145+
expanded, err := expandVariables(cfg.SpanID, "gateway.opentelemetry.spanId")
146+
if err != nil {
147+
return err
148+
}
149+
cfg.SpanID = expanded
150+
}
151+
152+
for key, value := range cfg.Headers {
153+
expanded, err := expandVariables(value, fmt.Sprintf("gateway.opentelemetry.headers.%s", key))
154+
if err != nil {
155+
return err
156+
}
157+
cfg.Headers[key] = expanded
158+
}
159+
160+
return nil
161+
}
162+
119163
// validateMounts validates mount specifications using centralized rules
120164
func validateMounts(mounts []string, jsonPath string) error {
121165
for i, mount := range mounts {
@@ -514,25 +558,39 @@ func validateOpenTelemetryConfig(cfg *TracingConfig, enforceHTTPS bool) error {
514558
}
515559
}
516560

517-
// Validate traceId: must be a 32-char lowercase hex string
561+
// Validate traceId: must be a 32-char lowercase hex string, not all-zero
518562
if cfg.TraceID != "" {
519563
if !traceIDPattern.MatchString(cfg.TraceID) {
520564
logValidation.Printf("Invalid traceId format: %s", cfg.TraceID)
521565
return rules.InvalidValue("traceId",
522566
fmt.Sprintf("traceId must be a 32-character lowercase hexadecimal string, got '%s'", cfg.TraceID),
523567
"gateway.opentelemetry.traceId",
524-
"Provide a valid W3C trace ID (32 lowercase hex chars, e.g., \"4bf92f3577b34da6a3ce929d0e0e4736\"); environment-variable expressions are not supported here")
568+
"Provide a valid W3C trace ID (32 lowercase hex chars, e.g., \"4bf92f3577b34da6a3ce929d0e0e4736\")")
569+
}
570+
if allZeroTraceID.MatchString(cfg.TraceID) {
571+
logValidation.Printf("All-zero traceId rejected per W3C Trace Context: %s", cfg.TraceID)
572+
return rules.InvalidValue("traceId",
573+
"traceId must not be all zeros (W3C Trace Context forbids an all-zero trace-id)",
574+
"gateway.opentelemetry.traceId",
575+
"Provide a non-zero W3C trace ID (e.g., \"4bf92f3577b34da6a3ce929d0e0e4736\")")
525576
}
526577
}
527578

528-
// Validate spanId: must be a 16-char lowercase hex string
579+
// Validate spanId: must be a 16-char lowercase hex string, not all-zero
529580
if cfg.SpanID != "" {
530581
if !spanIDPattern.MatchString(cfg.SpanID) {
531582
logValidation.Printf("Invalid spanId format: %s", cfg.SpanID)
532583
return rules.InvalidValue("spanId",
533584
fmt.Sprintf("spanId must be a 16-character lowercase hexadecimal string, got '%s'", cfg.SpanID),
534585
"gateway.opentelemetry.spanId",
535-
"Provide a valid W3C span ID (16 lowercase hex chars, e.g., \"00f067aa0ba902b7\"); environment-variable expressions are not supported here")
586+
"Provide a valid W3C span ID (16 lowercase hex chars, e.g., \"00f067aa0ba902b7\")")
587+
}
588+
if allZeroSpanID.MatchString(cfg.SpanID) {
589+
logValidation.Printf("All-zero spanId rejected per W3C Trace Context: %s", cfg.SpanID)
590+
return rules.InvalidValue("spanId",
591+
"spanId must not be all zeros (W3C Trace Context forbids an all-zero span-id)",
592+
"gateway.opentelemetry.spanId",
593+
"Provide a non-zero W3C span ID (e.g., \"00f067aa0ba902b7\")")
536594
}
537595
}
538596

internal/tracing/provider_test.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -313,16 +313,19 @@ func TestWrapHTTPHandler_GeneratesRootSpan(t *testing.T) {
313313
assert.False(t, capturedSpanCtx.IsRemote(), "span should not be marked remote — it is a local root span")
314314
}
315315

316-
// TestInitProvider_WithHeaders verifies that OTLP export headers are accepted.
317-
// The headers are applied to the exporter but cannot easily be verified without
318-
// intercepting the network; this test confirms provider initialization succeeds.
316+
// TestInitProvider_WithHeaders verifies that OTLP export headers are forwarded
317+
// to the collector. A channel synchronises with the test HTTP server so the
318+
// assertion is deterministic rather than timing-dependent.
319319
func TestInitProvider_WithHeaders(t *testing.T) {
320320
ctx := context.Background()
321321

322-
// Spin up a test server to receive OTLP export requests and capture headers.
323-
var capturedAuth string
322+
// Channel signals when the test server receives an export request.
323+
received := make(chan string, 1)
324324
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
325-
capturedAuth = r.Header.Get("Authorization")
325+
select {
326+
case received <- r.Header.Get("Authorization"):
327+
default:
328+
}
326329
w.WriteHeader(http.StatusOK)
327330
}))
328331
defer ts.Close()
@@ -341,16 +344,18 @@ func TestInitProvider_WithHeaders(t *testing.T) {
341344
_, span := tr.Start(ctx, "header-test-span")
342345
span.End()
343346

344-
// Shutdown with a brief timeout to flush pending spans.
345-
shutdownCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
347+
// Shutdown flushes the batch processor, ensuring the export is sent.
348+
shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
346349
defer cancel()
347350
_ = provider.Shutdown(shutdownCtx)
348351

349-
// The test server may or may not have received the export within the timeout.
350-
// If it did, verify the Authorization header was forwarded correctly.
351-
if capturedAuth != "" {
352-
assert.Equal(t, "Bearer test-token", capturedAuth,
352+
// Wait for the export request with a timeout.
353+
select {
354+
case auth := <-received:
355+
assert.Equal(t, "Bearer test-token", auth,
353356
"Authorization header must be forwarded to the OTLP collector")
357+
case <-time.After(3 * time.Second):
358+
t.Fatal("timed out waiting for OTLP export request — headers test is non-deterministic")
354359
}
355360
}
356361

0 commit comments

Comments
 (0)