Skip to content

Commit 29b2200

Browse files
merge master
2 parents aa79cb4 + 42ed87a commit 29b2200

File tree

3 files changed

+254
-25
lines changed

3 files changed

+254
-25
lines changed

sentry_sdk/integrations/mcp.py

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
except ImportError:
2525
raise DidNotEnable("MCP SDK not installed")
2626

27+
try:
28+
from fastmcp import FastMCP # type: ignore[import-not-found]
29+
except ImportError:
30+
FastMCP = None
31+
2732

2833
if TYPE_CHECKING:
2934
from typing import Any, Callable, Optional
@@ -50,6 +55,9 @@ def setup_once() -> None:
5055
"""
5156
_patch_lowlevel_server()
5257

58+
if FastMCP is not None:
59+
_patch_fastmcp()
60+
5361

5462
def _get_request_context_data() -> "tuple[Optional[str], Optional[str], str]":
5563
"""
@@ -280,26 +288,53 @@ def _set_span_output_data(
280288

281289

282290
def _prepare_handler_data(
283-
handler_type: str, original_args: "tuple[Any, ...]"
291+
handler_type: str,
292+
original_args: "tuple[Any, ...]",
293+
original_kwargs: "Optional[dict[str, Any]]" = None,
284294
) -> "tuple[str, dict[str, Any], str, str, str, Optional[str]]":
285295
"""
286296
Prepare common handler data for both async and sync wrappers.
287297
288298
Returns:
289299
Tuple of (handler_name, arguments, span_data_key, span_name, mcp_method_name, result_data_key)
290300
"""
301+
original_kwargs = original_kwargs or {}
302+
291303
# Extract handler-specific data based on handler type
292304
if handler_type == "tool":
293-
handler_name = original_args[0] # tool_name
294-
arguments = original_args[1] if len(original_args) > 1 else {}
305+
if original_args:
306+
handler_name = original_args[0]
307+
elif original_kwargs.get("name"):
308+
handler_name = original_kwargs["name"]
309+
310+
arguments = {}
311+
if len(original_args) > 1:
312+
arguments = original_args[1]
313+
elif original_kwargs.get("arguments"):
314+
arguments = original_kwargs["arguments"]
315+
295316
elif handler_type == "prompt":
296-
handler_name = original_args[0] # name
297-
arguments = original_args[1] if len(original_args) > 1 else {}
317+
if original_args:
318+
handler_name = original_args[0]
319+
elif original_kwargs.get("name"):
320+
handler_name = original_kwargs["name"]
321+
322+
arguments = {}
323+
if len(original_args) > 1:
324+
arguments = original_args[1]
325+
elif original_kwargs.get("arguments"):
326+
arguments = original_kwargs["arguments"]
327+
298328
# Include name in arguments dict for span data
299329
arguments = {"name": handler_name, **(arguments or {})}
330+
300331
else: # resource
301-
uri = original_args[0]
302-
handler_name = str(uri) if uri else "unknown"
332+
handler_name = "unknown"
333+
if original_args:
334+
handler_name = str(original_args[0])
335+
elif original_kwargs.get("uri"):
336+
handler_name = str(original_kwargs["uri"])
337+
303338
arguments = {}
304339

305340
# Get span configuration
@@ -318,7 +353,11 @@ def _prepare_handler_data(
318353

319354

320355
async def _async_handler_wrapper(
321-
handler_type: str, func: "Callable[..., Any]", original_args: "tuple[Any, ...]"
356+
handler_type: str,
357+
func: "Callable[..., Any]",
358+
original_args: "tuple[Any, ...]",
359+
original_kwargs: "Optional[dict[str, Any]]" = None,
360+
self: "Optional[Any]" = None,
322361
) -> "Any":
323362
"""
324363
Async wrapper for MCP handlers.
@@ -327,15 +366,20 @@ async def _async_handler_wrapper(
327366
handler_type: "tool", "prompt", or "resource"
328367
func: The async handler function to wrap
329368
original_args: Original arguments passed to the handler
369+
original_kwargs: Original keyword arguments passed to the handler
370+
self: Optional instance for bound methods
330371
"""
372+
if original_kwargs is None:
373+
original_kwargs = {}
374+
331375
(
332376
handler_name,
333377
arguments,
334378
span_data_key,
335379
span_name,
336380
mcp_method_name,
337381
result_data_key,
338-
) = _prepare_handler_data(handler_type, original_args)
382+
) = _prepare_handler_data(handler_type, original_args, original_kwargs)
339383

340384
# Start span and execute
341385
with get_start_span_function()(
@@ -360,7 +404,11 @@ async def _async_handler_wrapper(
360404

361405
# For resources, extract and set protocol
362406
if handler_type == "resource":
363-
uri = original_args[0]
407+
if original_args:
408+
uri = original_args[0]
409+
else:
410+
uri = original_kwargs.get("uri")
411+
364412
protocol = None
365413
if hasattr(uri, "scheme"):
366414
protocol = uri.scheme
@@ -371,7 +419,9 @@ async def _async_handler_wrapper(
371419

372420
try:
373421
# Execute the async handler
374-
result = await func(*original_args)
422+
if self is not None:
423+
original_args = (self, *original_args)
424+
result = await func(*original_args, **original_kwargs)
375425
except Exception as e:
376426
# Set error flag for tools
377427
if handler_type == "tool":
@@ -566,3 +616,48 @@ def patched_read_resource(
566616
)(func)
567617

568618
Server.read_resource = patched_read_resource
619+
620+
621+
def _patch_fastmcp():
622+
# type: () -> None
623+
"""
624+
Patches the standalone fastmcp package's FastMCP class.
625+
626+
The standalone fastmcp package (v2.14.0+) registers its own handlers for
627+
prompts and resources directly, bypassing the Server decorators we patch.
628+
This function patches the _get_prompt_mcp and _read_resource_mcp methods
629+
to add instrumentation for those handlers.
630+
"""
631+
if hasattr(FastMCP, "_get_prompt_mcp"):
632+
original_get_prompt_mcp = FastMCP._get_prompt_mcp
633+
634+
@wraps(original_get_prompt_mcp)
635+
async def patched_get_prompt_mcp(
636+
self: "Any", *args: "Any", **kwargs: "Any"
637+
) -> "Any":
638+
return await _async_handler_wrapper(
639+
"prompt",
640+
original_get_prompt_mcp,
641+
args,
642+
kwargs,
643+
self,
644+
)
645+
646+
FastMCP._get_prompt_mcp = patched_get_prompt_mcp
647+
648+
if hasattr(FastMCP, "_read_resource_mcp"):
649+
original_read_resource_mcp = FastMCP._read_resource_mcp
650+
651+
@wraps(original_read_resource_mcp)
652+
async def patched_read_resource_mcp(
653+
self: "Any", *args: "Any", **kwargs: "Any"
654+
) -> "Any":
655+
return await _async_handler_wrapper(
656+
"resource",
657+
original_read_resource_mcp,
658+
args,
659+
kwargs,
660+
self,
661+
)
662+
663+
FastMCP._read_resource_mcp = patched_read_resource_mcp

sentry_sdk/integrations/otlp.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
from sentry_sdk import get_client
1+
from sentry_sdk import get_client, capture_event
22
from sentry_sdk.integrations import Integration, DidNotEnable
33
from sentry_sdk.scope import register_external_propagation_context
4-
from sentry_sdk.utils import logger, Dsn
4+
from sentry_sdk.utils import (
5+
Dsn,
6+
logger,
7+
event_from_exception,
8+
capture_internal_exceptions,
9+
)
510
from sentry_sdk.consts import VERSION, EndpointType
611
from sentry_sdk.tracing_utils import Baggage
712
from sentry_sdk.tracing import (
@@ -11,7 +16,7 @@
1116

1217
try:
1318
from opentelemetry.propagate import set_global_textmap
14-
from opentelemetry.sdk.trace import TracerProvider
19+
from opentelemetry.sdk.trace import TracerProvider, Span
1520
from opentelemetry.sdk.trace.export import BatchSpanProcessor
1621
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
1722

@@ -82,6 +87,38 @@ def setup_otlp_traces_exporter(dsn: "Optional[str]" = None) -> None:
8287
tracer_provider.add_span_processor(span_processor)
8388

8489

90+
_sentry_patched_exception = False
91+
92+
93+
def setup_capture_exceptions() -> None:
94+
"""
95+
Intercept otel's Span.record_exception to automatically capture those exceptions in Sentry.
96+
"""
97+
global _sentry_patched_exception
98+
_original_record_exception = Span.record_exception
99+
100+
if _sentry_patched_exception:
101+
return
102+
103+
def _sentry_patched_record_exception(
104+
self: "Span", exception: "BaseException", *args: "Any", **kwargs: "Any"
105+
) -> None:
106+
otlp_integration = get_client().get_integration(OTLPIntegration)
107+
if otlp_integration and otlp_integration.capture_exceptions:
108+
with capture_internal_exceptions():
109+
event, hint = event_from_exception(
110+
exception,
111+
client_options=get_client().options,
112+
mechanism={"type": OTLPIntegration.identifier, "handled": False},
113+
)
114+
capture_event(event, hint=hint)
115+
116+
_original_record_exception(self, exception, *args, **kwargs)
117+
118+
Span.record_exception = _sentry_patched_record_exception # type: ignore[method-assign]
119+
_sentry_patched_exception = True
120+
121+
85122
class SentryOTLPPropagator(SentryPropagator):
86123
"""
87124
We need to override the inject of the older propagator since that
@@ -136,13 +173,28 @@ def _to_traceparent(span_context: "SpanContext") -> str:
136173

137174

138175
class OTLPIntegration(Integration):
176+
"""
177+
Automatically setup OTLP ingestion from the DSN.
178+
179+
:param setup_otlp_traces_exporter: Automatically configure an Exporter to send OTLP traces from the DSN, defaults to True.
180+
Set to False if using a custom collector or to setup the TracerProvider manually.
181+
:param setup_propagator: Automatically configure the Sentry Propagator for Distributed Tracing, defaults to True.
182+
Set to False to configure propagators manually or to disable propagation.
183+
:param capture_exceptions: Intercept and capture exceptions on the OpenTelemetry Span in Sentry as well, defaults to False.
184+
Set to True to turn on capturing but be aware that since Sentry captures most exceptions, duplicate exceptions might be dropped by DedupeIntegration in many cases.
185+
"""
186+
139187
identifier = "otlp"
140188

141189
def __init__(
142-
self, setup_otlp_traces_exporter: bool = True, setup_propagator: bool = True
190+
self,
191+
setup_otlp_traces_exporter: bool = True,
192+
setup_propagator: bool = True,
193+
capture_exceptions: bool = False,
143194
) -> None:
144195
self.setup_otlp_traces_exporter = setup_otlp_traces_exporter
145196
self.setup_propagator = setup_propagator
197+
self.capture_exceptions = capture_exceptions
146198

147199
@staticmethod
148200
def setup_once() -> None:
@@ -161,3 +213,5 @@ def setup_once_with_options(
161213
logger.debug("[OTLP] Setting up propagator for distributed tracing")
162214
# TODO-neel better propagator support, chain with existing ones if possible instead of replacing
163215
set_global_textmap(SentryOTLPPropagator())
216+
217+
setup_capture_exceptions()

0 commit comments

Comments
 (0)