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
94 changes: 49 additions & 45 deletions sentry_sdk/integrations/langgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
has_span_streaming_enabled,
should_truncate_gen_ai_input,
)
from sentry_sdk.utils import safe_serialize
from sentry_sdk.utils import has_data_collection_enabled, safe_serialize

try:
from langgraph.errors import GraphBubbleUp
Expand Down Expand Up @@ -53,6 +53,24 @@
Pregel.ainvoke = _wrap_pregel_ainvoke(Pregel.ainvoke)


def _should_record_inputs(integration: "LanggraphIntegration") -> bool:
client = sentry_sdk.get_client()
if has_data_collection_enabled(client.options):
return bool(client.options["data_collection"]["gen_ai"]["inputs"])

# To remove once data collection has been fully rolled out
return should_send_default_pii() and integration.include_prompts


def _should_record_outputs(integration: "LanggraphIntegration") -> bool:
client = sentry_sdk.get_client()
if has_data_collection_enabled(client.options):
return bool(client.options["data_collection"]["gen_ai"]["outputs"])

# To remove once data collection has been fully rolled out
return should_send_default_pii() and integration.include_prompts


def _get_graph_name(graph_obj: "Any") -> "Optional[str]":
for attr in ["name", "graph_name", "__name__", "_name"]:
if hasattr(graph_obj, attr):
Expand Down Expand Up @@ -153,7 +171,13 @@
tools = list(data.tools_by_name.keys())

if tools is not None:
span.set_data(SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools)
# Available tools aren't gated on the legacy PII settings, so they're
# only gated when data collection has been configured.
if has_data_collection_enabled(client.options):
if client.options["data_collection"]["gen_ai"]["inputs"]:
span.set_data(SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools)
else:
span.set_data(SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools)

return compiled_graph

Expand Down Expand Up @@ -188,18 +212,13 @@

# Store input messages to later compare with output
input_messages = None
if (
len(args) > 0
and should_send_default_pii()
and integration.include_prompts
):
if len(args) > 0 and _should_record_inputs(integration):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Inputs gate breaks message delta

Medium Severity

Parsing input_messages is gated on _should_record_inputs, but those messages are also required by _get_new_messages to compute the output delta. With gen_ai.inputs false and gen_ai.outputs true, input_messages stays None, so the full history is treated as new. That can put prior-turn or user input content into gen_ai.response.text and inflate usage totals.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f54e39e. Configure here.

input_messages = _parse_langgraph_messages(args[0])
if input_messages:
normalized_input_messages = normalize_message_roles(
input_messages
)

client = sentry_sdk.get_client()
scope = sentry_sdk.get_current_scope()
messages_data = (
truncate_and_annotate_messages(
Expand Down Expand Up @@ -235,18 +254,13 @@

# Store input messages to later compare with output
input_messages = None
if (
len(args) > 0
and should_send_default_pii()
and integration.include_prompts
):
if len(args) > 0 and _should_record_inputs(integration):
input_messages = _parse_langgraph_messages(args[0])
if input_messages:
normalized_input_messages = normalize_message_roles(
input_messages
)

client = sentry_sdk.get_client()
scope = sentry_sdk.get_current_scope()
messages_data = (
truncate_and_annotate_messages(
Expand Down Expand Up @@ -299,18 +313,13 @@
span.set_attribute(SPANDATA.GEN_AI_AGENT_NAME, graph_name)

input_messages = None
if (
len(args) > 0
and should_send_default_pii()
and integration.include_prompts
):
if len(args) > 0 and _should_record_inputs(integration):
input_messages = _parse_langgraph_messages(args[0])
if input_messages:
normalized_input_messages = normalize_message_roles(
input_messages
)

client = sentry_sdk.get_client()
scope = sentry_sdk.get_current_scope()
messages_data = (
truncate_and_annotate_messages(
Expand Down Expand Up @@ -345,16 +354,11 @@
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")

input_messages = None
if (
len(args) > 0
and should_send_default_pii()
and integration.include_prompts
):
if len(args) > 0 and _should_record_inputs(integration):
input_messages = _parse_langgraph_messages(args[0])
if input_messages:
normalized_input_messages = normalize_message_roles(input_messages)

client = sentry_sdk.get_client()
scope = sentry_sdk.get_current_scope()
messages_data = (
truncate_and_annotate_messages(
Expand Down Expand Up @@ -494,22 +498,22 @@
_set_usage_data(span, new_messages)
_set_response_model_name(span, new_messages)

if not (should_send_default_pii() and integration.include_prompts):
return

llm_response_text = _extract_llm_response_text(new_messages)
if llm_response_text:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, llm_response_text)
elif new_messages:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, new_messages)
else:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, result)

tool_calls = _extract_tool_calls(new_messages)
if tool_calls:
set_data_normalized(
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
safe_serialize(tool_calls),
unpack=False,
)
if _should_record_outputs(integration):
llm_response_text = _extract_llm_response_text(new_messages)
if llm_response_text:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, llm_response_text)
elif new_messages:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, new_messages)

Check warning on line 506 in sentry_sdk/integrations/langgraph.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Disabling gen_ai.inputs leaks original prompts via gen_ai.response.text fallback

When `gen_ai.inputs` is disabled but `gen_ai.outputs` is enabled, and the LLM returns a non-text response (e.g. a tool call), the fallback that writes `new_messages` into `gen_ai.response.text` serialises the full result state—including the original user prompts that were meant to be withheld.
Comment on lines +505 to +506

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Disabling gen_ai.inputs leaks original prompts via gen_ai.response.text fallback

When gen_ai.inputs is disabled but gen_ai.outputs is enabled, and the LLM returns a non-text response (e.g. a tool call), the fallback that writes new_messages into gen_ai.response.text serialises the full result state—including the original user prompts that were meant to be withheld.

Evidence
  • _wrap_pregel_invoke parses input_messages only when _should_record_inputs(integration) is True, otherwise passing None into _set_response_attributes.
  • _get_new_messages(None, output_messages) returns the entire result state, not just newly-added messages, because it has no input list to slice against.
  • _extract_llm_response_text returns None for assistant messages whose content is empty or missing (typical for pure tool-call responses).
  • The elif new_messages: branch then writes that full state list into GEN_AI_RESPONSE_TEXT, which includes the original user prompts that gen_ai.inputs=False was intended to suppress.
Also found at 1 additional location
  • tests/integrations/langgraph/test_langgraph.py:2443

Identified by Warden · find-bugs · VW6-3TE

else:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, result)

# Tool calls are an input to the model, so they're gated on inputs
if _should_record_inputs(integration):
tool_calls = _extract_tool_calls(new_messages)
if tool_calls:
set_data_normalized(
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
safe_serialize(tool_calls),
unpack=False,
)
Loading
Loading