Skip to content
162 changes: 66 additions & 96 deletions sentry_sdk/integrations/langchain.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import contextvars
import itertools
import sys
import json
Expand Down Expand Up @@ -162,44 +161,6 @@
return content


# Contextvar to track agent names in a stack for re-entrant agent support
_agent_stack: "contextvars.ContextVar[Optional[List[Optional[str]]]]" = (
contextvars.ContextVar("langchain_agent_stack", default=None)
)


def _push_agent(agent_name: "Optional[str]") -> None:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice to be getting rid of this code 🔥

"""Push an agent name onto the stack."""
stack = _agent_stack.get()
if stack is None:
stack = []
else:
# Copy the list to maintain contextvar isolation across async contexts
stack = stack.copy()
stack.append(agent_name)
_agent_stack.set(stack)


def _pop_agent() -> "Optional[str]":
"""Pop an agent name from the stack and return it."""
stack = _agent_stack.get()
if stack:
# Copy the list to maintain contextvar isolation across async contexts
stack = stack.copy()
agent_name = stack.pop()
_agent_stack.set(stack)
return agent_name
return None


def _get_current_agent() -> "Optional[str]":
"""Get the current agent name (top of stack) without removing it."""
stack = _agent_stack.get()
if stack:
return stack[-1]
return None


def _get_system_instructions(messages: "List[List[BaseMessage]]") -> "List[str]":
system_instructions = []

Expand Down Expand Up @@ -465,9 +426,11 @@
if ai_system:
span.set_data(SPANDATA.GEN_AI_SYSTEM, ai_system)

agent_name = _get_current_agent()
if agent_name:
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name)
agent_metadata = kwargs.get("metadata")
if isinstance(agent_metadata, dict) and "lc_agent_name" in agent_metadata:
span.set_data(
SPANDATA.GEN_AI_AGENT_NAME, agent_metadata["lc_agent_name"]
)

Comment thread
alexander-alderman-webb marked this conversation as resolved.
for key, attribute in DATA_FIELDS.items():
if key in all_params and all_params[key] is not None:
Expand Down Expand Up @@ -665,9 +628,11 @@
if tool_description is not None:
span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_description)

agent_name = _get_current_agent()
if agent_name:
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name)
agent_metadata = kwargs.get("metadata")
if isinstance(agent_metadata, dict) and "lc_agent_name" in agent_metadata:
span.set_data(
SPANDATA.GEN_AI_AGENT_NAME, agent_metadata["lc_agent_name"]
)

if should_send_default_pii() and self.include_prompts:
set_data_normalized(
Expand Down Expand Up @@ -793,9 +758,7 @@
span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens)


def _get_request_data(
obj: "Any", args: "Any", kwargs: "Any"
) -> "tuple[Optional[str], Optional[List[Any]]]":
def _get_available_tools(obj: "Any") -> "Optional[List[Any]]":
"""
Get the agent name and available tools for the agent.
"""
Expand All @@ -810,16 +773,23 @@
)
tools = tools if tools and len(tools) > 0 else None

return tools


def _get_run_name(obj: "Any", args: "Any") -> "Optional[str]":
agent = getattr(obj, "agent", None)
runnable = getattr(agent, "runnable", None)
runnable_config = getattr(runnable, "config", {})
try:
agent_name = None
if len(args) > 1:
agent_name = args[1].get("run_name")
if agent_name is None:
agent_name = runnable_config.get("run_name")
except Exception:
pass

return (agent_name, tools)
return agent_name

Check failure on line 792 in sentry_sdk/integrations/langchain.py

View check run for this annotation

@sentry/warden / warden: code-review

UnboundLocalError when exception occurs in _get_run_name

In `_get_run_name`, the variable `agent_name` is only defined inside the `try` block (line 784). If an exception occurs before the assignment (e.g., `args` doesn't support `len()` or indexing), the `except` block swallows the exception, but the `return agent_name` on line 792 will raise `UnboundLocalError: local variable 'agent_name' referenced before assignment`. This will cause the langchain integration to fail when invoking agents with unexpected argument types.


def _simplify_langchain_tools(tools: "Any") -> "Optional[List[Any]]":
Expand Down Expand Up @@ -987,58 +957,53 @@
if integration is None:
return f(self, *args, **kwargs)

agent_name, tools = _get_request_data(self, args, kwargs)
start_span_function = get_start_span_function()

run_name = _get_run_name(self, args)
with start_span_function(
op=OP.GEN_AI_INVOKE_AGENT,
name=f"invoke_agent {agent_name}" if agent_name else "invoke_agent",
name=f"invoke_agent {run_name}" if run_name else "invoke_agent",
origin=LangchainIntegration.origin,
) as span:
_push_agent(agent_name)
try:
if agent_name:
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name)
if run_name:
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, run_name)

Check warning on line 968 in sentry_sdk/integrations/langchain.py

View check run for this annotation

@sentry/warden / warden: find-bugs

new_invoke ignores lc_agent_name metadata unlike new_stream

