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