Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test-integrations-misc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements-linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/populate_tox/populate_tox.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"gcp",
"gevent",
"opentelemetry",
"otlp",
"potel",
}

Expand Down
7 changes: 7 additions & 0 deletions scripts/populate_tox/tox.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last thing that'd be good before merging.

Since pip install "opentelemetry-distro[otlp]" also pulls in grpcio, and grpcio does not distribute free-threading wheels, these new tests take like 17 mins to run in CI on free-threading 3.14.

https://github.com/getsentry/sentry-python/actions/runs/19139690916/job/54701021577?pr=4877

So I suggest we do not run the new test suite on 3.14t.

Also, as an fyi: for running populate_tox.py, I add a uv command for running it with the new pre-requisites to the README in https://github.com/getsentry/sentry-python/pull/5076/files

That said, in this case you can just edit tox.jinja and tox.ini manually, since it's just removing a single version.


# OpenTelemetry Experimental (POTel)
{py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel

Expand Down Expand Up @@ -113,6 +116,9 @@ deps =
# OpenTelemetry (OTel)
opentelemetry: opentelemetry-distro

# OpenTelemetry with OTLP
otlp: opentelemetry-distro[otlp]

# OpenTelemetry Experimental (POTel)
potel: -e .[opentelemetry-experimental]

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions scripts/split_tox_gh_actions/split_tox_gh_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"Misc": [
"loguru",
"opentelemetry",
"otlp",
"potel",
"pure_eval",
"trytond",
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class EndpointType(Enum):
"""

ENVELOPE = "envelope"
OTLP_TRACES = "integration/otlp/v1/traces"


class CompressionAlgo(Enum):
Expand Down
82 changes: 82 additions & 0 deletions sentry_sdk/integrations/otlp.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When dsn is None, is there any point in setting up the provider and processor?

Not sure if I am missing something because I am not as familiar with OTel. I would have thought if dsn is None we could exit early, maybe with a warning message.

Copy link
Member Author

@sl0thentr0py sl0thentr0py Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they can still manually set env variables as follows:

export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://o0.ingest.sentry.io/api/0/integration/otlp/v1/traces"
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="x-sentry-auth=sentry sentry_key=examplePublicKey"

which will be picked up by otel internally when initializing the exporter. Leaving it open as an option just in case someone has a complicated ops setup.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll iterate on the DX here once we get some feedback/complaints/confusion

# 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()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we set two headers in the transport and only one here? See

headers.update(
{
"User-Agent": str(self._auth.client),
"X-Sentry-Auth": str(self._auth.to_header()),
}
)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
3 changes: 3 additions & 0 deletions tests/integrations/otlp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("opentelemetry")
154 changes: 154 additions & 0 deletions tests/integrations/otlp/test_otlp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import pytest
import responses

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


@responses.activate
def test_otel_propagation_context(sentry_init):
Copy link
Contributor

@alexander-alderman-webb alexander-alderman-webb Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When running this test I see the message below. I'm trying to find where this is coming from.

Failed to export span batch code: 404, reason: You should be really using o*.ingest.sentry.io domains.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mocked

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()],
)

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()

# 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)
assert trace_id == format_trace_id(span.get_span_context().trace_id)
assert span_id == format_span_id(span.get_span_context().span_id)
7 changes: 7 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -350,6 +353,9 @@ deps =
# OpenTelemetry (OTel)
opentelemetry: opentelemetry-distro

# OpenTelemetry with OTLP
otlp: opentelemetry-distro[otlp]

# OpenTelemetry Experimental (POTel)
potel: -e .[opentelemetry-experimental]

Expand Down Expand Up @@ -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

Expand Down