The `new_invoke` function does not check `kwargs.get('metadata', {}).get('lc_agent_name')` like `new_stream` does. When users pass `metadata={'lc_agent_name': 'MyAgent'}` to `invoke()`, the agent name from metadata is ignored and only `run_name` (from args) is considered. This contradicts the PR's stated goal of setting agent name as `gen_ai.agent.name` and creates inconsistent behavior between `invoke()` and `stream()` methods.
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated

span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, False)
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, False)

_set_tools_on_span(span, tools)
tools = _get_available_tools(self)
_set_tools_on_span(span, tools)

# Run the agent
result = f(self, *args, **kwargs)
# Run the agent
result = f(self, *args, **kwargs)

input = result.get("input")
if (
input is not None
and should_send_default_pii()
and integration.include_prompts
):
normalized_messages = normalize_message_roles([input])
scope = sentry_sdk.get_current_scope()
messages_data = truncate_and_annotate_messages(
normalized_messages, span, scope
input = result.get("input")
if (
input is not None
and should_send_default_pii()
and integration.include_prompts
):
normalized_messages = normalize_message_roles([input])
scope = sentry_sdk.get_current_scope()
messages_data = truncate_and_annotate_messages(
normalized_messages, span, scope
)
if messages_data is not None:
set_data_normalized(
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)
if messages_data is not None:
set_data_normalized(
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
Comment thread
alexander-alderman-webb marked this conversation as resolved.
unpack=False,
)

output = result.get("output")
if (
output is not None
and should_send_default_pii()
and integration.include_prompts
):
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output)
output = result.get("output")
if (
output is not None
and should_send_default_pii()
and integration.include_prompts
):
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output)

return result
finally:
# Ensure agent is popped even if an exception occurs
_pop_agent()
return result

return new_invoke

Expand All @@ -1050,24 +1015,31 @@
if integration is None:
return f(self, *args, **kwargs)

agent_name, tools = _get_request_data(self, args, kwargs)
start_span_function = get_start_span_function()

agent_name = kwargs.get("metadata", {}).get("lc_agent_name")
run_name = _get_run_name(self, args)

span_name = "invoke_agent"
if agent_name is not None:
span_name = f"invoke_agent {agent_name}"
elif run_name:
span_name = f"invoke_agent {run_name}"

span = start_span_function(
op=OP.GEN_AI_INVOKE_AGENT,
name=f"invoke_agent {agent_name}" if agent_name else "invoke_agent",
name=span_name,
origin=LangchainIntegration.origin,
)
span.__enter__()

_push_agent(agent_name)

if agent_name:
if agent_name is not None:
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name)

Check warning on line 1037 in sentry_sdk/integrations/langchain.py

View check run for this annotation

@sentry/warden / warden: code-review

GEN_AI_AGENT_NAME not set when using run_name as fallback in new_stream

In `new_stream`, when `agent_name` is None but `run_name` is available, the span name includes `run_name` (line 1027) but `GEN_AI_AGENT_NAME` is never set (line 1036 only checks `agent_name`). This is inconsistent with `new_invoke` which sets `GEN_AI_AGENT_NAME` whenever `run_name` is available (line 967-968). This causes telemetry data to be incomplete when streaming with a run_name but no explicit agent_name.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated

span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True)

tools = _get_available_tools(self)
_set_tools_on_span(span, tools)

input = args[0].get("input") if len(args) >= 1 else None
Expand Down Expand Up @@ -1117,7 +1089,6 @@
raise
finally:
# Ensure cleanup happens even if iterator is abandoned or fails
_pop_agent()
span.__exit__(*exc_info)

async def new_iterator_async() -> "AsyncIterator[Any]":
Expand All @@ -1143,7 +1114,6 @@
raise
finally:
# Ensure cleanup happens even if iterator is abandoned or fails
_pop_agent()
span.__exit__(*exc_info)

if str(type(result)) == "<class 'async_generator'>":
Expand Down
6 changes: 6 additions & 0 deletions tests/integrations/langchain/test_langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ def test_langchain_create_agent(
assert chat_spans[0]["origin"] == "auto.ai.langchain"

assert chat_spans[0]["data"]["gen_ai.system"] == "openai-chat"
assert chat_spans[0]["data"]["gen_ai.agent.name"] == "word_length_agent"

assert chat_spans[0]["data"]["gen_ai.usage.input_tokens"] == 10
assert chat_spans[0]["data"]["gen_ai.usage.output_tokens"] == 20
assert chat_spans[0]["data"]["gen_ai.usage.total_tokens"] == 30
Expand Down Expand Up @@ -415,6 +417,10 @@ def test_tool_execution_span(
assert chat_spans[1]["origin"] == "auto.ai.langchain"
assert tool_exec_span["origin"] == "auto.ai.langchain"

assert chat_spans[0]["data"]["gen_ai.agent.name"] == "word_length_agent"
assert chat_spans[1]["data"]["gen_ai.agent.name"] == "word_length_agent"
assert tool_exec_span["data"]["gen_ai.agent.name"] == "word_length_agent"

assert chat_spans[0]["data"]["gen_ai.usage.input_tokens"] == 142
assert chat_spans[0]["data"]["gen_ai.usage.output_tokens"] == 50
assert chat_spans[0]["data"]["gen_ai.usage.total_tokens"] == 192
Expand Down
Loading