Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion sentry_sdk/integrations/langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from sentry_sdk.utils import (
capture_internal_exceptions,
event_from_exception,
has_data_collection_enabled,
logger,
)
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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__)
Expand Down
29 changes: 23 additions & 6 deletions sentry_sdk/integrations/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions tests/integrations/langchain/test_langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -3691,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"
Expand Down Expand Up @@ -3728,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"
Expand Down Expand Up @@ -3766,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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing LangChain version skip

Medium Severity

test_langchain_tool_error uses create_agent, which is only imported for LangChain 1.0+, but it lacks the @pytest.mark.skipif(LANGCHAIN_VERSION < (1,), ...) guard that sibling tests such as test_langchain_create_agent and test_tool_execution_span already apply. On tox envs still in the matrix (langchain-base-v0.1.20, v0.3.30), collection succeeds while the test raises NameError at runtime.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5fcb50. Configure here.



def test_manual_callback_no_duplication(sentry_init):
"""
Test that when a user manually provides a SentryLangchainCallback,
Expand Down
14 changes: 14 additions & 0 deletions tests/integrations/mcp/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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"
Expand Down Expand Up @@ -2008,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.
Expand Down