TASK-15: OpenTelemetry Traces and Metrics
Context
Implement full OpenTelemetry observability per SPEC.md §16. This includes: SDK initialisation with OTLP gRPC exporter, HTTP server and client instrumentation via otelhttp, MCP-specific span attributes and metrics, W3C Trace Context propagation from inbound traceparent headers and MCP params._meta.traceparent, and all metrics defined in SPEC.md §16.
No stubs.
Active development: No backward compatibility.
Prerequisites
- TASK-08 (multi-upstream handler pipeline where metrics attach)
- TASK-13 (hot-reload counters to be replaced with real OTel counters)
Spec References
SPEC.md §16 OpenTelemetry Observability
SPEC.md AC-28 (traces)
SPEC.md AC-29 (metrics)
What to Implement
internal/telemetry/provider.go
// Init initialises the OTel SDK: tracer provider, meter provider, OTLP gRPC exporter.
// Returns a shutdown function that flushes and closes all providers.
// Must be called before any HTTP handlers are set up.
func Init(ctx context.Context, cfg *config.TelemetryConfig) (shutdown func(context.Context) error, err error)
Implementation:
- Create OTLP gRPC trace exporter:
traceExp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
// TLS: use credentials.NewTLS(tlsCfg) unless cfg.Insecure == true
)
- Create trace provider with batch span processor:
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(cfg.ServiceName),
semconv.ServiceVersion(cfg.ServiceVersion),
)),
)
otel.SetTracerProvider(tp)
- Set text map propagator:
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
- Create Prometheus meter provider (for local scraping) and OTLP metrics exporter
- Return a
shutdown function that calls tp.Shutdown(ctx) and flushes metrics
If cfg.OTLPEndpoint == "", use a no-op provider (don't error).
internal/telemetry/middleware.go
// ServerMiddleware wraps an HTTP handler with OTel server instrumentation.
// Emits http.server.request.duration and http.server.active_requests per SPEC.md §16.
func ServerMiddleware(handler http.Handler, serverName string) http.Handler
// ClientTransport wraps an http.RoundTripper with OTel client instrumentation.
// Emits http.client.request.duration per SPEC.md §16.
func ClientTransport(base http.RoundTripper) http.RoundTripper
Use otelhttp.NewHandler(handler, serverName) for ServerMiddleware.
Use otelhttp.NewTransport(base) for ClientTransport.
Wrap upstream.NewHTTPClient's transport with ClientTransport.
Wrap the chi router in server.go with ServerMiddleware.
internal/telemetry/attributes.go
// ToolCallAttributes returns OTel attributes for an MCP tool call span.
func ToolCallAttributes(toolName, method, sessionID string) []attribute.KeyValue
// UpstreamAttributes returns OTel attributes for an upstream HTTP call span.
func UpstreamAttributes(upstreamName string) []attribute.KeyValue
MCP semantic convention attributes (SPEC.md §16, OTel gen-ai/mcp):
mcp.method = "tools/call" (or the actual MCP method)
mcp.tool.name = the prefixed tool name
mcp.request.id = from the JSON-RPC request ID
mcp.session.id = from the MCP session (if available from the SDK)
upstream.name = upstream name on upstream child spans
Tool call span instrumentation
Update internal/mcp/handler.go / internal/upstream/registry.go:
For each tool call, create a child span:
ctx, span := otel.Tracer("mcp-anything").Start(ctx, "tools/call "+entry.PrefixedName,
trace.WithAttributes(telemetry.ToolCallAttributes(entry.PrefixedName, "tools/call", sessionID)...),
)
defer span.End()
Record tool call duration and errors:
// mcp.tool.call.duration histogram
// mcp.tool.call.errors.total counter (on IsError: true)
// mcp_anything.transform.duration histogram (per stage: request, response, error)
All meters must be created once at startup and stored (not created per-request).
W3C Trace Context propagation
Extract traceparent from:
- The HTTP
traceparent header on the inbound Streamable HTTP request (handled automatically by otelhttp)
- MCP
params._meta.traceparent in the tools/call JSON body
For case 2, implement extraction in internal/mcp/handler.go:
// Extract trace context from MCP _meta field
if meta, ok := req.Params.Meta["traceparent"]; ok {
carrier := propagation.MapCarrier{"traceparent": meta.(string)}
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
}
MCP-specific metrics
Create meters in internal/telemetry/metrics.go:
var (
toolCallDuration metric.Float64Histogram
toolCallErrors metric.Int64Counter
transformDuration metric.Float64Histogram
configReloadTotal metric.Int64Counter
specRefreshTotal metric.Int64Counter
)
func InitMetrics(mp metric.MeterProvider) error
Replace the atomic.Int64 counters from TASK-13/TASK-14 with the real OTel counters.
Histogram boundaries per SPEC.md §16: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10].
Integration Test
Create tests/integration/otel_test.go with build tag //go:build integration and package integration_test.
This test does NOT import any internal/ packages. The proxy runs as a Docker container built via proxyContainerRequest(). All assertions go through the MCP protocol, HTTP endpoints, and OTel collector container logs.
Three sibling containers on a shared Docker network: proxy, WireMock (upstream API), and OTel collector.
Use the OTel collector container (otel/opentelemetry-collector-contrib:latest) with a custom config that has an OTLP gRPC receiver and debug exporter writing to stdout. Mount the collector config via testcontainers.ContainerFile. After test actions, read the collector container logs to assert trace/metric presence via string matching.
Test: TestTracesExportedOnToolCall
- Start OTel collector container with OTLP gRPC receiver + debug exporter config on shared network
- Start WireMock container on shared network with upstream API stubs
- Start proxy container with
telemetry.otlp_endpoint pointing at the collector (e.g., otel-collector:4317) on shared network, with spec and config mounted via testcontainers.ContainerFile
- Connect MCP client to proxy container's
/mcp endpoint
- Make an MCP
tools/call
- Wait 2 seconds for traces to flush
- Read OTel collector container logs
- Assert logs contain:
- A span named
tools/call {prefix}__{tool} with mcp.tool.name attribute
- A child span for the upstream HTTP call with
http.request.method attribute
Test: TestW3CTracePropagation
- Same container setup as above
- Make an MCP
tools/call with HTTP header traceparent: 00-{traceId}-{parentSpanId}-01
- Read OTel collector container logs
- Assert the trace exported by the collector uses the same
traceId from the header (parent span is connected)
Test: TestMetricsEmitted
- Start proxy container with Prometheus exporter enabled (exposes metrics at
/metrics)
- Start WireMock on shared network
- Make 3 successful tool calls and 1 failed tool call (configure WireMock to return 500 for one endpoint)
- HTTP GET to proxy container's
/metrics endpoint
- Assert:
mcp_tool_call_duration_bucket has entries
mcp_tool_call_errors_total counter is 1
http_server_request_duration_bucket has entries
Checks Before Committing
make check
make integration
Acceptance Criteria
Definition of Done
make check exits 0. Integration tests pass. Tool call traces appear in the OTel collector with correct MCP semantic attributes and W3C trace context is propagated end-to-end.
TASK-15: OpenTelemetry Traces and Metrics
Context
Implement full OpenTelemetry observability per SPEC.md §16. This includes: SDK initialisation with OTLP gRPC exporter, HTTP server and client instrumentation via
otelhttp, MCP-specific span attributes and metrics, W3C Trace Context propagation from inboundtraceparentheaders and MCPparams._meta.traceparent, and all metrics defined in SPEC.md §16.No stubs.
Active development: No backward compatibility.
Prerequisites
Spec References
SPEC.md§16 OpenTelemetry ObservabilitySPEC.mdAC-28 (traces)SPEC.mdAC-29 (metrics)What to Implement
internal/telemetry/provider.goImplementation:
shutdownfunction that callstp.Shutdown(ctx)and flushes metricsIf
cfg.OTLPEndpoint == "", use a no-op provider (don't error).internal/telemetry/middleware.goUse
otelhttp.NewHandler(handler, serverName)forServerMiddleware.Use
otelhttp.NewTransport(base)forClientTransport.Wrap
upstream.NewHTTPClient's transport withClientTransport.Wrap the chi router in
server.gowithServerMiddleware.internal/telemetry/attributes.goMCP semantic convention attributes (SPEC.md §16, OTel gen-ai/mcp):
mcp.method="tools/call"(or the actual MCP method)mcp.tool.name= the prefixed tool namemcp.request.id= from the JSON-RPC request IDmcp.session.id= from the MCP session (if available from the SDK)upstream.name= upstream name on upstream child spansTool call span instrumentation
Update
internal/mcp/handler.go/internal/upstream/registry.go:For each tool call, create a child span:
Record tool call duration and errors:
All meters must be created once at startup and stored (not created per-request).
W3C Trace Context propagation
Extract
traceparentfrom:traceparentheader on the inbound Streamable HTTP request (handled automatically byotelhttp)params._meta.traceparentin thetools/callJSON bodyFor case 2, implement extraction in
internal/mcp/handler.go:MCP-specific metrics
Create meters in
internal/telemetry/metrics.go:Replace the
atomic.Int64counters from TASK-13/TASK-14 with the real OTel counters.Histogram boundaries per SPEC.md §16:
[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10].Integration Test
Create
tests/integration/otel_test.gowith build tag//go:build integrationand packageintegration_test.This test does NOT import any
internal/packages. The proxy runs as a Docker container built viaproxyContainerRequest(). All assertions go through the MCP protocol, HTTP endpoints, and OTel collector container logs.Three sibling containers on a shared Docker network: proxy, WireMock (upstream API), and OTel collector.
Use the OTel collector container (
otel/opentelemetry-collector-contrib:latest) with a custom config that has an OTLP gRPC receiver anddebugexporter writing to stdout. Mount the collector config viatestcontainers.ContainerFile. After test actions, read the collector container logs to assert trace/metric presence via string matching.Test:
TestTracesExportedOnToolCalltelemetry.otlp_endpointpointing at the collector (e.g.,otel-collector:4317) on shared network, with spec and config mounted viatestcontainers.ContainerFile/mcpendpointtools/calltools/call {prefix}__{tool}withmcp.tool.nameattributehttp.request.methodattributeTest:
TestW3CTracePropagationtools/callwith HTTP headertraceparent: 00-{traceId}-{parentSpanId}-01traceIdfrom the header (parent span is connected)Test:
TestMetricsEmitted/metrics)/metricsendpointmcp_tool_call_duration_buckethas entriesmcp_tool_call_errors_totalcounter is 1http_server_request_duration_buckethas entriesChecks Before Committing
Acceptance Criteria
server.address,http.request.method,http.response.status_codemcp.tool.name,mcp.method,mcp.session.idadded to tool call spantraceparentheader andparams._meta.traceparenthttp.server.request.durationemitted for every inbound requesthttp.client.request.durationemitted for every upstream callmcp.tool.call.durationemitted pertools/callmcp.tool.call.errors.totalincremented onIsError: truemcp_anything.config_reload.totalcounter replaced with OTel countermcp_anything.spec_refresh.totalcounter replaced with OTel counterDefinition of Done
make checkexits 0. Integration tests pass. Tool call traces appear in the OTel collector with correct MCP semantic attributes and W3C trace context is propagated end-to-end.