Skip to content

Commit 3f25a81

Browse files
authored
feat(mcp-instrumentation): suppress HTTP and initialization spans (#695)
*Description of changes:* - Suppress redundant HTTP/ASGI spans on MCP client and server by default - Patch `OpenTelemetryMiddleware.__call__` to respect `suppress_http_instrumentation` context note: SHOULD remove this patch once open-telemetry/opentelemetry-python-contrib#4375 is merged - Skip creating MCP spans for initialize and notifications/initialized handshake messages - Introduce `OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION` env var, defaults to true <img width="1038" height="546" alt="image" src="https://github.com/user-attachments/assets/d6f0254a-3b4f-4d46-bb32-424d6fee12b3" /> By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent d3b512f commit 3f25a81

6 files changed

Lines changed: 227 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
1212

1313
## Unreleased
1414

15+
- feat: suppress redundant HTTP/ASGI and initialization spans in MCP instrumentation
16+
([#695](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/695))
1517
- feat: [BREAKING CHANGE] introduce AWS_AGENTIC_OBSERVABILITY_OPT_IN and refactor agent observability config
1618
([#691](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/691))
1719
- feat: propagate HTTP context for MCP requests and prefix all span names with mcp

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/instrumentation/mcp/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_STDIO_MODULE = "mcp.client.stdio"
1818
_HTTP_MODULE = "mcp.client.streamable_http"
1919
_SSE_MODULE = "mcp.client.sse"
20+
_FASTMCP_MODULE = "mcp.server.fastmcp.server"
2021

2122

2223
class McpInstrumentor(BaseInstrumentor):
@@ -76,10 +77,24 @@ def _instrument(self, **kwargs: Any) -> None:
7677
self._client_wrapper.wrap_handle_post_request,
7778
)
7879

80+
_LOG.debug("Instrument MCP server ASGI apps to suppress redundant HTTP spans.")
81+
82+
try_wrap(
83+
_FASTMCP_MODULE,
84+
"FastMCP.streamable_http_app",
85+
self._server_wrapper.wrap_mcp_http_sse_app_factory,
86+
)
87+
try_wrap(
88+
_FASTMCP_MODULE,
89+
"FastMCP.sse_app",
90+
self._server_wrapper.wrap_mcp_http_sse_app_factory,
91+
)
92+
7993
def _uninstrument(self, **kwargs: Any) -> None: # pylint: disable=no-self-use
8094
try:
8195
# pylint: disable=import-outside-toplevel
8296
from mcp.client import sse, stdio, streamable_http
97+
from mcp.server.fastmcp import server as fastmcp_server
8398
from mcp.server.lowlevel import server
8499
from mcp.shared import session
85100

@@ -100,5 +115,7 @@ def _uninstrument(self, **kwargs: Any) -> None: # pylint: disable=no-self-use
100115
streamable_http.StreamableHTTPTransport,
101116
"_handle_post_request",
102117
)
103-
except ImportError:
104-
_LOG.debug("MCP SDK not available, nothing to uninstrument")
118+
try_unwrap(fastmcp_server.FastMCP, "streamable_http_app")
119+
try_unwrap(fastmcp_server.FastMCP, "sse_app")
120+
except (ImportError, AttributeError) as exc:
121+
_LOG.debug("MCP SDK not fully available, nothing to uninstrument: %s", exc)

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/instrumentation/mcp/_wrappers.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import logging
5+
import os
56
from contextlib import asynccontextmanager
67
from contextvars import Token
78
from typing import Any, Callable, Coroutine, Dict, Optional, Tuple
89
from urllib.parse import urlparse
910

1011
from amazon.opentelemetry.distro.instrumentation.common.instrumentation_utils import serialize_to_json_string
1112
from opentelemetry import context, trace
13+
from opentelemetry.instrumentation.utils import suppress_http_instrumentation
1214
from opentelemetry.propagate import get_global_textmap
1315
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
1416
GEN_AI_OPERATION_NAME,
@@ -35,6 +37,8 @@
3537

3638
_LOG = logging.getLogger(__name__)
3739

40+
OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION = "OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION"
41+
3842
# Context key for storing client transport metadata alongside the session span.
3943
_TRANSPORT_KEY = context.create_key("mcp_client_transport")
4044

@@ -53,6 +57,20 @@ class McpWrapper:
5357
def __init__(self, tracer: trace.Tracer, **kwargs: Any) -> None:
5458
self._tracer = tracer
5559
self._propagators = kwargs.get("propagators") or get_global_textmap()
60+
self._should_suppress_http_spans = (
61+
os.environ.get(OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION, "true").lower() == "true"
62+
)
63+
64+
@staticmethod
65+
def _should_suppress_mcp_span(message: Any) -> bool:
66+
from mcp import types # pylint: disable=import-outside-toplevel
67+
68+
if isinstance(
69+
message, (types.ClientRequest, types.ClientNotification, types.ServerRequest, types.ServerNotification)
70+
):
71+
message = message.root
72+
# noisy spans most of the time
73+
return isinstance(message, (types.InitializeRequest, types.InitializedNotification))
5674

5775
@staticmethod
5876
def _set_mcp_attributes(span: trace.Span, message: Any, request_id: Optional[int]) -> None:
@@ -179,6 +197,9 @@ async def async_wrapper() -> Any:
179197
if not message:
180198
return await wrapped(*args, **kwargs)
181199

200+
if self._should_suppress_http_spans and self._should_suppress_mcp_span(message):
201+
return await wrapped(*args, **kwargs)
202+
182203
span = self._tracer.start_span(name=self._CLIENT_SPAN_NAME, kind=SpanKind.CLIENT)
183204
ctx = trace.set_span_in_context(span)
184205
token = context.attach(ctx)
@@ -252,8 +273,13 @@ async def wrapper():
252273
}
253274
)
254275
try:
255-
async with wrapped(*args, **kwargs) as streams:
256-
yield streams
276+
if self._should_suppress_http_spans:
277+
with suppress_http_instrumentation():
278+
async with wrapped(*args, **kwargs) as streams:
279+
yield streams
280+
else:
281+
async with wrapped(*args, **kwargs) as streams:
282+
yield streams
257283
finally:
258284
session_span.end()
259285
context.detach(token)
@@ -292,7 +318,11 @@ async def async_wrapper():
292318
if ctx:
293319
token = context.attach(ctx)
294320
try:
295-
return await wrapped(*args, **kwargs)
321+
if self._should_suppress_http_spans:
322+
with suppress_http_instrumentation():
323+
return await wrapped(*args, **kwargs)
324+
else:
325+
return await wrapped(*args, **kwargs)
296326
finally:
297327
context.detach(token)
298328
else:
@@ -332,6 +362,25 @@ class ServerWrapper(McpWrapper):
332362
# Though we can opt out of doing so as well as believe those spans provide minimum value with added
333363
# complexity.
334364

365+
def wrap_mcp_http_sse_app_factory(self, wrapped: Callable[..., Any], instance: Any, args: Any, kwargs: Any) -> Any:
366+
if not self._should_suppress_http_spans:
367+
return wrapped(*args, **kwargs)
368+
369+
class _HttpSuppressionMiddleware:
370+
def __init__(self, inner_app: Any) -> None:
371+
self.app = inner_app
372+
373+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
374+
with suppress_http_instrumentation():
375+
await self.app(scope, receive, send)
376+
377+
app = wrapped(*args, **kwargs)
378+
try:
379+
app.add_middleware(_HttpSuppressionMiddleware)
380+
except Exception: # pylint: disable=broad-exception-caught
381+
_LOG.debug("Failed to apply ASGI suppression to MCP server app", exc_info=True)
382+
return app
383+
335384
async def _wrap_server_handle_request(
336385
self,
337386
wrapped: Callable[..., Coroutine[Any, Any, Any]],
@@ -396,6 +445,9 @@ async def _wrap_server_message_handler(
396445
if not incoming_msg:
397446
return await wrapped(*args, **kwargs)
398447

448+
if self._should_suppress_http_spans and self._should_suppress_mcp_span(incoming_msg):
449+
return await wrapped(*args, **kwargs)
450+
399451
request_id = getattr(incoming_msg, "id", None)
400452

401453
carrier = self._extract_trace_context(incoming_msg)

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_starlette_patches.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def _apply_starlette_instrumentation_patches() -> None:
1616
try:
1717
# pylint: disable=import-outside-toplevel
1818
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
19+
from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled
1920

2021
# pylint: disable=line-too-long
2122
# Patch to exclude http receive/send ASGI event spans from Bedrock AgentCore,
@@ -36,6 +37,17 @@ def patched_init(self, app, **kwargs):
3637

3738
OpenTelemetryMiddleware.__init__ = patched_init
3839

40+
# TODO: Remove this patch once upstream PR is merged:
41+
# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4375
42+
original_call = OpenTelemetryMiddleware.__call__
43+
44+
async def patched_call(self, scope, receive, send):
45+
if not is_http_instrumentation_enabled():
46+
return await self.app(scope, receive, send)
47+
return await original_call(self, scope, receive, send)
48+
49+
OpenTelemetryMiddleware.__call__ = patched_call
50+
3951
_logger.debug("Successfully patched Starlette ASGI middleware")
4052
except Exception as exc: # pylint: disable=broad-except
4153
_logger.warning("Failed to apply Starlette instrumentation patches: %s", exc)

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/instrumentation/mcp/test_mcp_instrumentor.py

Lines changed: 75 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,22 @@
33

44
import asyncio
55
import json
6+
import os
67
import signal
78
import socket
89
import subprocess
910
import sys
1011
import time
1112
import unittest
13+
import unittest.mock
1214
from pathlib import Path
1315
from threading import Thread
1416
from unittest import TestCase
1517

1618
from collector import OTLPServer, Telemetry
1719

1820
from amazon.opentelemetry.distro.instrumentation.mcp import McpInstrumentor
21+
from amazon.opentelemetry.distro.instrumentation.mcp._wrappers import OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION
1922
from opentelemetry.baggage.propagation import W3CBaggagePropagator
2023

2124
try:
@@ -38,7 +41,6 @@
3841
)
3942
from opentelemetry.semconv._incubating.attributes.mcp_attributes import (
4043
MCP_METHOD_NAME,
41-
MCP_PROTOCOL_VERSION,
4244
MCP_RESOURCE_URI,
4345
MCP_SESSION_ID,
4446
McpMethodNameValues,
@@ -300,36 +302,38 @@ def _assert_client_and_server_spans(
300302
session_span = self._get_span(client_spans, "mcp.session")
301303
self.assertEqual(session_span.kind, SpanKind.INTERNAL)
302304

303-
init_span = self._get_span(client_spans, f"mcp {McpMethodNameValues.INITIALIZE.value}")
304-
self.assertIsNotNone(init_span.attributes.get(MCP_PROTOCOL_VERSION))
305-
306-
client_notif_init_span = self._get_span(
307-
client_spans, f"mcp {McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value}"
308-
)
309-
310-
self.assertEqual(init_span.kind, SpanKind.CLIENT)
311-
self._assert_span_attrs(
312-
init_span, {MCP_METHOD_NAME: McpMethodNameValues.INITIALIZE.value, NETWORK_TRANSPORT: expected_transport}
313-
)
314-
315-
self.assertEqual(client_notif_init_span.kind, SpanKind.CLIENT)
316-
self._assert_span_attrs(
317-
client_notif_init_span,
318-
{
319-
MCP_METHOD_NAME: McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value,
320-
NETWORK_TRANSPORT: expected_transport,
321-
},
322-
)
323-
324-
server_init_span = self._get_span(server_spans, f"mcp {McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value}")
325-
self.assertEqual(server_init_span.kind, ProtoSpan.SpanKind.SPAN_KIND_SERVER)
326-
self._assert_span_attrs(
327-
server_init_span, {MCP_METHOD_NAME: McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value}
328-
)
329-
self._assert_no_attr(server_init_span, NETWORK_TRANSPORT)
330-
self._assert_context_propagation(client_notif_init_span, server_init_span)
331-
332-
client_op_spans = [init_span, client_notif_init_span]
305+
# TODO: Uncomment once we get user requirement to unsuppress initialize spans.
306+
307+
# init_span = self._get_span(client_spans, f"mcp {McpMethodNameValues.INITIALIZE.value}")
308+
# self.assertIsNotNone(init_span.attributes.get(MCP_PROTOCOL_VERSION))
309+
#
310+
# client_notif_init_span = self._get_span(
311+
# client_spans, f"mcp {McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value}"
312+
# )
313+
#
314+
# self.assertEqual(init_span.kind, SpanKind.CLIENT)
315+
# self._assert_span_attrs(
316+
# init_span, {MCP_METHOD_NAME: McpMethodNameValues.INITIALIZE.value, NETWORK_TRANSPORT: expected_transport}
317+
# )
318+
#
319+
# self.assertEqual(client_notif_init_span.kind, SpanKind.CLIENT)
320+
# self._assert_span_attrs(
321+
# client_notif_init_span,
322+
# {
323+
# MCP_METHOD_NAME: McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value,
324+
# NETWORK_TRANSPORT: expected_transport,
325+
# },
326+
# )
327+
#
328+
# server_init_span = self._get_span(server_spans, f"mcp {McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value}")
329+
# self.assertEqual(server_init_span.kind, ProtoSpan.SpanKind.SPAN_KIND_SERVER)
330+
# self._assert_span_attrs(
331+
# server_init_span, {MCP_METHOD_NAME: McpMethodNameValues.NOTIFICATIONS_INITIALIZED.value}
332+
# )
333+
# self._assert_no_attr(server_init_span, NETWORK_TRANSPORT)
334+
# self._assert_context_propagation(client_notif_init_span, server_init_span)
335+
336+
client_op_spans = []
333337

334338
if operation_span_name:
335339
client_op_span = self._get_span(client_spans, operation_span_name)
@@ -428,30 +432,46 @@ async def run(session):
428432
self.assertIsNotNone(server_span.attributes.get(CLIENT_ADDRESS))
429433
self.assertIsNotNone(server_span.attributes.get(CLIENT_PORT))
430434

431-
def test_http_span_parented_under_mcp_request(self):
432-
433-
HTTPXClientInstrumentor().instrument(tracer_provider=self.tracer_provider)
434-
try:
435-
436-
async def run(session):
437-
await session.call_tool("hello", {"name": "World"})
438-
439-
asyncio.run(self._run_http_inprocess(run))
440-
spans = self.span_exporter.get_finished_spans()
441-
442-
tool_span = next(s for s in spans if s.name == "mcp tools/call hello" and s.kind == SpanKind.CLIENT)
443-
post_spans = [s for s in spans if s.name == "POST" and s.kind == SpanKind.CLIENT]
444-
445-
self.assertTrue(len(post_spans) > 0, "Expected at least one httpx POST span")
446-
447-
tool_span_id = format(tool_span.context.span_id, "016x")
448-
parented_posts = [s for s in post_spans if format(s.parent.span_id, "016x") == tool_span_id]
449-
self.assertTrue(
450-
len(parented_posts) > 0,
451-
"httpx POST span should be parented under MCP tool call span",
452-
)
453-
finally:
454-
HTTPXClientInstrumentor().uninstrument()
435+
def test_http_span_suppression(self):
436+
for suppress_value, expect_post_spans in [("false", True), ("true", False), (None, False)]:
437+
label = f"suppress={suppress_value}" if suppress_value else "suppress=default"
438+
with self.subTest(label):
439+
self.instrumentor.uninstrument()
440+
self.span_exporter.clear()
441+
442+
patch_env = {}
443+
if suppress_value is not None:
444+
patch_env[OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION] = suppress_value
445+
446+
with unittest.mock.patch.dict(os.environ, patch_env):
447+
if suppress_value is None:
448+
os.environ.pop(OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION, None)
449+
450+
self.instrumentor.instrument(tracer_provider=self.tracer_provider, propagators=self.propagator)
451+
self.server = self._create_server()
452+
HTTPXClientInstrumentor().instrument(tracer_provider=self.tracer_provider)
453+
try:
454+
455+
async def run(session):
456+
await session.call_tool("hello", {"name": "World"})
457+
458+
asyncio.run(self._run_http_inprocess(run))
459+
spans = self.span_exporter.get_finished_spans()
460+
461+
tool_span = next(
462+
s for s in spans if s.name == "mcp tools/call hello" and s.kind == SpanKind.CLIENT
463+
)
464+
post_spans = [s for s in spans if s.name == "POST" and s.kind == SpanKind.CLIENT]
465+
466+
if expect_post_spans:
467+
self.assertTrue(len(post_spans) > 0, "Expected httpx POST spans when unsuppressed")
468+
tool_span_id = format(tool_span.context.span_id, "016x")
469+
parented_posts = [s for s in post_spans if format(s.parent.span_id, "016x") == tool_span_id]
470+
self.assertTrue(len(parented_posts) > 0, "POST span should parent under MCP tool call")
471+
else:
472+
self.assertEqual(len(post_spans), 0, "Expected no httpx POST spans when suppressed")
473+
finally:
474+
HTTPXClientInstrumentor().uninstrument()
455475

456476
def test_mcp_respects_active_parent_span(self):
457477
tracer = get_tracer("test", tracer_provider=self.tracer_provider)

0 commit comments

Comments
 (0)