Skip to content

Commit 58a9827

Browse files
feat: Add LangGraph integration (#4727)
- Add LangGraph integration - Compilation of StateGraphs results in an agent creation span (according to [OTEL semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/)) - Runtime executions are done on Pregel instances - we are wrapping their invoke & ainvoke which produces the invoke_agent spans - There's some internals that automatically switch between invoke & stream on CompiledStateGraph (which is a subclass of Pregel), which results in duplicate spans if both are instrumented. For now, only invoke is wrapped to prevent this duplication. - Agent handoffs in LangGraph are done via tools - so there is no real possibility to create handoff spans within the SDK. Looks like this will be handled in product logic instead. Closes TET-991 Closes PY-1799 --------- Co-authored-by: Anton Pirker <anton.pirker@sentry.io>
1 parent c378c2d commit 58a9827

File tree

12 files changed

+981
-1
lines changed

12 files changed

+981
-1
lines changed

.github/workflows/test-integrations-ai.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ jobs:
7474
run: |
7575
set -x # print commands that are executed
7676
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai-notiktoken"
77+
- name: Test langgraph pinned
78+
run: |
79+
set -x # print commands that are executed
80+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langgraph"
7781
- name: Test openai_agents pinned
7882
run: |
7983
set -x # print commands that are executed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ ignore_missing_imports = true
130130
module = "langchain.*"
131131
ignore_missing_imports = true
132132

133+
[[tool.mypy.overrides]]
134+
module = "langgraph.*"
135+
ignore_missing_imports = true
136+
133137
[[tool.mypy.overrides]]
134138
module = "executing.*"
135139
ignore_missing_imports = true

scripts/populate_tox/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@
157157
},
158158
"include": "<1.0",
159159
},
160+
"langgraph": {
161+
"package": "langgraph",
162+
},
160163
"launchdarkly": {
161164
"package": "launchdarkly-server-sdk",
162165
},

scripts/populate_tox/tox.jinja

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ setenv =
338338
huggingface_hub: TESTPATH=tests/integrations/huggingface_hub
339339
langchain-base: TESTPATH=tests/integrations/langchain
340340
langchain-notiktoken: TESTPATH=tests/integrations/langchain
341+
langgraph: TESTPATH=tests/integrations/langgraph
341342
launchdarkly: TESTPATH=tests/integrations/launchdarkly
342343
litestar: TESTPATH=tests/integrations/litestar
343344
loguru: TESTPATH=tests/integrations/loguru

scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"langchain-notiktoken",
7979
"openai-base",
8080
"openai-notiktoken",
81+
"langgraph",
8182
"openai_agents",
8283
"huggingface_hub",
8384
],

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,7 @@ class OP:
792792
FUNCTION_AWS = "function.aws"
793793
FUNCTION_GCP = "function.gcp"
794794
GEN_AI_CHAT = "gen_ai.chat"
795+
GEN_AI_CREATE_AGENT = "gen_ai.create_agent"
795796
GEN_AI_EMBEDDINGS = "gen_ai.embeddings"
796797
GEN_AI_EXECUTE_TOOL = "gen_ai.execute_tool"
797798
GEN_AI_HANDOFF = "gen_ai.handoff"

sentry_sdk/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def iter_default_integrations(with_auto_enabling_integrations):
9595
"sentry_sdk.integrations.huey.HueyIntegration",
9696
"sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration",
9797
"sentry_sdk.integrations.langchain.LangchainIntegration",
98+
"sentry_sdk.integrations.langgraph.LanggraphIntegration",
9899
"sentry_sdk.integrations.litestar.LitestarIntegration",
99100
"sentry_sdk.integrations.loguru.LoguruIntegration",
100101
"sentry_sdk.integrations.openai.OpenAIIntegration",
@@ -142,6 +143,7 @@ def iter_default_integrations(with_auto_enabling_integrations):
142143
"grpc": (1, 32, 0), # grpcio
143144
"huggingface_hub": (0, 22),
144145
"langchain": (0, 1, 0),
146+
"langgraph": (0, 6, 6),
145147
"launchdarkly": (9, 8, 0),
146148
"loguru": (0, 7, 0),
147149
"openai": (1, 0, 0),

