From 387822630149ea2e7b4ab4dae711f6aaecd5faf1 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 1 Oct 2025 14:15:02 +0200 Subject: [PATCH 1/2] POC OtlpIntegration --- .github/workflows/test-integrations-misc.yml | 4 + requirements-linting.txt | 2 +- scripts/populate_tox/populate_tox.py | 1 + scripts/populate_tox/tox.jinja | 7 + .../split_tox_gh_actions.py | 1 + sentry_sdk/consts.py | 1 + sentry_sdk/integrations/otlp.py | 82 ++++++++++ setup.py | 1 + tests/integrations/otlp/__init__.py | 3 + tests/integrations/otlp/test_otlp.py | 143 ++++++++++++++++++ tox.ini | 7 + 11 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 sentry_sdk/integrations/otlp.py create mode 100644 tests/integrations/otlp/__init__.py create mode 100644 tests/integrations/otlp/test_otlp.py diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 10cccaebac..3819d24b06 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -58,6 +58,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-opentelemetry" + - name: Test otlp + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-otlp" - name: Test potel run: | set -x # print commands that are executed diff --git a/requirements-linting.txt b/requirements-linting.txt index 1cc8274795..56c26df8de 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -7,7 +7,7 @@ types-greenlet types-redis types-setuptools types-webob -opentelemetry-distro +opentelemetry-distro[otlp] pymongo # There is no separate types module. loguru # There is no separate types module. pre-commit # local linting diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 2d81a85ea2..5594593bfa 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -68,6 +68,7 @@ "gcp", "gevent", "opentelemetry", + "otlp", "potel", } diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index a04087ddfd..d284abcf8c 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -43,6 +43,9 @@ envlist = # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-opentelemetry + # OpenTelemetry with OTLP + {py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-otlp + # OpenTelemetry Experimental (POTel) {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel @@ -113,6 +116,9 @@ deps = # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro + # OpenTelemetry with OTLP + otlp: opentelemetry-distro[otlp] + # OpenTelemetry Experimental (POTel) potel: -e .[opentelemetry-experimental] @@ -158,6 +164,7 @@ setenv = cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context gcp: TESTPATH=tests/integrations/gcp opentelemetry: TESTPATH=tests/integrations/opentelemetry + otlp: TESTPATH=tests/integrations/otlp potel: TESTPATH=tests/integrations/opentelemetry socket: TESTPATH=tests/integrations/socket diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 59c3473d8c..541d0790e8 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -150,6 +150,7 @@ "Misc": [ "loguru", "opentelemetry", + "otlp", "potel", "pure_eval", "trytond", diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index dd45f1c872..09b3fd6de1 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -21,6 +21,7 @@ class EndpointType(Enum): """ ENVELOPE = "envelope" + OTLP_TRACES = "integration/otlp/v1/traces" class CompressionAlgo(Enum): diff --git a/sentry_sdk/integrations/otlp.py b/sentry_sdk/integrations/otlp.py new file mode 100644 index 0000000000..7fa705b832 --- /dev/null +++ b/sentry_sdk/integrations/otlp.py @@ -0,0 +1,82 @@ +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.scope import register_external_propagation_context +from sentry_sdk.utils import logger, Dsn +from sentry_sdk.consts import VERSION, EndpointType + +try: + from opentelemetry import trace + from opentelemetry.propagate import set_global_textmap + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + + from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator +except ImportError: + raise DidNotEnable("opentelemetry-distro[otlp] is not installed") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional, Dict, Any, Tuple + + +def otel_propagation_context(): + # type: () -> Optional[Tuple[str, str]] + """ + Get the (trace_id, span_id) from opentelemetry if exists. + """ + ctx = trace.get_current_span().get_span_context() + + if ctx.trace_id == trace.INVALID_TRACE_ID or ctx.span_id == trace.INVALID_SPAN_ID: + return None + + return (trace.format_trace_id(ctx.trace_id), trace.format_span_id(ctx.span_id)) + + +def setup_otlp_exporter(dsn=None): + # type: (Optional[str]) -> None + tracer_provider = trace.get_tracer_provider() + + if not isinstance(tracer_provider, TracerProvider): + logger.debug("[OTLP] No TracerProvider configured by user, creating a new one") + tracer_provider = TracerProvider() + trace.set_tracer_provider(tracer_provider) + + endpoint = None + headers = None + if dsn: + auth = Dsn(dsn).to_auth(f"sentry.python/{VERSION}") + endpoint = auth.get_api_url(EndpointType.OTLP_TRACES) + headers = {"X-Sentry-Auth": auth.to_header()} + logger.debug(f"[OTLP] Sending traces to {endpoint}") + + otlp_exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers) + span_processor = BatchSpanProcessor(otlp_exporter) + tracer_provider.add_span_processor(span_processor) + + +class OTLPIntegration(Integration): + identifier = "otlp" + + def __init__(self, setup_otlp_exporter=True, setup_propagator=True): + # type: (bool, bool) -> None + self.setup_otlp_exporter = setup_otlp_exporter + self.setup_propagator = setup_propagator + + @staticmethod + def setup_once(): + # type: () -> None + logger.debug("[OTLP] Setting up trace linking for all events") + register_external_propagation_context(otel_propagation_context) + + def setup_once_with_options(self, options=None): + # type: (Optional[Dict[str, Any]]) -> None + if self.setup_otlp_exporter: + logger.debug("[OTLP] Setting up OTLP exporter") + dsn = options.get("dsn") if options else None # type: Optional[str] + setup_otlp_exporter(dsn) + + if self.setup_propagator: + logger.debug("[OTLP] Setting up propagator for distributed tracing") + # TODO-neel better propagator support, chain with existing ones if possible instead of replacing + set_global_textmap(SentryPropagator()) diff --git a/setup.py b/setup.py index b3b7ebb737..2a0618fea8 100644 --- a/setup.py +++ b/setup.py @@ -73,6 +73,7 @@ def get_file_text(file_name): "openfeature": ["openfeature-sdk>=0.7.1"], "opentelemetry": ["opentelemetry-distro>=0.35b0"], "opentelemetry-experimental": ["opentelemetry-distro"], + "opentelemetry-otlp": ["opentelemetry-distro[otlp]>=0.35b0"], "pure-eval": ["pure_eval", "executing", "asttokens"], "pydantic_ai": ["pydantic-ai>=1.0.0"], "pymongo": ["pymongo>=3.1"], diff --git a/tests/integrations/otlp/__init__.py b/tests/integrations/otlp/__init__.py new file mode 100644 index 0000000000..75763c2fee --- /dev/null +++ b/tests/integrations/otlp/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("opentelemetry") diff --git a/tests/integrations/otlp/test_otlp.py b/tests/integrations/otlp/test_otlp.py new file mode 100644 index 0000000000..04eea11e33 --- /dev/null +++ b/tests/integrations/otlp/test_otlp.py @@ -0,0 +1,143 @@ +import pytest + +from opentelemetry import trace +from opentelemetry.trace import ( + get_tracer_provider, + set_tracer_provider, + ProxyTracerProvider, + format_span_id, + format_trace_id, +) +from opentelemetry.propagate import get_global_textmap, set_global_textmap +from opentelemetry.util._once import Once +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + +from sentry_sdk.integrations.otlp import OTLPIntegration +from sentry_sdk.integrations.opentelemetry import SentryPropagator +from sentry_sdk.scope import get_external_propagation_context + + +original_propagator = get_global_textmap() + + +@pytest.fixture(autouse=True) +def reset_otlp(uninstall_integration): + trace._TRACER_PROVIDER_SET_ONCE = Once() + trace._TRACER_PROVIDER = None + + set_global_textmap(original_propagator) + + uninstall_integration("otlp") + + +def test_sets_new_tracer_provider_with_otlp_exporter(sentry_init): + existing_tracer_provider = get_tracer_provider() + assert isinstance(existing_tracer_provider, ProxyTracerProvider) + + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration()], + ) + + tracer_provider = get_tracer_provider() + assert tracer_provider is not existing_tracer_provider + assert isinstance(tracer_provider, TracerProvider) + + (span_processor,) = tracer_provider._active_span_processor._span_processors + assert isinstance(span_processor, BatchSpanProcessor) + + exporter = span_processor.span_exporter + assert isinstance(exporter, OTLPSpanExporter) + assert ( + exporter._endpoint + == "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/" + ) + assert "X-Sentry-Auth" in exporter._headers + assert ( + "Sentry sentry_key=mysecret, sentry_version=7, sentry_client=sentry.python/" + in exporter._headers["X-Sentry-Auth"] + ) + + +def test_uses_existing_tracer_provider_with_otlp_exporter(sentry_init): + existing_tracer_provider = TracerProvider() + set_tracer_provider(existing_tracer_provider) + + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration()], + ) + + tracer_provider = get_tracer_provider() + assert tracer_provider == existing_tracer_provider + assert isinstance(tracer_provider, TracerProvider) + + (span_processor,) = tracer_provider._active_span_processor._span_processors + assert isinstance(span_processor, BatchSpanProcessor) + + exporter = span_processor.span_exporter + assert isinstance(exporter, OTLPSpanExporter) + assert ( + exporter._endpoint + == "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/" + ) + assert "X-Sentry-Auth" in exporter._headers + assert ( + "Sentry sentry_key=mysecret, sentry_version=7, sentry_client=sentry.python/" + in exporter._headers["X-Sentry-Auth"] + ) + + +def test_does_not_setup_exporter_when_disabled(sentry_init): + existing_tracer_provider = get_tracer_provider() + assert isinstance(existing_tracer_provider, ProxyTracerProvider) + + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration(setup_otlp_exporter=False)], + ) + + tracer_provider = get_tracer_provider() + assert tracer_provider is existing_tracer_provider + + +def test_sets_propagator(sentry_init): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration()], + ) + + propagator = get_global_textmap() + assert isinstance(get_global_textmap(), SentryPropagator) + assert propagator is not original_propagator + + +def test_does_not_set_propagator_if_disabled(sentry_init): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration(setup_propagator=False)], + ) + + propagator = get_global_textmap() + assert not isinstance(propagator, SentryPropagator) + assert propagator is original_propagator + + +def test_otel_propagation_context(sentry_init): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration()], + ) + + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("foo") as root_span: + with tracer.start_as_current_span("bar") as span: + external_propagation_context = get_external_propagation_context() + + assert external_propagation_context is not None + (trace_id, span_id) = external_propagation_context + assert trace_id == format_trace_id(root_span.get_span_context().trace_id) + assert trace_id == format_trace_id(span.get_span_context().trace_id) + assert span_id == format_span_id(span.get_span_context().span_id) diff --git a/tox.ini b/tox.ini index 7a1fd39f03..2617e4bbb5 100644 --- a/tox.ini +++ b/tox.ini @@ -43,6 +43,9 @@ envlist = # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-opentelemetry + # OpenTelemetry with OTLP + {py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-otlp + # OpenTelemetry Experimental (POTel) {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel @@ -350,6 +353,9 @@ deps = # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro + # OpenTelemetry with OTLP + otlp: opentelemetry-distro[otlp] + # OpenTelemetry Experimental (POTel) potel: -e .[opentelemetry-experimental] @@ -774,6 +780,7 @@ setenv = cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context gcp: TESTPATH=tests/integrations/gcp opentelemetry: TESTPATH=tests/integrations/opentelemetry + otlp: TESTPATH=tests/integrations/otlp potel: TESTPATH=tests/integrations/opentelemetry socket: TESTPATH=tests/integrations/socket From 75c416368c4823f125eadc8092e2165610e569d5 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Thu, 6 Nov 2025 15:51:18 +0100 Subject: [PATCH 2/2] Mock ingest --- tests/integrations/otlp/test_otlp.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/integrations/otlp/test_otlp.py b/tests/integrations/otlp/test_otlp.py index 04eea11e33..8806080be7 100644 --- a/tests/integrations/otlp/test_otlp.py +++ b/tests/integrations/otlp/test_otlp.py @@ -1,4 +1,5 @@ import pytest +import responses from opentelemetry import trace from opentelemetry.trace import ( @@ -125,7 +126,14 @@ def test_does_not_set_propagator_if_disabled(sentry_init): assert propagator is original_propagator +@responses.activate def test_otel_propagation_context(sentry_init): + responses.add( + responses.POST, + url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/", + status=200, + ) + sentry_init( dsn="https://mysecret@bla.ingest.sentry.io/12312012", integrations=[OTLPIntegration()], @@ -136,6 +144,9 @@ def test_otel_propagation_context(sentry_init): with tracer.start_as_current_span("bar") as span: external_propagation_context = get_external_propagation_context() + # Force flush to ensure spans are exported while mock is active + get_tracer_provider().force_flush() + assert external_propagation_context is not None (trace_id, span_id) = external_propagation_context assert trace_id == format_trace_id(root_span.get_span_context().trace_id)