diff --git a/.github/workflows/test-integration-opentelemetry.yml b/.github/workflows/test-integration-opentelemetry.yml new file mode 100644 index 0000000000..987d92b95c --- /dev/null +++ b/.github/workflows/test-integration-opentelemetry.yml @@ -0,0 +1,62 @@ +name: Test opentelemetry + +on: + push: + branches: + - master + - release/** + + pull_request: + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless + +jobs: + test: + name: opentelemetry, python ${{ matrix.python-version }}, ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + continue-on-error: true + + strategy: + matrix: + python-version: ["3.7","3.8","3.9","3.10"] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup Test Env + env: + PGHOST: localhost + PGPASSWORD: sentry + run: | + pip install codecov tox + + - name: Test opentelemetry + env: + CI_PYTHON_VERSION: ${{ matrix.python-version }} + timeout-minutes: 45 + shell: bash + run: | + set -x # print commands that are executed + coverage erase + + ./scripts/runtox.sh "${{ matrix.python-version }}-opentelemetry" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch + coverage combine .coverage* + coverage xml -i + codecov --file coverage.xml diff --git a/.vscode/settings.json b/.vscode/settings.json index c167a13dc2..ba2472c4c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,6 @@ { "python.pythonPath": ".venv/bin/python", - "python.formatting.provider": "black" -} \ No newline at end of file + "python.formatting.provider": "black", + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index cec914aca1..ffa017cfc1 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -4,6 +4,7 @@ from sentry_sdk.scope import Scope from sentry_sdk._types import MYPY +from sentry_sdk.tracing import NoOpSpan if MYPY: from typing import Any @@ -210,5 +211,5 @@ def start_transaction( transaction=None, # type: Optional[Transaction] **kwargs # type: Any ): - # type: (...) -> Transaction + # type: (...) -> Union[Transaction, NoOpSpan] return Hub.current.start_transaction(transaction, **kwargs) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index bf1e483634..8af7003156 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -20,6 +20,7 @@ from sentry_sdk.transport import make_transport from sentry_sdk.consts import ( DEFAULT_OPTIONS, + INSTRUMENTER, VERSION, ClientConstructor, ) @@ -86,6 +87,9 @@ def _get_options(*args, **kwargs): if rv["server_name"] is None and hasattr(socket, "gethostname"): rv["server_name"] = socket.gethostname() + if rv["instrumenter"] is None: + rv["instrumenter"] = INSTRUMENTER.SENTRY + return rv diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 6d463f3dc5..430558c366 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -44,6 +44,36 @@ DEFAULT_MAX_BREADCRUMBS = 100 +class INSTRUMENTER: + SENTRY = "sentry" + OTEL = "otel" + + +class OP: + DB = "db" + DB_REDIS = "db.redis" + EVENT_DJANGO = "event.django" + FUNCTION = "function" + FUNCTION_AWS = "function.aws" + FUNCTION_GCP = "function.gcp" + HTTP_CLIENT = "http.client" + HTTP_CLIENT_STREAM = "http.client.stream" + HTTP_SERVER = "http.server" + MIDDLEWARE_DJANGO = "middleware.django" + MIDDLEWARE_STARLETTE = "middleware.starlette" + MIDDLEWARE_STARLETTE_RECEIVE = "middleware.starlette.receive" + MIDDLEWARE_STARLETTE_SEND = "middleware.starlette.send" + QUEUE_SUBMIT_CELERY = "queue.submit.celery" + QUEUE_TASK_CELERY = "queue.task.celery" + QUEUE_TASK_RQ = "queue.task.rq" + SUBPROCESS = "subprocess" + SUBPROCESS_WAIT = "subprocess.wait" + SUBPROCESS_COMMUNICATE = "subprocess.communicate" + TEMPLATE_RENDER = "template.render" + VIEW_RENDER = "view.render" + WEBSOCKET_SERVER = "websocket.server" + + # This type exists to trick mypy and PyCharm into thinking `init` and `Client` # take these arguments (even though they take opaque **kwargs) class ClientConstructor(object): @@ -57,6 +87,7 @@ def __init__( server_name=None, # type: Optional[str] shutdown_timeout=2, # type: float integrations=[], # type: Sequence[Integration] # noqa: B006 + instrumenter=INSTRUMENTER.SENTRY, # type: Optional[str] in_app_include=[], # type: List[str] # noqa: B006 in_app_exclude=[], # type: List[str] # noqa: B006 default_integrations=True, # type: bool @@ -106,28 +137,3 @@ def _get_default_options(): VERSION = "1.11.1" - - -class OP: - DB = "db" - DB_REDIS = "db.redis" - EVENT_DJANGO = "event.django" - FUNCTION = "function" - FUNCTION_AWS = "function.aws" - FUNCTION_GCP = "function.gcp" - HTTP_CLIENT = "http.client" - HTTP_CLIENT_STREAM = "http.client.stream" - HTTP_SERVER = "http.server" - MIDDLEWARE_DJANGO = "middleware.django" - MIDDLEWARE_STARLETTE = "middleware.starlette" - MIDDLEWARE_STARLETTE_RECEIVE = "middleware.starlette.receive" - MIDDLEWARE_STARLETTE_SEND = "middleware.starlette.send" - QUEUE_SUBMIT_CELERY = "queue.submit.celery" - QUEUE_TASK_CELERY = "queue.task.celery" - QUEUE_TASK_RQ = "queue.task.rq" - SUBPROCESS = "subprocess" - SUBPROCESS_WAIT = "subprocess.wait" - SUBPROCESS_COMMUNICATE = "subprocess.communicate" - TEMPLATE_RENDER = "template.render" - VIEW_RENDER = "view.render" - WEBSOCKET_SERVER = "websocket.server" diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 3d4a28d526..b87c88f271 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -5,9 +5,10 @@ from contextlib import contextmanager from sentry_sdk._compat import with_metaclass +from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope from sentry_sdk.client import Client -from sentry_sdk.tracing import Span, Transaction +from sentry_sdk.tracing import NoOpSpan, Span, Transaction from sentry_sdk.session import Session from sentry_sdk.utils import ( exc_info_from_error, @@ -464,6 +465,12 @@ def start_span( for every incoming HTTP request. Use `start_transaction` to start a new transaction when one is not already in progress. """ + instrumenter = kwargs.get("instrumenter", INSTRUMENTER.SENTRY) + configuration_instrumenter = self.client and self.client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + # TODO: consider removing this in a future release. # This is for backwards compatibility with releases before # start_transaction existed, to allow for a smoother transition. @@ -496,7 +503,7 @@ def start_transaction( transaction=None, # type: Optional[Transaction] **kwargs # type: Any ): - # type: (...) -> Transaction + # type: (...) -> Union[Transaction, NoOpSpan] """ Start and return a transaction. @@ -519,6 +526,12 @@ def start_transaction( When the transaction is finished, it will be sent to Sentry with all its finished child spans. """ + instrumenter = kwargs.get("instrumenter", INSTRUMENTER.SENTRY) + configuration_instrumenter = self.client and self.client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + custom_sampling_context = kwargs.pop("custom_sampling_context", {}) # if we haven't been given a transaction, make one diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 52cce0b4b4..67c87b64f6 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -6,7 +6,7 @@ from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.scope import Scope -from sentry_sdk.tracing import SOURCE_FOR_STYLE +from sentry_sdk.tracing import SENTRY_TRACE_HEADER_NAME, SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, @@ -101,8 +101,11 @@ def _add_sentry_trace(sender, template, context, **extra): sentry_span = Hub.current.scope.span context["sentry_trace"] = ( Markup( - '' - % (sentry_span.to_traceparent(),) + '' + % ( + SENTRY_TRACE_HEADER_NAME, + sentry_span.to_traceparent(), + ) ) if sentry_span else "" diff --git a/sentry_sdk/integrations/opentelemetry/__init__.py b/sentry_sdk/integrations/opentelemetry/__init__.py new file mode 100644 index 0000000000..2ec31af8c9 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/__init__.py @@ -0,0 +1,3 @@ +from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 + SentrySpanProcessor, +) diff --git a/sentry_sdk/integrations/opentelemetry/propagator.py b/sentry_sdk/integrations/opentelemetry/propagator.py new file mode 100644 index 0000000000..141e017a93 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/propagator.py @@ -0,0 +1,113 @@ +from opentelemetry import trace # type: ignore +from opentelemetry.context import ( # type: ignore + Context, + create_key, + get_current, + set_value, +) +from opentelemetry.propagators.textmap import ( # type: ignore + CarrierT, + Getter, + Setter, + TextMapPropagator, + default_getter, + default_setter, +) +from opentelemetry.trace import ( # type: ignore + TraceFlags, + NonRecordingSpan, + SpanContext, +) + +from sentry_sdk.tracing import ( + BAGGAGE_HEADER_NAME, + SENTRY_TRACE_HEADER_NAME, +) +from sentry_sdk.tracing_utils import Baggage, extract_sentrytrace_data +from sentry_sdk._types import MYPY + +if MYPY: + from typing import Optional + from typing import Set + + +SENTRY_TRACE_KEY = create_key("sentry-trace") +SENTRY_BAGGAGE_KEY = create_key("sentry-baggage") + + +class SentryPropagator(TextMapPropagator): # type: ignore + def extract(self, carrier, context=None, getter=default_getter): + # type: (CarrierT, Optional[Context], Getter) -> Context + if context is None: + context = get_current() + + sentry_trace = getter.get(carrier, SENTRY_TRACE_HEADER_NAME) + if not sentry_trace: + return context + + sentrytrace = extract_sentrytrace_data(sentry_trace[0]) + if not sentrytrace: + return context + + sentry_trace_data = ( + sentrytrace.get("trace_id", "0"), + sentrytrace.get("parent_span_id", "0"), + sentrytrace.get("parent_sampled", None), + ) + + context = set_value(SENTRY_TRACE_KEY, sentry_trace_data, context) + + trace_id, span_id, _ = sentry_trace_data + + span_context = SpanContext( + trace_id=int(trace_id, 16), # type: ignore + span_id=int(span_id, 16), # type: ignore + # we simulate a sampled trace on the otel side and leave the sampling to sentry + trace_flags=TraceFlags(TraceFlags.SAMPLED), + is_remote=True, + ) + + baggage_header = getter.get(carrier, BAGGAGE_HEADER_NAME) + + if baggage_header: + baggage = Baggage.from_incoming_header(baggage_header[0]) + else: + # If there's an incoming sentry-trace but no incoming baggage header, + # for instance in traces coming from older SDKs, + # baggage will be empty and frozen and won't be populated as head SDK. + baggage = Baggage(sentry_items={}) + + baggage.freeze() + context = set_value(SENTRY_BAGGAGE_KEY, baggage, context) + + span = NonRecordingSpan(span_context) + modified_context = trace.set_span_in_context(span, context) + return modified_context + + def inject(self, carrier, context=None, setter=default_setter): + # type: (CarrierT, Optional[Context], Setter) -> None + if context is None: + context = get_current() + + current_span = trace.get_current_span(context) + span_id = trace.format_span_id(current_span.context.span_id) + + from sentry_sdk.integrations.opentelemetry.span_processor import ( + SentrySpanProcessor, + ) + + span_map = SentrySpanProcessor().otel_span_map + sentry_span = span_map.get(span_id, None) + if not sentry_span: + return + + setter.set(carrier, SENTRY_TRACE_HEADER_NAME, sentry_span.to_traceparent()) + + baggage = hasattr(sentry_span, "get_baggage") and sentry_span.get_baggage() + if baggage: + setter.set(carrier, BAGGAGE_HEADER_NAME, baggage.serialize()) + + @property + def fields(self): + # type: () -> Set[str] + return {SENTRY_TRACE_HEADER_NAME, BAGGAGE_HEADER_NAME} diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py new file mode 100644 index 0000000000..d62cbb4482 --- /dev/null +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -0,0 +1,220 @@ +from datetime import datetime + +from opentelemetry.context import get_value # type: ignore +from opentelemetry.sdk.trace import SpanProcessor # type: ignore +from opentelemetry.semconv.trace import SpanAttributes # type: ignore +from opentelemetry.trace import ( # type: ignore + format_span_id, + format_trace_id, + SpanContext, + Span as OTelSpan, + SpanKind, +) +from sentry_sdk.consts import INSTRUMENTER + +from sentry_sdk.hub import Hub +from sentry_sdk.integrations.opentelemetry.propagator import ( + SENTRY_BAGGAGE_KEY, + SENTRY_TRACE_KEY, +) +from sentry_sdk.tracing import Transaction, Span as SentrySpan +from sentry_sdk._types import MYPY +from sentry_sdk.utils import Dsn + +from urllib3.util import parse_url as urlparse # type: ignore + +if MYPY: + from typing import Any + from typing import Dict + from typing import Union + +OPEN_TELEMETRY_CONTEXT = "otel" + + +class SentrySpanProcessor(SpanProcessor): # type: ignore + """ + Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. + """ + + # The mapping from otel span ids to sentry spans + otel_span_map = {} # type: Dict[str, Union[Transaction, OTelSpan]] + + def __new__(cls): + # type: () -> SentrySpanProcessor + if not hasattr(cls, "instance"): + cls.instance = super(SentrySpanProcessor, cls).__new__(cls) + + return cls.instance + + def on_start(self, otel_span, parent_context=None): + # type: (OTelSpan, SpanContext) -> None + hub = Hub.current + if not hub: + return + + if hub.client and hub.client.options["instrumenter"] != INSTRUMENTER.OTEL: + return + + trace_data = self._get_trace_data(otel_span, parent_context) + + parent_span_id = trace_data["parent_span_id"] + sentry_parent_span = ( + self.otel_span_map.get(parent_span_id, None) if parent_span_id else None + ) + + sentry_span = None + if sentry_parent_span: + sentry_span = sentry_parent_span.start_child( + span_id=trace_data["span_id"], + description=otel_span.name, + start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9), + instrumenter=INSTRUMENTER.OTEL, + ) + else: + sentry_span = hub.start_transaction( + name=otel_span.name, + span_id=trace_data["span_id"], + parent_span_id=parent_span_id, + trace_id=trace_data["trace_id"], + baggage=trace_data["baggage"], + start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9), + instrumenter=INSTRUMENTER.OTEL, + ) + + self.otel_span_map[trace_data["span_id"]] = sentry_span + + def on_end(self, otel_span): + # type: (OTelSpan) -> None + hub = Hub.current + if not hub: + return + + if hub.client and hub.client.options["instrumenter"] != INSTRUMENTER.OTEL: + return + + # Break infinite 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 = hub.client and Dsn(hub.client.dsn or "").netloc + if otel_span_url and dsn_url in otel_span_url: + return + + span_id = format_span_id(otel_span.context.span_id) + sentry_span = self.otel_span_map.pop(span_id, None) + if not sentry_span: + return + + sentry_span.op = otel_span.name + + if isinstance(sentry_span, Transaction): + sentry_span.name = otel_span.name + sentry_span.set_context( + OPEN_TELEMETRY_CONTEXT, self._get_otel_context(otel_span) + ) + + else: + self._update_span_with_otel_data(sentry_span, otel_span) + + sentry_span.finish( + end_timestamp=datetime.fromtimestamp(otel_span.end_time / 1e9) + ) + + def _get_otel_context(self, otel_span): + # type: (OTelSpan) -> Dict[str, Any] + """ + Returns the OTel context for Sentry. + See: https://develop.sentry.dev/sdk/performance/opentelemetry/#step-5-add-opentelemetry-context + """ + ctx = {} + + if otel_span.attributes: + ctx["attributes"] = dict(otel_span.attributes) + + if otel_span.resource.attributes: + ctx["resource"] = dict(otel_span.resource.attributes) + + return ctx + + def _get_trace_data(self, otel_span, parent_context): + # type: (OTelSpan, SpanContext) -> Dict[str, Any] + """ + Extracts tracing information from one OTel span and its parent OTel context. + """ + trace_data = {} + + span_id = format_span_id(otel_span.context.span_id) + trace_data["span_id"] = span_id + + trace_id = format_trace_id(otel_span.context.trace_id) + trace_data["trace_id"] = trace_id + + parent_span_id = ( + format_span_id(otel_span.parent.span_id) if otel_span.parent else None + ) + trace_data["parent_span_id"] = parent_span_id + + sentry_trace_data = get_value(SENTRY_TRACE_KEY, parent_context) + trace_data["parent_sampled"] = ( + sentry_trace_data[2] if sentry_trace_data else None + ) + + baggage = get_value(SENTRY_BAGGAGE_KEY, parent_context) + trace_data["baggage"] = baggage + + return trace_data + + def _update_span_with_otel_data(self, sentry_span, otel_span): + # type: (SentrySpan, OTelSpan) -> None + """ + Convert OTel span data and update the Sentry span with it. + This should eventually happen on the server when ingesting the spans. + """ + for key in otel_span.attributes: + val = otel_span.attributes[key] + sentry_span.set_data(key, val) + + op = otel_span.name + description = otel_span.name + + http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD, None) + db_query = otel_span.attributes.get(SpanAttributes.DB_SYSTEM, None) + + if http_method: + op = "http" + + if otel_span.kind == SpanKind.SERVER: + op += ".server" + elif otel_span.kind == SpanKind.CLIENT: + op += ".client" + + description = http_method + print(f"~~~~~otel_span.attributes: {otel_span.attributes}") + + 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) + if url: + parsed_url = urlparse(url) + url = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" + description += " {}".format(url) + + status_code = otel_span.attributes.get( + SpanAttributes.HTTP_STATUS_CODE, None + ) + if status_code: + sentry_span.set_http_status(status_code) + + elif db_query: + op = "db" + statement = otel_span.attributes.get(SpanAttributes.DB_STATEMENT, None) + if statement: + description = statement + + sentry_span.op = op + sentry_span.description = description diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 3b81b6c2c5..687d9dd2c1 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -187,7 +187,6 @@ def sentry_patched_popen_init(self, *a, **kw): env = None with hub.start_span(op=OP.SUBPROCESS, description=description) as span: - for k, v in hub.iter_trace_propagation_headers(span): if env is None: env = _init_argument( diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index aacb3a5bb3..82320303c3 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta import sentry_sdk - +from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.utils import logger from sentry_sdk._types import MYPY @@ -24,6 +24,9 @@ import sentry_sdk.profiler from sentry_sdk._types import Event, SamplingContext, MeasurementUnit +BAGGAGE_HEADER_NAME = "baggage" +SENTRY_TRACE_HEADER_NAME = "sentry-trace" + # Transaction source # see https://develop.sentry.dev/sdk/event-payloads/transaction/#transaction-annotations @@ -90,6 +93,7 @@ class Span(object): "timestamp", "_tags", "_data", + "_contexts", "_span_recorder", "hub", "_context_manager_state", @@ -123,6 +127,8 @@ def __init__( status=None, # type: Optional[str] transaction=None, # type: Optional[str] # deprecated containing_transaction=None, # type: Optional[Transaction] + start_timestamp=None, # type: Optional[datetime] + instrumenter=None, # type: Optional[str] ): # type: (...) -> None self.trace_id = trace_id or uuid.uuid4().hex @@ -136,8 +142,9 @@ def __init__( self.hub = hub self._tags = {} # type: Dict[str, str] self._data = {} # type: Dict[str, Any] + self._contexts = {} # type: Dict[str, Any] self._containing_transaction = containing_transaction - self.start_timestamp = datetime.utcnow() + self.start_timestamp = start_timestamp or datetime.utcnow() try: # TODO: For Python 3.7+, we could use a clock with ns resolution: # self._start_timestamp_monotonic = time.perf_counter_ns() @@ -213,6 +220,15 @@ def start_child(self, **kwargs): trace id, sampling decision, transaction pointer, and span recorder are inherited from the current span/transaction. """ + hub = self.hub or sentry_sdk.Hub.current + client = hub.client + + instrumenter = kwargs.get("instrumenter", INSTRUMENTER.SENTRY) + configuration_instrumenter = client and client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + kwargs.setdefault("sampled", self.sampled) child = Span( @@ -278,10 +294,12 @@ def continue_from_headers( # TODO-neel move away from this kwargs stuff, it's confusing and opaque # make more explicit - baggage = Baggage.from_incoming_header(headers.get("baggage")) - kwargs.update({"baggage": baggage}) + baggage = Baggage.from_incoming_header(headers.get(BAGGAGE_HEADER_NAME)) + kwargs.update({BAGGAGE_HEADER_NAME: baggage}) - sentrytrace_kwargs = extract_sentrytrace_data(headers.get("sentry-trace")) + sentrytrace_kwargs = extract_sentrytrace_data( + headers.get(SENTRY_TRACE_HEADER_NAME) + ) if sentrytrace_kwargs is not None: kwargs.update(sentrytrace_kwargs) @@ -308,7 +326,7 @@ def iter_headers(self): `sentry_tracestate` value, this will cause one to be generated and stored. """ - yield "sentry-trace", self.to_traceparent() + yield SENTRY_TRACE_HEADER_NAME, self.to_traceparent() tracestate = self.to_tracestate() if has_tracestate_enabled(self) else None # `tracestate` will only be `None` if there's no client or no DSN @@ -320,7 +338,7 @@ def iter_headers(self): if self.containing_transaction: baggage = self.containing_transaction.get_baggage().serialize() if baggage: - yield "baggage", baggage + yield BAGGAGE_HEADER_NAME, baggage @classmethod def from_traceparent( @@ -344,7 +362,9 @@ def from_traceparent( if not traceparent: return None - return cls.continue_from_headers({"sentry-trace": traceparent}, **kwargs) + return cls.continue_from_headers( + {SENTRY_TRACE_HEADER_NAME: traceparent}, **kwargs + ) def to_traceparent(self): # type: () -> str @@ -355,6 +375,13 @@ def to_traceparent(self): sampled = "0" return "%s-%s-%s" % (self.trace_id, self.span_id, sampled) + @classmethod + def extract_sentry_trace(cls, sentry_trace): + # type: (str) -> Tuple[str, str, bool] + trace_id, parent_span_id, parent_sampled = sentry_trace.split("-") + parent_sampled = True if parent_sampled == "1" else False + return (trace_id, parent_span_id, parent_sampled) + def to_tracestate(self): # type: () -> Optional[str] """ @@ -451,12 +478,16 @@ def set_http_status(self, http_status): else: self.set_status("unknown_error") + def set_context(self, key, value): + # type: (str, Any) -> None + self._contexts[key] = value + def is_success(self): # type: () -> bool return self.status == "ok" - def finish(self, hub=None): - # type: (Optional[sentry_sdk.Hub]) -> Optional[str] + def finish(self, hub=None, **kwargs): + # type: (Optional[sentry_sdk.Hub], Optional[datetime]) -> Optional[str] # XXX: would be type: (Optional[sentry_sdk.Hub]) -> None, but that leads # to incompatible return types for Span.finish and Transaction.finish. if self.timestamp is not None: @@ -466,8 +497,14 @@ def finish(self, hub=None): hub = hub or self.hub or sentry_sdk.Hub.current try: - duration_seconds = time.perf_counter() - self._start_timestamp_monotonic - self.timestamp = self.start_timestamp + timedelta(seconds=duration_seconds) + end_timestamp = kwargs.get("end_timestamp", None) + if end_timestamp: + self.timestamp = end_timestamp + else: + duration_seconds = time.perf_counter() - self._start_timestamp_monotonic + self.timestamp = self.start_timestamp + timedelta( + seconds=duration_seconds + ) except AttributeError: self.timestamp = datetime.utcnow() @@ -613,8 +650,8 @@ def containing_transaction(self): # reference. return self - def finish(self, hub=None): - # type: (Optional[sentry_sdk.Hub]) -> Optional[str] + def finish(self, hub=None, **kwargs): + # type: (Optional[sentry_sdk.Hub], Optional[datetime]) -> Optional[str] if self.timestamp is not None: # This transaction is already finished, ignore. return None @@ -646,13 +683,14 @@ def finish(self, hub=None): ) self.name = "" - Span.finish(self, hub) + Span.finish(self, hub, **kwargs) if not self.sampled: # At this point a `sampled = None` should have already been resolved # to a concrete decision. if self.sampled is None: logger.warning("Discarding transaction without sampling decision.") + return None finished_spans = [ @@ -667,11 +705,15 @@ def finish(self, hub=None): # to be garbage collected self._span_recorder = None + contexts = {} + contexts.update(self._contexts) + contexts.update({"trace": self.get_trace_context()}) + event = { "type": "transaction", "transaction": self.name, "transaction_info": {"source": self.source}, - "contexts": {"trace": self.get_trace_context()}, + "contexts": contexts, "tags": self._tags, "timestamp": self.timestamp, "start_timestamp": self.start_timestamp, @@ -821,6 +863,48 @@ def _set_initial_sampling_decision(self, sampling_context): ) +class NoOpSpan(Span): + def __repr__(self): + # type: () -> Any + return self.__class__.__name__ + + def __enter__(self): + # type: () -> Any + return self + + def __exit__(self, ty, value, tb): + # type: (Any, Any, Any) -> Any + pass + + def start_child(self, **kwargs): + # type: (**Any) -> Any + pass + + def new_span(self, **kwargs): + # type: (**Any) -> Any + pass + + def set_tag(self, key, value): + # type: (Any, Any) -> Any + pass + + def set_data(self, key, value): + # type: (Any, Any) -> Any + pass + + def set_status(self, value): + # type: (Any) -> Any + pass + + def set_http_status(self, http_status): + # type: (Any) -> Any + pass + + def finish(self, hub=None, **kwargs): + # type: (Any, **Any) -> Any + pass + + # Circular imports from sentry_sdk.tracing_utils import ( diff --git a/tests/integrations/opentelemetry/__init__.py b/tests/integrations/opentelemetry/__init__.py new file mode 100644 index 0000000000..39ecc610d5 --- /dev/null +++ b/tests/integrations/opentelemetry/__init__.py @@ -0,0 +1,3 @@ +import pytest + +django = pytest.importorskip("opentelemetry") diff --git a/tests/integrations/opentelemetry/test_propagator.py b/tests/integrations/opentelemetry/test_propagator.py new file mode 100644 index 0000000000..f839d2d486 --- /dev/null +++ b/tests/integrations/opentelemetry/test_propagator.py @@ -0,0 +1,247 @@ +from mock import MagicMock +import mock + +from opentelemetry.context import get_current +from opentelemetry.trace.propagation import get_current_span +from opentelemetry.trace import ( + set_span_in_context, + TraceFlags, + SpanContext, +) + +from sentry_sdk.integrations.opentelemetry.propagator import ( + SENTRY_BAGGAGE_KEY, + SENTRY_TRACE_KEY, + SentryPropagator, +) +from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor +from sentry_sdk.tracing_utils import Baggage + + +def test_extract_no_context_no_sentry_trace_header(): + """ + No context and NO Sentry trace data in getter. + Extract should return empty context. + """ + carrier = None + context = None + getter = MagicMock() + getter.get.return_value = None + + modified_context = SentryPropagator().extract(carrier, context, getter) + + assert modified_context == {} + + +def test_extract_context_no_sentry_trace_header(): + """ + Context but NO Sentry trace data in getter. + Extract should return context as is. + """ + carrier = None + context = {"some": "value"} + getter = MagicMock() + getter.get.return_value = None + + modified_context = SentryPropagator().extract(carrier, context, getter) + + assert modified_context == context + + +def test_extract_empty_context_sentry_trace_header_no_baggage(): + """ + Empty context but Sentry trace data but NO Baggage in getter. + Extract should return context that has empty baggage in it and also a NoopSpan with span_id and trace_id. + """ + carrier = None + context = {} + getter = MagicMock() + getter.get.side_effect = [ + ["1234567890abcdef1234567890abcdef-1234567890abcdef-1"], + None, + ] + + modified_context = SentryPropagator().extract(carrier, context, getter) + + assert len(modified_context.keys()) == 3 + + assert modified_context[SENTRY_TRACE_KEY] == ( + "1234567890abcdef1234567890abcdef", + "1234567890abcdef", + True, + ) + assert modified_context[SENTRY_BAGGAGE_KEY].serialize() == "" + + span_context = get_current_span(modified_context).get_span_context() + assert span_context.span_id == int("1234567890abcdef", 16) + assert span_context.trace_id == int("1234567890abcdef1234567890abcdef", 16) + + +def test_extract_context_sentry_trace_header_baggage(): + """ + Empty context but Sentry trace data and Baggage in getter. + Extract should return context that has baggage in it and also a NoopSpan with span_id and trace_id. + """ + baggage_header = ( + "other-vendor-value-1=foo;bar;baz, sentry-trace_id=771a43a4192642f0b136d5159a501700, " + "sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=0.01337, " + "sentry-user_id=Am%C3%A9lie, other-vendor-value-2=foo;bar;" + ) + + carrier = None + context = {"some": "value"} + getter = MagicMock() + getter.get.side_effect = [ + ["1234567890abcdef1234567890abcdef-1234567890abcdef-1"], + [baggage_header], + ] + + modified_context = SentryPropagator().extract(carrier, context, getter) + + assert len(modified_context.keys()) == 4 + + assert modified_context[SENTRY_TRACE_KEY] == ( + "1234567890abcdef1234567890abcdef", + "1234567890abcdef", + True, + ) + assert modified_context[SENTRY_BAGGAGE_KEY].serialize() == ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=0.01337,sentry-user_id=Am%C3%A9lie" + ) + + span_context = get_current_span(modified_context).get_span_context() + assert span_context.span_id == int("1234567890abcdef", 16) + assert span_context.trace_id == int("1234567890abcdef1234567890abcdef", 16) + + +def test_inject_empty_otel_span_map(): + """ + Empty otel_span_map. + So there is no sentry_span to be found in inject() + and the function is returned early and no setters are called. + """ + carrier = None + context = get_current() + setter = MagicMock() + setter.set = MagicMock() + + span_context = SpanContext( + trace_id=int("1234567890abcdef1234567890abcdef", 16), + span_id=int("1234567890abcdef", 16), + trace_flags=TraceFlags(TraceFlags.SAMPLED), + is_remote=True, + ) + span = MagicMock() + span.context = span_context + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.propagator.trace.get_current_span", + return_value=span, + ): + full_context = set_span_in_context(span, context) + SentryPropagator().inject(carrier, full_context, setter) + + setter.set.assert_not_called() + + +def test_inject_sentry_span_no_baggage(): + """ + Inject a sentry span with no baggage. + """ + carrier = None + context = get_current() + setter = MagicMock() + setter.set = MagicMock() + + trace_id = "1234567890abcdef1234567890abcdef" + span_id = "1234567890abcdef" + + span_context = SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + trace_flags=TraceFlags(TraceFlags.SAMPLED), + is_remote=True, + ) + span = MagicMock() + span.context = span_context + + sentry_span = MagicMock() + sentry_span.to_traceparent = mock.Mock( + return_value="1234567890abcdef1234567890abcdef-1234567890abcdef-1" + ) + sentry_span.get_baggage = mock.Mock(return_value=None) + + span_processor = SentrySpanProcessor() + span_processor.otel_span_map[span_id] = sentry_span + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.propagator.trace.get_current_span", + return_value=span, + ): + full_context = set_span_in_context(span, context) + SentryPropagator().inject(carrier, full_context, setter) + + setter.set.assert_called_once_with( + carrier, + "sentry-trace", + "1234567890abcdef1234567890abcdef-1234567890abcdef-1", + ) + + +def test_inject_sentry_span_baggage(): + """ + Inject a sentry span with baggage. + """ + carrier = None + context = get_current() + setter = MagicMock() + setter.set = MagicMock() + + trace_id = "1234567890abcdef1234567890abcdef" + span_id = "1234567890abcdef" + + span_context = SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + trace_flags=TraceFlags(TraceFlags.SAMPLED), + is_remote=True, + ) + span = MagicMock() + span.context = span_context + + sentry_span = MagicMock() + sentry_span.to_traceparent = mock.Mock( + return_value="1234567890abcdef1234567890abcdef-1234567890abcdef-1" + ) + sentry_items = { + "sentry-trace_id": "771a43a4192642f0b136d5159a501700", + "sentry-public_key": "49d0f7386ad645858ae85020e393bef3", + "sentry-sample_rate": 0.01337, + "sentry-user_id": "Amélie", + } + baggage = Baggage(sentry_items=sentry_items) + sentry_span.get_baggage = MagicMock(return_value=baggage) + + span_processor = SentrySpanProcessor() + span_processor.otel_span_map[span_id] = sentry_span + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.propagator.trace.get_current_span", + return_value=span, + ): + full_context = set_span_in_context(span, context) + SentryPropagator().inject(carrier, full_context, setter) + + setter.set.assert_any_call( + carrier, + "sentry-trace", + "1234567890abcdef1234567890abcdef-1234567890abcdef-1", + ) + + setter.set.assert_any_call( + carrier, + "baggage", + baggage.serialize(), + ) diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py new file mode 100644 index 0000000000..b932da63da --- /dev/null +++ b/tests/integrations/opentelemetry/test_span_processor.py @@ -0,0 +1,376 @@ +from datetime import datetime +from mock import MagicMock +import mock +import time +from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor +from sentry_sdk.tracing import Span, Transaction + +from opentelemetry.trace import SpanKind + + +def test_get_otel_context(): + otel_span = MagicMock() + otel_span.attributes = {"foo": "bar"} + otel_span.resource = MagicMock() + otel_span.resource.attributes = {"baz": "qux"} + + span_processor = SentrySpanProcessor() + otel_context = span_processor._get_otel_context(otel_span) + + assert otel_context == { + "attributes": {"foo": "bar"}, + "resource": {"baz": "qux"}, + } + + +def test_get_trace_data_with_span_and_trace(): + otel_span = MagicMock() + otel_span.context = MagicMock() + otel_span.context.trace_id = int("1234567890abcdef1234567890abcdef", 16) + otel_span.context.span_id = int("1234567890abcdef", 16) + otel_span.parent = None + + parent_context = {} + + span_processor = SentrySpanProcessor() + sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) + assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" + assert sentry_trace_data["span_id"] == "1234567890abcdef" + assert sentry_trace_data["parent_span_id"] is None + assert sentry_trace_data["parent_sampled"] is None + assert sentry_trace_data["baggage"] is None + + +def test_get_trace_data_with_span_and_trace_and_parent(): + otel_span = MagicMock() + otel_span.context = MagicMock() + otel_span.context.trace_id = int("1234567890abcdef1234567890abcdef", 16) + otel_span.context.span_id = int("1234567890abcdef", 16) + otel_span.parent = MagicMock() + otel_span.parent.span_id = int("abcdef1234567890", 16) + + parent_context = {} + + span_processor = SentrySpanProcessor() + sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) + assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" + assert sentry_trace_data["span_id"] == "1234567890abcdef" + assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" + assert sentry_trace_data["parent_sampled"] is None + assert sentry_trace_data["baggage"] is None + + +def test_get_trace_data_with_sentry_trace(): + otel_span = MagicMock() + otel_span.context = MagicMock() + otel_span.context.trace_id = int("1234567890abcdef1234567890abcdef", 16) + otel_span.context.span_id = int("1234567890abcdef", 16) + otel_span.parent = MagicMock() + otel_span.parent.span_id = int("abcdef1234567890", 16) + + parent_context = {} + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.span_processor.get_value", + side_effect=[ + ("1234567890abcdef1234567890abcdef", "1234567890abcdef", True), + None, + ], + ): + span_processor = SentrySpanProcessor() + sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) + assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" + assert sentry_trace_data["span_id"] == "1234567890abcdef" + assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" + assert sentry_trace_data["parent_sampled"] is True + assert sentry_trace_data["baggage"] is None + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.span_processor.get_value", + side_effect=[ + ("1234567890abcdef1234567890abcdef", "1234567890abcdef", False), + None, + ], + ): + span_processor = SentrySpanProcessor() + sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) + assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" + assert sentry_trace_data["span_id"] == "1234567890abcdef" + assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" + assert sentry_trace_data["parent_sampled"] is False + assert sentry_trace_data["baggage"] is None + + +def test_get_trace_data_with_sentry_trace_and_baggage(): + otel_span = MagicMock() + otel_span.context = MagicMock() + otel_span.context.trace_id = int("1234567890abcdef1234567890abcdef", 16) + otel_span.context.span_id = int("1234567890abcdef", 16) + otel_span.parent = MagicMock() + otel_span.parent.span_id = int("abcdef1234567890", 16) + + parent_context = {} + + baggage = ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=0.01337,sentry-user_id=Am%C3%A9lie" + ) + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.span_processor.get_value", + side_effect=[ + ("1234567890abcdef1234567890abcdef", "1234567890abcdef", True), + baggage, + ], + ): + span_processor = SentrySpanProcessor() + sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) + assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" + assert sentry_trace_data["span_id"] == "1234567890abcdef" + assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" + assert sentry_trace_data["parent_sampled"] + assert sentry_trace_data["baggage"] == baggage + + +def test_update_span_with_otel_data_http_method(): + sentry_span = Span() + + otel_span = MagicMock() + otel_span.name = "Test OTel Span" + otel_span.kind = SpanKind.CLIENT + otel_span.attributes = { + "http.method": "GET", + "http.status_code": 429, + "http.status_text": "xxx", + "http.user_agent": "curl/7.64.1", + "net.peer.name": "example.com", + "http.target": "/", + } + + span_processor = SentrySpanProcessor() + span_processor._update_span_with_otel_data(sentry_span, otel_span) + + assert sentry_span.op == "http.client" + assert sentry_span.description == "GET example.com /" + assert sentry_span._tags["http.status_code"] == "429" + assert sentry_span.status == "resource_exhausted" + + assert sentry_span._data["http.method"] == "GET" + assert sentry_span._data["http.status_code"] == 429 + assert sentry_span._data["http.status_text"] == "xxx" + assert sentry_span._data["http.user_agent"] == "curl/7.64.1" + assert sentry_span._data["net.peer.name"] == "example.com" + assert sentry_span._data["http.target"] == "/" + + +def test_update_span_with_otel_data_http_method2(): + sentry_span = Span() + + otel_span = MagicMock() + otel_span.name = "Test OTel Span" + otel_span.kind = SpanKind.SERVER + otel_span.attributes = { + "http.method": "GET", + "http.status_code": 429, + "http.status_text": "xxx", + "http.user_agent": "curl/7.64.1", + "http.url": "https://httpbin.org/status/403?password=123&username=test@example.com&author=User123&auth=1234567890abcdef", + } + + span_processor = SentrySpanProcessor() + span_processor._update_span_with_otel_data(sentry_span, otel_span) + + assert sentry_span.op == "http.server" + assert sentry_span.description == "GET https://httpbin.org/status/403" + assert sentry_span._tags["http.status_code"] == "429" + assert sentry_span.status == "resource_exhausted" + + assert sentry_span._data["http.method"] == "GET" + assert sentry_span._data["http.status_code"] == 429 + assert sentry_span._data["http.status_text"] == "xxx" + assert sentry_span._data["http.user_agent"] == "curl/7.64.1" + assert ( + sentry_span._data["http.url"] + == "https://httpbin.org/status/403?password=123&username=test@example.com&author=User123&auth=1234567890abcdef" + ) + + +def test_update_span_with_otel_data_db_query(): + sentry_span = Span() + + otel_span = MagicMock() + otel_span.name = "Test OTel Span" + otel_span.attributes = { + "db.system": "postgresql", + "db.statement": "SELECT * FROM table where pwd = '123456'", + } + + span_processor = SentrySpanProcessor() + span_processor._update_span_with_otel_data(sentry_span, otel_span) + + assert sentry_span.op == "db" + assert sentry_span.description == "SELECT * FROM table where pwd = '123456'" + + assert sentry_span._data["db.system"] == "postgresql" + assert ( + sentry_span._data["db.statement"] == "SELECT * FROM table where pwd = '123456'" + ) + + +def test_on_start_transaction(): + otel_span = MagicMock() + otel_span.name = "Sample OTel Span" + otel_span.start_time = time.time_ns() + otel_span.context = MagicMock() + otel_span.context.trace_id = int("1234567890abcdef1234567890abcdef", 16) + otel_span.context.span_id = int("1234567890abcdef", 16) + otel_span.parent = MagicMock() + otel_span.parent.span_id = int("abcdef1234567890", 16) + + parent_context = {} + + fake_client = MagicMock() + fake_client.options = {"instrumenter": "otel"} + + current_hub = MagicMock() + current_hub.client = fake_client + + fake_hub = MagicMock() + fake_hub.current = current_hub + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.span_processor.Hub", fake_hub + ): + span_processor = SentrySpanProcessor() + span_processor.on_start(otel_span, parent_context) + + fake_hub.current.start_transaction.assert_called_once_with( + name="Sample OTel Span", + span_id="1234567890abcdef", + parent_span_id="abcdef1234567890", + trace_id="1234567890abcdef1234567890abcdef", + baggage=None, + start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9), + instrumenter="otel", + ) + + assert len(span_processor.otel_span_map.keys()) == 1 + assert list(span_processor.otel_span_map.keys())[0] == "1234567890abcdef" + + +def test_on_start_child(): + otel_span = MagicMock() + otel_span.name = "Sample OTel Span" + otel_span.start_time = time.time_ns() + otel_span.context = MagicMock() + otel_span.context.trace_id = int("1234567890abcdef1234567890abcdef", 16) + otel_span.context.span_id = int("1234567890abcdef", 16) + otel_span.parent = MagicMock() + otel_span.parent.span_id = int("abcdef1234567890", 16) + + parent_context = {} + + fake_client = MagicMock() + fake_client.options = {"instrumenter": "otel"} + + current_hub = MagicMock() + current_hub.client = fake_client + + fake_hub = MagicMock() + fake_hub.current = current_hub + + with mock.patch( + "sentry_sdk.integrations.opentelemetry.span_processor.Hub", fake_hub + ): + fake_span = MagicMock() + + span_processor = SentrySpanProcessor() + span_processor.otel_span_map["abcdef1234567890"] = fake_span + span_processor.on_start(otel_span, parent_context) + + fake_span.start_child.assert_called_once_with( + span_id="1234567890abcdef", + description="Sample OTel Span", + start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9), + instrumenter="otel", + ) + + assert len(span_processor.otel_span_map.keys()) == 2 + assert "abcdef1234567890" in span_processor.otel_span_map.keys() + assert "1234567890abcdef" in span_processor.otel_span_map.keys() + + +def test_on_end_no_sentry_span(): + """ + If on_end is called on a span that is not in the otel_span_map, it should be a no-op. + """ + otel_span = MagicMock() + otel_span.name = "Sample OTel Span" + otel_span.end_time = time.time_ns() + otel_span.context = MagicMock() + otel_span.context.span_id = int("1234567890abcdef", 16) + + span_processor = SentrySpanProcessor() + span_processor.otel_span_map = {} + span_processor._get_otel_context = MagicMock() + span_processor._update_span_with_otel_data = MagicMock() + + span_processor.on_end(otel_span) + + span_processor._get_otel_context.assert_not_called() + span_processor._update_span_with_otel_data.assert_not_called() + + +def test_on_end_sentry_transaction(): + """ + Test on_end for a sentry Transaction. + """ + otel_span = MagicMock() + otel_span.name = "Sample OTel Span" + otel_span.end_time = time.time_ns() + otel_span.context = MagicMock() + otel_span.context.span_id = int("1234567890abcdef", 16) + + fake_sentry_span = MagicMock(spec=Transaction) + fake_sentry_span.set_context = MagicMock() + fake_sentry_span.finish = MagicMock() + + span_processor = SentrySpanProcessor() + span_processor._get_otel_context = MagicMock() + span_processor._update_span_with_otel_data = MagicMock() + span_processor.otel_span_map["1234567890abcdef"] = fake_sentry_span + + span_processor.on_end(otel_span) + + fake_sentry_span.set_context.assert_called_once() + span_processor._update_span_with_otel_data.assert_not_called() + fake_sentry_span.finish.assert_called_once() + + +def test_on_end_sentry_span(): + """ + Test on_end for a sentry Span. + """ + otel_span = MagicMock() + otel_span.name = "Sample OTel Span" + otel_span.end_time = time.time_ns() + otel_span.context = MagicMock() + otel_span.context.span_id = int("1234567890abcdef", 16) + + fake_sentry_span = MagicMock(spec=Span) + fake_sentry_span.set_context = MagicMock() + fake_sentry_span.finish = MagicMock() + + span_processor = SentrySpanProcessor() + span_processor._get_otel_context = MagicMock() + span_processor._update_span_with_otel_data = MagicMock() + span_processor.otel_span_map["1234567890abcdef"] = fake_sentry_span + + span_processor.on_end(otel_span) + + fake_sentry_span.set_context.assert_not_called() + span_processor._update_span_with_otel_data.assert_called_once_with( + fake_sentry_span, otel_span + ) + fake_sentry_span.finish.assert_called_once() diff --git a/tests/tracing/test_misc.py b/tests/tracing/test_misc.py index b51b5dcddb..ca3217acb3 100644 --- a/tests/tracing/test_misc.py +++ b/tests/tracing/test_misc.py @@ -274,3 +274,21 @@ def test_set_meaurement(sentry_init, capture_events): assert event["measurements"]["metric.bar"] == {"value": 456, "unit": "second"} assert event["measurements"]["metric.baz"] == {"value": 420.69, "unit": "custom"} assert event["measurements"]["metric.foobar"] == {"value": 17.99, "unit": "percent"} + + +def test_extract_sentry_trace(): + # type: () -> None + + trace_id, parent_span_id, parent_sampled = Transaction.extract_sentry_trace( + "4c79f60c11214eb38604f4ae0781bfb2-fa90fdead5f74052-1" + ) + assert trace_id == "4c79f60c11214eb38604f4ae0781bfb2" + assert parent_span_id == "fa90fdead5f74052" + assert parent_sampled + + trace_id, parent_span_id, parent_sampled = Transaction.extract_sentry_trace( + "5e79f60c11214eb38604f4ae0781bfb2-0390fdead5f74052-0" + ) + assert trace_id == "5e79f60c11214eb38604f4ae0781bfb2" + assert parent_span_id == "0390fdead5f74052" + assert not parent_sampled diff --git a/tox.ini b/tox.ini index 98505caab1..34a9166902 100644 --- a/tox.ini +++ b/tox.ini @@ -101,6 +101,8 @@ envlist = {py3.6,py3.7,py3.8,py3.9,py3.10}-pymongo-{4.0} {py3.7,py3.8,py3.9,py3.10}-pymongo-{4.1,4.2} + {py3.7,py3.8,py3.9,py3.10}-opentelemetry + [testenv] deps = # if you change test-requirements.txt and your change is not being reflected @@ -293,6 +295,8 @@ deps = pymongo-4.1: pymongo>=4.1,<4.2 pymongo-4.2: pymongo>=4.2,<4.3 + opentelemetry: opentelemetry-distro + setenv = PYTHONDONTWRITEBYTECODE=1 TESTPATH=tests