From 28effd69809ee010b0e8cc64096be57a7942c4d2 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Mon, 10 Jun 2024 20:51:07 +0200 Subject: [PATCH 01/32] Skeletons for new components --- .../opentelemetry/contextvars_context.py | 14 ++++++ .../integrations/opentelemetry/integration.py | 15 +++++- .../opentelemetry/potel_span_exporter.py | 19 ++++++++ .../opentelemetry/potel_span_processor.py | 47 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 sentry_sdk/integrations/opentelemetry/contextvars_context.py create mode 100644 sentry_sdk/integrations/opentelemetry/potel_span_exporter.py create mode 100644 sentry_sdk/integrations/opentelemetry/potel_span_processor.py diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py new file mode 100644 index 0000000000..7a382064c9 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -0,0 +1,14 @@ +from opentelemetry.context.context import Context # type: ignore +from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext # type: ignore + + +class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): # type: ignore + def attach(self, context): + # type: (Context) -> object + # TODO-neel-potel do scope management + return super().attach(context) + + def detach(self, token): + # type: (object) -> None + # TODO-neel-potel not sure if we need anything here, see later + super().detach(token) diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 9e62d1feca..3a33c7f2d0 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -8,7 +8,12 @@ from importlib import import_module from sentry_sdk.integrations import DidNotEnable, Integration -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.propagator import SentryPropagator from sentry_sdk.utils import logger, _get_installed_modules from sentry_sdk._types import TYPE_CHECKING @@ -21,6 +26,7 @@ ) from opentelemetry.propagate import set_global_textmap # type: ignore from opentelemetry.sdk.trace import TracerProvider # type: ignore + from opentelemetry import context except ImportError: raise DidNotEnable("opentelemetry not installed") @@ -165,9 +171,14 @@ def _import_by_path(path): def _setup_sentry_tracing(): # type: () -> None + + # TODO-neel-potel make sure lifecycle is correct + # TODO-neel-potel contribute upstream so this is not necessary + context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() + provider = TracerProvider() - provider.add_span_processor(SentrySpanProcessor()) + provider.add_span_processor(PotelSentrySpanProcessor()) trace.set_tracer_provider(provider) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py new file mode 100644 index 0000000000..70cfc39105 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py @@ -0,0 +1,19 @@ +from opentelemetry.trace import Span # type: ignore + + +class PotelSentrySpanExporter: + """ + A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. + """ + + def __init__(self): + # type: () -> None + pass + + def export(self, span): + # type: (Span) -> None + pass + + def flush(self, timeout_millis): + # type: (int) -> bool + return True diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py new file mode 100644 index 0000000000..fef0491a10 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -0,0 +1,47 @@ +from opentelemetry.sdk.trace import SpanProcessor # type: ignore +from opentelemetry.context import Context # type: ignore +from opentelemetry.trace import Span # type: ignore + +from sentry_sdk.integrations.opentelemetry.potel_span_exporter import ( + PotelSentrySpanExporter, +) +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional + + +class PotelSentrySpanProcessor(SpanProcessor): # type: ignore + """ + Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. + """ + + def __new__(cls): + # type: () -> PotelSentrySpanProcessor + if not hasattr(cls, "instance"): + cls.instance = super().__new__(cls) + + return cls.instance + + def __init__(self): + # type: () -> None + self._exporter = PotelSentrySpanExporter() + + def on_start(self, span, parent_context=None): + # type: (Span, Optional[Context]) -> None + pass + + def on_end(self, span): + # type: (Span) -> None + self._exporter.export(span) + + # TODO-neel-potel not sure we need a clear like JS + def shutdown(self): + # type: () -> None + pass + + # TODO-neel-potel change default? this is 30 sec + # TODO-neel-potel call this in client.flush + def force_flush(self, timeout_millis=30000): + # type: (int) -> bool + return self._exporter.flush(timeout_millis) From e7cbb59b7adc0e545b64f5d6393e107773abc83c Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 12 Jun 2024 13:36:37 +0200 Subject: [PATCH 02/32] Add simple scope management whenever a context is attached * create a new otel context `_SCOPES_KEY` that will hold a tuple of `(curent_scope, isolation_scope)` * the `current_scope` will always be forked (like on every span creation/context update in practice) * note that this is on `attach`, so not on all copy-on-write context object creation but only on apis such as [`trace.use_span`](https://github.com/open-telemetry/opentelemetry-python/blob/ba22b165471bde2037620f2c850ab648a849fbc0/opentelemetry-api/src/opentelemetry/trace/__init__.py#L547) or [`tracer.start_as_current_span`](https://github.com/open-telemetry/opentelemetry-python/blob/ba22b165471bde2037620f2c850ab648a849fbc0/opentelemetry-api/src/opentelemetry/trace/__init__.py#L329) * basically every otel `context` fork corresponds to our `current_scope` fork * the `isolation_scope` currently will not be forked * these will later be updated, for instance when we update our top level scope apis that fork isolation scope, that will also have a corresponding change in this `attach` function --- .../opentelemetry/contextvars_context.py | 26 ++++++++++++++----- .../integrations/opentelemetry/integration.py | 1 - 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py index 7a382064c9..3291fca448 100644 --- a/sentry_sdk/integrations/opentelemetry/contextvars_context.py +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -1,14 +1,26 @@ -from opentelemetry.context.context import Context # type: ignore +from opentelemetry.context import Context, create_key, get_value, set_value from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext # type: ignore +from sentry_sdk.scope import Scope + + +_SCOPES_KEY = create_key("sentry_scopes") + class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): # type: ignore def attach(self, context): # type: (Context) -> object - # TODO-neel-potel do scope management - return super().attach(context) + scopes = get_value(_SCOPES_KEY, context) + + if scopes and isinstance(scopes, tuple): + (current_scope, isolation_scope) = scopes + else: + current_scope = Scope.get_current_scope() + isolation_scope = Scope.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) - def detach(self, token): - # type: (object) -> None - # TODO-neel-potel not sure if we need anything here, see later - super().detach(token) + return super().attach(new_context) diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 3a33c7f2d0..28a497c340 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -172,7 +172,6 @@ def _import_by_path(path): def _setup_sentry_tracing(): # type: () -> None - # TODO-neel-potel make sure lifecycle is correct # TODO-neel-potel contribute upstream so this is not necessary context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() From 618d6ca9a76441df411fa8c26c18d38b3882c4a2 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 12 Jun 2024 14:19:57 +0200 Subject: [PATCH 03/32] Don't parse DSN twice --- .../integrations/opentelemetry/span_processor.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index a09a93d284..e5dc86c53a 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -119,11 +119,6 @@ def on_start(self, otel_span, parent_context=None): if not client.dsn: return - try: - _ = Dsn(client.dsn) - except Exception: - return - if client.options["instrumenter"] != INSTRUMENTER.OTEL: return @@ -223,8 +218,12 @@ def _is_sentry_span(self, otel_span): dsn_url = None client = get_client() + if client.dsn: - dsn_url = Dsn(client.dsn).netloc + try: + dsn_url = Dsn(client.dsn).netloc + except Exception: + pass if otel_span_url and dsn_url in otel_span_url: return True From 7dba0299a7494b6a99ae06391f37eaa5f4c0f8ef Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 12 Jun 2024 18:02:00 +0200 Subject: [PATCH 04/32] wip --- .../opentelemetry/potel_span_exporter.py | 4 +-- .../opentelemetry/potel_span_processor.py | 18 ++++++++-- .../opentelemetry/span_processor.py | 26 ++------------- .../integrations/opentelemetry/utils.py | 33 +++++++++++++++++++ 4 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 sentry_sdk/integrations/opentelemetry/utils.py diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py index 70cfc39105..ab36c1df10 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py @@ -1,4 +1,4 @@ -from opentelemetry.trace import Span # type: ignore +from opentelemetry.sdk.trace import ReadableSpan # type: ignore class PotelSentrySpanExporter: @@ -11,7 +11,7 @@ def __init__(self): pass def export(self, span): - # type: (Span) -> None + # type: (ReadableSpan) -> None pass def flush(self, timeout_millis): diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index fef0491a10..8cb1baa898 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -1,10 +1,12 @@ -from opentelemetry.sdk.trace import SpanProcessor # type: ignore +from opentelemetry.trace import INVALID_SPAN, get_current_span # type: ignore from opentelemetry.context import Context # type: ignore -from opentelemetry.trace import Span # type: ignore +from opentelemetry.sdk.trace import Span, ReadableSpan, SpanProcessor # type: ignore +from sentry_sdk.integrations.opentelemetry.utils import is_sentry_span from sentry_sdk.integrations.opentelemetry.potel_span_exporter import ( PotelSentrySpanExporter, ) + from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: @@ -30,9 +32,19 @@ def __init__(self): def on_start(self, span, parent_context=None): # type: (Span, Optional[Context]) -> None pass + # if is_sentry_span(span): + # return + + # parent_span = get_current_span(parent_context) + + # # TODO-neel-potel check remote logic with propagation and incoming trace later + # if parent_span != INVALID_SPAN: + # # TODO-neel once we add our apis, we might need to store references on the span + # # directly, see if we need to do this like JS + # pass def on_end(self, span): - # type: (Span) -> None + # type: (ReadableSpan) -> None self._exporter.export(span) # TODO-neel-potel not sure we need a clear like JS diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index e5dc86c53a..50fc603611 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -22,9 +22,9 @@ SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, ) +from sentry_sdk.integrations.opentelemetry.utils import is_sentry_span from sentry_sdk.scope import add_global_event_processor from sentry_sdk.tracing import Transaction, Span as SentrySpan -from sentry_sdk.utils import Dsn from sentry_sdk._types import TYPE_CHECKING from urllib3.util import parse_url as urlparse @@ -125,7 +125,7 @@ def on_start(self, otel_span, parent_context=None): if not otel_span.get_span_context().is_valid: return - if self._is_sentry_span(otel_span): + if is_sentry_span(otel_span): return trace_data = self._get_trace_data(otel_span, parent_context) @@ -208,28 +208,6 @@ def on_end(self, otel_span): self.open_spans.setdefault(span_start_in_minutes, set()).discard(span_id) self._prune_old_spans() - def _is_sentry_span(self, otel_span): - # type: (OTelSpan) -> bool - """ - Break infinite loop: - HTTP requests to Sentry are caught by OTel and send again to Sentry. - """ - otel_span_url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None) - - dsn_url = None - client = get_client() - - if client.dsn: - try: - dsn_url = Dsn(client.dsn).netloc - except Exception: - pass - - if otel_span_url and dsn_url in otel_span_url: - return True - - return False - def _get_otel_context(self, otel_span): # type: (OTelSpan) -> Dict[str, Any] """ diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py new file mode 100644 index 0000000000..bd827449f1 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -0,0 +1,33 @@ +from opentelemetry.semconv.trace import SpanAttributes # type: ignore +from opentelemetry.trace import Span # type: ignore + +from sentry_sdk import get_client, start_transaction +from sentry_sdk.utils import Dsn + +def is_sentry_span(span): + # type: (Span) -> bool + """ + Break infinite loop: + HTTP requests to Sentry are caught by OTel and send again to Sentry. + """ + span_url = span.attributes.get(SpanAttributes.HTTP_URL, None) + + if not span_url: + return False + + dsn_url = None + client = get_client() + + if client.dsn: + try: + dsn_url = Dsn(client.dsn).netloc + except Exception: + pass + + if not dsn_url: + return False + + if dsn_url in span_url: + return True + + return False From 951477fd967c108768b1f1e006e0ba032eb89362 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Mon, 10 Jun 2024 20:51:07 +0200 Subject: [PATCH 05/32] Skeletons for new components --- .../opentelemetry/contextvars_context.py | 14 ++++++ .../integrations/opentelemetry/integration.py | 15 ++++++- .../opentelemetry/potel_span_processor.py | 44 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 sentry_sdk/integrations/opentelemetry/contextvars_context.py create mode 100644 sentry_sdk/integrations/opentelemetry/potel_span_processor.py diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py new file mode 100644 index 0000000000..7a382064c9 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -0,0 +1,14 @@ +from opentelemetry.context.context import Context # type: ignore +from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext # type: ignore + + +class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): # type: ignore + def attach(self, context): + # type: (Context) -> object + # TODO-neel-potel do scope management + return super().attach(context) + + def detach(self, token): + # type: (object) -> None + # TODO-neel-potel not sure if we need anything here, see later + super().detach(token) diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 9e62d1feca..3a33c7f2d0 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -8,7 +8,12 @@ from importlib import import_module from sentry_sdk.integrations import DidNotEnable, Integration -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.propagator import SentryPropagator from sentry_sdk.utils import logger, _get_installed_modules from sentry_sdk._types import TYPE_CHECKING @@ -21,6 +26,7 @@ ) from opentelemetry.propagate import set_global_textmap # type: ignore from opentelemetry.sdk.trace import TracerProvider # type: ignore + from opentelemetry import context except ImportError: raise DidNotEnable("opentelemetry not installed") @@ -165,9 +171,14 @@ def _import_by_path(path): def _setup_sentry_tracing(): # type: () -> None + + # TODO-neel-potel make sure lifecycle is correct + # TODO-neel-potel contribute upstream so this is not necessary + context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() + provider = TracerProvider() - provider.add_span_processor(SentrySpanProcessor()) + provider.add_span_processor(PotelSentrySpanProcessor()) trace.set_tracer_provider(provider) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py new file mode 100644 index 0000000000..795068033e --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -0,0 +1,44 @@ +from opentelemetry.sdk.trace import SpanProcessor # type: ignore +from opentelemetry.context import Context # type: ignore +from opentelemetry.trace import Span # type: ignore + +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional + + +class PotelSentrySpanProcessor(SpanProcessor): # type: ignore + """ + Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. + """ + + def __new__(cls): + # type: () -> PotelSentrySpanProcessor + if not hasattr(cls, "instance"): + cls.instance = super().__new__(cls) + + return cls.instance + + def __init__(self): + # type: () -> None + pass + + def on_start(self, span, parent_context=None): + # type: (Span, Optional[Context]) -> None + pass + + def on_end(self, span): + # type: (Span) -> None + pass + + # TODO-neel-potel not sure we need a clear like JS + def shutdown(self): + # type: () -> None + pass + + # TODO-neel-potel change default? this is 30 sec + # TODO-neel-potel call this in client.flush + def force_flush(self, timeout_millis=30000): + # type: (int) -> bool + return True From 1a35dae8e4e961a64f5270a078b52e975f38a0b7 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Mon, 10 Jun 2024 20:51:07 +0200 Subject: [PATCH 06/32] Skeletons for new components --- .../opentelemetry/potel_span_exporter.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 sentry_sdk/integrations/opentelemetry/potel_span_exporter.py diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py new file mode 100644 index 0000000000..70cfc39105 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py @@ -0,0 +1,19 @@ +from opentelemetry.trace import Span # type: ignore + + +class PotelSentrySpanExporter: + """ + A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. + """ + + def __init__(self): + # type: () -> None + pass + + def export(self, span): + # type: (Span) -> None + pass + + def flush(self, timeout_millis): + # type: (int) -> bool + return True From c52318219ede830d14c797165e557df61fc78832 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 12 Jun 2024 13:36:37 +0200 Subject: [PATCH 07/32] Add simple scope management whenever a context is attached * create a new otel context `_SCOPES_KEY` that will hold a tuple of `(curent_scope, isolation_scope)` * the `current_scope` will always be forked (like on every span creation/context update in practice) * note that this is on `attach`, so not on all copy-on-write context object creation but only on apis such as [`trace.use_span`](https://github.com/open-telemetry/opentelemetry-python/blob/ba22b165471bde2037620f2c850ab648a849fbc0/opentelemetry-api/src/opentelemetry/trace/__init__.py#L547) or [`tracer.start_as_current_span`](https://github.com/open-telemetry/opentelemetry-python/blob/ba22b165471bde2037620f2c850ab648a849fbc0/opentelemetry-api/src/opentelemetry/trace/__init__.py#L329) * basically every otel `context` fork corresponds to our `current_scope` fork * the `isolation_scope` currently will not be forked * these will later be updated, for instance when we update our top level scope apis that fork isolation scope, that will also have a corresponding change in this `attach` function --- .../opentelemetry/contextvars_context.py | 26 ++++++++++++++----- .../integrations/opentelemetry/integration.py | 1 - .../opentelemetry/potel_span_exporter.py | 19 -------------- 3 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 sentry_sdk/integrations/opentelemetry/potel_span_exporter.py diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py index 7a382064c9..3291fca448 100644 --- a/sentry_sdk/integrations/opentelemetry/contextvars_context.py +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -1,14 +1,26 @@ -from opentelemetry.context.context import Context # type: ignore +from opentelemetry.context import Context, create_key, get_value, set_value from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext # type: ignore +from sentry_sdk.scope import Scope + + +_SCOPES_KEY = create_key("sentry_scopes") + class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): # type: ignore def attach(self, context): # type: (Context) -> object - # TODO-neel-potel do scope management - return super().attach(context) + scopes = get_value(_SCOPES_KEY, context) + + if scopes and isinstance(scopes, tuple): + (current_scope, isolation_scope) = scopes + else: + current_scope = Scope.get_current_scope() + isolation_scope = Scope.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) - def detach(self, token): - # type: (object) -> None - # TODO-neel-potel not sure if we need anything here, see later - super().detach(token) + return super().attach(new_context) diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 3a33c7f2d0..28a497c340 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -172,7 +172,6 @@ def _import_by_path(path): def _setup_sentry_tracing(): # type: () -> None - # TODO-neel-potel make sure lifecycle is correct # TODO-neel-potel contribute upstream so this is not necessary context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py b/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py deleted file mode 100644 index 70cfc39105..0000000000 --- a/sentry_sdk/integrations/opentelemetry/potel_span_exporter.py +++ /dev/null @@ -1,19 +0,0 @@ -from opentelemetry.trace import Span # type: ignore - - -class PotelSentrySpanExporter: - """ - A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. - """ - - def __init__(self): - # type: () -> None - pass - - def export(self, span): - # type: (Span) -> None - pass - - def flush(self, timeout_millis): - # type: (int) -> bool - return True From 27e9e827501a0eab4aa52f1ac2e0e25fa48eb973 Mon Sep 17 00:00:00 2001 From: Ivana Kellyerova Date: Wed, 26 Jun 2024 11:41:18 +0200 Subject: [PATCH 08/32] mypy fixes --- .../opentelemetry/contextvars_context.py | 6 +++--- .../opentelemetry/potel_span_processor.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py index 7a382064c9..e74d67dc97 100644 --- a/sentry_sdk/integrations/opentelemetry/contextvars_context.py +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -1,8 +1,8 @@ -from opentelemetry.context.context import Context # type: ignore -from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext # type: ignore +from opentelemetry.context.context import Context +from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext -class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): # type: ignore +class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): def attach(self, context): # type: (Context) -> object # TODO-neel-potel do scope management diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index 795068033e..94f01b3283 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -1,14 +1,14 @@ -from opentelemetry.sdk.trace import SpanProcessor # type: ignore -from opentelemetry.context import Context # type: ignore -from opentelemetry.trace import Span # type: ignore +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.context import Context from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional + from opentelemetry.sdk.trace import ReadableSpan -class PotelSentrySpanProcessor(SpanProcessor): # type: ignore +class PotelSentrySpanProcessor(SpanProcessor): """ Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. """ @@ -25,11 +25,11 @@ def __init__(self): pass def on_start(self, span, parent_context=None): - # type: (Span, Optional[Context]) -> None + # type: (ReadableSpan, Optional[Context]) -> None pass def on_end(self, span): - # type: (Span) -> None + # type: (ReadableSpan) -> None pass # TODO-neel-potel not sure we need a clear like JS From 048acc93935c15f46757851614cac903954ef8a4 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Fri, 28 Jun 2024 13:24:04 +0200 Subject: [PATCH 09/32] working span processor --- .../opentelemetry/potel_span_processor.py | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index 4c3272e4b1..00bf58252f 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -64,8 +64,14 @@ def _flush_root_span(self, span): if not transaction_event: return - children = self._collect_children(span) - # TODO add converted spans + spans = [] + for child in self._collect_children(span): + span_json = self._span_to_json(child) + if span_json: + spans.append(span_json) + transaction_event.setdefault("spans", []).extend(spans) + # TODO-neel-potel sort and cutoff max spans + capture_event(transaction_event) @@ -126,7 +132,37 @@ def _root_span_to_transaction_event(self, span): "contexts": contexts, "start_timestamp": convert_otel_timestamp(span.start_time), "timestamp": convert_otel_timestamp(span.end_time), - "spans": [], } # type: Event return event + + 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: + 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 + + span_json = { + "trace_id": trace_id, + "span_id": span_id, + "origin": SPAN_ORIGIN, + "op": span.name, # TODO + "description": span.name, # TODO + "status": "ok", # TODO + "start_timestamp": convert_otel_timestamp(span.start_time), + "timestamp": convert_otel_timestamp(span.end_time), + } # type: dict[str, Any] + + if parent_span_id: + span_json["parent_span_id"] = parent_span_id + if span.attributes: + span_json["data"] = dict(span.attributes) + + return span_json From 8a08fb374f2f80ebfd50dd34e29b07b2888e6375 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Fri, 28 Jun 2024 13:31:30 +0200 Subject: [PATCH 10/32] lint --- .../opentelemetry/potel_span_processor.py | 38 +++++++++++-------- .../integrations/opentelemetry/utils.py | 9 ++++- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index 00bf58252f..bf11a51838 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -1,21 +1,26 @@ from collections import deque -from datetime import datetime -from opentelemetry.trace import INVALID_SPAN, get_current_span, format_trace_id, format_span_id +from opentelemetry.trace import format_trace_id, format_span_id from opentelemetry.context import Context from opentelemetry.sdk.trace import Span, ReadableSpan, SpanProcessor from sentry_sdk import capture_event -from sentry_sdk.integrations.opentelemetry.utils import is_sentry_span, convert_otel_timestamp -from sentry_sdk.integrations.opentelemetry.consts import OTEL_SENTRY_CONTEXT, SPAN_ORIGIN +from sentry_sdk.integrations.opentelemetry.utils import ( + is_sentry_span, + convert_otel_timestamp, +) +from sentry_sdk.integrations.opentelemetry.consts import ( + OTEL_SENTRY_CONTEXT, + SPAN_ORIGIN, +) from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional, List, Any + from typing import Optional, List, Any, Deque from sentry_sdk._types import Event -class PotelSentrySpanProcessor(SpanProcessor): # type: ignore +class PotelSentrySpanProcessor(SpanProcessor): """ Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. """ @@ -74,21 +79,22 @@ def _flush_root_span(self, span): capture_event(transaction_event) - def _collect_children(self, span): # type: (ReadableSpan) -> List[ReadableSpan] if not span.context: return [] children = [] - bfs_queue = deque() + bfs_queue = deque() # type: Deque[int] bfs_queue.append(span.context.span_id) while bfs_queue: parent_span_id = bfs_queue.popleft() node_children = self._children_spans.pop(parent_span_id, []) children.extend(node_children) - bfs_queue.extend([child.context.span_id for child in node_children if child.context]) + bfs_queue.extend( + [child.context.span_id for child in node_children if child.context] + ) return children @@ -112,8 +118,8 @@ def _root_span_to_transaction_event(self, span): "trace_id": trace_id, "span_id": span_id, "origin": SPAN_ORIGIN, - "op": span.name, # TODO - "status": "ok", # TODO + "op": span.name, # TODO + "status": "ok", # TODO } # type: dict[str, Any] if parent_span_id: @@ -127,8 +133,8 @@ def _root_span_to_transaction_event(self, span): event = { "type": "transaction", - "transaction": span.name, # TODO - "transaction_info": {"source": "custom"}, # TODO + "transaction": span.name, # TODO + "transaction_info": {"source": "custom"}, # TODO "contexts": contexts, "start_timestamp": convert_otel_timestamp(span.start_time), "timestamp": convert_otel_timestamp(span.end_time), @@ -153,9 +159,9 @@ def _span_to_json(self, span): "trace_id": trace_id, "span_id": span_id, "origin": SPAN_ORIGIN, - "op": span.name, # TODO - "description": span.name, # TODO - "status": "ok", # TODO + "op": span.name, # TODO + "description": span.name, # TODO + "status": "ok", # TODO "start_timestamp": convert_otel_timestamp(span.start_time), "timestamp": convert_otel_timestamp(span.end_time), } # type: dict[str, Any] diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py index fd8c8e5721..95d6d0accd 100644 --- a/sentry_sdk/integrations/opentelemetry/utils.py +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -4,7 +4,7 @@ from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.sdk.trace import ReadableSpan -from sentry_sdk import get_client, start_transaction +from sentry_sdk import get_client from sentry_sdk.utils import Dsn from sentry_sdk._types import TYPE_CHECKING @@ -12,12 +12,16 @@ if TYPE_CHECKING: from typing import Optional + def is_sentry_span(span): # type: (ReadableSpan) -> bool """ Break infinite loop: HTTP requests to Sentry are caught by OTel and send again to Sentry. """ + if not span.attributes: + return False + span_url = span.attributes.get(SpanAttributes.HTTP_URL, None) span_url = cast("Optional[str]", span_url) @@ -41,6 +45,7 @@ def is_sentry_span(span): return False + def convert_otel_timestamp(time): # type: (int) -> datetime - return datetime.fromtimestamp(time / 1e9, timezone.utc) + return datetime.fromtimestamp(time / 1e9, timezone.utc) From 2e2e5b9c2635774c95e9a7cec94ec6416789640b Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 2 Jul 2024 14:49:14 +0200 Subject: [PATCH 11/32] Port over op/description/status extraction --- .../opentelemetry/potel_span_processor.py | 21 +++-- .../opentelemetry/span_processor.py | 84 +++---------------- .../integrations/opentelemetry/utils.py | 56 ++++++++++++- 3 files changed, 79 insertions(+), 82 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index bf11a51838..d2bc84811f 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -8,6 +8,7 @@ from sentry_sdk.integrations.opentelemetry.utils import ( is_sentry_span, convert_otel_timestamp, + extract_span_data, ) from sentry_sdk.integrations.opentelemetry.consts import ( OTEL_SENTRY_CONTEXT, @@ -100,7 +101,6 @@ def _collect_children(self, span): # we construct the event from scratch here # and not use the current Transaction class for easier refactoring - # TODO-neel-potel op, description, status logic def _root_span_to_transaction_event(self, span): # type: (ReadableSpan) -> Optional[Event] if not span.context: @@ -114,12 +114,14 @@ 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, _) = extract_span_data(span) + trace_context = { "trace_id": trace_id, "span_id": span_id, "origin": SPAN_ORIGIN, - "op": span.name, # TODO - "status": "ok", # TODO + "op": op, + "status": "ok", # TODO-neel-potel span status mapping } # type: dict[str, Any] if parent_span_id: @@ -133,8 +135,9 @@ def _root_span_to_transaction_event(self, span): event = { "type": "transaction", - "transaction": span.name, # TODO - "transaction_info": {"source": "custom"}, # TODO + "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), @@ -155,13 +158,15 @@ 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, _) = extract_span_data(span) + span_json = { "trace_id": trace_id, "span_id": span_id, "origin": SPAN_ORIGIN, - "op": span.name, # TODO - "description": span.name, # TODO - "status": "ok", # TODO + "op": op, + "description": description, + "status": "ok", # TODO-neel-potel span status mapping "start_timestamp": convert_otel_timestamp(span.start_time), "timestamp": convert_otel_timestamp(span.end_time), } # type: dict[str, Any] diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 6a63a8a13b..14f89be9eb 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -4,12 +4,10 @@ from opentelemetry.context import get_value from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan -from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace import ( format_span_id, format_trace_id, get_current_span, - SpanKind, ) from opentelemetry.trace.span import ( INVALID_SPAN_ID, @@ -23,12 +21,14 @@ OTEL_SENTRY_CONTEXT, SPAN_ORIGIN, ) -from sentry_sdk.integrations.opentelemetry.utils import is_sentry_span +from sentry_sdk.integrations.opentelemetry.utils import ( + is_sentry_span, + extract_span_data, +) from sentry_sdk.scope import add_global_event_processor from sentry_sdk.tracing import Transaction, Span as SentrySpan from sentry_sdk._types import TYPE_CHECKING -from urllib3.util import parse_url as urlparse if TYPE_CHECKING: from typing import Any, Optional, Union @@ -289,81 +289,19 @@ def _update_span_with_otel_data(self, sentry_span, otel_span): """ sentry_span.set_data("otel.kind", otel_span.kind) - op = otel_span.name - description = otel_span.name - if otel_span.attributes is not None: for key, val in otel_span.attributes.items(): sentry_span.set_data(key, val) - http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD) - http_method = cast("Optional[str]", http_method) - - db_query = otel_span.attributes.get(SpanAttributes.DB_SYSTEM) - - if http_method: - op = "http" - - if otel_span.kind == SpanKind.SERVER: - op += ".server" - elif otel_span.kind == SpanKind.CLIENT: - op += ".client" - - description = http_method - - peer_name = otel_span.attributes.get(SpanAttributes.NET_PEER_NAME, None) - if peer_name: - description += " {}".format(peer_name) - - target = otel_span.attributes.get(SpanAttributes.HTTP_TARGET, None) - if target: - description += " {}".format(target) - - if not peer_name and not target: - url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None) - url = cast("Optional[str]", url) - if url: - parsed_url = urlparse(url) - url = "{}://{}{}".format( - parsed_url.scheme, parsed_url.netloc, parsed_url.path - ) - description += " {}".format(url) - - status_code = otel_span.attributes.get( - SpanAttributes.HTTP_STATUS_CODE, None - ) - status_code = cast("Optional[int]", status_code) - if status_code: - sentry_span.set_http_status(status_code) - - elif db_query: - op = "db" - statement = otel_span.attributes.get(SpanAttributes.DB_STATEMENT, None) - statement = cast("Optional[str]", statement) - if statement: - description = statement - + (op, description, status_code) = extract_span_data(otel_span) sentry_span.op = op sentry_span.description = description + if status_code: + sentry_span.set_http_status(status_code) def _update_transaction_with_otel_data(self, sentry_span, otel_span): # type: (SentrySpan, OTelSpan) -> None - if otel_span.attributes is None: - return - - http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD) - - if http_method: - status_code = otel_span.attributes.get(SpanAttributes.HTTP_STATUS_CODE) - status_code = cast("Optional[int]", status_code) - if status_code: - sentry_span.set_http_status(status_code) - - op = "http" - - if otel_span.kind == SpanKind.SERVER: - op += ".server" - elif otel_span.kind == SpanKind.CLIENT: - op += ".client" - - sentry_span.op = op + (op, _, status_code) = extract_span_data(otel_span) + sentry_span.op = op + if status_code: + sentry_span.set_http_status(status_code) diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py index 95d6d0accd..1dc77ab150 100644 --- a/sentry_sdk/integrations/opentelemetry/utils.py +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -1,8 +1,10 @@ from typing import cast from datetime import datetime, timezone +from opentelemetry.trace import SpanKind from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.sdk.trace import ReadableSpan +from urllib3.util import parse_url as urlparse from sentry_sdk import get_client from sentry_sdk.utils import Dsn @@ -10,7 +12,7 @@ from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional + from typing import Optional, Tuple def is_sentry_span(span): @@ -49,3 +51,55 @@ def is_sentry_span(span): def convert_otel_timestamp(time): # type: (int) -> datetime return datetime.fromtimestamp(time / 1e9, timezone.utc) + + +def extract_span_data(span): + # type: (ReadableSpan) -> Tuple[str, str, Optional[int]] + op = span.name + description = span.name + status_code = None + + if span.attributes is None: + return (op, description, status_code) + + http_method = span.attributes.get(SpanAttributes.HTTP_METHOD) + http_method = cast("Optional[str]", http_method) + db_query = span.attributes.get(SpanAttributes.DB_SYSTEM) + + if http_method: + op = "http" + if span.kind == SpanKind.SERVER: + op += ".server" + elif span.kind == SpanKind.CLIENT: + op += ".client" + + description = http_method + + peer_name = span.attributes.get(SpanAttributes.NET_PEER_NAME, None) + if peer_name: + description += " {}".format(peer_name) + + target = span.attributes.get(SpanAttributes.HTTP_TARGET, None) + if target: + description += " {}".format(target) + + if not peer_name and not target: + url = span.attributes.get(SpanAttributes.HTTP_URL, None) + url = cast("Optional[str]", url) + if url: + parsed_url = urlparse(url) + url = "{}://{}{}".format( + parsed_url.scheme, parsed_url.netloc, parsed_url.path + ) + description += " {}".format(url) + + status_code = span.attributes.get(SpanAttributes.HTTP_STATUS_CODE) + elif db_query: + op = "db" + statement = span.attributes.get(SpanAttributes.DB_STATEMENT, None) + statement = cast("Optional[str]", statement) + if statement: + description = statement + + status_code = cast("Optional[int]", status_code) + return (op, description, status_code) From 2c29711223f6328497d8e98227b95bb2ec921067 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 2 Jul 2024 14:59:19 +0200 Subject: [PATCH 12/32] defaultdict --- .../opentelemetry/potel_span_processor.py | 12 +++++++----- .../opentelemetry/test_span_processor.py | 10 +++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index d2bc84811f..faa583a18d 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -1,4 +1,4 @@ -from collections import deque +from collections import deque, defaultdict from opentelemetry.trace import format_trace_id, format_span_id from opentelemetry.context import Context @@ -17,7 +17,7 @@ from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional, List, Any, Deque + from typing import Optional, List, Any, Deque, DefaultDict from sentry_sdk._types import Event @@ -35,7 +35,9 @@ def __new__(cls): def __init__(self): # type: () -> None - self._children_spans = {} # type: dict[int, List[ReadableSpan]] + self._children_spans = defaultdict( + list + ) # type: DefaultDict[int, List[ReadableSpan]] def on_start(self, span, parent_context=None): # type: (Span, Optional[Context]) -> None @@ -48,7 +50,7 @@ def on_end(self, span): # TODO-neel-potel-remote only take parent if not remote if span.parent: - self._children_spans.setdefault(span.parent.span_id, []).append(span) + self._children_spans[span.parent.span_id].append(span) else: # if have a root span ending, we build a transaction and send it self._flush_root_span(span) @@ -75,7 +77,7 @@ def _flush_root_span(self, span): span_json = self._span_to_json(child) if span_json: spans.append(span_json) - transaction_event.setdefault("spans", []).extend(spans) + transaction_event["spans"] = spans # TODO-neel-potel sort and cutoff max spans capture_event(transaction_event) diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py index 8064e127f6..cc52735214 100644 --- a/tests/integrations/opentelemetry/test_span_processor.py +++ b/tests/integrations/opentelemetry/test_span_processor.py @@ -10,6 +10,7 @@ SentrySpanProcessor, link_trace_context_to_error_event, ) +from sentry_sdk.integrations.opentelemetry.utils import is_sentry_span from sentry_sdk.scope import Scope from sentry_sdk.tracing import Span, Transaction from sentry_sdk.tracing_utils import extract_sentrytrace_data @@ -18,25 +19,24 @@ def test_is_sentry_span(): otel_span = MagicMock() - span_processor = SentrySpanProcessor() - assert not span_processor._is_sentry_span(otel_span) + assert not is_sentry_span(otel_span) client = MagicMock() client.options = {"instrumenter": "otel"} client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456" Scope.get_global_scope().set_client(client) - assert not span_processor._is_sentry_span(otel_span) + assert not is_sentry_span(otel_span) otel_span.attributes = { "http.url": "https://example.com", } - assert not span_processor._is_sentry_span(otel_span) + assert not is_sentry_span(otel_span) otel_span.attributes = { "http.url": "https://o123456.ingest.sentry.io/api/123/envelope", } - assert span_processor._is_sentry_span(otel_span) + assert is_sentry_span(otel_span) def test_get_otel_context(): From 7afe4bb05fd687312dd449cfc50218a98a6df528 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 3 Jul 2024 16:45:28 +0200 Subject: [PATCH 13/32] naive impl --- sentry_sdk/api.py | 19 ++++++++++++----- sentry_sdk/tracing.py | 48 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 1 + 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 3dd6f9c737..dc7087646a 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -1,7 +1,7 @@ import inspect from contextlib import contextmanager -from sentry_sdk import tracing_utils, Client +from sentry_sdk import tracing, tracing_utils, Client from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope @@ -282,15 +282,20 @@ def flush( return Scope.get_client().flush(timeout=timeout, callback=callback) -@scopemethod def start_span( **kwargs, # type: Any ): # type: (...) -> Span - return Scope.get_current_scope().start_span(**kwargs) + return tracing.start_span(**kwargs) + + +def start_inactive_span( + **kwargs, # type: Any +): + # type: (...) -> Span + return tracing.start_inactive_span(**kwargs) -@scopemethod def start_transaction( transaction=None, # type: Optional[Transaction] instrumenter=INSTRUMENTER.SENTRY, # type: str @@ -299,6 +304,10 @@ def start_transaction( ): # type: (...) -> 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 on the current scope. Start an existing transaction if given, otherwise create and start a new @@ -328,7 +337,7 @@ def start_transaction( constructor. See :py:class:`sentry_sdk.tracing.Transaction` for available arguments. """ - return Scope.get_current_scope().start_transaction( + return tracing.start_transaction( transaction, instrumenter, custom_sampling_context, **kwargs ) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 96ef81496f..7633c47a90 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.sdk.trace import ReadableSpan + import sentry_sdk from sentry_sdk.consts import INSTRUMENTER, SPANDATA from sentry_sdk.profiler.continuous_profiler import get_profiler_id @@ -198,6 +201,7 @@ class Span: :param start_timestamp: The timestamp when the span started. If omitted, the current time will be used. :param scope: The scope to use for this span. If not provided, we use the current scope. + :param otel_span: The underlying OTel span this span is wrapping. """ __slots__ = ( @@ -1156,6 +1160,35 @@ def _set_initial_sampling_decision(self, sampling_context): pass +class OTelSpanWrapper: + """ + Light-weight OTel span wrapper. + + Meant for providing some level of compatibility with the old span interface. + """ + + def __init__(self, otel_span=None, **kwargs): + # type: (Optional[ReadableSpan]) -> None + self.description = kwargs.get("description") + self._otel_span = otel_span + + def __enter__(self): + # type: () -> OTelSpanWrapper + # Creates a Context object with parent set as current span + 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() + context.detach(self._ctx_token) + + def start_child(self, **kwargs): + pass + + if TYPE_CHECKING: @overload @@ -1198,6 +1231,21 @@ async def my_async_function(): return start_child_span_decorator +def start_span(**kwargs): + otel_span = otel_trace.get_tracer(__name__).start_span() + span = OTelSpanWrapper(otel_span=otel_span, **kwargs) + return span + + +def start_inactive_span(**kwargs): + pass + + +def start_transaction(**kwargs): + # XXX force_transaction? + pass + + # Circular imports from sentry_sdk.tracing_utils import ( diff --git a/setup.py b/setup.py index e1245c05bb..b00b63020d 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"], From 21b59218105a6e85c0ccf06514b58f2eb3fe1db0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 4 Jul 2024 16:44:26 +0200 Subject: [PATCH 14/32] wip --- sentry_sdk/api.py | 1 + sentry_sdk/tracing.py | 166 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 147 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index dc7087646a..9f53c2ce01 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -70,6 +70,7 @@ def overload(x): "set_tags", "set_user", "start_span", + "start_inactive_span", "start_transaction", ] diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 7633c47a90..4d4c73b3cd 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from opentelemetry import trace as otel_trace, context -from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.trace.status import StatusCode import sentry_sdk from sentry_sdk.consts import INSTRUMENTER, SPANDATA @@ -153,6 +153,8 @@ class TransactionKwargs(SpanKwargs, total=False): "url": TRANSACTION_SOURCE_ROUTE, } +tracer = otel_trace.get_tracer(__name__) + class _SpanRecorder: """Limits the number of spans recorded in a transaction.""" @@ -1160,32 +1162,158 @@ def _set_initial_sampling_decision(self, sampling_context): pass -class OTelSpanWrapper: +class POTelSpan: """ - Light-weight OTel span wrapper. - - Meant for providing some level of compatibility with the old span interface. + OTel span wrapper providing compatibility with the old span interface. """ - def __init__(self, otel_span=None, **kwargs): - # type: (Optional[ReadableSpan]) -> None - self.description = kwargs.get("description") - self._otel_span = otel_span + # 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 + trace_id=None, # type: Optional[str] + span_id=None, # type: Optional[str] + parent_span_id=None, # type: Optional[str] + same_process_as_parent=True, # type: bool + sampled=None, # type: Optional[bool] + op=None, # type: Optional[str] + description=None, # type: Optional[str] + status=None, # type: Optional[str] + 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 + ): + # type: (...) -> None + self._otel_span = tracer.start_span(description or "") + self._active = active + # XXX def __enter__(self): - # type: () -> OTelSpanWrapper - # Creates a Context object with parent set as current span - ctx = otel_trace.set_span_in_context(self._otel_span) - # Set as the implicit current context - self._ctx_token = context.attach(ctx) + # type: () -> POTelSpan + # 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() - context.detach(self._ctx_token) + + if self._active: + context.detach(self._ctx_token) + + @property + def containing_transaction(self): + # type: () -> Optional[Transaction] + pass def start_child(self, **kwargs): + # type: (str, **Any) -> POTelSpan + pass + + @classmethod + def continue_from_environ( + cls, + environ, # type: Mapping[str, str] + **kwargs, # type: Any + ): + # type: (...) -> POTelSpan + pass + + @classmethod + def continue_from_headers( + cls, + headers, # type: Mapping[str, str] + **kwargs, # type: Any + ): + # type: (...) -> POTelSpan + pass + + 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] + pass + + def to_traceparent(self): + # type: () -> str + pass + + 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 + pass + + def set_status(self, status): + # type: (str) -> None + pass + + def set_measurement(self, name, value, unit=""): + # type: (str, float, MeasurementUnit) -> None + pass + + def set_thread(self, thread_id, thread_name): + # type: (Optional[int], Optional[str]) -> None + pass + + def set_profiler_id(self, profiler_id): + # type: (Optional[str]) -> None + pass + + def set_http_status(self, http_status): + # type: (int) -> None + pass + + 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 @@ -1232,18 +1360,16 @@ async def my_async_function(): def start_span(**kwargs): - otel_span = otel_trace.get_tracer(__name__).start_span() - span = OTelSpanWrapper(otel_span=otel_span, **kwargs) - return span + return POTelSpan(active=True, **kwargs) def start_inactive_span(**kwargs): - pass + return POTelSpan(active=False, **kwargs) def start_transaction(**kwargs): # XXX force_transaction? - pass + return POTelSpan(active=True, **kwargs) # Circular imports From 2e1b785da1f73ffb450e78ec4d66e624704219e0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 4 Jul 2024 17:04:14 +0200 Subject: [PATCH 15/32] fix args --- sentry_sdk/tracing.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 4d4c73b3cd..64a3a8c1dd 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1359,17 +1359,17 @@ async def my_async_function(): return start_child_span_decorator -def start_span(**kwargs): - return POTelSpan(active=True, **kwargs) +def start_span(*args, **kwargs): + return POTelSpan(*args, active=True, **kwargs) -def start_inactive_span(**kwargs): - return POTelSpan(active=False, **kwargs) +def start_inactive_span(*args, **kwargs): + return POTelSpan(*args, active=False, **kwargs) -def start_transaction(**kwargs): +def start_transaction(*args, **kwargs): # XXX force_transaction? - return POTelSpan(active=True, **kwargs) + return POTelSpan(*args, active=True, **kwargs) # Circular imports From 74f1e4aa48ff2c03bdee08169a0ebf54627e15b4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 5 Jul 2024 13:54:48 +0200 Subject: [PATCH 16/32] wip --- sentry_sdk/api.py | 8 ++++---- sentry_sdk/integrations/opentelemetry/consts.py | 9 +++++++++ .../opentelemetry/span_processor.py | 2 +- sentry_sdk/tracing.py | 17 +++++++++++++---- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 9f53c2ce01..1a290f9cd2 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -5,7 +5,7 @@ from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope -from sentry_sdk.tracing import NoOpSpan, Transaction +from sentry_sdk.tracing import POTelSpan, Transaction if TYPE_CHECKING: from collections.abc import Mapping @@ -286,14 +286,14 @@ def flush( def start_span( **kwargs, # type: Any ): - # type: (...) -> Span + # type: (...) -> POTelSpan return tracing.start_span(**kwargs) def start_inactive_span( **kwargs, # type: Any ): - # type: (...) -> Span + # type: (...) -> POTelSpan return tracing.start_inactive_span(**kwargs) @@ -303,7 +303,7 @@ def start_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. diff --git a/sentry_sdk/integrations/opentelemetry/consts.py b/sentry_sdk/integrations/opentelemetry/consts.py index 69a770ad53..a23f8d4c6e 100644 --- a/sentry_sdk/integrations/opentelemetry/consts.py +++ b/sentry_sdk/integrations/opentelemetry/consts.py @@ -5,3 +5,12 @@ SENTRY_BAGGAGE_KEY = create_key("sentry-baggage") 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/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 14f89be9eb..b7f0664b13 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -3,7 +3,7 @@ from typing import cast from opentelemetry.context import get_value -from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan +from opentelemetry.sdk.trace import SpanProcessor, Span as OTelSpan from opentelemetry.trace import ( format_span_id, format_trace_id, diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 64a3a8c1dd..0291c47e18 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1170,7 +1170,7 @@ class POTelSpan: # 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. + # XXX The wrapper itself should have as little state as possible def __init__( self, @@ -1189,12 +1189,21 @@ def __init__( origin="manual", # type: str ): # type: (...) -> None - self._otel_span = tracer.start_span(description or "") + from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + + self._otel_span = tracer.start_span(description or op or "") # XXX self._active = active - # XXX + + self._otel_span.set_attribute(SentrySpanAttribute.ORIGIN, origin) + if op is not None: + self._otel_span.set_attribute(SentrySpanAttribute.OP, op) + if description is not None: + self._otel_span.set_attribute(SentrySpanAttribute.DESCRIPTION, description) 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) @@ -1206,7 +1215,7 @@ def __enter__(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) From 8993799d6b092cffab7fa509c88833cc25d28619 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 8 Jul 2024 15:36:56 +0200 Subject: [PATCH 17/32] remove extra docs --- sentry_sdk/tracing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 0291c47e18..f290d3cf54 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -203,7 +203,6 @@ class Span: :param start_timestamp: The timestamp when the span started. If omitted, the current time will be used. :param scope: The scope to use for this span. If not provided, we use the current scope. - :param otel_span: The underlying OTel span this span is wrapping. """ __slots__ = ( From cd3e140c040765d3edcaaa4b93a9f60b4f5fed2d Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 9 Jul 2024 14:35:41 +0200 Subject: [PATCH 18/32] Add simple scope management whenever a context is attached (#3159) Add simple scope management whenever a context is attached * create a new otel context `_SCOPES_KEY` that will hold a tuple of `(curent_scope, isolation_scope)` * the `current_scope` will always be forked (like on every span creation/context update in practice) * note that this is on `attach`, so not on all copy-on-write context object creation but only on apis such as [`trace.use_span`](https://github.com/open-telemetry/opentelemetry-python/blob/ba22b165471bde2037620f2c850ab648a849fbc0/opentelemetry-api/src/opentelemetry/trace/__init__.py#L547) or [`tracer.start_as_current_span`](https://github.com/open-telemetry/opentelemetry-python/blob/ba22b165471bde2037620f2c850ab648a849fbc0/opentelemetry-api/src/opentelemetry/trace/__init__.py#L329) * basically every otel `context` fork corresponds to our `current_scope` fork * the `isolation_scope` currently will not be forked * these will later be updated, for instance when we update our top level scope apis that fork isolation scope, that will also have a corresponding change in this `attach` function --- .../opentelemetry/contextvars_context.py | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/contextvars_context.py b/sentry_sdk/integrations/opentelemetry/contextvars_context.py index e74d67dc97..5e5eb9ba30 100644 --- a/sentry_sdk/integrations/opentelemetry/contextvars_context.py +++ b/sentry_sdk/integrations/opentelemetry/contextvars_context.py @@ -1,14 +1,26 @@ -from opentelemetry.context.context import Context +from opentelemetry.context import Context, create_key, get_value, set_value from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext +from sentry_sdk.scope import Scope + + +_SCOPES_KEY = create_key("sentry_scopes") + class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext): def attach(self, context): # type: (Context) -> object - # TODO-neel-potel do scope management - return super().attach(context) + scopes = get_value(_SCOPES_KEY, context) + + if scopes and isinstance(scopes, tuple): + (current_scope, isolation_scope) = scopes + else: + current_scope = Scope.get_current_scope() + isolation_scope = Scope.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) - def detach(self, token): - # type: (object) -> None - # TODO-neel-potel not sure if we need anything here, see later - super().detach(token) + return super().attach(new_context) From f0c1a84e7febf17efab72199947b3c4595013a48 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 9 Jul 2024 14:37:37 +0200 Subject: [PATCH 19/32] Implement new POTel span processor (#3223) * only acts on `on_end` instead of both `on_start/on_end` as before * store children spans in a dict mapping `span_id -> children` * new dict only stores otel span objects and no sentry transaction/span objects so we save a bit of useless memory allocation * I'm not using our current `Transaction/Span` classes at all to build the event because when we add our APIs later, we'll need to rip these out and we also avoid having to deal with the `instrumenter` problem * if we get a root span (without parent), we recursively walk the dict and find the children and package up the transaction event and send it * I didn't do it like JS because I think this way is better * they [group an array of `finished_spans`](https://github.com/getsentry/sentry-javascript/blob/7e298036a21a5658f3eb9ba184165178c48d7ef8/packages/opentelemetry/src/spanExporter.ts#L132) every time a root span ends and I think this uses more cpu than what I did * and the dict like I used it doesn't take more space than the array either * if we get a span with a parent we just update the dict to find the span later * moved the common `is_sentry_span` logic to utils --- .../integrations/opentelemetry/consts.py | 2 + .../opentelemetry/potel_span_processor.py | 149 +++++++++++++++++- .../opentelemetry/span_processor.py | 118 ++------------ .../integrations/opentelemetry/utils.py | 105 ++++++++++++ .../opentelemetry/test_span_processor.py | 10 +- 5 files changed, 270 insertions(+), 114 deletions(-) create mode 100644 sentry_sdk/integrations/opentelemetry/utils.py diff --git a/sentry_sdk/integrations/opentelemetry/consts.py b/sentry_sdk/integrations/opentelemetry/consts.py index ec493449d3..69a770ad53 100644 --- a/sentry_sdk/integrations/opentelemetry/consts.py +++ b/sentry_sdk/integrations/opentelemetry/consts.py @@ -3,3 +3,5 @@ SENTRY_TRACE_KEY = create_key("sentry-trace") SENTRY_BAGGAGE_KEY = create_key("sentry-baggage") +OTEL_SENTRY_CONTEXT = "otel" +SPAN_ORIGIN = "auto.otel" diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index 94f01b3283..faa583a18d 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -1,11 +1,24 @@ -from opentelemetry.sdk.trace import SpanProcessor +from collections import deque, defaultdict + +from opentelemetry.trace import format_trace_id, format_span_id from opentelemetry.context import Context +from opentelemetry.sdk.trace import Span, ReadableSpan, SpanProcessor +from sentry_sdk import capture_event +from sentry_sdk.integrations.opentelemetry.utils import ( + is_sentry_span, + convert_otel_timestamp, + extract_span_data, +) +from sentry_sdk.integrations.opentelemetry.consts import ( + OTEL_SENTRY_CONTEXT, + SPAN_ORIGIN, +) from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional - from opentelemetry.sdk.trace import ReadableSpan + from typing import Optional, List, Any, Deque, DefaultDict + from sentry_sdk._types import Event class PotelSentrySpanProcessor(SpanProcessor): @@ -22,15 +35,25 @@ def __new__(cls): def __init__(self): # type: () -> None - pass + self._children_spans = defaultdict( + list + ) # type: DefaultDict[int, List[ReadableSpan]] def on_start(self, span, parent_context=None): - # type: (ReadableSpan, Optional[Context]) -> None + # type: (Span, Optional[Context]) -> None pass def on_end(self, span): # type: (ReadableSpan) -> None - pass + if is_sentry_span(span): + return + + # TODO-neel-potel-remote only take parent if not remote + if span.parent: + self._children_spans[span.parent.span_id].append(span) + else: + # if have a root span ending, we build a transaction and send it + self._flush_root_span(span) # TODO-neel-potel not sure we need a clear like JS def shutdown(self): @@ -42,3 +65,117 @@ def shutdown(self): def force_flush(self, timeout_millis=30000): # type: (int) -> bool return True + + def _flush_root_span(self, span): + # type: (ReadableSpan) -> None + transaction_event = self._root_span_to_transaction_event(span) + if not transaction_event: + return + + spans = [] + for child in self._collect_children(span): + span_json = self._span_to_json(child) + if span_json: + spans.append(span_json) + transaction_event["spans"] = spans + # TODO-neel-potel sort and cutoff max spans + + capture_event(transaction_event) + + def _collect_children(self, span): + # type: (ReadableSpan) -> List[ReadableSpan] + if not span.context: + return [] + + children = [] + bfs_queue = deque() # type: Deque[int] + bfs_queue.append(span.context.span_id) + + while bfs_queue: + parent_span_id = bfs_queue.popleft() + node_children = self._children_spans.pop(parent_span_id, []) + children.extend(node_children) + bfs_queue.extend( + [child.context.span_id for child in node_children if child.context] + ) + + return children + + # we construct the event from scratch here + # and not use the current Transaction class for easier refactoring + 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: + 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, _) = extract_span_data(span) + + trace_context = { + "trace_id": trace_id, + "span_id": span_id, + "origin": SPAN_ORIGIN, + "op": op, + "status": "ok", # TODO-neel-potel span status mapping + } # type: dict[str, Any] + + if parent_span_id: + trace_context["parent_span_id"] = parent_span_id + if span.attributes: + trace_context["data"] = dict(span.attributes) + + contexts = {"trace": trace_context} + 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 + + return event + + 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: + 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, _) = extract_span_data(span) + + span_json = { + "trace_id": trace_id, + "span_id": span_id, + "origin": SPAN_ORIGIN, + "op": op, + "description": description, + "status": "ok", # TODO-neel-potel span status mapping + "start_timestamp": convert_otel_timestamp(span.start_time), + "timestamp": convert_otel_timestamp(span.end_time), + } # type: dict[str, Any] + + if parent_span_id: + span_json["parent_span_id"] = parent_span_id + if span.attributes: + span_json["data"] = dict(span.attributes) + + return span_json diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 1429161c2f..14f89be9eb 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -4,12 +4,10 @@ from opentelemetry.context import get_value from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan -from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace import ( format_span_id, format_trace_id, get_current_span, - SpanKind, ) from opentelemetry.trace.span import ( INVALID_SPAN_ID, @@ -20,22 +18,24 @@ from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, + OTEL_SENTRY_CONTEXT, + SPAN_ORIGIN, +) +from sentry_sdk.integrations.opentelemetry.utils import ( + is_sentry_span, + extract_span_data, ) from sentry_sdk.scope import add_global_event_processor from sentry_sdk.tracing import Transaction, Span as SentrySpan -from sentry_sdk.utils import Dsn from sentry_sdk._types import TYPE_CHECKING -from urllib3.util import parse_url as urlparse if TYPE_CHECKING: from typing import Any, Optional, Union from opentelemetry import context as context_api from sentry_sdk._types import Event, Hint -OPEN_TELEMETRY_CONTEXT = "otel" SPAN_MAX_TIME_OPEN_MINUTES = 10 -SPAN_ORIGIN = "auto.otel" def link_trace_context_to_error_event(event, otel_span_map): @@ -117,18 +117,13 @@ def on_start(self, otel_span, parent_context=None): if not client.dsn: return - try: - _ = Dsn(client.dsn) - except Exception: - return - if client.options["instrumenter"] != INSTRUMENTER.OTEL: return if not otel_span.get_span_context().is_valid: return - if self._is_sentry_span(otel_span): + if is_sentry_span(otel_span): return trace_data = self._get_trace_data(otel_span, parent_context) @@ -200,7 +195,7 @@ def on_end(self, otel_span): if isinstance(sentry_span, Transaction): sentry_span.name = otel_span.name sentry_span.set_context( - OPEN_TELEMETRY_CONTEXT, self._get_otel_context(otel_span) + OTEL_SENTRY_CONTEXT, self._get_otel_context(otel_span) ) self._update_transaction_with_otel_data(sentry_span, otel_span) @@ -223,27 +218,6 @@ def on_end(self, otel_span): self._prune_old_spans() - def _is_sentry_span(self, otel_span): - # type: (OTelSpan) -> bool - """ - Break infinite loop: - HTTP requests to Sentry are caught by OTel and send again to Sentry. - """ - otel_span_url = None - if otel_span.attributes is not None: - otel_span_url = otel_span.attributes.get(SpanAttributes.HTTP_URL) - otel_span_url = cast("Optional[str]", otel_span_url) - - dsn_url = None - client = get_client() - if client.dsn: - dsn_url = Dsn(client.dsn).netloc - - if otel_span_url and dsn_url and dsn_url in otel_span_url: - return True - - return False - def _get_otel_context(self, otel_span): # type: (OTelSpan) -> dict[str, Any] """ @@ -315,81 +289,19 @@ def _update_span_with_otel_data(self, sentry_span, otel_span): """ sentry_span.set_data("otel.kind", otel_span.kind) - op = otel_span.name - description = otel_span.name - if otel_span.attributes is not None: for key, val in otel_span.attributes.items(): sentry_span.set_data(key, val) - http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD) - http_method = cast("Optional[str]", http_method) - - db_query = otel_span.attributes.get(SpanAttributes.DB_SYSTEM) - - if http_method: - op = "http" - - if otel_span.kind == SpanKind.SERVER: - op += ".server" - elif otel_span.kind == SpanKind.CLIENT: - op += ".client" - - description = http_method - - peer_name = otel_span.attributes.get(SpanAttributes.NET_PEER_NAME, None) - if peer_name: - description += " {}".format(peer_name) - - target = otel_span.attributes.get(SpanAttributes.HTTP_TARGET, None) - if target: - description += " {}".format(target) - - if not peer_name and not target: - url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None) - url = cast("Optional[str]", url) - if url: - parsed_url = urlparse(url) - url = "{}://{}{}".format( - parsed_url.scheme, parsed_url.netloc, parsed_url.path - ) - description += " {}".format(url) - - status_code = otel_span.attributes.get( - SpanAttributes.HTTP_STATUS_CODE, None - ) - status_code = cast("Optional[int]", status_code) - if status_code: - sentry_span.set_http_status(status_code) - - elif db_query: - op = "db" - statement = otel_span.attributes.get(SpanAttributes.DB_STATEMENT, None) - statement = cast("Optional[str]", statement) - if statement: - description = statement - + (op, description, status_code) = extract_span_data(otel_span) sentry_span.op = op sentry_span.description = description + if status_code: + sentry_span.set_http_status(status_code) def _update_transaction_with_otel_data(self, sentry_span, otel_span): # type: (SentrySpan, OTelSpan) -> None - if otel_span.attributes is None: - return - - http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD) - - if http_method: - status_code = otel_span.attributes.get(SpanAttributes.HTTP_STATUS_CODE) - status_code = cast("Optional[int]", status_code) - if status_code: - sentry_span.set_http_status(status_code) - - op = "http" - - if otel_span.kind == SpanKind.SERVER: - op += ".server" - elif otel_span.kind == SpanKind.CLIENT: - op += ".client" - - sentry_span.op = op + (op, _, status_code) = extract_span_data(otel_span) + sentry_span.op = op + if status_code: + sentry_span.set_http_status(status_code) diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py new file mode 100644 index 0000000000..1dc77ab150 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -0,0 +1,105 @@ +from typing import cast +from datetime import datetime, timezone + +from opentelemetry.trace import SpanKind +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.sdk.trace import ReadableSpan +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, Tuple + + +def is_sentry_span(span): + # type: (ReadableSpan) -> bool + """ + Break infinite loop: + HTTP requests to Sentry are caught by OTel and send again to Sentry. + """ + if not span.attributes: + return False + + span_url = span.attributes.get(SpanAttributes.HTTP_URL, None) + span_url = cast("Optional[str]", span_url) + + if not span_url: + return False + + dsn_url = None + client = get_client() + + if client.dsn: + try: + dsn_url = Dsn(client.dsn).netloc + except Exception: + pass + + if not dsn_url: + return False + + if dsn_url in span_url: + return True + + return False + + +def convert_otel_timestamp(time): + # type: (int) -> datetime + return datetime.fromtimestamp(time / 1e9, timezone.utc) + + +def extract_span_data(span): + # type: (ReadableSpan) -> Tuple[str, str, Optional[int]] + op = span.name + description = span.name + status_code = None + + if span.attributes is None: + return (op, description, status_code) + + http_method = span.attributes.get(SpanAttributes.HTTP_METHOD) + http_method = cast("Optional[str]", http_method) + db_query = span.attributes.get(SpanAttributes.DB_SYSTEM) + + if http_method: + op = "http" + if span.kind == SpanKind.SERVER: + op += ".server" + elif span.kind == SpanKind.CLIENT: + op += ".client" + + description = http_method + + peer_name = span.attributes.get(SpanAttributes.NET_PEER_NAME, None) + if peer_name: + description += " {}".format(peer_name) + + target = span.attributes.get(SpanAttributes.HTTP_TARGET, None) + if target: + description += " {}".format(target) + + if not peer_name and not target: + url = span.attributes.get(SpanAttributes.HTTP_URL, None) + url = cast("Optional[str]", url) + if url: + parsed_url = urlparse(url) + url = "{}://{}{}".format( + parsed_url.scheme, parsed_url.netloc, parsed_url.path + ) + description += " {}".format(url) + + status_code = span.attributes.get(SpanAttributes.HTTP_STATUS_CODE) + elif db_query: + op = "db" + statement = span.attributes.get(SpanAttributes.DB_STATEMENT, None) + statement = cast("Optional[str]", statement) + if statement: + description = statement + + status_code = cast("Optional[int]", status_code) + return (op, description, status_code) diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py index 8064e127f6..cc52735214 100644 --- a/tests/integrations/opentelemetry/test_span_processor.py +++ b/tests/integrations/opentelemetry/test_span_processor.py @@ -10,6 +10,7 @@ SentrySpanProcessor, link_trace_context_to_error_event, ) +from sentry_sdk.integrations.opentelemetry.utils import is_sentry_span from sentry_sdk.scope import Scope from sentry_sdk.tracing import Span, Transaction from sentry_sdk.tracing_utils import extract_sentrytrace_data @@ -18,25 +19,24 @@ def test_is_sentry_span(): otel_span = MagicMock() - span_processor = SentrySpanProcessor() - assert not span_processor._is_sentry_span(otel_span) + assert not is_sentry_span(otel_span) client = MagicMock() client.options = {"instrumenter": "otel"} client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456" Scope.get_global_scope().set_client(client) - assert not span_processor._is_sentry_span(otel_span) + assert not is_sentry_span(otel_span) otel_span.attributes = { "http.url": "https://example.com", } - assert not span_processor._is_sentry_span(otel_span) + assert not is_sentry_span(otel_span) otel_span.attributes = { "http.url": "https://o123456.ingest.sentry.io/api/123/envelope", } - assert span_processor._is_sentry_span(otel_span) + assert is_sentry_span(otel_span) def test_get_otel_context(): From 0fe78f6e6f4d890eb77843c56fd8a458cecff7f9 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 16 Jul 2024 14:27:47 +0200 Subject: [PATCH 20/32] Basic test cases for potel (#3286) --- .../opentelemetry/potel_span_processor.py | 1 + .../integrations/opentelemetry/test_potel.py | 210 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 tests/integrations/opentelemetry/test_potel.py diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index faa583a18d..849e75d471 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -118,6 +118,7 @@ def _root_span_to_transaction_event(self, span): (op, description, _) = extract_span_data(span) + # TODO-neel-potel DSC trace_context = { "trace_id": trace_id, "span_id": span_id, diff --git a/tests/integrations/opentelemetry/test_potel.py b/tests/integrations/opentelemetry/test_potel.py new file mode 100644 index 0000000000..deaa0df8c9 --- /dev/null +++ b/tests/integrations/opentelemetry/test_potel.py @@ -0,0 +1,210 @@ +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"] == "auto.otel" + 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"] == "auto.otel" + 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"] == "auto.otel" + 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"] == "auto.otel" + 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"] From d80af4c51303c78ba4a99903a9d068e8cd90e8d5 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 16 Jul 2024 15:15:31 +0200 Subject: [PATCH 21/32] Proxy POTelSpan.set_data to underlying otel span attributes (#3297) --- sentry_sdk/tracing.py | 2 +- .../integrations/opentelemetry/test_potel.py | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index c76d0ca3dc..156769080a 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1272,7 +1272,7 @@ def set_tag(self, key, value): def set_data(self, key, value): # type: (str, Any) -> None - pass + self._otel_span.set_attribute(key, value) def set_status(self, status): # type: (str) -> None diff --git a/tests/integrations/opentelemetry/test_potel.py b/tests/integrations/opentelemetry/test_potel.py index deaa0df8c9..1d315b9974 100644 --- a/tests/integrations/opentelemetry/test_potel.py +++ b/tests/integrations/opentelemetry/test_potel.py @@ -208,3 +208,45 @@ def test_children_span_nesting_mixed(capture_envelopes): 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", + } From 25914a57f389537fb766a1ad052ed2ece3ca5919 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Fri, 2 Aug 2024 19:12:55 +0200 Subject: [PATCH 22/32] ref(tracing): Simplify backwards-compat code (#3379) With this change, we aim to simplify the backwards-compatibility code for POTel tracing. We do this as follows: - Remove `start_*` functions from `tracing` - Remove unused parameters from `tracing.POTelSpan.__init__`. - Make all parameters to `tracing.POTelSpan.__init__` kwarg-only. - Allow `tracing.POTelSpan.__init__` to accept arbitrary kwargs, which are all ignored, for compatibility with old `Span` interface. - Completely remove `start_inactive_span`, since inactive spans can be created by setting `active=False` when constructing a `POTelSpan`. --- sentry_sdk/api.py | 16 ++++++---------- sentry_sdk/tracing.py | 29 +++++++---------------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 4c3104f35f..94ee10421e 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -77,7 +77,6 @@ def overload(x): "set_tags", "set_user", "start_span", - "start_inactive_span", "start_transaction", "trace", "monitor", @@ -338,14 +337,11 @@ def start_span( **kwargs, # type: Any ): # type: (...) -> POTelSpan - return tracing.start_span(**kwargs) - - -def start_inactive_span( - **kwargs, # type: Any -): - # type: (...) -> POTelSpan - return tracing.start_inactive_span(**kwargs) + """ + Alias for tracing.POTelSpan constructor. The method signature is the same. + """ + # TODO: Consider adding type hints to the method signature. + return tracing.POTelSpan(**kwargs) def start_transaction( @@ -387,7 +383,7 @@ def start_transaction( constructor. See :py:class:`sentry_sdk.tracing.Transaction` for available arguments. """ - return tracing.start_transaction(transaction, custom_sampling_context, **kwargs) + return start_span(**kwargs) def set_measurement(name, value, unit=""): diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 145104c6f6..1db105fa62 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1249,21 +1249,19 @@ class POTelSpan: def __init__( self, + *, active=True, # type: bool - trace_id=None, # type: Optional[str] - span_id=None, # type: Optional[str] - parent_span_id=None, # type: Optional[str] - same_process_as_parent=True, # type: bool - sampled=None, # type: Optional[bool] op=None, # type: Optional[str] description=None, # type: Optional[str] - status=None, # type: Optional[str] - 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 + **_, # 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.consts import SentrySpanAttribute self._otel_span = tracer.start_span(description or op or "") # XXX @@ -1443,19 +1441,6 @@ async def my_async_function(): return start_child_span_decorator -def start_span(*args, **kwargs): - return POTelSpan(*args, active=True, **kwargs) - - -def start_inactive_span(*args, **kwargs): - return POTelSpan(*args, active=False, **kwargs) - - -def start_transaction(*args, **kwargs): - # XXX force_transaction? - return POTelSpan(*args, active=True, **kwargs) - - # Circular imports from sentry_sdk.tracing_utils import ( From cb6b6869c82911355b053d60d7114a41049e491d Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 6 Aug 2024 13:25:07 +0200 Subject: [PATCH 23/32] New Scope implementation based on OTel Context (#3389) * New `PotelScope` inherits from scope and reads the scope from the otel context key `SENTRY_SCOPES_KEY` * New `isolation_scope` and `new_scope` context managers just use the context manager forking and yield with the scopes living on the above context key * isolation scope forking is done with the `SENTRY_FORK_ISOLATION_SCOPE_KEY` boolean context key --- sentry_sdk/api.py | 3 +- .../integrations/opentelemetry/__init__.py | 13 +-- .../integrations/opentelemetry/consts.py | 6 ++ .../opentelemetry/contextvars_context.py | 30 ++++--- .../integrations/opentelemetry/integration.py | 14 +++- .../integrations/opentelemetry/scope.py | 84 +++++++++++++++++++ sentry_sdk/scope.py | 38 ++++++--- tests/conftest.py | 4 + .../integrations/opentelemetry/test_potel.py | 64 ++++++++++++++ 9 files changed, 223 insertions(+), 33 deletions(-) create mode 100644 sentry_sdk/integrations/opentelemetry/scope.py diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 94ee10421e..45c9d1e2c5 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -4,9 +4,10 @@ from sentry_sdk import tracing, tracing_utils, Client from sentry_sdk._init_implementation import init -from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope 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 diff --git a/sentry_sdk/integrations/opentelemetry/__init__.py b/sentry_sdk/integrations/opentelemetry/__init__.py index e0020204d5..43587f2e01 100644 --- a/sentry_sdk/integrations/opentelemetry/__init__.py +++ b/sentry_sdk/integrations/opentelemetry/__init__.py @@ -1,7 +1,8 @@ -from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 - SentrySpanProcessor, -) +# TODO-neel-potel fix circular imports +# from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 +# SentrySpanProcessor, +# ) -from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401 - SentryPropagator, -) +# from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401 +# SentryPropagator, +# ) diff --git a/sentry_sdk/integrations/opentelemetry/consts.py b/sentry_sdk/integrations/opentelemetry/consts.py index a23f8d4c6e..3c5fc61cf6 100644 --- a/sentry_sdk/integrations/opentelemetry/consts.py +++ b/sentry_sdk/integrations/opentelemetry/consts.py @@ -1,8 +1,14 @@ 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" 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/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/scope.py b/sentry_sdk/scope.py index ac47445e17..a4c7510faa 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -255,13 +255,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 @@ -281,13 +289,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 @@ -342,13 +358,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) @@ -374,7 +388,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: @@ -383,7 +397,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: @@ -1361,8 +1375,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/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 index 1d315b9974..2e094b41b5 100644 --- a/tests/integrations/opentelemetry/test_potel.py +++ b/tests/integrations/opentelemetry/test_potel.py @@ -250,3 +250,67 @@ def test_span_data_started_with_sentry(capture_envelopes): "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"} From 0e4755b8301eb19656d68a9db2c060888d11b52b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 12 Aug 2024 14:20:05 +0200 Subject: [PATCH 24/32] Fix circular imports (#3431) --- sentry_sdk/integrations/opentelemetry/__init__.py | 13 ++++++------- .../integrations/opentelemetry/span_processor.py | 3 ++- sentry_sdk/integrations/opentelemetry/utils.py | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/__init__.py b/sentry_sdk/integrations/opentelemetry/__init__.py index 43587f2e01..e0020204d5 100644 --- a/sentry_sdk/integrations/opentelemetry/__init__.py +++ b/sentry_sdk/integrations/opentelemetry/__init__.py @@ -1,8 +1,7 @@ -# TODO-neel-potel fix circular imports -# from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 -# SentrySpanProcessor, -# ) +from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 + SentrySpanProcessor, +) -# from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401 -# SentryPropagator, -# ) +from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401 + SentryPropagator, +) diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 594ccbb71f..7671c798c8 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: diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py index cb04dd8e1a..df668799cf 100644 --- a/sentry_sdk/integrations/opentelemetry/utils.py +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -8,7 +8,6 @@ from sentry_sdk.tracing import get_span_status_from_http_code 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 @@ -43,6 +42,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 From 45e281ff6dc42ed479baaab1f0434cc39d9b5c18 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 12 Aug 2024 16:19:07 +0200 Subject: [PATCH 25/32] Random tweaks (#3437) --- sentry_sdk/integrations/boto3.py | 3 ++- sentry_sdk/scope.py | 20 ++------------------ sentry_sdk/tracing_utils.py | 5 +---- tests/test_api.py | 4 ++-- tests/tracing/test_misc.py | 6 +++--- 5 files changed, 10 insertions(+), 28 deletions(-) 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/scope.py b/sentry_sdk/scope.py index 342c499ee0..2d7af53ea4 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -697,23 +697,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 """ @@ -802,11 +785,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 diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 0dabfbc486..a39b5d61f4 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -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/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) From 52fca296fc909c0f5d42c2a20fd27dedfdc4506c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 13 Aug 2024 12:40:45 +0200 Subject: [PATCH 26/32] Origin improvements (#3432) --- .../opentelemetry/potel_span_processor.py | 8 ++--- .../opentelemetry/span_processor.py | 4 +-- .../integrations/opentelemetry/utils.py | 33 ++++++++++--------- .../integrations/opentelemetry/test_potel.py | 8 ++--- .../integrations/opentelemetry/test_utils.py | 23 ++++++++++--- 5 files changed, 47 insertions(+), 29 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index 9604676dce..cddaf24ab2 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -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, "op": op, "status": status, } # type: dict[str, Any] @@ -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), + "origin": origin or SPAN_ORIGIN, } # type: dict[str, Any] if parent_span_id: diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 7671c798c8..2140b0e70b 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -259,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 @@ -270,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 df668799cf..ecb1852404 100644 --- a/sentry_sdk/integrations/opentelemetry/utils.py +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -6,6 +6,7 @@ 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.utils import Dsn @@ -77,13 +78,17 @@ def convert_otel_timestamp(time): 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) @@ -96,26 +101,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" @@ -151,11 +151,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" @@ -164,8 +166,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/tests/integrations/opentelemetry/test_potel.py b/tests/integrations/opentelemetry/test_potel.py index 2e094b41b5..5e44cc3888 100644 --- a/tests/integrations/opentelemetry/test_potel.py +++ b/tests/integrations/opentelemetry/test_potel.py @@ -41,7 +41,7 @@ def test_root_span_transaction_payload_started_with_otel_only(capture_envelopes) trace_context = contexts["trace"] assert "trace_id" in trace_context assert "span_id" in trace_context - assert trace_context["origin"] == "auto.otel" + assert trace_context["origin"] == "manual" assert trace_context["op"] == "request" assert trace_context["status"] == "ok" @@ -62,7 +62,7 @@ def test_child_span_payload_started_with_otel_only(capture_envelopes): assert span["op"] == "db" assert span["description"] == "db" - assert span["origin"] == "auto.otel" + assert span["origin"] == "manual" assert span["status"] == "ok" assert span["span_id"] is not None assert span["trace_id"] == payload["contexts"]["trace"]["trace_id"] @@ -124,7 +124,7 @@ def test_root_span_transaction_payload_started_with_sentry_only(capture_envelope trace_context = contexts["trace"] assert "trace_id" in trace_context assert "span_id" in trace_context - assert trace_context["origin"] == "auto.otel" + assert trace_context["origin"] == "manual" assert trace_context["op"] == "request" assert trace_context["status"] == "ok" @@ -145,7 +145,7 @@ def test_child_span_payload_started_with_sentry_only(capture_envelopes): assert span["op"] == "db" assert span["description"] == "db" - assert span["origin"] == "auto.otel" + assert span["origin"] == "manual" assert span["status"] == "ok" assert span["span_id"] is not None assert span["trace_id"] == payload["contexts"]["trace"]["trace_id"] 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( From 67a5823eca559bad51f86adfa0653f659adcb6e8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 13 Aug 2024 12:44:22 +0200 Subject: [PATCH 27/32] Tweak OTel timestamp utils (#3436) --- .../opentelemetry/potel_span_processor.py | 10 +++++----- sentry_sdk/integrations/opentelemetry/utils.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index cddaf24ab2..ebb5bbc17a 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -7,7 +7,7 @@ from sentry_sdk import capture_event 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 ( @@ -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 @@ -168,8 +168,8 @@ def _span_to_json(self, span): "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 SPAN_ORIGIN, } # type: dict[str, Any] diff --git a/sentry_sdk/integrations/opentelemetry/utils.py b/sentry_sdk/integrations/opentelemetry/utils.py index ecb1852404..2444131002 100644 --- a/sentry_sdk/integrations/opentelemetry/utils.py +++ b/sentry_sdk/integrations/opentelemetry/utils.py @@ -14,7 +14,7 @@ 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 = { @@ -72,11 +72,20 @@ 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], Optional[str]] op = span.name From 74d62f94fc24dddd03eb9c76098735a01f7309bf Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 13 Aug 2024 13:51:54 +0200 Subject: [PATCH 28/32] Create spans on scope (#3442) --- MIGRATION_GUIDE.md | 3 +++ sentry_sdk/api.py | 24 ++++++++++++++++++++---- sentry_sdk/scope.py | 39 +++++++++++---------------------------- sentry_sdk/tracing.py | 17 +++++++++++++---- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index df3ee6ea7d..0c94c797cb 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -9,10 +9,13 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh ### Changed +- `sentry_sdk.start_span` now only takes keyword arguments. + ### Removed ### 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 44472f2720..4a070936a4 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -1,6 +1,6 @@ import inspect -from sentry_sdk import tracing, tracing_utils, Client +from sentry_sdk import tracing_utils, Client from sentry_sdk._init_implementation import init from sentry_sdk.tracing import POTelSpan, Transaction, trace from sentry_sdk.crons import monitor @@ -233,14 +233,26 @@ def flush( def start_span( + *, + span=None, + custom_sampling_context=None, **kwargs, # type: Any ): # type: (...) -> POTelSpan """ - Alias for tracing.POTelSpan constructor. The method signature is the same. + 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 tracing.POTelSpan(**kwargs) + return get_current_scope().start_span(span, custom_sampling_context, **kwargs) def start_transaction( @@ -282,7 +294,11 @@ def start_transaction( constructor. See :py:class:`sentry_sdk.tracing.Transaction` for available arguments. """ - return start_span(**kwargs) + return start_span( + span=transaction, + custom_sampling_context=custom_sampling_context, + **kwargs, + ) def set_measurement(name, value, unit=""): diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 2d7af53ea4..740a7c7f4b 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 ( @@ -963,6 +964,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 @@ -993,19 +998,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 @@ -1024,39 +1022,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 @@ -1065,7 +1048,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) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 41c998cb99..6873cb8be8 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1308,7 +1308,10 @@ def containing_transaction(self): def start_child(self, **kwargs): # type: (str, **Any) -> POTelSpan - pass + kwargs.setdefault("sampled", self.sampled) + + span = POTelSpan(**kwargs) + return span @classmethod def continue_from_environ( @@ -1317,7 +1320,9 @@ def continue_from_environ( **kwargs, # type: Any ): # type: (...) -> POTelSpan - pass + # XXX actually propagate + span = POTelSpan(**kwargs) + return span @classmethod def continue_from_headers( @@ -1326,7 +1331,9 @@ def continue_from_headers( **kwargs, # type: Any ): # type: (...) -> POTelSpan - pass + # XXX actually propagate + span = POTelSpan(**kwargs) + return span def iter_headers(self): # type: () -> Iterator[Tuple[str, str]] @@ -1339,7 +1346,9 @@ def from_traceparent( **kwargs, # type: Any ): # type: (...) -> Optional[Transaction] - pass + # XXX actually propagate + span = POTelSpan(**kwargs) + return span def to_traceparent(self): # type: () -> str From 5ccfb343bdadd7dbeda51b8ce4367ba2b42ac7f7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 13 Aug 2024 13:53:00 +0200 Subject: [PATCH 29/32] Fill out more property/method stubs (#3441) --- MIGRATION_GUIDE.md | 2 + sentry_sdk/tracing.py | 212 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 203 insertions(+), 11 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 0c94c797cb..274e20cec7 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -13,6 +13,8 @@ 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. + ### Deprecated - `sentry_sdk.start_transaction` is deprecated. Use `sentry_sdk.start_span` instead. diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 6873cb8be8..ab6aeb4508 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -36,6 +36,7 @@ R = TypeVar("R") import sentry_sdk.profiler + from sentry_sdk.scope import Scope from sentry_sdk._types import ( Event, MeasurementUnit, @@ -1262,6 +1263,9 @@ def __init__( 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="manual", # type: str **_, # type: dict[str, object] ): @@ -1272,15 +1276,41 @@ def __init__( listed in the signature. These additional arguments are ignored. """ from sentry_sdk.integrations.opentelemetry.consts import SentrySpanAttribute + 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) - self._otel_span = tracer.start_span(description or op or "") # XXX + # 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._otel_span.set_attribute(SentrySpanAttribute.ORIGIN, origin) - if op is not None: - self._otel_span.set_attribute(SentrySpanAttribute.OP, op) - if description is not None: - self._otel_span.set_attribute(SentrySpanAttribute.DESCRIPTION, description) + 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 @@ -1301,9 +1331,142 @@ def __exit__(self, ty, value, tb): 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): @@ -1352,7 +1515,18 @@ def from_traceparent( def to_traceparent(self): # type: () -> str - pass + 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] @@ -1368,23 +1542,39 @@ def set_data(self, key, value): def set_status(self, status): # type: (str) -> None - pass + 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 - pass + # 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 - pass + 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 - pass + if profiler_id is not None: + self.set_data(SPANDATA.PROFILER_ID, profiler_id) def set_http_status(self, http_status): # type: (int) -> None - pass + 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 From c764ebed9cd426c60b7fb64af59598d06548afc6 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 13 Aug 2024 14:34:35 +0200 Subject: [PATCH 30/32] Cleanup origin handling and defaults (#3445) --- sentry_sdk/api.py | 6 ++---- sentry_sdk/integrations/asgi.py | 4 ++-- .../integrations/opentelemetry/potel_span_processor.py | 6 +++--- sentry_sdk/integrations/wsgi.py | 4 ++-- sentry_sdk/scope.py | 4 ++-- sentry_sdk/tracing.py | 10 ++++++---- sentry_sdk/tracing_utils.py | 2 +- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 4a070936a4..0b88ea3274 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -336,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/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py index ebb5bbc17a..d90ac7d5e4 100644 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py @@ -5,6 +5,7 @@ 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_from_otel_timestamp, @@ -12,7 +13,6 @@ ) from sentry_sdk.integrations.opentelemetry.consts import ( OTEL_SENTRY_CONTEXT, - SPAN_ORIGIN, ) from sentry_sdk._types import TYPE_CHECKING @@ -121,7 +121,7 @@ def _root_span_to_transaction_event(self, span): trace_context = { "trace_id": trace_id, "span_id": span_id, - "origin": origin, + "origin": origin or DEFAULT_SPAN_ORIGIN, "op": op, "status": status, } # type: dict[str, Any] @@ -170,7 +170,7 @@ def _span_to_json(self, span): "status": status, "start_timestamp": convert_from_otel_timestamp(span.start_time), "timestamp": convert_from_otel_timestamp(span.end_time), - "origin": origin or SPAN_ORIGIN, + "origin": origin or DEFAULT_SPAN_ORIGIN, } # type: dict[str, Any] if parent_span_id: 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 740a7c7f4b..eceee4d391 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1056,9 +1056,9 @@ def start_span(self, span=None, custom_sampling_context=None, **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. """ diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index ab6aeb4508..bf17daa3d0 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -153,6 +153,8 @@ class TransactionKwargs(SpanKwargs, total=False): "url": TRANSACTION_SOURCE_ROUTE, } +DEFAULT_SPAN_ORIGIN = "manual" + tracer = otel_trace.get_tracer(__name__) @@ -282,7 +284,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 @@ -295,7 +297,7 @@ def __init__( self.status = status self.hub = hub # backwards compatibility 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] @@ -1266,7 +1268,7 @@ def __init__( status=None, # type: Optional[str] scope=None, # type: Optional[Scope] start_timestamp=None, # type: Optional[Union[datetime, float]] - origin="manual", # type: str + origin=None, # type: Optional[str] **_, # type: dict[str, object] ): # type: (...) -> None @@ -1290,7 +1292,7 @@ def __init__( ) # XXX self._active = active - self._otel_span.set_attribute(SentrySpanAttribute.ORIGIN, origin) + self.origin = origin or DEFAULT_SPAN_ORIGIN self.op = op self.description = description if status is not None: diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index a39b5d61f4..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] From 29b7fa88fbdd3f545dcfc536fb66d082e5cffcd8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 14 Aug 2024 10:25:22 +0200 Subject: [PATCH 31/32] add note to migration guide --- MIGRATION_GUIDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index ecdf69480a..7a71c3e872 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -9,6 +9,7 @@ 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. From 780b93d0df0d51741fefdcb7af7b036a9e86b00b Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Mon, 2 Sep 2024 16:14:12 +0200 Subject: [PATCH 32/32] First version of sampler --- .../integrations/opentelemetry/integration.py | 3 +- .../integrations/opentelemetry/sampler.py | 115 ++++++++++++++++ .../opentelemetry/test_sampler.py | 129 ++++++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 sentry_sdk/integrations/opentelemetry/sampler.py create mode 100644 tests/integrations/opentelemetry/test_sampler.py diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 4cd969f0e0..3f71e86f02 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -12,6 +12,7 @@ from sentry_sdk.integrations.opentelemetry.contextvars_context import ( SentryContextVarsRuntimeContext, ) +from sentry_sdk.integrations.opentelemetry.sampler import SentrySampler from sentry_sdk.utils import logger try: @@ -55,7 +56,7 @@ def _setup_sentry_tracing(): opentelemetry.context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext() - provider = TracerProvider() + provider = TracerProvider(sampler=SentrySampler()) provider.add_span_processor(PotelSentrySpanProcessor()) trace.set_tracer_provider(provider) 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/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