Skip to content

Commit 3bbd0e4

Browse files
feat(gen_ai): add function set_conversation_id and managing functions on the Scope and apply it on the Span on .finish() (#5362)
#### Issues Closes https://linear.app/getsentry/issue/TET-1736/python-sdk-add-gen-aiconversationid-to-the-integrations-where-it-is
1 parent 68daea3 commit 3bbd0e4

6 files changed

Lines changed: 263 additions & 0 deletions

File tree

sentry_sdk/ai/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING,
55
normalize_message_role,
66
normalize_message_roles,
7+
set_conversation_id,
78
) # noqa: F401

sentry_sdk/ai/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,3 +697,11 @@ def truncate_and_annotate_messages(
697697
scope._gen_ai_original_message_count[span.span_id] = len(messages)
698698

699699
return truncated_messages
700+
701+
702+
def set_conversation_id(conversation_id: str) -> None:
703+
"""
704+
Set the conversation_id in the scope.
705+
"""
706+
scope = sentry_sdk.get_current_scope()
707+
scope.set_conversation_id(conversation_id)

sentry_sdk/scope.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ class Scope:
221221
"_breadcrumbs",
222222
"_n_breadcrumbs_truncated",
223223
"_gen_ai_original_message_count",
224+
"_gen_ai_conversation_id",
224225
"_event_processors",
225226
"_error_processors",
226227
"_should_capture",
@@ -303,6 +304,8 @@ def __copy__(self) -> "Scope":
303304

304305
rv._attributes = self._attributes.copy()
305306

307+
rv._gen_ai_conversation_id = self._gen_ai_conversation_id
308+
306309
return rv
307310

308311
@classmethod
@@ -720,6 +723,8 @@ def clear(self) -> None:
720723

721724
self._attributes: "Attributes" = {}
722725

726+
self._gen_ai_conversation_id: "Optional[str]" = None
727+
723728
@_attr_setter
724729
def level(self, value: "LogLevelStr") -> None:
725730
"""
@@ -912,6 +917,26 @@ def remove_extra(
912917
"""Removes a specific extra key."""
913918
self._extras.pop(key, None)
914919

920+
def set_conversation_id(self, conversation_id: str) -> None:
921+
"""
922+
Sets the conversation ID for gen_ai spans.
923+
924+
:param conversation_id: The conversation ID to set.
925+
"""
926+
self._gen_ai_conversation_id = conversation_id
927+
928+
def get_conversation_id(self) -> "Optional[str]":
929+
"""
930+
Gets the conversation ID for gen_ai spans.
931+
932+
:returns: The conversation ID, or None if not set.
933+
"""
934+
return self._gen_ai_conversation_id
935+
936+
def remove_conversation_id(self) -> None:
937+
"""Removes the conversation ID."""
938+
self._gen_ai_conversation_id = None
939+
915940
def clear_breadcrumbs(self) -> None:
916941
"""Clears breadcrumb buffer."""
917942
self._breadcrumbs: "Deque[Breadcrumb]" = deque()
@@ -1668,6 +1693,8 @@ def update_from_scope(self, scope: "Scope") -> None:
16681693
self._gen_ai_original_message_count.update(
16691694
scope._gen_ai_original_message_count
16701695
)
1696+
if scope._gen_ai_conversation_id:
1697+
self._gen_ai_conversation_id = scope._gen_ai_conversation_id
16711698
if scope._span:
16721699
self._span = scope._span
16731700
if scope._attachments:

sentry_sdk/tracing.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,17 @@ def finish(
676676
self.timestamp = datetime.now(timezone.utc)
677677

678678
scope = scope or sentry_sdk.get_current_scope()
679+
680+
# Copy conversation_id from scope to span data if this is an AI span
681+
conversation_id = scope.get_conversation_id()
682+
if conversation_id:
683+
has_ai_op = SPANDATA.GEN_AI_OPERATION_NAME in self._data
684+
is_ai_span_op = self.op is not None and (
685+
self.op.startswith("ai.") or self.op.startswith("gen_ai.")
686+
)
687+
if has_ai_op or is_ai_span_op:
688+
self.set_data("gen_ai.conversation.id", conversation_id)
689+
679690
maybe_create_breadcrumbs_from_span(scope, self)
680691

681692
return None

tests/test_scope.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,3 +1021,54 @@ def test_trace_context_without_performance(sentry_init):
10211021
assert trace_context["span_id"] == propagation_context.span_id
10221022
assert trace_context["parent_span_id"] == propagation_context.parent_span_id
10231023
assert "dynamic_sampling_context" in trace_context
1024+
1025+
1026+
def test_conversation_id_set_get():
1027+
"""Test that set_conversation_id and get_conversation_id work correctly."""
1028+
scope = Scope()
1029+
assert scope.get_conversation_id() is None
1030+
1031+
scope.set_conversation_id("test-conv-123")
1032+
assert scope.get_conversation_id() == "test-conv-123"
1033+
1034+
1035+
def test_conversation_id_remove():
1036+
"""Test that remove_conversation_id clears the conversation ID."""
1037+
scope = Scope()
1038+
scope.set_conversation_id("test-conv-456")
1039+
assert scope.get_conversation_id() == "test-conv-456"
1040+
1041+
scope.remove_conversation_id()
1042+
assert scope.get_conversation_id() is None
1043+
1044+
1045+
def test_conversation_id_overwrite():
1046+
"""Test that set_conversation_id overwrites existing value."""
1047+
scope = Scope()
1048+
scope.set_conversation_id("first-conv")
1049+
scope.set_conversation_id("second-conv")
1050+
assert scope.get_conversation_id() == "second-conv"
1051+
1052+
1053+
def test_conversation_id_copy():
1054+
"""Test that conversation_id is preserved when scope is copied."""
1055+
scope1 = Scope()
1056+
scope1.set_conversation_id("copy-test-conv")
1057+
1058+
scope2 = copy.copy(scope1)
1059+
assert scope2.get_conversation_id() == "copy-test-conv"
1060+
1061+
# Modifying copy should not affect original
1062+
scope2.set_conversation_id("modified-conv")
1063+
assert scope1.get_conversation_id() == "copy-test-conv"
1064+
assert scope2.get_conversation_id() == "modified-conv"
1065+
1066+
1067+
def test_conversation_id_clear():
1068+
"""Test that conversation_id is cleared when scope.clear() is called."""
1069+
scope = Scope()
1070+
scope.set_conversation_id("clear-test-conv")
1071+
assert scope.get_conversation_id() == "clear-test-conv"
1072+
1073+
scope.clear()
1074+
assert scope.get_conversation_id() is None

tests/tracing/test_misc.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,168 @@ def test_update_current_span(sentry_init, capture_events):
605605
"thread.id": mock.ANY,
606606
"thread.name": mock.ANY,
607607
}
608+
609+
610+
class TestConversationIdPropagation:
611+
"""Tests for conversation_id propagation to AI spans."""
612+
613+
def test_conversation_id_propagates_to_span_with_gen_ai_operation_name(
614+
self, sentry_init, capture_events
615+
):
616+
"""Span with gen_ai.operation.name data should get conversation_id."""
617+
sentry_init(traces_sample_rate=1.0)
618+
events = capture_events()
619+
620+
scope = sentry_sdk.get_current_scope()
621+
scope.set_conversation_id("conv-op-name-test")
622+
623+
with sentry_sdk.start_transaction(name="test-tx"):
624+
with start_span(op="http.client") as span:
625+
span.set_data("gen_ai.operation.name", "chat")
626+
627+
(event,) = events
628+
span_data = event["spans"][0]["data"]
629+
assert span_data.get("gen_ai.conversation.id") == "conv-op-name-test"
630+
631+
def test_conversation_id_propagates_to_span_with_ai_op(
632+
self, sentry_init, capture_events
633+
):
634+
"""Span with ai.* op should get conversation_id."""
635+
sentry_init(traces_sample_rate=1.0)
636+
events = capture_events()
637+
638+
scope = sentry_sdk.get_current_scope()
639+
scope.set_conversation_id("conv-ai-op-test")
640+
641+
with sentry_sdk.start_transaction(name="test-tx"):
642+
with start_span(op="ai.chat.completions"):
643+
pass
644+
645+
(event,) = events
646+
span_data = event["spans"][0]["data"]
647+
assert span_data.get("gen_ai.conversation.id") == "conv-ai-op-test"
648+
649+
def test_conversation_id_propagates_to_span_with_gen_ai_op(
650+
self, sentry_init, capture_events
651+
):
652+
"""Span with gen_ai.* op should get conversation_id."""
653+
sentry_init(traces_sample_rate=1.0)
654+
events = capture_events()
655+
656+
scope = sentry_sdk.get_current_scope()
657+
scope.set_conversation_id("conv-gen-ai-op-test")
658+
659+
with sentry_sdk.start_transaction(name="test-tx"):
660+
with start_span(op="gen_ai.invoke_agent"):
661+
pass
662+
663+
(event,) = events
664+
span_data = event["spans"][0]["data"]
665+
assert span_data.get("gen_ai.conversation.id") == "conv-gen-ai-op-test"
666+
667+
def test_conversation_id_not_propagated_to_non_ai_span(
668+
self, sentry_init, capture_events
669+
):
670+
"""Non-AI span should NOT get conversation_id."""
671+
sentry_init(traces_sample_rate=1.0)
672+
events = capture_events()
673+
674+
scope = sentry_sdk.get_current_scope()
675+
scope.set_conversation_id("conv-should-not-appear")
676+
677+
with sentry_sdk.start_transaction(name="test-tx"):
678+
with start_span(op="http.client") as span:
679+
span.set_data("some.other.data", "value")
680+
681+
(event,) = events
682+
span_data = event["spans"][0]["data"]
683+
assert "gen_ai.conversation.id" not in span_data
684+
685+
def test_conversation_id_not_propagated_when_not_set(
686+
self, sentry_init, capture_events
687+
):
688+
"""AI span should not have conversation_id if not set on scope."""
689+
sentry_init(traces_sample_rate=1.0)
690+
events = capture_events()
691+
692+
# Ensure no conversation_id is set
693+
scope = sentry_sdk.get_current_scope()
694+
scope.remove_conversation_id()
695+
696+
with sentry_sdk.start_transaction(name="test-tx"):
697+
with start_span(op="ai.chat.completions"):
698+
pass
699+
700+
(event,) = events
701+
span_data = event["spans"][0]["data"]
702+
assert "gen_ai.conversation.id" not in span_data
703+
704+
def test_conversation_id_not_propagated_to_span_without_op(
705+
self, sentry_init, capture_events
706+
):
707+
"""Span without op and without gen_ai.operation.name should NOT get conversation_id."""
708+
sentry_init(traces_sample_rate=1.0)
709+
events = capture_events()
710+
711+
scope = sentry_sdk.get_current_scope()
712+
scope.set_conversation_id("conv-no-op-test")
713+
714+
with sentry_sdk.start_transaction(name="test-tx"):
715+
with start_span(name="unnamed-span") as span:
716+
span.set_data("regular.data", "value")
717+
718+
(event,) = events
719+
span_data = event["spans"][0]["data"]
720+
assert "gen_ai.conversation.id" not in span_data
721+
722+
def test_conversation_id_propagates_with_gen_ai_operation_name_no_op(
723+
self, sentry_init, capture_events
724+
):
725+
"""Span with gen_ai.operation.name but no op should still get conversation_id."""
726+
sentry_init(traces_sample_rate=1.0)
727+
events = capture_events()
728+
729+
scope = sentry_sdk.get_current_scope()
730+
scope.set_conversation_id("conv-no-op-but-data-test")
731+
732+
with sentry_sdk.start_transaction(name="test-tx"):
733+
with start_span(name="unnamed-span") as span:
734+
span.set_data("gen_ai.operation.name", "embedding")
735+
736+
(event,) = events
737+
span_data = event["spans"][0]["data"]
738+
assert span_data.get("gen_ai.conversation.id") == "conv-no-op-but-data-test"
739+
740+
def test_conversation_id_propagates_to_transaction_with_ai_op(
741+
self, sentry_init, capture_events
742+
):
743+
"""Transaction with ai.* op should get conversation_id."""
744+
sentry_init(traces_sample_rate=1.0)
745+
events = capture_events()
746+
747+
scope = sentry_sdk.get_current_scope()
748+
scope.set_conversation_id("conv-tx-ai-op-test")
749+
750+
with sentry_sdk.start_transaction(op="ai.workflow", name="AI Workflow"):
751+
pass
752+
753+
(event,) = events
754+
trace_data = event["contexts"]["trace"]["data"]
755+
assert trace_data.get("gen_ai.conversation.id") == "conv-tx-ai-op-test"
756+
757+
def test_conversation_id_not_propagated_to_non_ai_transaction(
758+
self, sentry_init, capture_events
759+
):
760+
"""Non-AI transaction should NOT get conversation_id."""
761+
sentry_init(traces_sample_rate=1.0)
762+
events = capture_events()
763+
764+
scope = sentry_sdk.get_current_scope()
765+
scope.set_conversation_id("conv-tx-should-not-appear")
766+
767+
with sentry_sdk.start_transaction(op="http.server", name="HTTP Request"):
768+
pass
769+
770+
(event,) = events
771+
trace_data = event["contexts"]["trace"]["data"]
772+
assert "gen_ai.conversation.id" not in trace_data

0 commit comments

Comments
 (0)