Skip to content

Commit 6d60191

Browse files
committed
POC OtlpIntegration
1 parent faa327c commit 6d60191

File tree

11 files changed

+251
-1
lines changed

11 files changed

+251
-1
lines changed

.github/workflows/test-integrations-misc.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ jobs:
5858
run: |
5959
set -x # print commands that are executed
6060
./scripts/runtox.sh "py${{ matrix.python-version }}-opentelemetry"
61+
- name: Test otlp
62+
run: |
63+
set -x # print commands that are executed
64+
./scripts/runtox.sh "py${{ matrix.python-version }}-otlp"
6165
- name: Test potel
6266
run: |
6367
set -x # print commands that are executed

requirements-linting.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ types-greenlet
77
types-redis
88
types-setuptools
99
types-webob
10-
opentelemetry-distro
10+
opentelemetry-distro[otlp]
1111
pymongo # There is no separate types module.
1212
loguru # There is no separate types module.
1313
pre-commit # local linting

scripts/populate_tox/populate_tox.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"gcp",
6969
"gevent",
7070
"opentelemetry",
71+
"otlp",
7172
"potel",
7273
}
7374

scripts/populate_tox/tox.jinja

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ envlist =
4343
# OpenTelemetry (OTel)
4444
{py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-opentelemetry
4545

46+
# OpenTelemetry with OTLP
47+
{py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-otlp
48+
4649
# OpenTelemetry Experimental (POTel)
4750
{py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel
4851

@@ -113,6 +116,9 @@ deps =
113116
# OpenTelemetry (OTel)
114117
opentelemetry: opentelemetry-distro
115118
119+
# OpenTelemetry with OTLP
120+
otlp: opentelemetry-distro[otlp]
121+
116122
# OpenTelemetry Experimental (POTel)
117123
potel: -e .[opentelemetry-experimental]
118124
@@ -158,6 +164,7 @@ setenv =
158164
cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context
159165
gcp: TESTPATH=tests/integrations/gcp
160166
opentelemetry: TESTPATH=tests/integrations/opentelemetry
167+
otlp: TESTPATH=tests/integrations/otlp
161168
potel: TESTPATH=tests/integrations/opentelemetry
162169
socket: TESTPATH=tests/integrations/socket
163170

scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
"Misc": [
151151
"loguru",
152152
"opentelemetry",
153+
"otlp",
153154
"potel",
154155
"pure_eval",
155156
"trytond",

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class EndpointType(Enum):
2121
"""
2222

2323
ENVELOPE = "envelope"
24+
OTLP_TRACES = "integration/otlp/v1/traces"
2425

2526

2627
class CompressionAlgo(Enum):

sentry_sdk/integrations/otlp.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from sentry_sdk.integrations import Integration, DidNotEnable
2+
from sentry_sdk.scope import register_external_propagation_context
3+
from sentry_sdk.utils import logger, Dsn
4+
from sentry_sdk.consts import VERSION, EndpointType
5+
6+
try:
7+
from opentelemetry import trace
8+
from opentelemetry.propagate import set_global_textmap
9+
from opentelemetry.sdk.trace import TracerProvider
10+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
11+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12+
13+
from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
14+
except ImportError:
15+
raise DidNotEnable("opentelemetry-distro[otlp] is not installed")
16+
17+
from typing import TYPE_CHECKING
18+
19+
if TYPE_CHECKING:
20+
from typing import Optional, Dict, Any, Tuple
21+
22+
23+
def otel_propagation_context():
24+
# type: () -> Optional[Tuple[str, str]]
25+
"""
26+
Get the (trace_id, span_id) from opentelemetry if exists.
27+
"""
28+
ctx = trace.get_current_span().get_span_context()
29+
30+
if ctx.trace_id == trace.INVALID_TRACE_ID or ctx.span_id == trace.INVALID_SPAN_ID:
31+
return None
32+
33+
return (trace.format_trace_id(ctx.trace_id), trace.format_span_id(ctx.span_id))
34+
35+
36+
def setup_otlp_exporter(dsn=None):
37+
# type: (Optional[str]) -> None
38+
tracer_provider = trace.get_tracer_provider()
39+
40+
if not isinstance(tracer_provider, TracerProvider):
41+
logger.debug("[OTLP] No TracerProvider configured by user, creating a new one")
42+
tracer_provider = TracerProvider()
43+
trace.set_tracer_provider(tracer_provider)
44+
45+
endpoint = None
46+
headers = None
47+
if dsn:
48+
auth = Dsn(dsn).to_auth(f"sentry.python/{VERSION}")
49+
endpoint = auth.get_api_url(EndpointType.OTLP_TRACES)
50+
headers = {"X-Sentry-Auth": auth.to_header()}
51+
logger.debug(f"[OTLP] Sending traces to {endpoint}")
52+
53+
otlp_exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers)
54+
span_processor = BatchSpanProcessor(otlp_exporter)
55+
tracer_provider.add_span_processor(span_processor)
56+
57+
58+
class OTLPIntegration(Integration):
59+
identifier = "otlp"
60+
61+
def __init__(self, setup_otlp_exporter=True, setup_propagator=True):
62+
# type: (bool, bool) -> None
63+
self.setup_otlp_exporter = setup_otlp_exporter
64+
self.setup_propagator = setup_propagator
65+
66+
@staticmethod
67+
def setup_once():
68+
# type: () -> None
69+
logger.debug("[OTLP] Setting up trace linking for all events")
70+
register_external_propagation_context(otel_propagation_context)
71+
72+
def setup_once_with_options(self, options=None):
73+
# type: (Optional[Dict[str, Any]]) -> None
74+
if self.setup_otlp_exporter:
75+
logger.debug("[OTLP] Setting up OTLP exporter")
76+
dsn = options.get("dsn") if options else None # type: Optional[str]
77+
setup_otlp_exporter(dsn)
78+
79+
if self.setup_propagator:
80+
logger.debug("[OTLP] Setting up propagator for distributed tracing")
81+
# TODO-neel better propagator support, chain with existing ones if possible instead of replacing
82+
set_global_textmap(SentryPropagator())

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def get_file_text(file_name):
7373
"openfeature": ["openfeature-sdk>=0.7.1"],
7474
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
7575
"opentelemetry-experimental": ["opentelemetry-distro"],
76+
"opentelemetry-otlp": ["opentelemetry-distro[otlp]>=0.35b0"],
7677
"pure-eval": ["pure_eval", "executing", "asttokens"],
7778
"pydantic_ai": ["pydantic-ai>=1.0.0"],
7879
"pymongo": ["pymongo>=3.1"],
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("opentelemetry")
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import pytest
2+
3+
from opentelemetry import trace
4+
from opentelemetry.trace import (
5+
get_tracer_provider,
6+
set_tracer_provider,
7+
ProxyTracerProvider,
8+
format_span_id,
9+
format_trace_id,
10+
)
11+
from opentelemetry.propagate import get_global_textmap, set_global_textmap
12+
from opentelemetry.util._once import Once
13+
from opentelemetry.sdk.trace import TracerProvider
14+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
15+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
16+
17+
from sentry_sdk.integrations.otlp import OTLPIntegration
18+
from sentry_sdk.integrations.opentelemetry import SentryPropagator
19+
from sentry_sdk.scope import get_external_propagation_context
20+
21+
22+
original_propagator = get_global_textmap()
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def reset_otlp(uninstall_integration):
27+
trace._TRACER_PROVIDER_SET_ONCE = Once()
28+
trace._TRACER_PROVIDER = None
29+
30+
set_global_textmap(original_propagator)
31+
32+
uninstall_integration("otlp")
33+
34+
35+
def test_sets_new_tracer_provider_with_otlp_exporter(sentry_init):
36+
existing_tracer_provider = get_tracer_provider()
37+
assert isinstance(existing_tracer_provider, ProxyTracerProvider)
38+
39+
sentry_init(
40+
dsn="https://mysecret@bla.ingest.sentry.io/12312012",
41+
integrations=[OTLPIntegration()],
42+
)
43+
44+
tracer_provider = get_tracer_provider()
45+
assert tracer_provider is not existing_tracer_provider
46+
assert isinstance(tracer_provider, TracerProvider)
47+
48+
(span_processor,) = tracer_provider._active_span_processor._span_processors
49+
assert isinstance(span_processor, BatchSpanProcessor)
50+
51+
exporter = span_processor.span_exporter
52+
assert isinstance(exporter, OTLPSpanExporter)
53+
assert (
54+
exporter._endpoint
55+
== "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/"
56+
)
57+
assert "X-Sentry-Auth" in exporter._headers
58+
assert (
59+
"Sentry sentry_key=mysecret, sentry_version=7, sentry_client=sentry.python/"
60+
in exporter._headers["X-Sentry-Auth"]
61+
)
62+
63+
64+
def test_uses_existing_tracer_provider_with_otlp_exporter(sentry_init):
65+
existing_tracer_provider = TracerProvider()
66+
set_tracer_provider(existing_tracer_provider)
67+
68+
sentry_init(
69+
dsn="https://mysecret@bla.ingest.sentry.io/12312012",
70+
integrations=[OTLPIntegration()],
71+
)
72+
73+
tracer_provider = get_tracer_provider()
74+
assert tracer_provider == existing_tracer_provider
75+
assert isinstance(tracer_provider, TracerProvider)
76+
77+
(span_processor,) = tracer_provider._active_span_processor._span_processors
78+
assert isinstance(span_processor, BatchSpanProcessor)
79+
80+
exporter = span_processor.span_exporter
81+
assert isinstance(exporter, OTLPSpanExporter)
82+
assert (
83+
exporter._endpoint
84+
== "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/"
85+
)
86+
assert "X-Sentry-Auth" in exporter._headers
87+
assert (
88+
"Sentry sentry_key=mysecret, sentry_version=7, sentry_client=sentry.python/"
89+
in exporter._headers["X-Sentry-Auth"]
90+
)
91+
92+
93+
def test_does_not_setup_exporter_when_disabled(sentry_init):
94+
existing_tracer_provider = get_tracer_provider()
95+
assert isinstance(existing_tracer_provider, ProxyTracerProvider)
96+
97+
sentry_init(
98+
dsn="https://mysecret@bla.ingest.sentry.io/12312012",
99+
integrations=[OTLPIntegration(setup_otlp_exporter=False)],
100+
)
101+
102+
tracer_provider = get_tracer_provider()
103+
assert tracer_provider is existing_tracer_provider
104+
105+
106+
def test_sets_propagator(sentry_init):
107+
sentry_init(
108+
dsn="https://mysecret@bla.ingest.sentry.io/12312012",
109+
integrations=[OTLPIntegration()],
110+
)
111+
112+
propagator = get_global_textmap()
113+
assert isinstance(get_global_textmap(), SentryPropagator)
114+
assert propagator is not original_propagator
115+
116+
117+
def test_does_not_set_propagator_if_disabled(sentry_init):
118+
sentry_init(
119+
dsn="https://mysecret@bla.ingest.sentry.io/12312012",
120+
integrations=[OTLPIntegration(setup_propagator=False)],
121+
)
122+
123+
propagator = get_global_textmap()
124+
assert not isinstance(propagator, SentryPropagator)
125+
assert propagator is original_propagator
126+
127+
128+
def test_otel_propagation_context(sentry_init):
129+
sentry_init(
130+
dsn="https://mysecret@bla.ingest.sentry.io/12312012",
131+
integrations=[OTLPIntegration()],
132+
)
133+
134+
tracer = trace.get_tracer(__name__)
135+
with tracer.start_as_current_span("foo") as root_span:
136+
with tracer.start_as_current_span("bar") as span:
137+
external_propagation_context = get_external_propagation_context()
138+
139+
assert external_propagation_context is not None
140+
(trace_id, span_id) = external_propagation_context
141+
assert trace_id == format_trace_id(root_span.get_span_context().trace_id)
142+
assert trace_id == format_trace_id(span.get_span_context().trace_id)
143+
assert span_id == format_span_id(span.get_span_context().span_id)

0 commit comments

Comments
 (0)