Repository navigation
fix(mcp-instrumentation): inject W3C trace context into outbound HTTP headers - #827
Conversation
|
Let’s hold on merging this until next week as I want to run a few more e2e tests. Will post test results to the PR. |
…ption guard, positional-arg safety Fixes identified during xhigh-effort code review: 1. Resource leak (line 373): When streamable_http_client's http_client was None, the PR substituted a fabricated client that MCP treated as caller-owned (skipping lifecycle management). Fix: the enclosing wrap_http_client now manages our fabricated client's lifecycle (__aenter__ + aclose in its own try/finally). 2. Hook accumulation (line 348): _inject_trace_context was a fresh closure per call so identity-based dedup never matched. Fix: extracted to module-level _attach_propagation_hook with a sentinel attribute (_adot_mcp_propagation_hook_installed) for idempotent attachment. 3. Exception guard (line 342): The hook lacked try/except so a propagator error would break the MCP tool call. Fix: wrapped propagators.inject in try/except with debug log inside _inject_trace_context. 4. Positional-argument crash (line 384): httpx_client_factory is POSITIONAL_OR_KEYWORD in streamablehttp_client/sse_client. If passed positionally, kwargs.get returned None and the code double-supplied the argument. Fix: check both args (by parameter index) and kwargs; strip from args if found positionally. 5. Span-leak window (line 299): _install_trace_header_injection was called between _start_mcp_session_span and the try/finally that ends the span. Fix: moved inside the try block. 6. Dead try/except around _get_parameter_names removed. 7. Silent no-op paths now emit _LOG.debug explaining why injection was skipped. 8. Test typo: pretbuilt → prebuilt. 9. Test cell-var-from-loop: extracted _make_capture factory function. 10. CHANGELOG: link aws-observability/pull/827 instead of aws-observability/issues/826. Empirically verified: - Per-tool-call parenting: tools/call POST carries traceparent with parent-span-id == CLIENT TOOL SPAN (correct). - No resource leak: httpx client is properly closed (no ResourceWarning). - Hook accumulation: 3 calls → 1 hook (sentinel dedup works). - All 15 MCP tests pass.
82924aa to
5a501dd
Compare
|
Verified end-to-end on an AWS deployment where the MCP server is a Lambda Java service fronted by API Gateway with X-Ray active tracing. Before this change: client trace dead-ended at Sample client span: |
… headers Even with OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION=true (the default from request headers so MCP servers that read trace context only from HTTP headers can join the caller's trace. The default from aws-observability#695 correctly deduplicates the redundant HTTP CLIENT span, but it does so via `suppress_http_instrumentation()`, which also short-circuits the HTTP client instrumentation's own header injection — the only mechanism that would set `traceparent` on the wire. MCP servers that only participate in HTTP-header propagation (API Gateways, service meshes, non-Python MCP servers, any HTTP endpoint without ADOT MCP instrumentation) therefore receive no context and start a fresh trace, silently breaking E2E propagation for a large class of real-world topologies. Reported in aws-observability#826 with before/after trace evidence. This is compatible with the OTel MCP semconv (https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md): > MCP server instrumentation SHOULD, by default, use context extracted > from MCP `params._meta` as a parent for MCP server span and SHOULD > link current ambient context, if it's present. The spec designates `_meta` as the MCP parent channel and HTTP context as an orthogonal transport signal (a span link on ADOT-instrumented servers). Nothing in the spec forbids injecting into HTTP headers; the existing ADOT server-side wrapper only extracts from `_meta`, so double-parenting is not a risk. This change adds HTTP-header propagation as a compatible fallback for non-ADOT servers while leaving the existing `_meta` propagation unchanged. Implementation: `ClientWrapper.wrap_http_client` now wraps the transport's `httpx_client_factory` (or supplies its own pre-built `http_client` for `streamable_http_client(url, http_client=...)`) so every httpx.AsyncClient the MCP transport uses has a request event hook that calls `self._propagators.inject(carrier=request.headers)`. The hook takes the OTel context at request-send time, so per-tool-call parents are honored correctly across a shared MCP session. If httpx or MCP is not importable, or the wrapped function signature doesn't match, the patcher no-ops silently. Tests: - `test_http_headers_carry_trace_context_regardless_of_suppression` exercises `OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION` in {true, false, default} and asserts every outgoing MCP HTTP request carries a well-formed W3C `traceparent` header. - `test_http_headers_prebuilt_client_gets_hook` verifies the hook is installed on a user-supplied pre-built `http_client` (the newer `streamable_http_client(url, http_client=...)` API). Existing tests (span suppression, ping suppression, active-parent respect, tool/prompt/resource spans, error handling across stdio+http+sse transports) continue to pass unchanged. Fixes: aws-observability#826
…ption guard, positional-arg safety Fixes identified during xhigh-effort code review: 1. Resource leak (line 373): When streamable_http_client's http_client was None, the PR substituted a fabricated client that MCP treated as caller-owned (skipping lifecycle management). Fix: the enclosing wrap_http_client now manages our fabricated client's lifecycle (__aenter__ + aclose in its own try/finally). 2. Hook accumulation (line 348): _inject_trace_context was a fresh closure per call so identity-based dedup never matched. Fix: extracted to module-level _attach_propagation_hook with a sentinel attribute (_adot_mcp_propagation_hook_installed) for idempotent attachment. 3. Exception guard (line 342): The hook lacked try/except so a propagator error would break the MCP tool call. Fix: wrapped propagators.inject in try/except with debug log inside _inject_trace_context. 4. Positional-argument crash (line 384): httpx_client_factory is POSITIONAL_OR_KEYWORD in streamablehttp_client/sse_client. If passed positionally, kwargs.get returned None and the code double-supplied the argument. Fix: check both args (by parameter index) and kwargs; strip from args if found positionally. 5. Span-leak window (line 299): _install_trace_header_injection was called between _start_mcp_session_span and the try/finally that ends the span. Fix: moved inside the try block. 6. Dead try/except around _get_parameter_names removed. 7. Silent no-op paths now emit _LOG.debug explaining why injection was skipped. 8. Test typo: pretbuilt → prebuilt. 9. Test cell-var-from-loop: extracted _make_capture factory function. 10. CHANGELOG: link aws-observability/pull/827 instead of aws-observability/issues/826. Empirically verified: - Per-tool-call parenting: tools/call POST carries traceparent with parent-span-id == CLIENT TOOL SPAN (correct). - No resource leak: httpx client is properly closed (no ResourceWarning). - Hook accumulation: 3 calls → 1 hook (sentinel dedup works). - All 15 MCP tests pass.
5a501dd to
86ab436
Compare
|
Thanks @jaredcnance for the changes and testing! The MCP function to wrap would be is StreamableHTTPTransport._prepare_headers, which already runs under our attached _meta context. under under _instrument() and _uninstrument() in: |
…ntainer review) Adopt the approach suggested by @liustve in PR review: wrap StreamableHTTPTransport._prepare_headers to inject W3C trace context instead of hooking httpx.AsyncClient event_hooks. This is dramatically simpler and eliminates all the complexity and edge cases of the previous approach: - No httpx client creation/lifecycle management - No hook accumulation on reused clients - No positional-argument detection - No signature introspection - Proper uninstrument support (try_unwrap) - Naturally runs under the per-tool-call context (because wrap_handle_post_request already restores it from _meta before _handle_post_request calls _prepare_headers) The wrap only injects when _should_suppress_http_spans is True (the default). When False, the httpx client instrumentation handles injection itself — no duplicate headers. Removed ~100 lines of _install_trace_header_injection, _attach_propagation_hook, _get_parameter_names, and the managed-client lifecycle code. The net diff from upstream main is now +25 lines.
303d190 to
4cca406
Compare
|
sweet -- thanks for the pointers. PR is updated, let me know if this makes sense. verified end-to-end in deployed environment. |
Summary
Even with
OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION=true(the default from #695), injecttraceparent/tracestate/baggageinto the outgoing HTTP request headers so MCP servers that read trace context only from HTTP headers can join the caller's trace.Fixes #826.
Background
The default from #695 correctly deduplicates the redundant HTTP CLIENT span, but it does so via
suppress_http_instrumentation(), which also short-circuits the HTTP client instrumentation's own header injection — the only mechanism that would otherwise settraceparenton the wire. MCP servers that only participate in HTTP-header propagation (API Gateways, service meshes, non-Python MCP servers, any HTTP endpoint without ADOT MCP instrumentation) therefore receive no context and start a fresh trace, silently breaking E2E propagation for a large class of real-world topologies.#826 has before/after trace evidence from a real AWS deployment showing the client trace dead-ending at
mcp.sessionwhile every server-side hop lands on its own fresh trace ID.Design
Compatible with the OTel MCP semantic convention:
The spec designates
_metaas the MCP parent channel and HTTP context as an orthogonal transport signal (a span link on ADOT-instrumented servers). Nothing in the spec forbids injecting into HTTP headers as well; the existing ADOT server-side wrapper only extracts from_meta, so double-parenting on ADOT-instrumented servers is not a risk. This change adds HTTP-header propagation as a compatible fallback for non-ADOT servers while leaving the existing_metapropagation and the span-suppression semantics unchanged.Implementation
ClientWrapper.wrap_http_clientnow wraps the transport'shttpx_client_factory(or supplies its own pre-builthttp_clientfor the newerstreamable_http_client(url, http_client=...)API) so everyhttpx.AsyncClientthe MCP transport uses has a request event hook that callsself._propagators.inject(carrier=request.headers).The hook takes the OTel context at request-send time, so per-tool-call parents (which change during a shared MCP session) are honored correctly. If httpx or MCP is not importable, or the wrapped function signature doesn't match, the patcher no-ops silently.
The change is purely additive with respect to
OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION:OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATIONtraceparentHTTP headertrue(default) — before this PRtrue(default) — after this PRfalse— before this PRfalse— after this PRSpan suppression, ping suppression, active-parent respect, tool/prompt/resource spans, and error handling across stdio+http+sse transports are unchanged.
Testing
New tests in
test_mcp_instrumentor.py:test_http_headers_carry_trace_context_regardless_of_suppression— exercisesOTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATIONin{true, false, default}and asserts every outgoing MCP HTTP request carries a well-formed W3Ctraceparentheader (validates format:00-<32hex trace-id>-<16hex span-id>-<2hex flags>).test_http_headers_prebuilt_client_gets_hook— verifies the hook is installed on a user-supplied pre-builthttp_client(newerstreamable_http_client(url, http_client=...)API).Existing tests (15 total including the two new ones):
Lint:
Related PRs
_meta+_handle_post_requestrestoration)OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATIONdefaulting totrue(this is the PR whose default silently broke non-ADOT-server topologies)