Skip to content

Commit a05ccb5

Browse files
authored
feat: Support spec v1.11.0 — OpenTelemetry OTLP tracing configuration (§4.1.3.6) (#3188)
Implements the `opentelemetry` gateway config section from MCP Gateway Spec v1.11.0 §4.1.3.6, bridging the gap between the existing `TracingConfig` skeleton and full spec compliance. ## Config changes - **New fields on `TracingConfig`**: `Headers map[string]string`, `TraceID string`, `SpanID string` - **New TOML key** `[gateway.opentelemetry]` alongside legacy `[gateway.tracing]` (backward-compatible; `opentelemetry` takes precedence) - **JSON stdin**: Added `StdinOpenTelemetryConfig` wired into `StdinGatewayConfig.opentelemetry` - **JSON schema**: `opentelemetry` added to `gatewayConfig` definition with correct types and patterns ```toml [gateway.opentelemetry] endpoint = "https://otel-collector.example.com" trace_id = "4bf92f3577b34da6a3ce929d0e0e4736" span_id = "00f067aa0ba902b7" service_name = "mcp-gateway" [gateway.opentelemetry.headers] Authorization = "******" ``` ## Validation (enforced only for the `opentelemetry` section) - `endpoint` required and **must be HTTPS** - `traceId` must be 32-char lowercase hex (or `${VAR}`) - `spanId` must be 16-char lowercase hex (or `${VAR}`) - `spanId` without `traceId` → warning, not error ## OTLP provider - Headers forwarded to exporter via `otlptracehttp.WithHeaders()` - W3C remote parent context constructed from `traceId`+`spanId` at startup; missing `spanId` generates a random one (T-OTEL-008) - `tracing.ParentContext(ctx, cfg)` exported for use at startup ## Instrumentation (`callBackendTool`) - Span renamed `gateway.tool_call` → `mcp.tool_call` - Attributes updated to spec names: `mcp.server`, `mcp.method` (`"tools/call"`), `mcp.tool` - `http.status_code` attribute added: 200 (success) / 403 (DIFC denied) / 500 (error) ## Tests Compliance tests T-OTEL-001 through T-OTEL-010 covering config validation, header propagation, W3C traceparent construction, random span ID generation, and service name defaulting. > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `example.com` > - Triggering command: `/tmp/go-build344646749/b510/launcher.test /tmp/go-build344646749/b510/launcher.test -test.testlogfile=/tmp/go-build344646749/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 0/clients.go 0/doc.go x_amd64/compile OUTPUT -d 168.63.129.16 x_amd64/compile o_.o�� ache/go/1.25.8/x-errorsas oZMU/gIMg3r8eCE3-ifaceassert de/node/bin/as -p sh -lang=go1.25 646749/b171/_x00-buildtags` (dns block) > - Triggering command: `/tmp/go-build952104743/b510/launcher.test /tmp/go-build952104743/b510/launcher.test -test.testlogfile=/tmp/go-build952104743/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true cmCl2WHYI 646749/b318/ 64/pkg/tool/linux_amd64/vet --gdwarf-5 --64 -o 64/pkg/tool/linux_amd64/vet 6467�� olang.org/grpc@v1.80.0/internal/pretty/pretty.go-p pkg/mod/github.com/go-logr/logr@v1.4.3/context_smain cfg -b 646749/b318/_cgo/usr/bin/runc p/bin/git ache/go/1.25.8/x64/pkg/tool/linux_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build3330052160/b514/launcher.test /tmp/go-build3330052160/b514/launcher.test -test.testlogfile=/tmp/go-build3330052160/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s e=/t�� t0 m0s x_amd64/vet pkg/mod/google.ggit ��4.1.3.6) alongside legacy tracing key - Add StdinOpenTelemetryConfig &#43; wire into �� ache/go/1.25.8/x--exclude-standard x_amd64/vet --ve�� -o ache/go/1.25.8/x64/pkg/tool/linux_amd64/vet x_amd64/vet /tmp/go-build344bash -trimpath 646749/b371/vet.--noprofile x_amd64/vet` (dns block) > - `invalid-host-that-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build344646749/b492/config.test /tmp/go-build344646749/b492/config.test -test.testlogfile=/tmp/go-build344646749/b492/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true go t.go x_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build952104743/b492/config.test /tmp/go-build952104743/b492/config.test -test.testlogfile=/tmp/go-build952104743/b492/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true _.a 646749/b197/vet.cfg 64/pkg/tool/linux_amd64/vet . ateway/v2/utilitls-files --64 64/pkg/tool/linu--others -I 2mEnnrRfF 646749/b318/ 64/pkg/tool/linux_amd64/vet --gdwarf-5 --64 -o 64/pkg/tool/linusecurity` (dns block) > - Triggering command: `/tmp/go-build1778975665/b222/config.test /tmp/go-build1778975665/b222/config.test -test.testlogfile=/tmp/go-build1778975665/b222/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true pkg/mod/google.golang.org/grpc@v1.80.0/internal/grpclog/prefix_l-errorsas -fPIC docker-buildx -pthread -Wl,--no-gc-sectinspect -fmessage-length--format docker-buildx -o /tmp/go-build344646749/b370/_pkg_.a -trimpath x_amd64/link -p google.golang.org/grpc/internal/--version -lang=go1.24 x_amd64/link` (dns block) > - `nonexistent.local` > - Triggering command: `/tmp/go-build344646749/b510/launcher.test /tmp/go-build344646749/b510/launcher.test -test.testlogfile=/tmp/go-build344646749/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 0/clients.go 0/doc.go x_amd64/compile OUTPUT -d 168.63.129.16 x_amd64/compile o_.o�� ache/go/1.25.8/x-errorsas oZMU/gIMg3r8eCE3-ifaceassert de/node/bin/as -p sh -lang=go1.25 646749/b171/_x00-buildtags` (dns block) > - Triggering command: `/tmp/go-build952104743/b510/launcher.test /tmp/go-build952104743/b510/launcher.test -test.testlogfile=/tmp/go-build952104743/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true cmCl2WHYI 646749/b318/ 64/pkg/tool/linux_amd64/vet --gdwarf-5 --64 -o 64/pkg/tool/linux_amd64/vet 6467�� olang.org/grpc@v1.80.0/internal/pretty/pretty.go-p pkg/mod/github.com/go-logr/logr@v1.4.3/context_smain cfg -b 646749/b318/_cgo/usr/bin/runc p/bin/git ache/go/1.25.8/x64/pkg/tool/linux_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build3330052160/b514/launcher.test /tmp/go-build3330052160/b514/launcher.test -test.testlogfile=/tmp/go-build3330052160/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s e=/t�� t0 m0s x_amd64/vet pkg/mod/google.ggit ��4.1.3.6) alongside legacy tracing key - Add StdinOpenTelemetryConfig &#43; wire into �� ache/go/1.25.8/x--exclude-standard x_amd64/vet --ve�� -o ache/go/1.25.8/x64/pkg/tool/linux_amd64/vet x_amd64/vet /tmp/go-build344bash -trimpath 646749/b371/vet.--noprofile x_amd64/vet` (dns block) > - `slow.example.com` > - Triggering command: `/tmp/go-build344646749/b510/launcher.test /tmp/go-build344646749/b510/launcher.test -test.testlogfile=/tmp/go-build344646749/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 0/clients.go 0/doc.go x_amd64/compile OUTPUT -d 168.63.129.16 x_amd64/compile o_.o�� ache/go/1.25.8/x-errorsas oZMU/gIMg3r8eCE3-ifaceassert de/node/bin/as -p sh -lang=go1.25 646749/b171/_x00-buildtags` (dns block) > - Triggering command: `/tmp/go-build952104743/b510/launcher.test /tmp/go-build952104743/b510/launcher.test -test.testlogfile=/tmp/go-build952104743/b510/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true cmCl2WHYI 646749/b318/ 64/pkg/tool/linux_amd64/vet --gdwarf-5 --64 -o 64/pkg/tool/linux_amd64/vet 6467�� olang.org/grpc@v1.80.0/internal/pretty/pretty.go-p pkg/mod/github.com/go-logr/logr@v1.4.3/context_smain cfg -b 646749/b318/_cgo/usr/bin/runc p/bin/git ache/go/1.25.8/x64/pkg/tool/linux_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build3330052160/b514/launcher.test /tmp/go-build3330052160/b514/launcher.test -test.testlogfile=/tmp/go-build3330052160/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s e=/t�� t0 m0s x_amd64/vet pkg/mod/google.ggit ��4.1.3.6) alongside legacy tracing key - Add StdinOpenTelemetryConfig &#43; wire into �� ache/go/1.25.8/x--exclude-standard x_amd64/vet --ve�� -o ache/go/1.25.8/x64/pkg/tool/linux_amd64/vet x_amd64/vet /tmp/go-build344bash -trimpath 646749/b371/vet.--noprofile x_amd64/vet` (dns block) > - `this-host-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build344646749/b519/mcp.test /tmp/go-build344646749/b519/mcp.test -test.testlogfile=/tmp/go-build344646749/b519/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/net -trimpath 64/pkg/tool/linu-nilfunc -p math/rand/v2 -lang=go1.25 64/pkg/tool/linu-buildtags lid_�� 1.10.2/active_he-errorsas 1.10.2/args.go x_amd64/compile 646749/b165/ crypto/rsa ctor x_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build952104743/b519/mcp.test /tmp/go-build952104743/b519/mcp.test -test.testlogfile=/tmp/go-build952104743/b519/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 646749/b440/_pkg-s /tmp/go-build344-w cfg go g/grpc/internal//usr/bin/runc 64/pkg/tool/linu--version ache/go/1.25.8/x-extld=gcc -W 646749/b458/_pkg_.a /tmp/go-build344646749/b318/ docker-buildx . b/gh-aw-mcpg/int/usr/bin/runc --64 docker-buildx` (dns block) > - Triggering command: `/tmp/go-build3330052160/b523/mcp.test /tmp/go-build3330052160/b523/mcp.test -test.testlogfile=/tmp/go-build3330052160/b523/testlog.txt -test.paniconexit0 -test.timeout=10m0s /usr�� --version ache/go/1.25.8/x64/pkg/tool/linux_amd64/vet x_amd64/vet g pkg/mod/google.gls-files ache/go/1.25.8/x--exclude-standard x_amd64/vet --ve�� -o ache/go/1.25.8/x64/pkg/tool/linux_amd64/vet x_amd64/vet /tmp/go-build344bash -trimpath docker-buildx x_amd64/vet` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent) (admins only) > > </details>
2 parents 40083e5 + 071d382 commit a05ccb5

10 files changed

Lines changed: 1178 additions & 347 deletions

File tree

internal/cmd/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ func run(cmd *cobra.Command, args []string) error {
354354
}
355355
}()
356356

