diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index c2e071a078..7a71c3e872 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -9,6 +9,8 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh ### Changed +- The SDK now supports Python 3.7 and higher. +- `sentry_sdk.start_span` now only takes keyword arguments. - The `Span()` constructor does not accept a `hub` parameter anymore. - `Span.finish()` does not accept a `hub` parameter anymore. - The `Profile()` constructor does not accept a `hub` parameter anymore. @@ -16,15 +18,17 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh ### Removed +- When setting span status, the HTTP status code is no longer automatically added as a tag. - Class `Hub` has been removed. - Class `_ScopeManager` has been removed. - The context manager `auto_session_tracking()` has been removed. Use `track_session()` instead. - The context manager `auto_session_tracking_scope()` has been removed. Use `track_session()` instead. -- Utility function `is_auto_session_tracking_enabled()` has been removed. There is no public replacement. There is a private `_is_auto_session_tracking_enabled()` (if you absolutely need this function) It accepts a `scope` parameter instead of the previously used `hub` parameter. +- Utility function `is_auto_session_tracking_enabled()` has been removed. There is no public replacement. There is a private `_is_auto_session_tracking_enabled()` (if you absolutely need this function) It accepts a `scope` parameter instead of the previously used `hub` parameter. - Utility function `is_auto_session_tracking_enabled_scope()` has been removed. There is no public replacement. There is a private `_is_auto_session_tracking_enabled()` (if you absolutely need this function) ### Deprecated +- `sentry_sdk.start_transaction` is deprecated. Use `sentry_sdk.start_span` instead. ## Upgrading to 2.0 diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 49a3bf7ecf..0b88ea3274 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -2,10 +2,15 @@ from sentry_sdk import tracing_utils, Client from sentry_sdk._init_implementation import init -from sentry_sdk.scope import Scope, new_scope, isolation_scope -from sentry_sdk.tracing import NoOpSpan, Transaction, trace +from sentry_sdk.tracing import POTelSpan, Transaction, trace from sentry_sdk.crons import monitor +# TODO-neel-potel make 2 scope strategies/impls and switch +from sentry_sdk.integrations.opentelemetry.scope import ( + PotelScope as Scope, + new_scope, + isolation_scope, +) from sentry_sdk._types import TYPE_CHECKING @@ -227,22 +232,40 @@ def flush( return get_client().flush(timeout=timeout, callback=callback) -@scopemethod def start_span( + *, + span=None, + custom_sampling_context=None, **kwargs, # type: Any ): - # type: (...) -> Span - return get_current_scope().start_span(**kwargs) + # type: (...) -> POTelSpan + """ + Start and return a span. + + This is the entry point to manual tracing instrumentation. + + A tree structure can be built by adding child spans to the span. + To start a new child span within the span, call the `start_child()` method. + + When used as a context manager, spans are automatically finished at the end + of the `with` block. If not using context managers, call the `finish()` + method. + """ + # TODO: Consider adding type hints to the method signature. + return get_current_scope().start_span(span, custom_sampling_context, **kwargs) -@scopemethod def start_transaction( transaction=None, # type: Optional[Transaction] custom_sampling_context=None, # type: Optional[SamplingContext] **kwargs, # type: Unpack[TransactionKwargs] ): - # type: (...) -> Union[Transaction, NoOpSpan] + # type: (...) -> POTelSpan """ + .. deprecated:: 3.0.0 + This function is deprecated and will be removed in a future release. + Use :py:meth:`sentry_sdk.start_span` instead. + Start and return a transaction on the current scope. Start an existing transaction if given, otherwise create and start a new @@ -271,8 +294,10 @@ def start_transaction( constructor. See :py:class:`sentry_sdk.tracing.Transaction` for available arguments. """ - return get_current_scope().start_transaction( - transaction, custom_sampling_context, **kwargs + return start_span( + span=transaction, + custom_sampling_context=custom_sampling_context, + **kwargs, ) @@ -311,10 +336,8 @@ def get_baggage(): return None -def continue_trace( - environ_or_headers, op=None, name=None, source=None, origin="manual" -): - # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str], str) -> Transaction +def continue_trace(environ_or_headers, op=None, name=None, source=None, origin=None): + # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str], Optional[str]) -> Transaction """ Sets the propagation context from environment or headers and returns a transaction. """ diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index b952da021d..426f7c4902 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -96,9 +96,9 @@ def __init__( unsafe_context_data=False, transaction_style="endpoint", mechanism_type="asgi", - span_origin="manual", + span_origin=None, ): - # type: (Any, bool, str, str, str) -> None + # type: (Any, bool, str, str, Optional[str]) -> None """ Instrument an ASGI application with Sentry. Provides HTTP/websocket data to sent events and basic handling for exceptions bubbling up diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index 0fb997767b..3c5131e9d0 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -3,7 +3,6 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.tracing import Span from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import ( @@ -19,6 +18,8 @@ from typing import Optional from typing import Type + from sentry_sdk.tracing import Span + try: from botocore import __version__ as BOTOCORE_VERSION # type: ignore from botocore.client import BaseClient # type: ignore diff --git a/sentry_sdk/integrations/opentelemetry/consts.py b/sentry_sdk/integrations/opentelemetry/consts.py index 69a770ad53..aca364fd54 100644 --- a/sentry_sdk/integrations/opentelemetry/consts.py +++ b/sentry_sdk/integrations/opentelemetry/consts.py @@ -1,7 +1,23 @@ from opentelemetry.context import create_key +# propagation keys SENTRY_TRACE_KEY = create_key("sentry-trace") SENTRY_BAGGAGE_KEY = create_key("sentry-baggage") + +# scope management keys +SENTRY_SCOPES_KEY = create_key("sentry_scopes") +SENTRY_FORK_ISOLATION_SCOPE_KEY = create_key("sentry_fork_isolation_scope") + OTEL_SENTRY_CONTEXT = "otel" SPAN_ORIGIN = "auto.otel" + + +class SentrySpanAttribute: + # XXX not all of these need separate attributes, we might just use + # existing otel attrs for some + DESCRIPTION = "sentry.description" + OP = "sentry.op" + ORIGIN = "sentry.origin" + MEASUREMENT = "sentry.measurement" + TAG = "sentry.tag" diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py index 5e5eb9ba30..86fc253af8 100644 --- a/sentry_sdk/integrations/opentelemetry/contextvars_context.py +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -1,26 +1,32 @@ -from opentelemetry.context import Context, create_key, get_value, set_value +from opentelemetry.context import Context, get_value, set_value from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext -from sentry_sdk.scope import Scope - - -_SCOPES_KEY = create_key("sentry_scopes") +import sentry_sdk +from sentry_sdk.integrations.opentelemetry.consts import ( + SENTRY_SCOPES_KEY, + SENTRY_FORK_ISOLATION_SCOPE_KEY, +) class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): def attach(self, context): # type: (Context) -> object - scopes = get_value(_SCOPES_KEY, context) + scopes = get_value(SENTRY_SCOPES_KEY, context) + should_fork_isolation_scope = context.pop( + SENTRY_FORK_ISOLATION_SCOPE_KEY, False + ) if scopes and isinstance(scopes, tuple): (current_scope, isolation_scope) = scopes else: - current_scope = Scope.get_current_scope() - isolation_scope = Scope.get_isolation_scope() + current_scope = sentry_sdk.get_current_scope() + isolation_scope = sentry_sdk.get_isolation_scope() - # TODO-neel-potel fork isolation_scope too like JS - # once we setup our own apis to pass through to otel - new_scopes = (current_scope.fork(), isolation_scope) - new_context = set_value(_SCOPES_KEY, new_scopes, context) + new_scope = current_scope.fork() + new_isolation_scope = ( + isolation_scope.fork() if should_fork_isolation_scope else isolation_scope + ) + new_scopes = (new_scope, new_isolation_scope) + new_context = set_value(SENTRY_SCOPES_KEY, new_scopes, context) return super().attach(new_context) diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 43e0396c16..4cd969f0e0 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -6,7 +6,12 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator -from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor +from sentry_sdk.integrations.opentelemetry.potel_span_processor import ( + PotelSentrySpanProcessor, +) +from sentry_sdk.integrations.opentelemetry.contextvars_context import ( + SentryContextVarsRuntimeContext, +) from sentry_sdk.utils import logger try: @@ -46,9 +51,14 @@ def setup_once(): def _setup_sentry_tracing(): # type: () -> None + import opentelemetry.context + + opentelemetry.context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() + provider = TracerProvider() - provider.add_span_processor(SentrySpanProcessor()) + provider.add_span_processor(PotelSentrySpanProcessor()) trace.set_tracer_provider(provider) + set_global_textmap(SentryPropagator()) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index 9604676dce..8b2a2f4c36 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -5,14 +5,16 @@ from opentelemetry.sdk.trace import Span, ReadableSpan, SpanProcessor from sentry_sdk import capture_event +from sentry_sdk.tracing import DEFAULT_SPAN_ORIGIN from sentry_sdk.integrations.opentelemetry.utils import ( is_sentry_span, - convert_otel_timestamp, + convert_from_otel_timestamp, + extract_span_attributes, extract_span_data, ) from sentry_sdk.integrations.opentelemetry.consts import ( OTEL_SENTRY_CONTEXT, - SPAN_ORIGIN, + SentrySpanAttribute, ) from sentry_sdk._types import TYPE_CHECKING @@ -107,21 +109,21 @@ def _root_span_to_transaction_event(self, span): # type: (ReadableSpan) -> Optional[Event] if not span.context: return None - if not span.start_time: - return None - if not span.end_time: + + event = self._common_span_transaction_attributes_as_json(span) + if event is None: return None trace_id = format_trace_id(span.context.trace_id) span_id = format_span_id(span.context.span_id) parent_span_id = format_span_id(span.parent.span_id) if span.parent else None - (op, description, status, _) = extract_span_data(span) + (op, description, status, _, origin) = extract_span_data(span) trace_context = { "trace_id": trace_id, "span_id": span_id, - "origin": SPAN_ORIGIN, + "origin": origin or DEFAULT_SPAN_ORIGIN, "op": op, "status": status, } # type: dict[str, Any] @@ -135,15 +137,15 @@ def _root_span_to_transaction_event(self, span): if span.resource.attributes: contexts[OTEL_SENTRY_CONTEXT] = {"resource": dict(span.resource.attributes)} - event = { - "type": "transaction", - "transaction": description, - # TODO-neel-potel tx source based on integration - "transaction_info": {"source": "custom"}, - "contexts": contexts, - "start_timestamp": convert_otel_timestamp(span.start_time), - "timestamp": convert_otel_timestamp(span.end_time), - } # type: Event + event.update( + { + "type": "transaction", + "transaction": description, + # TODO-neel-potel tx source based on integration + "transaction_info": {"source": "custom"}, + "contexts": contexts, + } + ) # type: Event return event @@ -151,31 +153,52 @@ def _span_to_json(self, span): # type: (ReadableSpan) -> Optional[dict[str, Any]] if not span.context: return None - if not span.start_time: - return None - if not span.end_time: + + span_json = self._common_span_transaction_attributes_as_json(span) + if span_json is None: return None trace_id = format_trace_id(span.context.trace_id) span_id = format_span_id(span.context.span_id) parent_span_id = format_span_id(span.parent.span_id) if span.parent else None - (op, description, status, _) = extract_span_data(span) + (op, description, status, _, origin) = extract_span_data(span) - span_json = { - "trace_id": trace_id, - "span_id": span_id, - "origin": SPAN_ORIGIN, - "op": op, - "description": description, - "status": status, - "start_timestamp": convert_otel_timestamp(span.start_time), - "timestamp": convert_otel_timestamp(span.end_time), - } # type: dict[str, Any] + span_json.update( + { + "trace_id": trace_id, + "span_id": span_id, + "op": op, + "description": description, + "status": status, + "origin": origin or DEFAULT_SPAN_ORIGIN, + } + ) if parent_span_id: span_json["parent_span_id"] = parent_span_id + if span.attributes: span_json["data"] = dict(span.attributes) return span_json + + def _common_span_transaction_attributes_as_json(self, span): + # type: (ReadableSpan) -> Optional[dict[str, Any]] + if not span.start_time or not span.end_time: + return None + + common_json = { + "start_timestamp": convert_from_otel_timestamp(span.start_time), + "timestamp": convert_from_otel_timestamp(span.end_time), + } # type: dict[str, Any] + + measurements = extract_span_attributes(span, SentrySpanAttribute.MEASUREMENT) + if measurements: + common_json["measurements"] = measurements + + tags = extract_span_attributes(span, SentrySpanAttribute.TAG) + if tags: + common_json["tags"] = tags + + return common_json diff --git a/sentry_sdk/integrations/opentelemetry/scope.py b/sentry_sdk/integrations/opentelemetry/scope.py new file mode 100644 index 0000000000..6d6f8f6acf --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/scope.py @@ -0,0 +1,84 @@ +from typing import cast +from contextlib import contextmanager + +from opentelemetry.context import get_value, set_value, attach, detach, get_current + +from sentry_sdk.scope import Scope, ScopeType +from sentry_sdk.integrations.opentelemetry.consts import ( + SENTRY_SCOPES_KEY, + SENTRY_FORK_ISOLATION_SCOPE_KEY, +) + +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Tuple, Optional, Generator + + +class PotelScope(Scope): + @classmethod + def _get_scopes(cls): + # type: () -> Optional[Tuple[Scope, Scope]] + """ + Returns the current scopes tuple on the otel context. Internal use only. + """ + return cast("Optional[Tuple[Scope, Scope]]", get_value(SENTRY_SCOPES_KEY)) + + @classmethod + def get_current_scope(cls): + # type: () -> Scope + """ + Returns the current scope. + """ + return cls._get_current_scope() or _INITIAL_CURRENT_SCOPE + + @classmethod + def _get_current_scope(cls): + # type: () -> Optional[Scope] + """ + Returns the current scope without creating a new one. Internal use only. + """ + scopes = cls._get_scopes() + return scopes[0] if scopes else None + + @classmethod + def get_isolation_scope(cls): + """ + Returns the isolation scope. + """ + # type: () -> Scope + return cls._get_isolation_scope() or _INITIAL_ISOLATION_SCOPE + + @classmethod + def _get_isolation_scope(cls): + # type: () -> Optional[Scope] + """ + Returns the isolation scope without creating a new one. Internal use only. + """ + scopes = cls._get_scopes() + return scopes[1] if scopes else None + + +_INITIAL_CURRENT_SCOPE = PotelScope(ty=ScopeType.CURRENT) +_INITIAL_ISOLATION_SCOPE = PotelScope(ty=ScopeType.ISOLATION) + + +@contextmanager +def isolation_scope(): + # type: () -> Generator[Scope, None, None] + context = set_value(SENTRY_FORK_ISOLATION_SCOPE_KEY, True) + token = attach(context) + try: + yield PotelScope.get_isolation_scope() + finally: + detach(token) + + +@contextmanager +def new_scope(): + # type: () -> Generator[Scope, None, None] + token = attach(get_current()) + try: + yield PotelScope.get_current_scope() + finally: + detach(token) diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 594ccbb71f..2140b0e70b 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -13,7 +13,6 @@ INVALID_SPAN_ID, INVALID_TRACE_ID, ) -from sentry_sdk import get_client, start_transaction from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, @@ -106,6 +105,8 @@ def _prune_old_spans(self): def on_start(self, otel_span, parent_context=None): # type: (OTelSpan, Optional[context_api.Context]) -> None + from sentry_sdk import get_client, start_transaction + client = get_client() if not client.dsn: @@ -258,7 +259,7 @@ def _update_span_with_otel_data(self, sentry_span, otel_span): for key, val in otel_span.attributes.items(): sentry_span.set_data(key, val) - (op, description, status, http_status) = extract_span_data(otel_span) + (op, description, status, http_status, _) = extract_span_data(otel_span) sentry_span.op = op sentry_span.description = description @@ -269,7 +270,7 @@ def _update_span_with_otel_data(self, sentry_span, otel_span): def _update_transaction_with_otel_data(self, sentry_span, otel_span): # type: (SentrySpan, OTelSpan) -> None - (op, _, status, http_status) = extract_span_data(otel_span) + (op, _, status, http_status, _) = extract_span_data(otel_span) sentry_span.op = op if http_status: diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py index cb04dd8e1a..afa42ea772 100644 --- a/sentry_sdk/integrations/opentelemetry/utils.py +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -6,15 +6,15 @@ from opentelemetry.sdk.trace import ReadableSpan from sentry_sdk.consts import SPANSTATUS from sentry_sdk.tracing import get_span_status_from_http_code +from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute from urllib3.util import parse_url as urlparse -from sentry_sdk import get_client from sentry_sdk.utils import Dsn from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional, Mapping, Sequence + from typing import Any, Optional, Mapping, Sequence, Union GRPC_ERROR_MAP = { @@ -43,6 +43,8 @@ def is_sentry_span(span): Break infinite loop: HTTP requests to Sentry are caught by OTel and send again to Sentry. """ + from sentry_sdk import get_client + if not span.attributes: return False @@ -70,19 +72,32 @@ def is_sentry_span(span): return False -def convert_otel_timestamp(time): +def convert_from_otel_timestamp(time): # type: (int) -> datetime + """Convert an OTel nanosecond-level timestamp to a datetime.""" return datetime.fromtimestamp(time / 1e9, timezone.utc) +def convert_to_otel_timestamp(time): + # type: (Union[datetime.datetime, float]) -> int + """Convert a datetime to an OTel timestamp (with nanosecond precision).""" + if isinstance(time, datetime): + return int(time.timestamp() * 1e9) + return int(time * 1e9) + + def extract_span_data(span): - # type: (ReadableSpan) -> tuple[str, str, Optional[str], Optional[int]] + # type: (ReadableSpan) -> tuple[str, str, Optional[str], Optional[int], Optional[str]] op = span.name description = span.name status, http_status = extract_span_status(span) + origin = None if span.attributes is None: - return (op, description, status, http_status) + return (op, description, status, http_status, origin) + + origin = span.attributes.get(SentrySpanAttribute.ORIGIN) + description = span.attributes.get(SentrySpanAttribute.DESCRIPTION) or description http_method = span.attributes.get(SpanAttributes.HTTP_METHOD) http_method = cast("Optional[str]", http_method) @@ -95,26 +110,21 @@ def extract_span_data(span): rpc_service = span.attributes.get(SpanAttributes.RPC_SERVICE) if rpc_service: - return ("rpc", description, status, http_status) + return ("rpc", description, status, http_status, origin) messaging_system = span.attributes.get(SpanAttributes.MESSAGING_SYSTEM) if messaging_system: - return ("message", description, status, http_status) + return ("message", description, status, http_status, origin) faas_trigger = span.attributes.get(SpanAttributes.FAAS_TRIGGER) if faas_trigger: - return ( - str(faas_trigger), - description, - status, - http_status, - ) + return (str(faas_trigger), description, status, http_status, origin) - return (op, description, status, http_status) + return (op, description, status, http_status, origin) def span_data_for_http_method(span): - # type: (ReadableSpan) -> tuple[str, str, Optional[str], Optional[int]] + # type: (ReadableSpan) -> tuple[str, str, Optional[str], Optional[int], Optional[str]] span_attributes = span.attributes or {} op = "http" @@ -150,11 +160,13 @@ def span_data_for_http_method(span): status, http_status = extract_span_status(span) - return (op, description, status, http_status) + origin = span_attributes.get(SentrySpanAttribute.ORIGIN) + + return (op, description, status, http_status, origin) def span_data_for_db_query(span): - # type: (ReadableSpan) -> tuple[str, str, Optional[str], Optional[int]] + # type: (ReadableSpan) -> tuple[str, str, Optional[str], Optional[int], Optional[str]] span_attributes = span.attributes or {} op = "db" @@ -163,8 +175,9 @@ def span_data_for_db_query(span): statement = cast("Optional[str]", statement) description = statement or span.name + origin = span_attributes.get(SentrySpanAttribute.ORIGIN) - return (op, description, None, None) + return (op, description, None, None, origin) def extract_span_status(span): @@ -225,3 +238,25 @@ def get_http_status_code(span_attributes): http_status = cast("Optional[int]", http_status) return http_status + + +def extract_span_attributes(span, namespace): + # type: (ReadableSpan, str) -> dict[str, Any] + """ + Extract Sentry-specific span attributes and make them look the way Sentry expects. + """ + extracted_attrs = {} + + for attr, value in (span.attributes or {}).items(): + if attr.startswith(namespace): + key = attr[len(namespace) + 1 :] + + if namespace == SentrySpanAttribute.MEASUREMENT: + value = { + "value": float(value[0]), + "unit": value[1], + } + + extracted_attrs[key] = value + + return extracted_attrs diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 7a95611d78..9ea83a629c 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -67,8 +67,8 @@ def get_request_url(environ, use_x_forwarded_for=False): class SentryWsgiMiddleware: __slots__ = ("app", "use_x_forwarded_for", "span_origin") - def __init__(self, app, use_x_forwarded_for=False, span_origin="manual"): - # type: (Callable[[Dict[str, str], Callable[..., Any]], Any], bool, str) -> None + def __init__(self, app, use_x_forwarded_for=False, span_origin=None): + # type: (Callable[[Dict[str, str], Callable[..., Any]], Any], bool, Optional[str]) -> None self.app = app self.use_x_forwarded_for = use_x_forwarded_for self.span_origin = span_origin diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 111c28dc7f..acf7d2b83e 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -25,6 +25,7 @@ NoOpSpan, Span, Transaction, + POTelSpan, ) from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import ( @@ -234,13 +235,21 @@ def get_current_scope(cls): Returns the current scope. """ - current_scope = _current_scope.get() + current_scope = cls._get_current_scope() if current_scope is None: current_scope = Scope(ty=ScopeType.CURRENT) _current_scope.set(current_scope) return current_scope + @classmethod + def _get_current_scope(cls): + # type: () -> Optional[Scope] + """ + Returns the current scope without creating a new one. Internal use only. + """ + return _current_scope.get() + @classmethod def set_current_scope(cls, new_current_scope): # type: (Scope) -> None @@ -260,13 +269,21 @@ def get_isolation_scope(cls): Returns the isolation scope. """ - isolation_scope = _isolation_scope.get() + isolation_scope = cls._get_isolation_scope() if isolation_scope is None: isolation_scope = Scope(ty=ScopeType.ISOLATION) _isolation_scope.set(isolation_scope) return isolation_scope + @classmethod + def _get_isolation_scope(cls): + # type: () -> Optional[Scope] + """ + Returns the isolation scope without creating a new one. Internal use only. + """ + return _isolation_scope.get() + @classmethod def set_isolation_scope(cls, new_isolation_scope): # type: (Scope) -> None @@ -321,13 +338,11 @@ def _merge_scopes(self, additional_scope=None, additional_scope_kwargs=None): final_scope = copy(_global_scope) if _global_scope is not None else Scope() final_scope._type = ScopeType.MERGED - isolation_scope = _isolation_scope.get() - if isolation_scope is not None: - final_scope.update_from_scope(isolation_scope) + isolation_scope = self.get_isolation_scope() + final_scope.update_from_scope(isolation_scope) - current_scope = _current_scope.get() - if current_scope is not None: - final_scope.update_from_scope(current_scope) + current_scope = self.get_current_scope() + final_scope.update_from_scope(current_scope) if self != current_scope and self != isolation_scope: final_scope.update_from_scope(self) @@ -353,7 +368,7 @@ def get_client(cls): This checks the current scope, the isolation scope and the global scope for a client. If no client is available a :py:class:`sentry_sdk.client.NonRecordingClient` is returned. """ - current_scope = _current_scope.get() + current_scope = cls._get_current_scope() try: client = current_scope.client except AttributeError: @@ -362,7 +377,7 @@ def get_client(cls): if client is not None and client.is_active(): return client - isolation_scope = _isolation_scope.get() + isolation_scope = cls._get_isolation_scope() try: client = isolation_scope.client except AttributeError: @@ -661,23 +676,6 @@ def clear(self): # self._last_event_id is only applicable to isolation scopes self._last_event_id = None # type: Optional[str] - @_attr_setter - def level(self, value): - # type: (LogLevelStr) -> None - """ - When set this overrides the level. - - .. deprecated:: 1.0.0 - Use :func:`set_level` instead. - - :param value: The level to set. - """ - logger.warning( - "Deprecated: use .set_level() instead. This will be removed in the future." - ) - - self._level = value - def set_level(self, value): # type: (LogLevelStr) -> None """ @@ -766,11 +764,12 @@ def set_user(self, value): @property def span(self): # type: () -> Optional[Span] - """Get/set current tracing span or transaction.""" + """Get current tracing span.""" return self._span @span.setter def span(self, span): + """Set current tracing span.""" # type: (Optional[Span]) -> None self._span = span # XXX: this differs from the implementation in JS, there Scope.setSpan @@ -943,6 +942,10 @@ def start_transaction( ): # type: (Optional[Transaction], Optional[SamplingContext], Unpack[TransactionKwargs]) -> Union[Transaction, NoOpSpan] """ + .. deprecated:: 3.0.0 + This function is deprecated and will be removed in a future release. + Use :py:meth:`sentry_sdk.start_span` instead. + Start and return a transaction. Start an existing transaction if given, otherwise create and start a new @@ -973,19 +976,12 @@ def start_transaction( """ kwargs.setdefault("scope", self) - client = self.get_client() - try_autostart_continuous_profiler() custom_sampling_context = custom_sampling_context or {} - # kwargs at this point has type TransactionKwargs, since we have removed - # the client and custom_sampling_context from it. - transaction_kwargs = kwargs # type: TransactionKwargs - # if we haven't been given a transaction, make one - if transaction is None: - transaction = Transaction(**transaction_kwargs) + transaction = transaction or POTelSpan(**kwargs) # use traces_sample_rate, traces_sampler, and/or inheritance to make a # sampling decision @@ -1004,39 +1000,24 @@ def start_transaction( transaction._profile = profile - # we don't bother to keep spans if we already know we're not going to - # send the transaction - max_spans = (client.options["_experiments"].get("max_spans")) or 1000 - transaction.init_span_recorder(maxlen=max_spans) - return transaction - def start_span(self, **kwargs): - # type: (Any) -> Span + def start_span(self, span=None, custom_sampling_context=None, **kwargs): + # type: (Optional[Span], Optional[SamplingContext], Any) -> Span """ - Start a span whose parent is the currently active span or transaction, if any. + Start a span whose parent is the currently active span, if any. The return value is a :py:class:`sentry_sdk.tracing.Span` instance, typically used as a context manager to start and stop timing in a `with` block. - Only spans contained in a transaction are sent to Sentry. Most - integrations start a transaction at the appropriate time, for example - for every incoming HTTP request. Use - :py:meth:`sentry_sdk.start_transaction` to start a new transaction when - one is not already in progress. - For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Span`. - - The instrumenter parameter is deprecated for user code, and it will - be removed in the next major version. Going forward, it should only - be used by the SDK itself. """ with new_scope(): kwargs.setdefault("scope", self) # get current span or transaction - span = self.span or self.get_isolation_scope().span + span = span or self.span or self.get_isolation_scope().span if span is None: # New spans get the `trace_id` from the scope @@ -1045,7 +1026,7 @@ def start_span(self, **kwargs): if propagation_context is not None: kwargs["trace_id"] = propagation_context.trace_id - span = Span(**kwargs) + span = POTelSpan(**kwargs) else: # Children take `trace_id`` from the parent span. span = span.start_child(**kwargs) @@ -1053,9 +1034,9 @@ def start_span(self, **kwargs): return span def continue_trace( - self, environ_or_headers, op=None, name=None, source=None, origin="manual" + self, environ_or_headers, op=None, name=None, source=None, origin=None ): - # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str], str) -> Transaction + # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str], Optional[str]) -> Transaction """ Sets the propagation context from environment or headers and returns a transaction. """ @@ -1349,8 +1330,8 @@ def run_event_processors(self, event, hint): if not is_check_in: # Get scopes without creating them to prevent infinite recursion - isolation_scope = _isolation_scope.get() - current_scope = _current_scope.get() + isolation_scope = self._get_isolation_scope() + current_scope = self._get_current_scope() event_processors = chain( global_event_processors, diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 88ea7f55ff..c5812c9864 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -2,6 +2,9 @@ import random from datetime import datetime, timedelta, timezone +from opentelemetry import trace as otel_trace, context +from opentelemetry.trace.status import StatusCode + import sentry_sdk from sentry_sdk.consts import SPANSTATUS, SPANDATA from sentry_sdk.profiler.continuous_profiler import get_profiler_id @@ -32,6 +35,7 @@ R = TypeVar("R") import sentry_sdk.profiler + from sentry_sdk.scope import Scope from sentry_sdk._types import ( Event, MeasurementUnit, @@ -145,6 +149,10 @@ class TransactionKwargs(SpanKwargs, total=False): "url": TRANSACTION_SOURCE_ROUTE, } +DEFAULT_SPAN_ORIGIN = "manual" + +tracer = otel_trace.get_tracer(__name__) + def get_span_status_from_http_code(http_status_code): # type: (int) -> str @@ -266,7 +274,7 @@ def __init__( containing_transaction=None, # type: Optional[Transaction] start_timestamp=None, # type: Optional[Union[datetime, float]] scope=None, # type: Optional[sentry_sdk.Scope] - origin="manual", # type: str + origin=None, # type: Optional[str] ): # type: (...) -> None self.trace_id = trace_id or uuid.uuid4().hex @@ -278,7 +286,7 @@ def __init__( self.description = description self.status = status self.scope = scope - self.origin = origin + self.origin = origin or DEFAULT_SPAN_ORIGIN self._measurements = {} # type: Dict[str, MeasurementValue] self._tags = {} # type: MutableMapping[str, str] self._data = {} # type: Dict[str, Any] @@ -1172,6 +1180,373 @@ def _set_initial_sampling_decision(self, sampling_context): pass +class POTelSpan: + """ + OTel span wrapper providing compatibility with the old span interface. + """ + + # XXX Maybe it makes sense to repurpose the existing Span class for this. + # For now I'm keeping this class separate to have a clean slate. + + # XXX The wrapper itself should have as little state as possible + + def __init__( + self, + *, + active=True, # type: bool + op=None, # type: Optional[str] + description=None, # type: Optional[str] + status=None, # type: Optional[str] + scope=None, # type: Optional[Scope] + start_timestamp=None, # type: Optional[Union[datetime, float]] + origin=None, # type: Optional[str] + **_, # type: dict[str, object] + ): + # type: (...) -> None + """ + For backwards compatibility with old the old Span interface, this class + accepts arbitrary keyword arguments, in addition to the ones explicitly + listed in the signature. These additional arguments are ignored. + """ + from sentry_sdk.integrations.opentelemetry.utils import ( + convert_to_otel_timestamp, + ) + + if start_timestamp is not None: + # OTel timestamps have nanosecond precision + start_timestamp = convert_to_otel_timestamp(start_timestamp) + + # XXX deal with _otel_span being a NonRecordingSpan + self._otel_span = tracer.start_span( + description or op or "", start_time=start_timestamp + ) # XXX + self._active = active + + self.origin = origin or DEFAULT_SPAN_ORIGIN + self.op = op + self.description = description + if status is not None: + self.set_status(status) + + def __repr__(self): + # type: () -> str + return ( + "<%s(op=%r, description:%r, trace_id=%r, span_id=%r, parent_span_id=%r, sampled=%r, origin=%r)>" + % ( + self.__class__.__name__, + self.op, + self.description, + self.trace_id, + self.span_id, + self.parent_span_id, + self.sampled, + self.origin, + ) + ) + + def __enter__(self): + # type: () -> POTelSpan + # XXX use_span? https://github.com/open-telemetry/opentelemetry-python/blob/3836da8543ce9751051e38a110c0468724042e62/opentelemetry-api/src/opentelemetry/trace/__init__.py#L547 + # + # create a Context object with parent set as current span + if self._active: + ctx = otel_trace.set_span_in_context(self._otel_span) + # set as the implicit current context + self._ctx_token = context.attach(ctx) + + return self + + def __exit__(self, ty, value, tb): + # type: (Optional[Any], Optional[Any], Optional[Any]) -> None + self._otel_span.end() + # XXX set status to error if unset and an exception occurred? + if self._active: + context.detach(self._ctx_token) + + @property + def description(self): + # type: () -> Optional[str] + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + return self._otel_span.attributes.get(SentrySpanAttribute.DESCRIPTION) + + @description.setter + def description(self, value): + # type: (Optional[str]) -> None + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + if value is not None: + self._otel_span.set_attribute(SentrySpanAttribute.DESCRIPTION, value) + + @property + def origin(self): + # type: () -> Optional[str] + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + return self._otel_span.attributes.get(SentrySpanAttribute.ORIGIN) + + @origin.setter + def origin(self, value): + # type: (Optional[str]) -> None + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + if value is not None: + self._otel_span.set_attribute(SentrySpanAttribute.ORIGIN, value) + + @property + def containing_transaction(self): + # type: () -> Optional[Transaction] + """ + Get the transaction this span is a child of. + + .. deprecated:: 3.0.0 + This will be removed in the future. Use :func:`root_span` instead. + """ + logger.warning("Deprecated: This will be removed in the future.") + return self.root_span + + @containing_transaction.setter + def containing_transaction(self, value): + # type: (Span) -> None + """ + Set this span's transaction. + .. deprecated:: 3.0.0 + Use :func:`root_span` instead. + """ + pass + + @property + def root_span(self): + if isinstance(self._otel_span, otel_trace.NonRecordingSpan): + return None + + parent = None + while True: + # XXX test if this actually works + if self._otel_span.parent: + parent = self._otel_span.parent + else: + break + + return parent + + @root_span.setter + def root_span(self, value): + pass + + @property + def is_root_span(self): + if isinstance(self._otel_span, otel_trace.NonRecordingSpan): + return False + + return self._otel_span.parent is None + + @property + def parent_span_id(self): + # type: () -> Optional[str] + return self._otel_span.parent if hasattr(self._otel_span, "parent") else None + + @property + def trace_id(self): + # type: () -> Optional[str] + return self._otel_span.get_span_context().trace_id + + @property + def span_id(self): + # type: () -> Optional[str] + return self._otel_span.get_span_context().span_id + + @property + def sampled(self): + # type: () -> Optional[bool] + return self._otel_span.get_span_context().trace_flags.sampled + + @sampled.setter + def sampled(self, value): + # type: () -> Optional[bool] + pass + + @property + def op(self): + # type: () -> Optional[str] + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + self._otel_span.attributes.get(SentrySpanAttribute.OP) + + @op.setter + def op(self, value): + # type: (Optional[str]) -> None + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + if value is not None: + self._otel_span.set_attribute(SentrySpanAttribute.OP, value) + + @property + def name(self): + # type: () -> str + pass + + @name.setter + def name(self, value): + # type: (str) -> None + pass + + @property + def source(self): + # type: () -> str + pass + + @source.setter + def source(self, value): + # type: (str) -> None + pass + + def start_child(self, **kwargs): + # type: (str, **Any) -> POTelSpan + kwargs.setdefault("sampled", self.sampled) + + span = POTelSpan(**kwargs) + return span + + @classmethod + def continue_from_environ( + cls, + environ, # type: Mapping[str, str] + **kwargs, # type: Any + ): + # type: (...) -> POTelSpan + # XXX actually propagate + span = POTelSpan(**kwargs) + return span + + @classmethod + def continue_from_headers( + cls, + headers, # type: Mapping[str, str] + **kwargs, # type: Any + ): + # type: (...) -> POTelSpan + # XXX actually propagate + span = POTelSpan(**kwargs) + return span + + def iter_headers(self): + # type: () -> Iterator[Tuple[str, str]] + pass + + @classmethod + def from_traceparent( + cls, + traceparent, # type: Optional[str] + **kwargs, # type: Any + ): + # type: (...) -> Optional[Transaction] + # XXX actually propagate + span = POTelSpan(**kwargs) + return span + + def to_traceparent(self): + # type: () -> 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): + # type: () -> Optional[Baggage] + pass + + def set_tag(self, key, value): + # type: (str, Any) -> None + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + self.set_attribute(f"{SentrySpanAttribute.TAG}.{key}", value) + + def set_data(self, key, value): + # type: (str, Any) -> None + self.set_attribute(key, value) + + def set_attribute(self, key, value): + # type: (str, Any) -> None + self._otel_span.set_attribute(key, value) + + def set_status(self, status): + # type: (str) -> None + if status == SPANSTATUS.OK: + otel_status = StatusCode.OK + otel_description = None + else: + otel_status = StatusCode.ERROR + otel_description = status.value + + self._otel_span.set_status(otel_status, otel_description) + + def set_measurement(self, name, value, unit=""): + # type: (str, float, MeasurementUnit) -> None + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + # Stringify value here since OTel expects all seq items to be of one type + self.set_attribute( + f"{SentrySpanAttribute.MEASUREMENT}.{name}", (str(value), unit) + ) + + def set_thread(self, thread_id, thread_name): + # type: (Optional[int], Optional[str]) -> None + if thread_id is not None: + self.set_data(SPANDATA.THREAD_ID, str(thread_id)) + + if thread_name is not None: + self.set_data(SPANDATA.THREAD_NAME, thread_name) + + def set_profiler_id(self, profiler_id): + # type: (Optional[str]) -> None + if profiler_id is not None: + self.set_data(SPANDATA.PROFILER_ID, profiler_id) + + def set_http_status(self, http_status): + # type: (int) -> None + self.set_data(SPANDATA.HTTP_STATUS_CODE, http_status) + self.set_status(get_span_status_from_http_code(http_status)) + + def is_success(self): + # type: () -> bool + return self._otel_span.status.code == StatusCode.OK + + def finish(self, scope=None, end_timestamp=None): + # type: (Optional[sentry_sdk.Scope], Optional[Union[float, datetime]]) -> Optional[str] + pass + + def to_json(self): + # type: () -> dict[str, Any] + pass + + def get_trace_context(self): + # type: () -> Any + pass + + def get_profile_context(self): + # type: () -> Optional[ProfileContext] + pass + + # transaction/root span methods + + def set_context(self, key, value): + # type: (str, Any) -> None + pass + + def get_baggage(self): + # type: () -> Baggage + pass + + if TYPE_CHECKING: @overload diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 0dabfbc486..aa34398884 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -112,7 +112,7 @@ def record_sql_queries( paramstyle, # type: Optional[str] executemany, # type: bool record_cursor_repr=False, # type: bool - span_origin="manual", # type: str + span_origin=None, # type: Optional[str] ): # type: (...) -> Generator[sentry_sdk.tracing.Span, None, None] @@ -687,7 +687,7 @@ def func_with_tracing(*args, **kwargs): def get_current_span(scope=None): - # type: (Optional[sentry_sdk.Scope]) -> Optional[Span] + # type: (Optional[sentry_sdk.Scope]) -> Optional[sentry_sdk.tracing.Span] """ Returns the currently active span if there is one running, otherwise `None` """ @@ -702,6 +702,3 @@ def get_current_span(scope=None): LOW_QUALITY_TRANSACTION_SOURCES, SENTRY_TRACE_HEADER_NAME, ) - -if TYPE_CHECKING: - from sentry_sdk.tracing import Span diff --git a/setup.py b/setup.py index a949553dfd..246569c1d2 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ def get_file_text(file_name): install_requires=[ "urllib3>=1.26.11", "certifi", + "opentelemetry-distro>=0.35b0", # XXX check lower bound ], extras_require={ "aiohttp": ["aiohttp>=3.5"], diff --git a/tests/conftest.py b/tests/conftest.py index c31a394fb5..46f08a0232 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,7 @@ def benchmark(): from sentry_sdk import scope +import sentry_sdk.integrations.opentelemetry.scope as potel_scope @pytest.fixture(autouse=True) @@ -74,6 +75,9 @@ def clean_scopes(): scope._isolation_scope.set(None) scope._current_scope.set(None) + potel_scope._INITIAL_CURRENT_SCOPE.clear() + potel_scope._INITIAL_ISOLATION_SCOPE.clear() + @pytest.fixture(autouse=True) def internal_exceptions(request): diff --git a/tests/integrations/opentelemetry/test_potel.py b/tests/integrations/opentelemetry/test_potel.py new file mode 100644 index 0000000000..5e44cc3888 --- /dev/null +++ b/tests/integrations/opentelemetry/test_potel.py @@ -0,0 +1,316 @@ +import pytest + +from opentelemetry import trace + +import sentry_sdk + + +tracer = trace.get_tracer(__name__) + + +@pytest.fixture(autouse=True) +def sentry_init_potel(sentry_init): + sentry_init( + traces_sample_rate=1.0, + _experiments={"otel_powered_performance": True}, + ) + + +def test_root_span_transaction_payload_started_with_otel_only(capture_envelopes): + envelopes = capture_envelopes() + + with tracer.start_as_current_span("request"): + pass + + (envelope,) = envelopes + # TODO-neel-potel DSC header + (item,) = envelope.items + payload = item.payload.json + + assert payload["type"] == "transaction" + assert payload["transaction"] == "request" + assert payload["transaction_info"] == {"source": "custom"} + assert payload["timestamp"] is not None + assert payload["start_timestamp"] is not None + + contexts = payload["contexts"] + assert "runtime" in contexts + assert "otel" in contexts + assert "resource" in contexts["otel"] + + trace_context = contexts["trace"] + assert "trace_id" in trace_context + assert "span_id" in trace_context + assert trace_context["origin"] == "manual" + assert trace_context["op"] == "request" + assert trace_context["status"] == "ok" + + assert payload["spans"] == [] + + +def test_child_span_payload_started_with_otel_only(capture_envelopes): + envelopes = capture_envelopes() + + with tracer.start_as_current_span("request"): + with tracer.start_as_current_span("db"): + pass + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + (span,) = payload["spans"] + + assert span["op"] == "db" + assert span["description"] == "db" + assert span["origin"] == "manual" + assert span["status"] == "ok" + assert span["span_id"] is not None + assert span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert span["timestamp"] is not None + assert span["start_timestamp"] is not None + + +def test_children_span_nesting_started_with_otel_only(capture_envelopes): + envelopes = capture_envelopes() + + with tracer.start_as_current_span("request"): + with tracer.start_as_current_span("db"): + with tracer.start_as_current_span("redis"): + pass + with tracer.start_as_current_span("http"): + pass + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + (db_span, http_span, redis_span) = payload["spans"] + + assert db_span["op"] == "db" + assert redis_span["op"] == "redis" + assert http_span["op"] == "http" + + assert db_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert redis_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert http_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + + assert db_span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert http_span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert redis_span["parent_span_id"] == db_span["span_id"] + + +def test_root_span_transaction_payload_started_with_sentry_only(capture_envelopes): + envelopes = capture_envelopes() + + with sentry_sdk.start_span(description="request"): + pass + + (envelope,) = envelopes + # TODO-neel-potel DSC header + (item,) = envelope.items + payload = item.payload.json + + assert payload["type"] == "transaction" + assert payload["transaction"] == "request" + assert payload["transaction_info"] == {"source": "custom"} + assert payload["timestamp"] is not None + assert payload["start_timestamp"] is not None + + contexts = payload["contexts"] + assert "runtime" in contexts + assert "otel" in contexts + assert "resource" in contexts["otel"] + + trace_context = contexts["trace"] + assert "trace_id" in trace_context + assert "span_id" in trace_context + assert trace_context["origin"] == "manual" + assert trace_context["op"] == "request" + assert trace_context["status"] == "ok" + + assert payload["spans"] == [] + + +def test_child_span_payload_started_with_sentry_only(capture_envelopes): + envelopes = capture_envelopes() + + with sentry_sdk.start_span(description="request"): + with sentry_sdk.start_span(description="db"): + pass + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + (span,) = payload["spans"] + + assert span["op"] == "db" + assert span["description"] == "db" + assert span["origin"] == "manual" + assert span["status"] == "ok" + assert span["span_id"] is not None + assert span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert span["timestamp"] is not None + assert span["start_timestamp"] is not None + + +def test_children_span_nesting_started_with_sentry_only(capture_envelopes): + envelopes = capture_envelopes() + + with sentry_sdk.start_span(description="request"): + with sentry_sdk.start_span(description="db"): + with sentry_sdk.start_span(description="redis"): + pass + with sentry_sdk.start_span(description="http"): + pass + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + (db_span, http_span, redis_span) = payload["spans"] + + assert db_span["op"] == "db" + assert redis_span["op"] == "redis" + assert http_span["op"] == "http" + + assert db_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert redis_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert http_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + + assert db_span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert http_span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert redis_span["parent_span_id"] == db_span["span_id"] + + +def test_children_span_nesting_mixed(capture_envelopes): + envelopes = capture_envelopes() + + with sentry_sdk.start_span(description="request"): + with tracer.start_as_current_span("db"): + with sentry_sdk.start_span(description="redis"): + pass + with tracer.start_as_current_span("http"): + pass + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + (db_span, http_span, redis_span) = payload["spans"] + + assert db_span["op"] == "db" + assert redis_span["op"] == "redis" + assert http_span["op"] == "http" + + assert db_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert redis_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + assert http_span["trace_id"] == payload["contexts"]["trace"]["trace_id"] + + assert db_span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert http_span["parent_span_id"] == payload["contexts"]["trace"]["span_id"] + assert redis_span["parent_span_id"] == db_span["span_id"] + + +def test_span_attributes_in_data_started_with_otel(capture_envelopes): + envelopes = capture_envelopes() + + with tracer.start_as_current_span("request") as request_span: + request_span.set_attributes({"foo": "bar", "baz": 42}) + with tracer.start_as_current_span("db") as db_span: + db_span.set_attributes({"abc": 99, "def": "moo"}) + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + + assert payload["contexts"]["trace"]["data"] == {"foo": "bar", "baz": 42} + assert payload["spans"][0]["data"] == {"abc": 99, "def": "moo"} + + +def test_span_data_started_with_sentry(capture_envelopes): + envelopes = capture_envelopes() + + with sentry_sdk.start_span(op="http", description="request") as request_span: + request_span.set_data("foo", "bar") + with sentry_sdk.start_span(op="db", description="statement") as db_span: + db_span.set_data("baz", 42) + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + + assert payload["contexts"]["trace"]["data"] == { + "foo": "bar", + "sentry.origin": "manual", + "sentry.description": "request", + "sentry.op": "http", + } + assert payload["spans"][0]["data"] == { + "baz": 42, + "sentry.origin": "manual", + "sentry.description": "statement", + "sentry.op": "db", + } + + +def test_transaction_tags_started_with_otel(capture_envelopes): + envelopes = capture_envelopes() + + sentry_sdk.set_tag("tag.global", 99) + with tracer.start_as_current_span("request"): + sentry_sdk.set_tag("tag.inner", "foo") + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + + assert payload["tags"] == {"tag.global": 99, "tag.inner": "foo"} + + +def test_transaction_tags_started_with_sentry(capture_envelopes): + envelopes = capture_envelopes() + + sentry_sdk.set_tag("tag.global", 99) + with sentry_sdk.start_span(description="request"): + sentry_sdk.set_tag("tag.inner", "foo") + + (envelope,) = envelopes + (item,) = envelope.items + payload = item.payload.json + + assert payload["tags"] == {"tag.global": 99, "tag.inner": "foo"} + + +def test_multiple_transaction_tags_isolation_scope_started_with_otel(capture_envelopes): + envelopes = capture_envelopes() + + sentry_sdk.set_tag("tag.global", 99) + with sentry_sdk.isolation_scope(): + with tracer.start_as_current_span("request a"): + sentry_sdk.set_tag("tag.inner.a", "a") + with sentry_sdk.isolation_scope(): + with tracer.start_as_current_span("request b"): + sentry_sdk.set_tag("tag.inner.b", "b") + + (payload_a, payload_b) = [envelope.items[0].payload.json for envelope in envelopes] + + assert payload_a["tags"] == {"tag.global": 99, "tag.inner.a": "a"} + assert payload_b["tags"] == {"tag.global": 99, "tag.inner.b": "b"} + + +def test_multiple_transaction_tags_isolation_scope_started_with_sentry( + capture_envelopes, +): + envelopes = capture_envelopes() + + sentry_sdk.set_tag("tag.global", 99) + with sentry_sdk.isolation_scope(): + with sentry_sdk.start_span(description="request a"): + sentry_sdk.set_tag("tag.inner.a", "a") + with sentry_sdk.isolation_scope(): + with sentry_sdk.start_span(description="request b"): + sentry_sdk.set_tag("tag.inner.b", "b") + + (payload_a, payload_b) = [envelope.items[0].payload.json for envelope in envelopes] + + assert payload_a["tags"] == {"tag.global": 99, "tag.inner.a": "a"} + assert payload_b["tags"] == {"tag.global": 99, "tag.inner.b": "b"} diff --git a/tests/integrations/opentelemetry/test_utils.py b/tests/integrations/opentelemetry/test_utils.py index ceb58a58ef..66ffd7898a 100644 --- a/tests/integrations/opentelemetry/test_utils.py +++ b/tests/integrations/opentelemetry/test_utils.py @@ -23,6 +23,7 @@ "description": "OTel Span Blank", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -36,6 +37,7 @@ "description": "OTel Span RPC", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -49,6 +51,7 @@ "description": "OTel Span Messaging", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -62,6 +65,7 @@ "description": "OTel Span FaaS", "status": "ok", "http_status_code": None, + "origin": None, }, ), ], @@ -72,12 +76,13 @@ def test_extract_span_data(name, status, attributes, expected): otel_span.status = Status(StatusCode.UNSET) otel_span.attributes = attributes - op, description, status, http_status_code = extract_span_data(otel_span) + op, description, status, http_status_code, origin = extract_span_data(otel_span) result = { "op": op, "description": description, "status": status, "http_status_code": http_status_code, + "origin": origin, } assert result == expected @@ -99,6 +104,7 @@ def test_extract_span_data(name, status, attributes, expected): "description": "GET", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -113,6 +119,7 @@ def test_extract_span_data(name, status, attributes, expected): "description": "GET /target", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -127,6 +134,7 @@ def test_extract_span_data(name, status, attributes, expected): "description": "GET example.com", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -142,6 +150,7 @@ def test_extract_span_data(name, status, attributes, expected): "description": "GET /target", "status": "ok", "http_status_code": None, + "origin": None, }, ), ( @@ -156,6 +165,7 @@ def test_extract_span_data(name, status, attributes, expected): "description": "GET https://example.com/bla/", "status": "ok", "http_status_code": None, + "origin": None, }, ), ], @@ -166,12 +176,15 @@ def test_span_data_for_http_method(kind, status, attributes, expected): otel_span.status = status otel_span.attributes = attributes - op, description, status, http_status_code = span_data_for_http_method(otel_span) + op, description, status, http_status_code, origin = span_data_for_http_method( + otel_span + ) result = { "op": op, "description": description, "status": status, "http_status_code": http_status_code, + "origin": origin, } assert result == expected @@ -181,19 +194,21 @@ def test_span_data_for_db_query(): otel_span.name = "OTel Span" otel_span.attributes = {} - op, description, status, http_status = span_data_for_db_query(otel_span) + op, description, status, http_status, origin = span_data_for_db_query(otel_span) assert op == "db" assert description == "OTel Span" assert status is None assert http_status is None + assert origin is None otel_span.attributes = {"db.statement": "SELECT * FROM table;"} - op, description, status, http_status = span_data_for_db_query(otel_span) + op, description, status, http_status, origin = span_data_for_db_query(otel_span) assert op == "db" assert description == "SELECT * FROM table;" assert status is None assert http_status is None + assert origin is None @pytest.mark.parametrize( diff --git a/tests/test_api.py b/tests/test_api.py index ffe1be756d..46fc24fd24 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -30,7 +30,7 @@ def test_get_current_span(): @pytest.mark.forked -def test_get_current_span_default_hub(sentry_init): +def test_get_current_span_current_scope(sentry_init): sentry_init() assert get_current_span() is None @@ -43,7 +43,7 @@ def test_get_current_span_default_hub(sentry_init): @pytest.mark.forked -def test_get_current_span_default_hub_with_transaction(sentry_init): +def test_get_current_span_current_scope_with_transaction(sentry_init): sentry_init() assert get_current_span() is None diff --git a/tests/tracing/test_misc.py b/tests/tracing/test_misc.py index 02966642fd..996d9c4d5d 100644 --- a/tests/tracing/test_misc.py +++ b/tests/tracing/test_misc.py @@ -258,7 +258,7 @@ def test_circular_references(monkeypatch, sentry_init, request): assert gc.collect() == 0 -def test_set_meaurement(sentry_init, capture_events): +def test_set_measurement(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0) events = capture_events() @@ -286,7 +286,7 @@ def test_set_meaurement(sentry_init, capture_events): assert event["measurements"]["metric.foobar"] == {"value": 17.99, "unit": "percent"} -def test_set_meaurement_public_api(sentry_init, capture_events): +def test_set_measurement_public_api(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0) events = capture_events() @@ -412,7 +412,7 @@ def test_transaction_dropped_debug_not_started(sentry_init, sampled): ) -def test_transaction_dropeed_sampled_false(sentry_init): +def test_transaction_dropped_sampled_false(sentry_init): sentry_init(enable_tracing=True) tx = Transaction(sampled=False)