Skip to content

Commit 7cf7373

Browse files
authored
Fix langchain integration (#3921)
* Add optional `parent_span` argument to `POTelSpan` constructor and fix `start_child` * `run_id` is reused for the top level pipeline, so make sure to close that span or else we get orphans * Don't use context manager enter/exit since we're doing manual span management * Set correct statuses while finishing the spans
1 parent ab5d8a7 commit 7cf7373

File tree

6 files changed

+53
-33
lines changed

6 files changed

+53
-33
lines changed

sentry_sdk/ai/monitoring.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import sentry_sdk.utils
55
from sentry_sdk import start_span
6-
from sentry_sdk.tracing import Span
6+
from sentry_sdk.tracing import POTelSpan as Span
77
from sentry_sdk.utils import ContextVar
88

99
from typing import TYPE_CHECKING

sentry_sdk/ai/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
if TYPE_CHECKING:
44
from typing import Any
55

6-
from sentry_sdk.tracing import Span
6+
from sentry_sdk.tracing import POTelSpan as Span
77
from sentry_sdk.utils import logger
88

99

sentry_sdk/integrations/langchain.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33

44
import sentry_sdk
55
from sentry_sdk.ai.monitoring import set_ai_pipeline_name, record_token_usage
6-
from sentry_sdk.consts import OP, SPANDATA
6+
from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS
77
from sentry_sdk.ai.utils import set_data_normalized
88
from sentry_sdk.scope import should_send_default_pii
9-
from sentry_sdk.tracing import Span
9+
from sentry_sdk.tracing import POTelSpan as Span
1010
from sentry_sdk.integrations import DidNotEnable, Integration
1111
from sentry_sdk.utils import logger, capture_internal_exceptions
1212

@@ -72,7 +72,6 @@ def setup_once():
7272

7373

7474
class WatchedSpan:
75-
span = None # type: Span
7675
num_completion_tokens = 0 # type: int
7776
num_prompt_tokens = 0 # type: int
7877
no_collect_tokens = False # type: bool
@@ -123,8 +122,9 @@ def _handle_error(self, run_id, error):
123122
span_data = self.span_map[run_id]
124123
if not span_data:
125124
return
126-
sentry_sdk.capture_exception(error, span_data.span.scope)
127-
span_data.span.__exit__(None, None, None)
125+
sentry_sdk.capture_exception(error)
126+
span_data.span.set_status(SPANSTATUS.INTERNAL_ERROR)
127+
span_data.span.finish()
128128
del self.span_map[run_id]
129129

130130
def _normalize_langchain_message(self, message):
@@ -136,23 +136,27 @@ def _normalize_langchain_message(self, message):
136136
def _create_span(self, run_id, parent_id, **kwargs):
137137
# type: (SentryLangchainCallback, UUID, Optional[Any], Any) -> WatchedSpan
138138

139-
watched_span = None # type: Optional[WatchedSpan]
140-
if parent_id:
141-
parent_span = self.span_map.get(parent_id) # type: Optional[WatchedSpan]
142-
if parent_span:
143-
watched_span = WatchedSpan(parent_span.span.start_child(**kwargs))
144-
parent_span.children.append(watched_span)
145-
if watched_span is None:
146-
watched_span = WatchedSpan(
147-
sentry_sdk.start_span(only_if_parent=True, **kwargs)
148-
)
139+
parent_watched_span = self.span_map.get(parent_id) if parent_id else None
140+
sentry_span = sentry_sdk.start_span(
141+
parent_span=parent_watched_span.span if parent_watched_span else None,
142+
only_if_parent=True,
143+
**kwargs,
144+
)
145+
watched_span = WatchedSpan(sentry_span)
146+
if parent_watched_span:
147+
parent_watched_span.children.append(watched_span)
149148

150149
if kwargs.get("op", "").startswith("ai.pipeline."):
151150
if kwargs.get("name"):
152151
set_ai_pipeline_name(kwargs.get("name"))
153152
watched_span.is_pipeline = True
154153

155-
watched_span.span.__enter__()
154+
# the same run_id is reused for the pipeline it seems
155+
# so we need to end the older span to avoid orphan spans
156+
existing_span_data = self.span_map.get(run_id)
157+
if existing_span_data is not None:
158+
self._exit_span(existing_span_data, run_id)
159+
156160
self.span_map[run_id] = watched_span
157161
self.gc_span_map()
158162
return watched_span
@@ -163,7 +167,8 @@ def _exit_span(self, span_data, run_id):
163167
if span_data.is_pipeline:
164168
set_ai_pipeline_name(None)
165169

166-
span_data.span.__exit__(None, None, None)
170+
span_data.span.set_status(SPANSTATUS.OK)
171+
span_data.span.finish()
167172
del self.span_map[run_id]
168173

169174
def on_llm_start(

sentry_sdk/integrations/opentelemetry/span_processor.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,17 @@ def _common_span_transaction_attributes_as_json(self, span):
291291
common_json["tags"] = tags
292292

293293
return common_json
294+
295+
def _log_debug_info(self):
296+
# type: () -> None
297+
import pprint
298+
299+
pprint.pprint(
300+
{
301+
format_span_id(span_id): [
302+
(format_span_id(child.context.span_id), child.name)
303+
for child in children
304+
]
305+
for span_id, children in self._children_spans.items()
306+
}
307+
)

sentry_sdk/tracing.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,7 @@ def __init__(
12131213
source=TRANSACTION_SOURCE_CUSTOM, # type: str
12141214
attributes=None, # type: OTelSpanAttributes
12151215
only_if_parent=False, # type: bool
1216+
parent_span=None, # type: Optional[POTelSpan]
12161217
otel_span=None, # type: Optional[OtelSpan]
12171218
**_, # type: dict[str, object]
12181219
):
@@ -1231,7 +1232,7 @@ def __init__(
12311232
self._otel_span = otel_span
12321233
else:
12331234
skip_span = False
1234-
if only_if_parent:
1235+
if only_if_parent and parent_span is None:
12351236
parent_span_context = get_current_span().get_span_context()
12361237
skip_span = (
12371238
not parent_span_context.is_valid or parent_span_context.is_remote
@@ -1262,8 +1263,17 @@ def __init__(
12621263
if sampled is not None:
12631264
attributes[SentrySpanAttribute.CUSTOM_SAMPLED] = sampled
12641265

1266+
parent_context = None
1267+
if parent_span is not None:
1268+
parent_context = otel_trace.set_span_in_context(
1269+
parent_span._otel_span
1270+
)
1271+
12651272
self._otel_span = tracer.start_span(
1266-
span_name, start_time=start_timestamp, attributes=attributes
1273+
span_name,
1274+
context=parent_context,
1275+
start_time=start_timestamp,
1276+
attributes=attributes,
12671277
)
12681278

12691279
self.origin = origin or DEFAULT_SPAN_ORIGIN
@@ -1506,10 +1516,7 @@ def timestamp(self):
15061516

15071517
def start_child(self, **kwargs):
15081518
# type: (**Any) -> POTelSpan
1509-
kwargs.setdefault("sampled", self.sampled)
1510-
1511-
span = POTelSpan(only_if_parent=True, **kwargs)
1512-
return span
1519+
return POTelSpan(sampled=self.sampled, parent_span=self, **kwargs)
15131520

15141521
def iter_headers(self):
15151522
# type: () -> Iterator[Tuple[str, str]]

tests/integrations/langchain/test_langchain.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,11 @@ def test_langchain_agent(
187187
assert "measurements" not in chat_spans[0]
188188

189189
if send_default_pii and include_prompts:
190-
assert (
191-
"You are very powerful"
192-
in chat_spans[0]["data"]["ai.input_messages"][0]["content"]
193-
)
190+
assert "You are very powerful" in chat_spans[0]["data"]["ai.input_messages"]
194191
assert "5" in chat_spans[0]["data"]["ai.responses"]
195192
assert "word" in tool_exec_span["data"]["ai.input_messages"]
196193
assert 5 == int(tool_exec_span["data"]["ai.responses"])
197-
assert (
198-
"You are very powerful"
199-
in chat_spans[1]["data"]["ai.input_messages"][0]["content"]
200-
)
194+
assert "You are very powerful" in chat_spans[1]["data"]["ai.input_messages"]
201195
assert "5" in chat_spans[1]["data"]["ai.responses"]
202196
else:
203197
assert "ai.input_messages" not in chat_spans[0].get("data", {})

0 commit comments

Comments
 (0)