357+
// Apply W3C parent context from configured traceId/spanId (spec §4.1.3.6).
358+
// This links the gateway process lifetime span into a pre-existing trace when provided.
359+
ctx = tracing.ParentContext(ctx, tracingCfg)
360+
357361
if tracingProvider.Tracer() != nil {
358362
// Log what InitProvider actually resolved (config already has env var defaults merged via CLI flags)
359363
endpoint := ""

internal/config/config_core.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,16 @@ type GatewayConfig struct {
118118
// Example values: "copilot-swe-agent[bot]", "my-org-bot[bot]"
119119
TrustedBots []string `toml:"trusted_bots" json:"trusted_bots,omitempty"`
120120

121-
// Tracing holds OpenTelemetry OTLP tracing configuration.
121+
// Tracing holds OpenTelemetry OTLP tracing configuration (legacy TOML key).
122+
// New configurations should use the opentelemetry key (spec §4.1.3.6).
122123
// When Endpoint is set, traces are exported to the specified OTLP endpoint.
123124
// When omitted or Endpoint is empty, a noop tracer is used (zero overhead).
124125
Tracing *TracingConfig `toml:"tracing" json:"tracing,omitempty"`
126+
127+
// Opentelemetry holds OpenTelemetry OTLP tracing configuration per spec §4.1.3.6.
128+
// This key takes precedence over the legacy tracing key when both are present.
129+
// MUST use an HTTPS endpoint when configured.
130+
Opentelemetry *TracingConfig `toml:"opentelemetry" json:"opentelemetry,omitempty"`
125131
}
126132

127133
// HTTPKeepaliveInterval returns the keepalive interval as a time.Duration.
@@ -349,6 +355,21 @@ func LoadFromFile(path string) (*Config, error) {
349355
return nil, err
350356
}
351357

358+
// Merge opentelemetry key into tracing when present (spec §4.1.3.6).
359+
// opentelemetry takes precedence over the legacy tracing key.
360+
if cfg.Gateway.Opentelemetry != nil {
361+
cfg.Gateway.Tracing = cfg.Gateway.Opentelemetry
362+
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+
}
367+
// Validate HTTPS endpoint requirement for the opentelemetry section
368+
if err := validateOpenTelemetryConfig(cfg.Gateway.Tracing, true); err != nil {
369+
return nil, err
370+
}
371+
}
372+
352373
// Apply core gateway defaults
353374
applyGatewayDefaults(cfg.Gateway)
354375