sentry_sdk/integrations/langgraph.py

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
from functools import wraps
2+
from typing import Any, Callable, List, Optional
3+
4+
import sentry_sdk
5+
from sentry_sdk.ai.utils import set_data_normalized
6+
from sentry_sdk.consts import OP, SPANDATA
7+
from sentry_sdk.integrations import DidNotEnable, Integration
8+
from sentry_sdk.scope import should_send_default_pii
9+
from sentry_sdk.utils import safe_serialize
10+
11+
12+
try:
13+
from langgraph.graph import StateGraph
14+
from langgraph.pregel import Pregel
15+
except ImportError:
16+
raise DidNotEnable("langgraph not installed")
17+
18+
19+
class LanggraphIntegration(Integration):
20+
identifier = "langgraph"
21+
origin = f"auto.ai.{identifier}"
22+
23+
def __init__(self, include_prompts=True):
24+
# type: (LanggraphIntegration, bool) -> None
25+
self.include_prompts = include_prompts
26+
27+
@staticmethod
28+
def setup_once():
29+
# type: () -> None
30+
# LangGraph lets users create agents using a StateGraph or the Functional API.
31+
# StateGraphs are then compiled to a CompiledStateGraph. Both CompiledStateGraph and
32+
# the functional API execute on a Pregel instance. Pregel is the runtime for the graph
33+
# and the invocation happens on Pregel, so patching the invoke methods takes care of both.
34+
# The streaming methods are not patched, because due to some internal reasons, LangGraph
35+
# will automatically patch the streaming methods to run through invoke, and by doing this
36+
# we prevent duplicate spans for invocations.
37+
StateGraph.compile = _wrap_state_graph_compile(StateGraph.compile)
38+
if hasattr(Pregel, "invoke"):
39+
Pregel.invoke = _wrap_pregel_invoke(Pregel.invoke)
40+
if hasattr(Pregel, "ainvoke"):
41+
Pregel.ainvoke = _wrap_pregel_ainvoke(Pregel.ainvoke)
42+
43+
44+
def _get_graph_name(graph_obj):
45+
# type: (Any) -> Optional[str]
46+
for attr in ["name", "graph_name", "__name__", "_name"]:
47+
if hasattr(graph_obj, attr):
48+
name = getattr(graph_obj, attr)
49+
if name and isinstance(name, str):
50+
return name
51+
return None
52+
53+
54+
def _normalize_langgraph_message(message):
55+
# type: (Any) -> Any
56+
if not hasattr(message, "content"):
57+
return None
58+
59+
parsed = {"role": getattr(message, "type", None), "content": message.content}
60+
61+
for attr in ["name", "tool_calls", "function_call", "tool_call_id"]:
62+
if hasattr(message, attr):
63+
value = getattr(message, attr)
64+
if value is not None:
65+
parsed[attr] = value
66+
67+
return parsed
68+
69+
70+
def _parse_langgraph_messages(state):
71+
# type: (Any) -> Optional[List[Any]]
72+
if not state:
73+
return None
74+
75+
messages = None
76+
77+
if isinstance(state, dict):
78+
messages = state.get("messages")
79+
elif hasattr(state, "messages"):
80+
messages = state.messages
81+
elif hasattr(state, "get") and callable(state.get):
82+
try:
83+
messages = state.get("messages")
84+
except Exception:
85+
pass
86+
87+
if not messages or not isinstance(messages, (list, tuple)):
88+
return None
89+
90+
normalized_messages = []
91+
for message in messages:
92+
try:
93+
normalized = _normalize_langgraph_message(message)
94+
if normalized:
95+
normalized_messages.append(normalized)
96+
except Exception:
97+
continue
98+
99+
return normalized_messages if normalized_messages else None
100+
101+
102+
def _wrap_state_graph_compile(f):
103+
# type: (Callable[..., Any]) -> Callable[..., Any]
104+
@wraps(f)
105+
def new_compile(self, *args, **kwargs):
106+
# type: (Any, Any, Any) -> Any
107+
integration = sentry_sdk.get_client().get_integration(LanggraphIntegration)
108+
if integration is None:
109+
return f(self, *args, **kwargs)
110+
with sentry_sdk.start_span(
111+
op=OP.GEN_AI_CREATE_AGENT,
112+
origin=LanggraphIntegration.origin,
113+
) as span:
114+
compiled_graph = f(self, *args, **kwargs)
115+
116+
compiled_graph_name = getattr(compiled_graph, "name", None)
117+
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "create_agent")
118+
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, compiled_graph_name)
119+
120+
if compiled_graph_name:
121+
span.description = f"create_agent {compiled_graph_name}"
122+
else:
123+
span.description = "create_agent"
124+
125+
if kwargs.get("model", None) is not None:
126+
span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, kwargs.get("model"))
127+
128+
tools = None
129+
get_graph = getattr(compiled_graph, "get_graph", None)
130+
if get_graph and callable(get_graph):
131+
graph_obj = compiled_graph.get_graph()
132+
nodes = getattr(graph_obj, "nodes", None)
133+
if nodes and isinstance(nodes, dict):
134+
tools_node = nodes.get("tools")
135+
if tools_node:
136+
data = getattr(tools_node, "data", None)
137+
if data and hasattr(data, "tools_by_name"):
138+
tools = list(data.tools_by_name.keys())
139+
140+
if tools is not None:
141+
span.set_data(SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools)
142+
143+
return compiled_graph
144+
145+
return new_compile
146+
147+
148+
def _wrap_pregel_invoke(f):
149+
# type: (Callable[..., Any]) -> Callable[..., Any]
150+
151+
@wraps(f)
152+
def new_invoke(self, *args, **kwargs):
153+
# type: (Any, Any, Any) -> Any
154+
integration = sentry_sdk.get_client().get_integration(LanggraphIntegration)
155+
if integration is None:
156+
return f(self, *args, **kwargs)
157+
158+
graph_name = _get_graph_name(self)
159+
span_name = (
160+
f"invoke_agent {graph_name}".strip() if graph_name else "invoke_agent"
161+
)
162+
163+
with sentry_sdk.start_span(
164+
op=OP.GEN_AI_INVOKE_AGENT,
165+
name=span_name,
166+
origin=LanggraphIntegration.origin,
167+
) as span:
168+
if graph_name:
169+
span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name)
170+
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name)
171+
172+
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
173+
174+
# Store input messages to later compare with output
175+
input_messages = None
176+
if (
177+
len(args) > 0
178+
and should_send_default_pii()
179+
and integration.include_prompts
180+
):
181+
input_messages = _parse_langgraph_messages(args[0])
182+
if input_messages:
183+
set_data_normalized(
184+
span,
185+
SPANDATA.GEN_AI_REQUEST_MESSAGES,
186+
safe_serialize(input_messages),
187+
)
188+
189+
result = f(self, *args, **kwargs)
190+
191+
_set_response_attributes(span, input_messages, result, integration)
192+
193+
return result
194+
195+
return new_invoke
196+
197+
198+
def _wrap_pregel_ainvoke(f):
199+
# type: (Callable[..., Any]) -> Callable[..., Any]
200+
201+
@wraps(f)
202+
async def new_ainvoke(self, *args, **kwargs):
203+
# type: (Any, Any, Any) -> Any
204+
integration = sentry_sdk.get_client().get_integration(LanggraphIntegration)
205+
if integration is None:
206+
return await f(self, *args, **kwargs)
207+
208+
graph_name = _get_graph_name(self)
209+
span_name = (
210+
f"invoke_agent {graph_name}".strip() if graph_name else "invoke_agent"
211+
)
212+
213+
with sentry_sdk.start_span(
214+
op=OP.GEN_AI_INVOKE_AGENT,
215+
name=span_name,
216+
origin=LanggraphIntegration.origin,
217+
) as span:
218+
if graph_name:
219+
span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name)
220+
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name)
221+
222+
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
223+
224+
input_messages = None
225+
if (
226+
len(args) > 0
227+
and should_send_default_pii()
228+
and integration.include_prompts
229+
):
230+
input_messages = _parse_langgraph_messages(args[0])
231+
if input_messages:
232+
set_data_normalized(
233+
span,
234+
SPANDATA.GEN_AI_REQUEST_MESSAGES,
235+
safe_serialize(input_messages),
236+
)
237+
238+
result = await f(self, *args, **kwargs)
239+
240+
_set_response_attributes(span, input_messages, result, integration)
241+
242+
return result
243+
244+
return new_ainvoke
245+
246+
247+
def _get_new_messages(input_messages, output_messages):
248+
# type: (Optional[List[Any]], Optional[List[Any]]) -> Optional[List[Any]]
249+
"""Extract only the new messages added during this invocation."""
250+
if not output_messages:
251+
return None
252+
253+
if not input_messages:
254+
return output_messages
255+
256+
# only return the new messages, aka the output messages that are not in the input messages
257+
input_count = len(input_messages)
258+
new_messages = (
259+
output_messages[input_count:] if len(output_messages) > input_count else []
260+
)
261+
262+
return new_messages if new_messages else None
263+
264+
265+
def _extract_llm_response_text(messages):
266+
# type: (Optional[List[Any]]) -> Optional[str]
267+
if not messages:
268+
return None
269+
270+
for message in reversed(messages):
271+
if isinstance(message, dict):
272+
role = message.get("role")
273+
if role in ["assistant", "ai"]:
274+
content = message.get("content")
275+
if content and isinstance(content, str):
276+
return content
277+
278+
return None
279+
280+
281+
def _extract_tool_calls(messages):
282+
# type: (Optional[List[Any]]) -> Optional[List[Any]]
283+
if not messages:
284+
return None
285+
286+
tool_calls = []
287+
for message in messages:
288+
if isinstance(message, dict):
289+
msg_tool_calls = message.get("tool_calls")
290+
if msg_tool_calls and isinstance(msg_tool_calls, list):
291+
tool_calls.extend(msg_tool_calls)
292+
293+
return tool_calls if tool_calls else None
294+
295+
296+
def _set_response_attributes(span, input_messages, result, integration):
297+
# type: (Any, Optional[List[Any]], Any, LanggraphIntegration) -> None
298+
if not (should_send_default_pii() and integration.include_prompts):
299+
return
300+
301+
parsed_response_messages = _parse_langgraph_messages(result)
302+
new_messages = _get_new_messages(input_messages, parsed_response_messages)
303+
304+
llm_response_text = _extract_llm_response_text(new_messages)
305+
if llm_response_text:
306+
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, llm_response_text)
307+
elif new_messages:
308+
set_data_normalized(
309+
span, SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(new_messages)
310+
)
311+
else:
312+
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(result))
313+
314+
tool_calls = _extract_tool_calls(new_messages)
315+
if tool_calls:
316+
set_data_normalized(
317+
span,
318+
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
319+
safe_serialize(tool_calls),
320+
unpack=False,
321+
)

0 commit comments

Comments
 (0)