diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 09985d9301..132760625f 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -114,9 +114,7 @@ def _flush(self) -> None: envelopes = [] for trace_id, spans in self._span_buffer.items(): if spans: - # TODO[span-first] - # dsc = spans[0].dynamic_sampling_context() - dsc = None + dsc = spans[0]._dynamic_sampling_context() # Max per envelope is 1000, so if we happen to have more than # 1000 spans in one bucket, we'll need to separate them. diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 6c5073ab31..cb4c858c0f 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -12,6 +12,8 @@ import sentry_sdk from sentry_sdk.utils import logger +from sentry_sdk.traces import StreamedSpan +from sentry_sdk.tracing_utils import has_span_streaming_enabled MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB # Maximum characters when only a single message is left after bytes truncation @@ -523,7 +525,14 @@ def normalize_message_roles(messages: "list[dict[str, Any]]") -> "list[dict[str, def get_start_span_function() -> "Callable[..., Any]": + if has_span_streaming_enabled(sentry_sdk.get_client().options): + return sentry_sdk.traces.start_span + current_span = sentry_sdk.get_current_span() + if isinstance(current_span, StreamedSpan): + # mypy + return sentry_sdk.traces.start_span + transaction_exists = ( current_span is not None and current_span.containing_transaction is not None ) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index bea22d8be7..a9936230ad 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -6,6 +6,7 @@ from sentry_sdk._init_implementation import init from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import NoOpSpan, Transaction, trace from sentry_sdk.crons import monitor @@ -37,6 +38,7 @@ LogLevelStr, SamplingContext, ) + from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import Span, TransactionKwargs T = TypeVar("T") @@ -409,7 +411,9 @@ def set_measurement(name: str, value: float, unit: "MeasurementUnit" = "") -> No transaction.set_measurement(name, value, unit) -def get_current_span(scope: "Optional[Scope]" = None) -> "Optional[Span]": +def get_current_span( + scope: "Optional[Scope]" = None, +) -> "Optional[Union[Span, StreamedSpan]]": """ Returns the currently active span if there is one running, otherwise `None` """ @@ -525,6 +529,16 @@ def update_current_span( if current_span is None: return + if isinstance(current_span, StreamedSpan): + warnings.warn( + "The `update_current_span` API isn't available in streaming mode. " + "Retrieve the current span with get_current_span() and use its API " + "directly.", + DeprecationWarning, + stacklevel=2, + ) + return + if op is not None: current_span.op = op diff --git a/sentry_sdk/feature_flags.py b/sentry_sdk/feature_flags.py index c9f3f303f9..de0cefbcad 100644 --- a/sentry_sdk/feature_flags.py +++ b/sentry_sdk/feature_flags.py @@ -1,6 +1,7 @@ import copy import sentry_sdk from sentry_sdk._lru_cache import LRUCache +from sentry_sdk.tracing import Span from threading import Lock from typing import TYPE_CHECKING, Any @@ -61,5 +62,5 @@ def add_feature_flag(flag: str, result: bool) -> None: flags.set(flag, result) span = sentry_sdk.get_current_span() - if span: + if span and isinstance(span, Span): span.set_flag(f"flag.evaluation.{flag}", result) diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 0aa812cab3..37ace1a64a 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -247,6 +247,7 @@ def _set_input_data( """ Set input data for the span based on the provided keyword arguments for the anthropic message creation. """ + span.set_data(SPANDATA.GEN_AI_SYSTEM, "anthropic") span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") system_instructions: "Union[str, Iterable[TextBlockParam]]" = kwargs.get("system") # type: ignore messages = kwargs.get("messages") diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py index fe832f159e..b8b1691979 100644 --- a/sentry_sdk/integrations/celery/__init__.py +++ b/sentry_sdk/integrations/celery/__init__.py @@ -14,7 +14,7 @@ ) from sentry_sdk.integrations.celery.utils import _now_seconds_since_epoch from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, TransactionSource +from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span, TransactionSource from sentry_sdk.tracing_utils import Baggage from sentry_sdk.utils import ( capture_internal_exceptions, @@ -34,7 +34,6 @@ from typing import Union from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo - from sentry_sdk.tracing import Span F = TypeVar("F", bound=Callable[..., Any]) @@ -100,7 +99,10 @@ def _set_status(status: str) -> None: with capture_internal_exceptions(): scope = sentry_sdk.get_current_scope() if scope.span is not None: - scope.span.set_status(status) + if isinstance(scope.span, Span): + scope.span.set_status(status) + else: + scope.span.status = "ok" if status == "ok" else "error" def _capture_exception(task: "Any", exc_info: "ExcInfo") -> None: diff --git a/sentry_sdk/integrations/openai_agents/utils.py b/sentry_sdk/integrations/openai_agents/utils.py index 15119a393c..e496ed7fb5 100644 --- a/sentry_sdk/integrations/openai_agents/utils.py +++ b/sentry_sdk/integrations/openai_agents/utils.py @@ -11,6 +11,7 @@ from sentry_sdk.consts import SPANDATA, SPANSTATUS, OP from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import Span from sentry_sdk.tracing_utils import set_span_errored from sentry_sdk.utils import event_from_exception, safe_serialize from sentry_sdk.ai._openai_completions_api import _transform_system_instructions @@ -22,10 +23,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Union from agents import Usage, TResponseInputItem - from sentry_sdk.tracing import Span + from sentry_sdk.traces import StreamedSpan from sentry_sdk._types import TextPart try: @@ -46,8 +47,15 @@ def _capture_exception(exc: "Any") -> None: sentry_sdk.capture_event(event, hint=hint) -def _record_exception_on_span(span: "Span", error: Exception) -> "Any": +def _record_exception_on_span( + span: "Union[Span, StreamedSpan]", error: Exception +) -> "Any": set_span_errored(span) + + if not isinstance(span, Span): + # TODO[span-first]: make this work with streamedspans + return + span.set_data("span.status", "error") # Optionally capture the error details if we have them diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 103ab20712..e92c0bf7fc 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -581,8 +581,12 @@ def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": client = self.get_client() # If we have an active span, return traceparent from there - if has_tracing_enabled(client.options) and self.span is not None: - return self.span.to_traceparent() + if ( + has_tracing_enabled(client.options) + and self.span is not None + and not isinstance(self.span, NoOpStreamedSpan) + ): + return self.span._to_traceparent() # else return traceparent from the propagation context return self.get_active_propagation_context().to_traceparent() @@ -595,8 +599,12 @@ def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": client = self.get_client() # If we have an active span, return baggage from there - if has_tracing_enabled(client.options) and self.span is not None: - return self.span.to_baggage() + if ( + has_tracing_enabled(client.options) + and self.span is not None + and not isinstance(self.span, NoOpStreamedSpan) + ): + return self.span._to_baggage() # else return baggage from the propagation context return self.get_active_propagation_context().get_baggage() @@ -605,8 +613,12 @@ def get_trace_context(self) -> "Dict[str, Any]": """ Returns the Sentry "trace" context from the Propagation Context. """ - if has_tracing_enabled(self.get_client().options) and self._span is not None: - return self._span.get_trace_context() + if ( + has_tracing_enabled(self.get_client().options) + and self._span is not None + and not isinstance(self._span, NoOpStreamedSpan) + ): + return self._span._get_trace_context() # if we are tracing externally (otel), those values take precedence external_propagation_context = get_external_propagation_context() @@ -670,8 +682,12 @@ def iter_trace_propagation_headers( span = kwargs.pop("span", None) span = span or self.span - if has_tracing_enabled(client.options) and span is not None: - for header in span.iter_headers(): + if ( + has_tracing_enabled(client.options) + and span is not None + and not isinstance(span, NoOpStreamedSpan) + ): + for header in span._iter_headers(): yield header elif has_external_propagation_context(): # when we have an external_propagation_context (otlp) @@ -718,7 +734,7 @@ def clear(self) -> None: self.clear_breadcrumbs() self._should_capture: bool = True - self._span: "Optional[Span]" = None + self._span: "Optional[Union[Span, StreamedSpan]]" = None self._session: "Optional[Session]" = None self._force_auto_session_tracking: "Optional[bool]" = None @@ -772,6 +788,14 @@ def transaction(self) -> "Any": if self._span is None: return None + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.transaction is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + # there is an orphan span on the scope if self._span.containing_transaction is None: return None @@ -801,17 +825,36 @@ def transaction(self, value: "Any") -> None: "Assigning to scope.transaction directly is deprecated: use scope.set_transaction_name() instead." ) self._transaction = value - if self._span and self._span.containing_transaction: - self._span.containing_transaction.name = value + if self._span: + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.transaction is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + + if self._span.containing_transaction: + self._span.containing_transaction.name = value def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> None: """Set the transaction name and optionally the transaction source.""" self._transaction = name - - if self._span and self._span.containing_transaction: - self._span.containing_transaction.name = name - if source: - self._span.containing_transaction.source = source + if self._span: + if isinstance(self._span, NoOpStreamedSpan): + return + + elif isinstance(self._span, StreamedSpan): + self._span._segment.name = name + if source: + self._span._segment.set_attribute( + "sentry.span.source", getattr(source, "value", source) + ) + + elif self._span.containing_transaction: + self._span.containing_transaction.name = name + if source: + self._span.containing_transaction.source = source if source: self._transaction_info["source"] = source @@ -834,12 +877,12 @@ def set_user(self, value: "Optional[Dict[str, Any]]") -> None: session.update(user=value) @property - def span(self) -> "Optional[Span]": + def span(self) -> "Optional[Union[Span, StreamedSpan]]": """Get/set current tracing span or transaction.""" return self._span @span.setter - def span(self, span: "Optional[Span]") -> None: + def span(self, span: "Optional[Union[Span, StreamedSpan]]") -> None: self._span = span # XXX: this differs from the implementation in JS, there Scope.setSpan # does not set Scope._transactionName. @@ -1148,6 +1191,15 @@ def start_span( be removed in the next major version. Going forward, it should only be used by the SDK itself. """ + client = sentry_sdk.get_client() + if has_span_streaming_enabled(client.options): + warnings.warn( + "Scope.start_span is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return NoOpSpan() + if kwargs.get("description") is not None: warnings.warn( "The `description` parameter is deprecated. Please use `name` instead.", @@ -1167,6 +1219,9 @@ def start_span( # get current span or transaction span = self.span or self.get_isolation_scope().span + if isinstance(span, StreamedSpan): + # make mypy happy + return NoOpSpan() if span is None: # New spans get the `trace_id` from the scope diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index adf77a4ca8..e10f2ca2a7 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -26,13 +26,17 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union + from typing import Any, Callable, Iterator, Optional, ParamSpec, TypeVar, Union from sentry_sdk._types import Attributes, AttributeValue P = ParamSpec("P") R = TypeVar("R") +BAGGAGE_HEADER_NAME = "baggage" +SENTRY_TRACE_HEADER_NAME = "sentry-trace" + + class SpanStatus(str, Enum): OK = "ok" ERROR = "error" @@ -328,7 +332,7 @@ def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> No def _start(self) -> None: if self._active: old_span = self._scope.span - self._scope.span = self # type: ignore + self._scope.span = self self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: @@ -463,6 +467,67 @@ def _update_active_thread(self) -> None: if thread_name is not None: self.set_attribute(SPANDATA.THREAD_NAME, thread_name) + def _dynamic_sampling_context(self) -> "dict[str, str]": + return self._segment._get_baggage().dynamic_sampling_context() + + def _to_traceparent(self) -> str: + if self.sampled is True: + sampled = "1" + elif self.sampled is False: + sampled = "0" + else: + sampled = None + + traceparent = "%s-%s" % (self.trace_id, self.span_id) + if sampled is not None: + traceparent += "-%s" % (sampled,) + + return traceparent + + def _to_baggage(self) -> "Optional[Baggage]": + if self._segment: + return self._segment._get_baggage() + return None + + def _get_baggage(self) -> "Baggage": + """ + Return the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with + the segment. + + The first time a new baggage with Sentry items is made, it will be frozen. + """ + if not self._baggage or self._baggage.mutable: + self._baggage = Baggage.populate_from_segment(self) + + return self._baggage + + def _iter_headers(self) -> "Iterator[tuple[str, str]]": + if not self._segment: + return + + yield SENTRY_TRACE_HEADER_NAME, self._to_traceparent() + + baggage = self._segment._get_baggage().serialize() + if baggage: + yield BAGGAGE_HEADER_NAME, baggage + + def _get_trace_context(self) -> "dict[str, Any]": + # Even if spans themselves are not event-based anymore, we need this + # to populate trace context on events + context: "dict[str, Any]" = { + "trace_id": self.trace_id, + "span_id": self.span_id, + "parent_span_id": self._parent_span_id, + "dynamic_sampling_context": self._dynamic_sampling_context(), + } + + if "sentry.op" in self._attributes: + context["op"] = self._attributes["sentry.op"] + if "sentry.origin" in self._attributes: + context["origin"] = self._attributes["sentry.origin"] + + return context + class NoOpStreamedSpan(StreamedSpan): __slots__ = ( @@ -498,7 +563,7 @@ def _start(self) -> None: return old_span = self._scope.span - self._scope.span = self # type: ignore + self._scope.span = self self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 13aed0d9d8..7f2baba0c9 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -770,6 +770,12 @@ def update_active_thread(self) -> None: thread_id, thread_name = get_current_thread_meta() self.set_thread(thread_id, thread_name) + # Private aliases matching StreamedSpan's private API + _to_traceparent = to_traceparent + _to_baggage = to_baggage + _iter_headers = iter_headers + _get_trace_context = get_trace_context + class Transaction(Span): """The Transaction is the root element that holds all the spans @@ -1242,6 +1248,10 @@ def _set_initial_sampling_decision( ) ) + # Private aliases matching StreamedSpan's private API + _get_baggage = get_baggage + _get_trace_context = get_trace_context + class NoOpSpan(Span): def __repr__(self) -> str: @@ -1323,6 +1333,13 @@ def _set_initial_sampling_decision( ) -> None: pass + # Private aliases matching StreamedSpan's private API + _to_traceparent = to_traceparent + _to_baggage = to_baggage + _get_baggage = get_baggage + _iter_headers = iter_headers + _get_trace_context = get_trace_context + if TYPE_CHECKING: diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index d8d1d3d712..9efb01000c 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -767,6 +767,55 @@ def populate_from_transaction( return Baggage(sentry_items, mutable=False) + @classmethod + def populate_from_segment(cls, segment: "StreamedSpan") -> "Baggage": + """ + Populate fresh baggage entry with sentry_items and make it immutable + if this is the head SDK which originates traces. + """ + client = sentry_sdk.get_client() + sentry_items: "Dict[str, str]" = {} + + if not client.is_active(): + return Baggage(sentry_items) + + options = client.options or {} + + sentry_items["trace_id"] = segment.trace_id + sentry_items["sample_rand"] = f"{segment._sample_rand:.6f}" # noqa: E231 + + if options.get("environment"): + sentry_items["environment"] = options["environment"] + + if options.get("release"): + sentry_items["release"] = options["release"] + + if client.parsed_dsn: + sentry_items["public_key"] = client.parsed_dsn.public_key + if client.parsed_dsn.org_id: + sentry_items["org_id"] = client.parsed_dsn.org_id + + if ( + segment.get_attributes().get("sentry.span.source") + not in LOW_QUALITY_SEGMENT_SOURCES + ) and segment._name: + sentry_items["transaction"] = segment._name + + if segment._sample_rate is not None: + sentry_items["sample_rate"] = str(segment._sample_rate) + + if segment.sampled is not None: + sentry_items["sampled"] = "true" if segment.sampled else "false" + + # There's an existing baggage but it was mutable, which is why we are + # creating this new baggage. + # However, if by chance the user put some sentry items in there, give + # them precedence. + if segment._baggage and segment._baggage.sentry_items: + sentry_items.update(segment._baggage.sentry_items) + + return Baggage(sentry_items, mutable=False) + def freeze(self) -> None: self.mutable = False @@ -890,6 +939,14 @@ async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": ) return await f(*args, **kwargs) + if isinstance(current_span, StreamedSpan): + warnings.warn( + "Use the @sentry_sdk.traces.trace decorator in span streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return await f(*args, **kwargs) + span_op = op or _get_span_op(template) function_name = name or qualname_from_function(f) or "" span_name = _get_span_name(template, function_name, kwargs) @@ -927,6 +984,14 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": ) return f(*args, **kwargs) + if isinstance(current_span, StreamedSpan): + warnings.warn( + "Use the @sentry_sdk.traces.trace decorator in span streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return f(*args, **kwargs) + span_op = op or _get_span_op(template) function_name = name or qualname_from_function(f) or "" span_name = _get_span_name(template, function_name, kwargs) @@ -1027,7 +1092,9 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": return span_decorator -def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Span]": +def get_current_span( + scope: "Optional[sentry_sdk.Scope]" = None, +) -> "Optional[Union[Span, StreamedSpan]]": """ Returns the currently active span if there is one running, otherwise `None` """ @@ -1036,16 +1103,24 @@ def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Sp return current_span -def set_span_errored(span: "Optional[Span]" = None) -> None: +def set_span_errored(span: "Optional[Union[Span, StreamedSpan]]" = None) -> None: """ Set the status of the current or given span to INTERNAL_ERROR. Also sets the status of the transaction (root span) to INTERNAL_ERROR. """ + from sentry_sdk.traces import StreamedSpan, SpanStatus + span = span or get_current_span() + if span is not None: - span.set_status(SPANSTATUS.INTERNAL_ERROR) - if span.containing_transaction is not None: - span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + if isinstance(span, Span): + span.set_status(SPANSTATUS.INTERNAL_ERROR) + if span.containing_transaction is not None: + span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + elif isinstance(span, StreamedSpan): + span.status = SpanStatus.ERROR + if span._segment is not None: + span._segment.status = SpanStatus.ERROR def _generate_sample_rand( @@ -1534,8 +1609,11 @@ def _matches(rule: "Any", value: "Any") -> bool: BAGGAGE_HEADER_NAME, LOW_QUALITY_TRANSACTION_SOURCES, SENTRY_TRACE_HEADER_NAME, + Span, ) from sentry_sdk.traces import ( + LOW_QUALITY_SEGMENT_SOURCES, + StreamedSpan, start_span as start_streaming_span, ) diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py index ea48f5d4db..1c9ddfa3c7 100644 --- a/tests/integrations/anthropic/test_anthropic.py +++ b/tests/integrations/anthropic/test_anthropic.py @@ -117,6 +117,7 @@ def test_nonstreaming_create_message( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -187,6 +188,7 @@ async def test_nonstreaming_create_message_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -288,6 +290,7 @@ def test_streaming_create_message( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -393,6 +396,7 @@ async def test_streaming_create_message_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -525,6 +529,7 @@ def test_streaming_create_message_with_input_json_delta( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -666,6 +671,7 @@ async def test_streaming_create_message_with_input_json_delta_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -730,6 +736,7 @@ def test_span_status_error(sentry_init, capture_events): assert transaction["spans"][0]["status"] == "internal_error" assert transaction["spans"][0]["tags"]["status"] == "internal_error" assert transaction["contexts"]["trace"]["status"] == "internal_error" + assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -755,6 +762,7 @@ async def test_span_status_error_async(sentry_init, capture_events): assert transaction["spans"][0]["status"] == "internal_error" assert transaction["spans"][0]["tags"]["status"] == "internal_error" assert transaction["contexts"]["trace"]["status"] == "internal_error" + assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -803,6 +811,7 @@ def test_span_origin(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.ai.anthropic" + assert event["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert event["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -831,6 +840,7 @@ async def test_span_origin_async(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.ai.anthropic" + assert event["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert event["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -951,6 +961,7 @@ def mock_messages_create(*args, **kwargs): # Verify that the span was created correctly assert span["op"] == "gen_ai.chat" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"] @@ -996,6 +1007,7 @@ def test_anthropic_message_truncation(sentry_init, capture_events): assert len(chat_spans) > 0 chat_span = chat_spans[0] + assert chat_span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"] @@ -1047,6 +1059,7 @@ async def test_anthropic_message_truncation_async(sentry_init, capture_events): assert len(chat_spans) > 0 chat_span = chat_spans[0] + assert chat_span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"] @@ -1115,6 +1128,7 @@ def test_nonstreaming_create_message_with_system_prompt( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1199,6 +1213,7 @@ async def test_nonstreaming_create_message_with_system_prompt_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1315,6 +1330,7 @@ def test_streaming_create_message_with_system_prompt( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1435,6 +1451,7 @@ async def test_streaming_create_message_with_system_prompt_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1501,6 +1518,7 @@ def test_system_prompt_with_complex_structure(sentry_init, capture_events): assert len(event["spans"]) == 1 (span,) = event["spans"] + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS in span["data"] diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index eb9446c1ee..68b7ff7e6f 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -678,6 +678,67 @@ def test_continue_trace_no_sample_rand(sentry_init, capture_envelopes): assert segment["trace_id"] == trace_id +def test_outgoing_traceparent_and_baggage(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + sentry_sdk.traces.new_trace() + + with sentry_sdk.traces.start_span(name="span") as span: + assert span.sampled is True + + trace_id = span.trace_id + span_id = span.span_id + + traceparent = sentry_sdk.get_traceparent() + assert traceparent == f"{trace_id}-{span_id}-1" + + baggage = sentry_sdk.get_baggage() + baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) + assert "sentry-trace_id" in baggage_items + assert baggage_items["sentry-trace_id"] == trace_id + assert "sentry-sampled" in baggage_items + assert baggage_items["sentry-sampled"] == "true" + + +def test_outgoing_traceparent_and_baggage_when_noop_span_is_active( + sentry_init, capture_envelopes +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + sentry_sdk.traces.new_trace() + + propagation_context = ( + sentry_sdk.get_current_scope().get_active_propagation_context() + ) + propagation_trace_id = propagation_context.trace_id + propagation_span_id = propagation_context.span_id + + with sentry_sdk.traces.start_span(name="ignored") as span: + assert span.sampled is False + + noop_trace_id = span.trace_id + noop_span_id = span.span_id + + traceparent = sentry_sdk.get_traceparent() + assert traceparent != f"{noop_trace_id}-{noop_span_id}" + assert traceparent == f"{propagation_trace_id}-{propagation_span_id}" + + baggage = sentry_sdk.get_baggage() + baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) + assert "sentry-trace_id" in baggage_items + assert baggage_items["sentry-trace_id"] != noop_trace_id + assert baggage_items["sentry-trace_id"] == propagation_trace_id + + def test_trace_decorator(sentry_init, capture_envelopes): sentry_init( traces_sample_rate=1.0,