Skip to content

TASK-15: OpenTelemetry Traces and Metrics #18

Description

@gaarutyunov

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:

  1. Create OTLP gRPC trace exporter:
    traceExp, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
        // TLS: use credentials.NewTLS(tlsCfg) unless cfg.Insecure == true
    )
  2. 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)
  3. Set text map propagator:
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))
  4. Create Prometheus meter provider (for local scraping) and OTLP metrics exporter
  5. 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:

  1. The HTTP traceparent header on the inbound Streamable HTTP request (handled automatically by otelhttp)
  2. 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

  1. Start OTel collector container with OTLP gRPC receiver + debug exporter config on shared network
  2. Start WireMock container on shared network with upstream API stubs
  3. 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
  4. Connect MCP client to proxy container's /mcp endpoint
  5. Make an MCP tools/call
  6. Wait 2 seconds for traces to flush
  7. Read OTel collector container logs
  8. 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

  1. Same container setup as above
  2. Make an MCP tools/call with HTTP header traceparent: 00-{traceId}-{parentSpanId}-01
  3. Read OTel collector container logs
  4. Assert the trace exported by the collector uses the same traceId from the header (parent span is connected)

Test: TestMetricsEmitted

  1. Start proxy container with Prometheus exporter enabled (exposes metrics at /metrics)
  2. Start WireMock on shared network
  3. Make 3 successful tool calls and 1 failed tool call (configure WireMock to return 500 for one endpoint)
  4. HTTP GET to proxy container's /metrics endpoint
  5. 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

  • AC-28.1: OTel span created for every inbound MCP HTTP request
  • AC-28.2: Child span created for every upstream HTTP call with server.address, http.request.method, http.response.status_code
  • AC-28.3: mcp.tool.name, mcp.method, mcp.session.id added to tool call span
  • AC-28.4: W3C Trace Context propagated from traceparent header and params._meta.traceparent
  • AC-28.5: Traces exported to OTLP gRPC endpoint
  • AC-29.1: http.server.request.duration emitted for every inbound request
  • AC-29.2: http.client.request.duration emitted for every upstream call
  • AC-29.3: mcp.tool.call.duration emitted per tools/call
  • AC-29.4: mcp.tool.call.errors.total incremented on IsError: true
  • AC-29.5: mcp_anything.config_reload.total counter replaced with OTel counter
  • AC-29.6: mcp_anything.spec_refresh.total counter replaced with OTel counter
  • All integration tests pass with real OTel collector container

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions