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..3c5fc61cf6 100644 --- a/sentry_sdk/integrations/opentelemetry/consts.py +++ b/sentry_sdk/integrations/opentelemetry/consts.py @@ -1,7 +1,22 @@ 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 better name + # 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" 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..3f71e86f02 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -6,7 +6,13 @@ 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.integrations.opentelemetry.sampler import SentrySampler from sentry_sdk.utils import logger try: @@ -46,9 +52,14 @@ def setup_once(): def _setup_sentry_tracing(): # type: () -> None - provider = TracerProvider() - provider.add_span_processor(SentrySpanProcessor()) + import opentelemetry.context + + opentelemetry.context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() + + provider = TracerProvider(sampler=SentrySampler()) + 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..d90ac7d5e4 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -5,14 +5,14 @@ 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_data, ) from sentry_sdk.integrations.opentelemetry.consts import ( OTEL_SENTRY_CONTEXT, - SPAN_ORIGIN, ) from sentry_sdk._types import TYPE_CHECKING @@ -116,12 +116,12 @@ def _root_span_to_transaction_event(self, span): 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] @@ -141,8 +141,8 @@ def _root_span_to_transaction_event(self, span): # 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), + "start_timestamp": convert_from_otel_timestamp(span.start_time), + "timestamp": convert_from_otel_timestamp(span.end_time), } # type: Event return event @@ -160,17 +160,17 @@ def _span_to_json(self, span): 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), + "start_timestamp": convert_from_otel_timestamp(span.start_time), + "timestamp": convert_from_otel_timestamp(span.end_time), + "origin": origin or DEFAULT_SPAN_ORIGIN, } # type: dict[str, Any] if parent_span_id: diff --git a/sentry_sdk/integrations/opentelemetry/sampler.py b/sentry_sdk/integrations/opentelemetry/sampler.py new file mode 100644 index 0000000000..e614a9b402 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/sampler.py @@ -0,0 +1,115 @@ +from random import random + +from opentelemetry import trace +from opentelemetry.sdk.trace.sampling import Sampler, SamplingResult, Decision +from opentelemetry.trace import SpanKind +from opentelemetry.util.types import Attributes + +import sentry_sdk +from sentry_sdk.tracing_utils import has_tracing_enabled + +from typing import Optional, Sequence + + +class SentrySampler(Sampler): + def should_sample( + self, + parent_context: Optional["Context"], + trace_id: int, + name: str, + kind: Optional[SpanKind] = None, + attributes: Attributes = None, + links: Optional[Sequence["Link"]] = None, + trace_state: Optional["TraceState"] = None, + ) -> "SamplingResult": + """ + Decisions: + # IsRecording() == false, span will not be recorded and all events and attributes will be dropped. + DROP = 0 + # IsRecording() == true, but Sampled flag MUST NOT be set. + RECORD_ONLY = 1 + # IsRecording() == true AND Sampled flag` MUST be set. + RECORD_AND_SAMPLE = 2 + + Recorded: + sent to sentry + Sampled: + it is not dropped, but could be NonRecordingSpan that is never sent to Sentry (for trace propagation) + """ + """ + SamplingResult + decision: A sampling decision based off of whether the span is recorded + and the sampled flag in trace flags in the span context. + attributes: Attributes to add to the `opentelemetry.trace.Span`. + trace_state: The tracestate used for the `opentelemetry.trace.Span`. + Could possibly have been modified by the sampler. + """ + """ + 1. If a sampling decision is passed to `start_transaction` + (`start_transaction(name: "my transaction", sampled: True)`), that + decision will be used, regardless of anything else + + 2. If `traces_sampler` is defined, its decision will be used. It can + choose to keep or ignore any parent sampling decision, or use the + sampling context data to make its own decision or to choose a sample + rate for the transaction. + + 3. If `traces_sampler` is not defined, but there's a parent sampling + decision, the parent sampling decision will be used. + + 4. If `traces_sampler` is not defined and there's no parent sampling + decision, `traces_sample_rate` will be used. + """ + client = sentry_sdk.get_client() + + # No tracing enabled, thus no sampling + if not has_tracing_enabled(client.options): + return SamplingResult(Decision.DROP) + + # Check if sampled=True was passed to start_transaction + #TODO-anton: Do we want to keep the start_transaction(sampled=True) thing? + + parent_span = trace.get_current_span() + parent_context = parent_span.get_span_context() + + # Check if there is a traces_sampler + import ipdb; ipdb.set_trace() + has_traces_sampler = callable(client.options.get("traces_sampler")) + if has_traces_sampler: + #TODO-anton: Make proper sampling_context + sampling_context = { + "transaction_context": { + "name": name, + }, + "parent_sampled": parent_context.trace_flags.sampled, + } + + # TODO-anton: use is_valid_sample_rate() + sample_rate = client.options["traces_sampler"](sampling_context) + + # Check if there is a parent with a sampling decision + has_parent = parent_context is not None and parent_context.is_valid + if has_parent: + if parent_context.trace_flags.sampled: + return SamplingResult(Decision.RECORD_AND_SAMPLE) + else: + return SamplingResult(Decision.DROP) + + + # Roll the dice on traces_sample_rate + sample_rate = client.options.get("traces_sample_rate") + if sample_rate is not None: + sampled = random() < sample_rate + if sampled: + return SamplingResult(Decision.RECORD_AND_SAMPLE) + else: + return SamplingResult(Decision.DROP) + + if sampled: + return SamplingResult(Decision.RECORD_AND_SAMPLE) + else: + return SamplingResult(Decision.DROP) + + def get_description(self) -> str: + print("YYYYYYYYYYYYYYYY") + return "SentrySampler" 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..2444131002 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 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): 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..308f4774ff 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,365 @@ 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 + pass + + def set_data(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 + # XXX own namespace, e.g. sentry.measurement.xxx, so that we can group + # these back together in the processor? + # XXX otel throws a warning about value, unit being different types + self._otel_span.set_attribute(name, (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_sampler.py b/tests/integrations/opentelemetry/test_sampler.py new file mode 100644 index 0000000000..aadcde8ca1 --- /dev/null +++ b/tests/integrations/opentelemetry/test_sampler.py @@ -0,0 +1,129 @@ +import pytest +from unittest import mock + +from opentelemetry import trace + +import sentry_sdk + + +tracer = trace.get_tracer(__name__) + + +@pytest.fixture() +def init_sentry_with_potel(sentry_init): + def wrapped_sentry_init(*args, **kwargs): + kwargs.update({ + "_experiments": {"otel_powered_performance": True}, + }) + sentry_init(*args, **kwargs) + + return wrapped_sentry_init + + +@pytest.mark.parametrize( + "traces_sampling_rate,expected_num_of_envelopes", + [ + (-1, 0), # special case, do not pass any traces_sampling_rate to init() + (None, 0), + (0, 0), + (1, 2), + ] +) +def test_sampling_traces_sample_rate_0_or_100(init_sentry_with_potel, capture_envelopes, traces_sampling_rate,expected_num_of_envelopes): + kwargs = {} + if traces_sampling_rate != -1: + kwargs["traces_sample_rate"] = traces_sampling_rate + + init_sentry_with_potel(**kwargs) + + envelopes = capture_envelopes() + + with sentry_sdk.start_span(description="request a"): + with sentry_sdk.start_span(description="cache a"): + with sentry_sdk.start_span(description="db a"): + ... + + with sentry_sdk.start_span(description="request b"): + with sentry_sdk.start_span(description="cache b"): + with sentry_sdk.start_span(description="db b"): + ... + + assert len(envelopes) == expected_num_of_envelopes + + if expected_num_of_envelopes == 2: + (transaction_a, transaction_b) = [envelope.items[0].payload.json for envelope in envelopes] + + assert transaction_a["transaction"] == "request a" + assert transaction_b["transaction"] == "request b" + + spans_a = transaction_a["spans"] + assert len(spans_a) == 2 + assert spans_a[0]["description"] == "cache a" + assert spans_a[1]["description"] == "db a" + spans_b = transaction_b["spans"] + assert len(spans_b) == 2 + assert spans_b[0]["description"] == "cache b" + assert spans_b[1]["description"] == "db b" + + +def test_sampling_traces_sample_rate_50(init_sentry_with_potel, capture_envelopes): + init_sentry_with_potel(traces_sample_rate=0.5) + + envelopes = capture_envelopes() + + # Make sure random() always returns the same values + with mock.patch("sentry_sdk.integrations.opentelemetry.sampler.random", side_effect=[0.7, 0.2]): + with sentry_sdk.start_span(description="request a"): + with sentry_sdk.start_span(description="cache a"): + with sentry_sdk.start_span(description="db a"): + ... + + with sentry_sdk.start_span(description="request b"): + with sentry_sdk.start_span(description="cache b"): + with sentry_sdk.start_span(description="db b"): + ... + + assert len(envelopes) == 1 + + (envelope,) = envelopes + transaction = envelope.items[0].payload.json + assert transaction["transaction"] == "request b" + spans = transaction["spans"] + assert len(spans) == 2 + assert spans[0]["description"] == "cache b" + assert spans[1]["description"] == "db b" + + +def test_sampling_traces_sampler(init_sentry_with_potel, capture_envelopes): + def custom_traces_sampler(sampling_context): + if " a" in sampling_context["transaction_context"]["name"]: + return 0.05 + else: + return 0 + + init_sentry_with_potel( + traces_sample_rate=1.0, + traces_sampler=custom_traces_sampler, + ) + + envelopes = capture_envelopes() + + # Make sure random() always returns the same values + with mock.patch("sentry_sdk.integrations.opentelemetry.sampler.random", side_effect=[0.04, 0.04]): + with sentry_sdk.start_span(description="request a"): + with sentry_sdk.start_span(description="cache a"): + with sentry_sdk.start_span(description="db a"): + ... + + with sentry_sdk.start_span(description="request b"): + with sentry_sdk.start_span(description="cache b"): + with sentry_sdk.start_span(description="db b"): + ... + + assert len(envelopes) == 1 + (envelope,) = envelopes + transaction = envelope.items[0].payload.json + assert transaction["transaction"] == "request a" + + +# TODO-anton: write traces_sampler with booleans \ No newline at end of file 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)