internal/config/config_stdin.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,33 @@ type StdinConfig struct {
3232
// StdinGatewayConfig represents gateway configuration in stdin JSON format.
3333
// Uses pointers for optional fields to distinguish between unset and zero values.
3434
type StdinGatewayConfig struct {
35-
Port *int `json:"port,omitempty"`
36-
APIKey string `json:"apiKey,omitempty"`
37-
Domain string `json:"domain,omitempty"`
38-
StartupTimeout *int `json:"startupTimeout,omitempty"`
39-
ToolTimeout *int `json:"toolTimeout,omitempty"`
40-
KeepaliveInterval *int `json:"keepaliveInterval,omitempty"`
41-
PayloadDir string `json:"payloadDir,omitempty"`
42-
TrustedBots []string `json:"trustedBots,omitempty"`
35+
Port *int `json:"port,omitempty"`
36+
APIKey string `json:"apiKey,omitempty"`
37+
Domain string `json:"domain,omitempty"`
38+
StartupTimeout *int `json:"startupTimeout,omitempty"`
39+
ToolTimeout *int `json:"toolTimeout,omitempty"`
40+
KeepaliveInterval *int `json:"keepaliveInterval,omitempty"`
41+
PayloadDir string `json:"payloadDir,omitempty"`
42+
TrustedBots []string `json:"trustedBots,omitempty"`
43+
OpenTelemetry *StdinOpenTelemetryConfig `json:"opentelemetry,omitempty"`
44+
}
45+
46+
// StdinOpenTelemetryConfig represents the OpenTelemetry configuration in stdin JSON format (spec §4.1.3.6).
47+
type StdinOpenTelemetryConfig struct {
48+
// Endpoint is the OTLP/HTTP collector URL. MUST be HTTPS. Supports ${VAR} expansion.
49+
Endpoint string `json:"endpoint"`
50+
51+
// Headers are HTTP headers for export requests (e.g. auth tokens). Values support ${VAR}.
52+
Headers map[string]string `json:"headers,omitempty"`
53+
54+
// TraceID is the parent trace ID (32-char lowercase hex, W3C format). Supports ${VAR}.
55+
TraceID string `json:"traceId,omitempty"`
56+
57+
// SpanID is the parent span ID (16-char lowercase hex, W3C format). Ignored without TraceID. Supports ${VAR}.
58+
SpanID string `json:"spanId,omitempty"`
59+
60+
// ServiceName is the service.name resource attribute. Default: "mcp-gateway".
61+
ServiceName string `json:"serviceName,omitempty"`
4362
}
4463

4564
// StdinGuardConfig represents a guard configuration in stdin JSON format.

internal/config/config_tracing.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,48 @@ const DefaultTracingServiceName = "mcp-gateway"
1515
// - OTEL_EXPORTER_OTLP_ENDPOINT — overrides Endpoint
1616
// - OTEL_SERVICE_NAME — overrides ServiceName
1717
//
18-
// Example TOML:
18+
// Example TOML (spec §4.1.3.6, using the opentelemetry section):
1919
//
20-
// [gateway.tracing]
21-
// endpoint = "http://localhost:4318"
20+
// [gateway.opentelemetry]
21+
// endpoint = "https://otel-collector.example.com"
2222
// service_name = "mcp-gateway"
23-
// sample_rate = 1.0
23+
// trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
24+
// span_id = "00f067aa0ba902b7"
25+
//
26+
// [gateway.opentelemetry.headers]
27+
// Authorization = "Bearer ${OTEL_TOKEN}"
2428
type TracingConfig struct {
2529
// Endpoint is the OTLP HTTP endpoint to export traces to.
26-
// Example: "http://localhost:4318" (Jaeger, Grafana Tempo, Honeycomb, etc.)
30+
// When using the opentelemetry section (spec §4.1.3.6), this MUST be an HTTPS URL.
2731
// If empty, tracing is disabled and a noop tracer is used.
2832
Endpoint string `toml:"endpoint" json:"endpoint,omitempty"`
2933

34+
// Headers are HTTP headers sent with every OTLP export request (e.g. auth tokens).
35+
// Header values support ${VAR} variable expansion (expanded at config load time).
36+
Headers map[string]string `toml:"headers" json:"headers,omitempty"`
37+
38+
// TraceID is an optional W3C trace ID (32-char lowercase hex) used to construct the
39+
// parent traceparent header, linking gateway spans into a pre-existing trace.
40+
// Supports ${VAR} variable expansion (expanded at config load time).
41+
// Must be 32 lowercase hex characters and must not be all zeros.
42+
TraceID string `toml:"trace_id" json:"traceId,omitempty"`
43+
44+
// SpanID is an optional W3C span ID (16-char lowercase hex) paired with TraceID
45+
// to construct the parent traceparent header. Ignored when TraceID is absent.
46+
// Supports ${VAR} variable expansion (expanded at config load time).
47+
// Must be 16 lowercase hex characters and must not be all zeros.
48+
SpanID string `toml:"span_id" json:"spanId,omitempty"`
49+
3050
// ServiceName is the service name reported in traces.
3151
// Defaults to "mcp-gateway".
32-
ServiceName string `toml:"service_name" json:"service_name,omitempty"`
52+
ServiceName string `toml:"service_name" json:"serviceName,omitempty"`
3353

3454
// SampleRate controls the fraction of traces that are sampled and exported.
3555
// Valid range: 0.0 (no sampling) to 1.0 (sample everything).
3656
// Defaults to 1.0 (100% sampling).
3757
// Uses a pointer so that 0.0 can be distinguished from "unset".
38-
SampleRate *float64 `toml:"sample_rate" json:"sample_rate,omitempty"`
58+
// Note: SampleRate is a gateway extension field not present in spec §4.1.3.6.
59+
SampleRate *float64 `toml:"sample_rate" json:"sampleRate,omitempty"`
3960
}
4061

4162
// GetSampleRate returns the configured sample rate, defaulting to 1.0 if unset.
@@ -55,4 +76,25 @@ func init() {
5576
}
5677
}
5778
})
79+
80+
// Register stdin converter for the opentelemetry gateway config field (spec §4.1.3.6).
81+
RegisterStdinConverter(func(cfg *Config, stdinCfg *StdinConfig) {
82+
if stdinCfg.Gateway == nil || stdinCfg.Gateway.OpenTelemetry == nil {
83+
return
84+
}
85+
otel := stdinCfg.Gateway.OpenTelemetry
86+
if cfg.Gateway == nil {
87+
cfg.Gateway = &GatewayConfig{}
88+
}
89+
cfg.Gateway.Tracing = &TracingConfig{
90+
Endpoint: otel.Endpoint,
91+
Headers: otel.Headers,
92+
TraceID: otel.TraceID,
93+
SpanID: otel.SpanID,
94+
ServiceName: otel.ServiceName,
95+
}
96+
if cfg.Gateway.Tracing.ServiceName == "" {
97+
cfg.Gateway.Tracing.ServiceName = DefaultTracingServiceName
98+
}
99+
})
58100
}

0 commit comments

Comments
 (0)