From bbdf1351408f0ee668bf0f5bacff65e04c7e1354 Mon Sep 17 00:00:00 2001 From: gmassello Date: Fri, 21 Aug 2026 21:21:30 -0300 Subject: [PATCH 1/2] test(langchain, mcp): Validate error mechanism type and handling in error events --- tests/integrations/langchain/test_langchain.py | 2 ++ tests/integrations/mcp/test_mcp.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/tests/integrations/langchain/test_langchain.py b/tests/integrations/langchain/test_langchain.py index 353df628f1..5698fc51cf 100644 --- a/tests/integrations/langchain/test_langchain.py +++ b/tests/integrations/langchain/test_langchain.py @@ -3621,6 +3621,8 @@ def _llm_type(self) -> str: error = events[0] assert error["level"] == "error" + assert error["exception"]["values"][0]["mechanism"]["type"] == "langchain" + assert not error["exception"]["values"][0]["mechanism"]["handled"] @pytest.mark.parametrize("span_streaming", [True, False]) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index a65bf9539f..f1b3f2c761 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -618,6 +618,8 @@ def failing_tool(tool_name, arguments): assert ( error_payload["exception"]["values"][0]["value"] == "Tool execution failed" ) + assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] assert span["status"] == "error" else: @@ -647,6 +649,8 @@ def failing_tool(tool_name, arguments): assert error_event["level"] == "error" assert error_event["exception"]["values"][0]["type"] == "ValueError" assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_event["exception"]["values"][0]["mechanism"]["handled"] # Check transaction and span assert tx["type"] == "transaction" @@ -944,6 +948,8 @@ async def failing_prompt(name, arguments): assert error_payload["level"] == "error" assert error_payload["exception"]["values"][0]["type"] == "RuntimeError" + assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] assert span["status"] == "error" else: events = capture_events() @@ -966,6 +972,8 @@ async def failing_prompt(name, arguments): assert error_event["level"] == "error" assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_event["exception"]["values"][0]["mechanism"]["handled"] # Check transaction and span assert tx["type"] == "transaction" @@ -1229,6 +1237,8 @@ def failing_resource(uri): assert error_payload["level"] == "error" assert error_payload["exception"]["values"][0]["type"] == "FileNotFoundError" + assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] assert span["status"] == "error" else: events = capture_events() @@ -1248,6 +1258,8 @@ def failing_resource(uri): assert error_event["level"] == "error" assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_event["exception"]["values"][0]["mechanism"]["handled"] # Check transaction and span assert tx["type"] == "transaction" From f5fcb50c23bf972a53ccdcb3f2e97070d3562669 Mon Sep 17 00:00:00 2001 From: gmassello Date: Sat, 22 Aug 2026 16:05:44 -0300 Subject: [PATCH 2/2] Add mechanism to captured exceptions. Fixes #5242 --- sentry_sdk/integrations/langchain.py | 12 ++- sentry_sdk/integrations/mcp.py | 29 +++++-- .../integrations/langchain/test_langchain.py | 77 +++++++++++++++++++ tests/integrations/mcp/test_mcp.py | 2 + 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/langchain.py b/sentry_sdk/integrations/langchain.py index a62fbf45d8..0d1cd69fef 100644 --- a/sentry_sdk/integrations/langchain.py +++ b/sentry_sdk/integrations/langchain.py @@ -26,6 +26,7 @@ ) from sentry_sdk.utils import ( capture_internal_exceptions, + event_from_exception, has_data_collection_enabled, logger, ) @@ -265,6 +266,15 @@ def setup_once() -> None: _patch_embeddings_provider(OllamaEmbeddings) +def _capture_exception(exc: "Any", scope: "Optional[Any]" = None) -> None: + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "langchain", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint, scope=scope) + + class SentryLangchainCallback(BaseCallbackHandler): """Callback handler that creates Sentry spans.""" @@ -293,7 +303,7 @@ def _handle_error(self, run_id: "UUID", error: "Any") -> None: if is_ignored: span.__exit__(None, None, None) else: - sentry_sdk.capture_exception( + _capture_exception( error, span._scope if isinstance(span, StreamedSpan) else span.scope ) span.__exit__(type(error), error, error.__traceback__) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 744010c0ee..3dafc2e649 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -21,6 +21,8 @@ from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, has_data_collection_enabled, nullcontext, package_version, @@ -100,6 +102,15 @@ def setup_once() -> None: _patch_fastmcp() +def _capture_exception(exc: "Any") -> None: + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "mcp", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + @contextmanager def _active_http_scopes( ctx: "Any", @@ -394,7 +405,8 @@ async def _tool_handler_wrapper( result = await result except Exception as e: - sentry_sdk.capture_exception(e) + with capture_internal_exceptions(): + _capture_exception(e) raise if result is None: @@ -490,7 +502,8 @@ async def _instrument_v2_tool_call( result = await call_next(ctx) except Exception as e: - sentry_sdk.capture_exception(e) + with capture_internal_exceptions(): + _capture_exception(e) raise if not isinstance(result, dict): @@ -615,7 +628,8 @@ async def _prompt_handler_wrapper( result = await result except Exception as e: - sentry_sdk.capture_exception(e) + with capture_internal_exceptions(): + _capture_exception(e) raise if result is None: @@ -764,7 +778,8 @@ async def _instrument_v2_prompt_get( try: result = await call_next(ctx) except Exception as e: - sentry_sdk.capture_exception(e) + with capture_internal_exceptions(): + _capture_exception(e) raise if not isinstance(result, dict): @@ -919,7 +934,8 @@ async def _resource_handler_wrapper( result = await result except Exception as e: - sentry_sdk.capture_exception(e) + with capture_internal_exceptions(): + _capture_exception(e) raise return result @@ -984,7 +1000,8 @@ async def _instrument_v2_resource_read( result = await call_next(ctx) except Exception as e: - sentry_sdk.capture_exception(e) + with capture_internal_exceptions(): + _capture_exception(e) raise return result diff --git a/tests/integrations/langchain/test_langchain.py b/tests/integrations/langchain/test_langchain.py index 5698fc51cf..b4e051dc44 100644 --- a/tests/integrations/langchain/test_langchain.py +++ b/tests/integrations/langchain/test_langchain.py @@ -3693,6 +3693,8 @@ def _llm_type(self) -> str: (error,) = (item.payload for item in items if item.type == "event") assert error["level"] == "error" + assert error["exception"]["values"][0]["mechanism"]["type"] == "langchain" + assert not error["exception"]["values"][0]["mechanism"]["handled"] sentry_sdk.flush() spans = [item.payload for item in items if item.type == "span"] assert spans[0]["status"] == "error" @@ -3730,6 +3732,8 @@ def _llm_type(self) -> str: (error,) = (item.payload for item in items if item.type == "event") assert error["level"] == "error" + assert error["exception"]["values"][0]["mechanism"]["type"] == "langchain" + assert not error["exception"]["values"][0]["mechanism"]["handled"] sentry_sdk.flush() spans = [item.payload for item in items if item.type == "span"] assert spans[0]["status"] == "error" @@ -3768,10 +3772,83 @@ def _llm_type(self) -> str: (error, transaction) = events assert error["level"] == "error" + assert error["exception"]["values"][0]["mechanism"]["type"] == "langchain" + assert not error["exception"]["values"][0]["mechanism"]["handled"] assert transaction["spans"][0]["status"] == "internal_error" assert transaction["spans"][0]["tags"]["status"] == "internal_error" +@tool +def failing_tool(word: str) -> int: + """Raises instead of returning a length.""" + raise ValueError("Tool execution failed") + + +def test_langchain_tool_error( + sentry_init, + capture_events, + get_model_response, + nonstreaming_responses_tool_call_model_responses, +): + sentry_init( + integrations=[LangchainIntegration(include_prompts=True)], + disabled_integrations=[StdlibIntegration], + traces_sample_rate=1.0, + ) + + responses = nonstreaming_responses_tool_call_model_responses( + tool_name="failing_tool", + arguments='{"word": "eudca"}', + response_model="gpt-4-0613", + response_text="", + response_ids=iter(["resp_1"]), + usages=iter( + [ + ResponseUsage( + input_tokens=0, + input_tokens_details=InputTokensDetails( + cached_tokens=0, + cache_write_tokens=0, + ), + output_tokens=0, + output_tokens_details=OutputTokensDetails( + reasoning_tokens=0, + ), + total_tokens=0, + ), + ] + ), + ) + tool_response = get_model_response( + next(responses), + serialize_pydantic=True, + request_headers={ + "X-Stainless-Raw-Response": "True", + }, + ) + + llm = ChatOpenAI( + model_name="gpt-4", + temperature=0, + openai_api_key="badkey", + use_responses_api=True, + ) + agent = create_agent(model=llm, tools=[failing_tool], name="failing_agent") + + events = capture_events() + + with patch.object( + llm.client._client._client, "send", side_effect=[tool_response] + ), start_transaction(name="tx"), pytest.raises(ValueError): + agent.invoke({"messages": [HumanMessage(content="hi")]}) + + error_events = [event for event in events if event.get("level") == "error"] + assert len(error_events) == 1 + assert error_events[0]["exception"]["values"][0]["type"] == "ValueError" + assert error_events[0]["exception"]["values"][0]["mechanism"]["type"] == "langchain" + assert not error_events[0]["exception"]["values"][0]["mechanism"]["handled"] + + def test_manual_callback_no_duplication(sentry_init): """ Test that when a user manually provides a SentryLangchainCallback, diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index f1b3f2c761..3101904424 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -2020,6 +2020,8 @@ def failing_tool(tool_name, arguments): error_event = error_events[0] assert error_event["exception"]["values"][0]["type"] == "ValueError" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_event["exception"]["values"][0]["mechanism"]["handled"] # The captured error shares the trace of the MCP transaction, proving the # handler executed under the propagated request scope.